diff --git a/apps/server/src/lib/app-spec-format.ts b/apps/server/src/lib/app-spec-format.ts index ff5af0e6..f25ebaef 100644 --- a/apps/server/src/lib/app-spec-format.ts +++ b/apps/server/src/lib/app-spec-format.ts @@ -4,6 +4,235 @@ * This format must be included in all prompts that generate, modify, or regenerate * app specifications to ensure consistency across the application. */ + +/** + * TypeScript interface for structured spec output + */ +export interface SpecOutput { + project_name: string; + overview: string; + technology_stack: string[]; + core_capabilities: string[]; + implemented_features: Array<{ + name: string; + description: string; + file_locations?: string[]; + }>; + additional_requirements?: string[]; + development_guidelines?: string[]; + implementation_roadmap?: Array<{ + phase: string; + status: "completed" | "in_progress" | "pending"; + description: string; + }>; +} + +/** + * JSON Schema for structured spec output + * Used with Claude's structured output feature for reliable parsing + */ +export const specOutputSchema = { + type: "object", + properties: { + project_name: { + type: "string", + description: "The name of the project", + }, + overview: { + type: "string", + description: + "A comprehensive description of what the project does, its purpose, and key goals", + }, + technology_stack: { + type: "array", + items: { type: "string" }, + description: + "List of all technologies, frameworks, libraries, and tools used", + }, + core_capabilities: { + type: "array", + items: { type: "string" }, + description: "List of main features and capabilities the project provides", + }, + implemented_features: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string", + description: "Name of the implemented feature", + }, + description: { + type: "string", + description: "Description of what the feature does", + }, + file_locations: { + type: "array", + items: { type: "string" }, + description: "File paths where this feature is implemented", + }, + }, + required: ["name", "description"], + }, + description: "Features that have been implemented based on code analysis", + }, + additional_requirements: { + type: "array", + items: { type: "string" }, + description: "Any additional requirements or constraints", + }, + development_guidelines: { + type: "array", + items: { type: "string" }, + description: "Development standards and practices", + }, + implementation_roadmap: { + type: "array", + items: { + type: "object", + properties: { + phase: { + type: "string", + description: "Name of the implementation phase", + }, + status: { + type: "string", + enum: ["completed", "in_progress", "pending"], + description: "Current status of this phase", + }, + description: { + type: "string", + description: "Description of what this phase involves", + }, + }, + required: ["phase", "status", "description"], + }, + description: "Phases or roadmap items for implementation", + }, + }, + required: [ + "project_name", + "overview", + "technology_stack", + "core_capabilities", + "implemented_features", + ], + additionalProperties: false, +}; + +/** + * Escape special XML characters + */ +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Convert structured spec output to XML format + */ +export function specToXml(spec: SpecOutput): string { + const indent = " "; + + let xml = ` + +${indent}${escapeXml(spec.project_name)} + +${indent} +${indent}${indent}${escapeXml(spec.overview)} +${indent} + +${indent} +${spec.technology_stack.map((t) => `${indent}${indent}${escapeXml(t)}`).join("\n")} +${indent} + +${indent} +${spec.core_capabilities.map((c) => `${indent}${indent}${escapeXml(c)}`).join("\n")} +${indent} + +${indent} +${spec.implemented_features + .map( + (f) => `${indent}${indent} +${indent}${indent}${indent}${escapeXml(f.name)} +${indent}${indent}${indent}${escapeXml(f.description)}${ + f.file_locations && f.file_locations.length > 0 + ? `\n${indent}${indent}${indent} +${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}${escapeXml(loc)}`).join("\n")} +${indent}${indent}${indent}` + : "" + } +${indent}${indent}` + ) + .join("\n")} +${indent}`; + + // Optional sections + if (spec.additional_requirements && spec.additional_requirements.length > 0) { + xml += ` + +${indent} +${spec.additional_requirements.map((r) => `${indent}${indent}${escapeXml(r)}`).join("\n")} +${indent}`; + } + + if (spec.development_guidelines && spec.development_guidelines.length > 0) { + xml += ` + +${indent} +${spec.development_guidelines.map((g) => `${indent}${indent}${escapeXml(g)}`).join("\n")} +${indent}`; + } + + if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) { + xml += ` + +${indent} +${spec.implementation_roadmap + .map( + (r) => `${indent}${indent} +${indent}${indent}${indent}${escapeXml(r.phase)} +${indent}${indent}${indent}${escapeXml(r.status)} +${indent}${indent}${indent}${escapeXml(r.description)} +${indent}${indent}` + ) + .join("\n")} +${indent}`; + } + + xml += ` +`; + + return xml; +} + +/** + * Get prompt instruction for structured output (simpler than XML instructions) + */ +export function getStructuredSpecPromptInstruction(): string { + return ` +Analyze the project and provide a comprehensive specification with: + +1. **project_name**: The name of the project +2. **overview**: A comprehensive description of what the project does, its purpose, and key goals +3. **technology_stack**: List all technologies, frameworks, libraries, and tools used +4. **core_capabilities**: List the main features and capabilities the project provides +5. **implemented_features**: For each implemented feature, provide: + - name: Feature name + - description: What it does + - file_locations: Key files where it's implemented (optional) +6. **additional_requirements**: Any system requirements, dependencies, or constraints (optional) +7. **development_guidelines**: Development standards and best practices (optional) +8. **implementation_roadmap**: Project phases with status (completed/in_progress/pending) (optional) + +Be thorough in your analysis. The output will be automatically formatted as structured JSON. +`; +} export const APP_SPEC_XML_FORMAT = ` The app_spec.txt file MUST follow this exact XML format: diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index beb54c7e..711ad1f3 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -174,6 +174,7 @@ export function createSpecGenerationOptions( allowedTools: [...TOOL_PRESETS.specGeneration], ...(config.systemPrompt && { systemPrompt: config.systemPrompt }), ...(config.abortController && { abortController: config.abortController }), + ...(config.outputFormat && { outputFormat: config.outputFormat }), }; } diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index fe6a3145..ea53be85 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -6,7 +6,12 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; import path from "path"; import fs from "fs/promises"; import type { EventEmitter } from "../../lib/events.js"; -import { getAppSpecFormatInstruction } from "../../lib/app-spec-format.js"; +import { + specOutputSchema, + specToXml, + getStructuredSpecPromptInstruction, + type SpecOutput, +} from "../../lib/app-spec-format.js"; import { createLogger } from "../../lib/logger.js"; import { createSpecGenerationOptions } from "../../lib/sdk-options.js"; import { logAuthStatus } from "./common.js"; @@ -42,9 +47,7 @@ export async function generateSpec( - Existing technologies and frameworks - Project structure and architecture - Current features and capabilities -- Code patterns and conventions - -After your analysis, OUTPUT the complete XML specification in your response. Do NOT attempt to write any files - just return the XML content and it will be saved automatically.`; +- Code patterns and conventions`; } else { // Use default tech stack techStackDefaults = `Default Technology Stack: @@ -54,9 +57,7 @@ After your analysis, OUTPUT the complete XML specification in your response. Do - Styling: Tailwind CSS - Frontend: React -Use these technologies as the foundation for the specification. - -OUTPUT the complete XML specification in your response. Do NOT attempt to write any files - just return the XML content and it will be saved automatically.`; +Use these technologies as the foundation for the specification.`; } const prompt = `You are helping to define a software project specification. @@ -70,7 +71,7 @@ ${techStackDefaults} ${analysisInstructions} -${getAppSpecFormatInstruction()}`; +${getStructuredSpecPromptInstruction()}`; logger.info("========== PROMPT BEING SENT =========="); logger.info(`Prompt length: ${prompt.length} chars`); @@ -85,6 +86,10 @@ ${getAppSpecFormatInstruction()}`; const options = createSpecGenerationOptions({ cwd: projectPath, abortController, + outputFormat: { + type: "json_schema", + schema: specOutputSchema, + }, }); logger.debug("SDK Options:", JSON.stringify(options, null, 2)); @@ -105,6 +110,7 @@ ${getAppSpecFormatInstruction()}`; let responseText = ""; let messageCount = 0; + let structuredOutput: SpecOutput | null = null; logger.info("Starting to iterate over stream..."); @@ -118,72 +124,49 @@ ${getAppSpecFormatInstruction()}`; ); if (msg.type === "assistant") { - // Log the full message structure to debug - logger.info(`Assistant msg keys: ${Object.keys(msg).join(", ")}`); const msgAny = msg as any; - if (msgAny.message) { - logger.info( - `msg.message keys: ${Object.keys(msgAny.message).join(", ")}` - ); - if (msgAny.message.content) { - logger.info( - `msg.message.content length: ${msgAny.message.content.length}` - ); - for (const block of msgAny.message.content) { + if (msgAny.message?.content) { + for (const block of msgAny.message.content) { + if (block.type === "text") { + responseText += block.text; logger.info( - `Block keys: ${Object.keys(block).join(", ")}, type: ${ - block.type - }` + `Text block received (${block.text.length} chars), total now: ${responseText.length} chars` ); - if (block.type === "text") { - responseText += block.text; - logger.info( - `Text block received (${block.text.length} chars), total now: ${responseText.length} chars` - ); - logger.info(`Text preview: ${block.text.substring(0, 200)}...`); - events.emit("spec-regeneration:event", { - type: "spec_regeneration_progress", - content: block.text, - projectPath: projectPath, - }); - } else if (block.type === "tool_use") { - logger.info("Tool use:", block.name); - events.emit("spec-regeneration:event", { - type: "spec_tool", - tool: block.name, - input: block.input, - }); - } + events.emit("spec-regeneration:event", { + type: "spec_regeneration_progress", + content: block.text, + projectPath: projectPath, + }); + } else if (block.type === "tool_use") { + logger.info("Tool use:", block.name); + events.emit("spec-regeneration:event", { + type: "spec_tool", + tool: block.name, + input: block.input, + }); } - } else { - logger.warn("msg.message.content is falsy"); } - } else { - logger.warn("msg.message is falsy"); - // Log full message to see structure - logger.info( - `Full assistant msg: ${JSON.stringify(msg).substring(0, 1000)}` - ); } } else if (msg.type === "result" && (msg as any).subtype === "success") { logger.info("Received success result"); - logger.info(`Result value length: ${((msg as any).result || "").length}`); - logger.info( - `Final accumulated responseText length: ${responseText.length}` - ); - // Don't overwrite responseText - the accumulated text contains the XML spec - // The result.result field contains Claude's conversational summary (e.g., "I've created the spec...") - // which is NOT what we want to save to app_spec.txt - // See: https://github.com/AutoMaker-Org/automaker/issues/149 + // Check for structured output - this is the reliable way to get spec data + const resultMsg = msg as any; + if (resultMsg.structured_output) { + structuredOutput = resultMsg.structured_output as SpecOutput; + logger.info("✅ Received structured output"); + logger.debug("Structured output:", JSON.stringify(structuredOutput, null, 2)); + } else { + logger.warn("⚠️ No structured output in result, will fall back to text parsing"); + } } else if (msg.type === "result") { - // Handle all result types + // Handle error result types const subtype = (msg as any).subtype; logger.info(`Result message: subtype=${subtype}`); if (subtype === "error_max_turns") { - logger.error( - "❌ Hit max turns limit! Claude used too many tool calls." - ); - logger.info(`responseText so far: ${responseText.length} chars`); + logger.error("❌ Hit max turns limit!"); + } else if (subtype === "error_max_structured_output_retries") { + logger.error("❌ Failed to produce valid structured output after retries"); + throw new Error("Could not produce valid spec output"); } } else if ((msg as { type: string }).type === "error") { logger.error("❌ Received error message from stream:"); @@ -203,32 +186,45 @@ ${getAppSpecFormatInstruction()}`; logger.info(`Stream iteration complete. Total messages: ${messageCount}`); logger.info(`Response text length: ${responseText.length} chars`); - logger.info("========== FINAL RESPONSE TEXT =========="); - logger.info(responseText || "(empty)"); - logger.info("========== END RESPONSE TEXT =========="); - if (!responseText || responseText.trim().length === 0) { - logger.error("❌ WARNING: responseText is empty! Nothing to save."); - } + // Determine XML content to save + let xmlContent: string; - // Extract XML content from response - Claude might include conversational text before/after - // See: https://github.com/AutoMaker-Org/automaker/issues/149 - let xmlContent = responseText; - const xmlStart = responseText.indexOf(""); - const xmlEnd = responseText.lastIndexOf(""); - - if (xmlStart !== -1 && xmlEnd !== -1) { - // Extract just the XML content - xmlContent = responseText.substring(xmlStart, xmlEnd + "".length); - logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`); - } else if (xmlStart === -1) { - logger.warn("⚠️ Response does not contain tag - saving raw response"); + if (structuredOutput) { + // Use structured output - convert JSON to XML + logger.info("✅ Using structured output for XML generation"); + xmlContent = specToXml(structuredOutput); + logger.info(`Generated XML from structured output: ${xmlContent.length} chars`); } else { - logger.warn("⚠️ Response has incomplete XML (missing closing tag) - saving raw response"); + // Fallback: Extract XML content from response text + // Claude might include conversational text before/after + // See: https://github.com/AutoMaker-Org/automaker/issues/149 + logger.warn("⚠️ No structured output, falling back to text parsing"); + logger.info("========== FINAL RESPONSE TEXT =========="); + logger.info(responseText || "(empty)"); + logger.info("========== END RESPONSE TEXT =========="); + + if (!responseText || responseText.trim().length === 0) { + throw new Error("No response text and no structured output - cannot generate spec"); + } + + xmlContent = responseText; + const xmlStart = responseText.indexOf(""); + const xmlEnd = responseText.lastIndexOf(""); + + if (xmlStart !== -1 && xmlEnd !== -1) { + // Extract just the XML content + xmlContent = responseText.substring(xmlStart, xmlEnd + "".length); + logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`); + } else if (xmlStart === -1) { + logger.warn("⚠️ Response does not contain tag - saving raw response"); + } else { + logger.warn("⚠️ Response has incomplete XML (missing closing tag) - saving raw response"); + } } // Save spec to .automaker directory - const specDir = await ensureAutomakerDir(projectPath); + await ensureAutomakerDir(projectPath); const specPath = getAppSpecPath(projectPath); logger.info("Saving spec to:", specPath);