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);