MAESTRO: Add iterate mode document merging to inline wizard

- Created wizard-inline-iterate-generation.md prompt for generating/updating
  documents in iterate mode with existing document context
- Updated parseGeneratedDocuments() to detect UPDATE: true marker that signals
  when documents should update existing files vs create new ones
- Modified generateDocumentPrompt() to use iterate-specific prompt when mode
  is 'iterate', including existing docs content and user's goal
- Added isUpdate field to ParsedDocument interface for tracking update intent
- Updated saveDocument() to log whether creating or updating files
- Added 24 comprehensive tests for document parsing and iterate mode features

The iterate mode now supports:
- Creating new phase files (Phase-03-NewFeature.md) for new work
- Updating existing phase files when marked with UPDATE: true
- Mixed operations (some updates, some new) in a single generation
This commit is contained in:
Pedram Amini
2025-12-28 07:53:59 -06:00
parent 53988f3498
commit 393c978a18
4 changed files with 536 additions and 12 deletions

View File

@@ -0,0 +1,360 @@
/**
* Tests for inlineWizardDocumentGeneration.ts
*
* These tests verify the document parsing and iterate mode functionality.
*/
import { describe, it, expect } from 'vitest';
import {
parseGeneratedDocuments,
splitIntoPhases,
sanitizeFilename,
countTasks,
} from '../../../renderer/services/inlineWizardDocumentGeneration';
describe('inlineWizardDocumentGeneration', () => {
describe('parseGeneratedDocuments', () => {
it('should parse documents with standard markers', () => {
const output = `
---BEGIN DOCUMENT---
FILENAME: Phase-01-Setup.md
CONTENT:
# Phase 01: Setup
## Tasks
- [ ] Install dependencies
- [ ] Configure project
---END DOCUMENT---
`;
const docs = parseGeneratedDocuments(output);
expect(docs).toHaveLength(1);
expect(docs[0].filename).toBe('Phase-01-Setup.md');
expect(docs[0].phase).toBe(1);
expect(docs[0].isUpdate).toBe(false);
expect(docs[0].content).toContain('# Phase 01: Setup');
expect(docs[0].content).toContain('- [ ] Install dependencies');
});
it('should parse multiple documents', () => {
const output = `
---BEGIN DOCUMENT---
FILENAME: Phase-01-Setup.md
CONTENT:
# Phase 01: Setup
- [ ] Task 1
---END DOCUMENT---
---BEGIN DOCUMENT---
FILENAME: Phase-02-Build.md
CONTENT:
# Phase 02: Build
- [ ] Task 2
---END DOCUMENT---
`;
const docs = parseGeneratedDocuments(output);
expect(docs).toHaveLength(2);
expect(docs[0].filename).toBe('Phase-01-Setup.md');
expect(docs[0].phase).toBe(1);
expect(docs[1].filename).toBe('Phase-02-Build.md');
expect(docs[1].phase).toBe(2);
});
it('should detect UPDATE marker for iterate mode', () => {
const output = `
---BEGIN DOCUMENT---
FILENAME: Phase-01-Setup.md
UPDATE: true
CONTENT:
# Phase 01: Setup (Updated)
## Tasks
- [ ] Updated task 1
- [ ] New task added
---END DOCUMENT---
`;
const docs = parseGeneratedDocuments(output);
expect(docs).toHaveLength(1);
expect(docs[0].filename).toBe('Phase-01-Setup.md');
expect(docs[0].isUpdate).toBe(true);
expect(docs[0].content).toContain('(Updated)');
});
it('should handle UPDATE: false explicitly', () => {
const output = `
---BEGIN DOCUMENT---
FILENAME: Phase-03-NewFeature.md
UPDATE: false
CONTENT:
# Phase 03: New Feature
- [ ] New task
---END DOCUMENT---
`;
const docs = parseGeneratedDocuments(output);
expect(docs).toHaveLength(1);
expect(docs[0].isUpdate).toBe(false);
});
it('should handle mixed update and new documents', () => {
const output = `
---BEGIN DOCUMENT---
FILENAME: Phase-01-Setup.md
UPDATE: true
CONTENT:
# Phase 01: Setup (Updated)
- [ ] Updated task
---END DOCUMENT---
---BEGIN DOCUMENT---
FILENAME: Phase-03-NewFeature.md
CONTENT:
# Phase 03: New Feature
- [ ] New feature task
---END DOCUMENT---
`;
const docs = parseGeneratedDocuments(output);
expect(docs).toHaveLength(2);
expect(docs[0].filename).toBe('Phase-01-Setup.md');
expect(docs[0].isUpdate).toBe(true);
expect(docs[0].phase).toBe(1);
expect(docs[1].filename).toBe('Phase-03-NewFeature.md');
expect(docs[1].isUpdate).toBe(false);
expect(docs[1].phase).toBe(3);
});
it('should sort documents by phase number', () => {
const output = `
---BEGIN DOCUMENT---
FILENAME: Phase-03-Deploy.md
CONTENT:
# Phase 03
- [ ] Task
---END DOCUMENT---
---BEGIN DOCUMENT---
FILENAME: Phase-01-Setup.md
CONTENT:
# Phase 01
- [ ] Task
---END DOCUMENT---
---BEGIN DOCUMENT---
FILENAME: Phase-02-Build.md
CONTENT:
# Phase 02
- [ ] Task
---END DOCUMENT---
`;
const docs = parseGeneratedDocuments(output);
expect(docs).toHaveLength(3);
expect(docs[0].phase).toBe(1);
expect(docs[1].phase).toBe(2);
expect(docs[2].phase).toBe(3);
});
it('should handle documents without phase numbers in filename', () => {
const output = `
---BEGIN DOCUMENT---
FILENAME: README.md
CONTENT:
# Project README
Some content here.
---END DOCUMENT---
`;
const docs = parseGeneratedDocuments(output);
expect(docs).toHaveLength(1);
expect(docs[0].filename).toBe('README.md');
expect(docs[0].phase).toBe(0);
expect(docs[0].isUpdate).toBe(false);
});
it('should handle empty output', () => {
const docs = parseGeneratedDocuments('');
expect(docs).toHaveLength(0);
});
it('should handle output without document markers', () => {
const output = 'Just some random text without markers';
const docs = parseGeneratedDocuments(output);
expect(docs).toHaveLength(0);
});
it('should handle UPDATE marker case-insensitively', () => {
const output = `
---BEGIN DOCUMENT---
FILENAME: Phase-01-Setup.md
UPDATE: TRUE
CONTENT:
# Phase 01
- [ ] Task
---END DOCUMENT---
`;
const docs = parseGeneratedDocuments(output);
expect(docs).toHaveLength(1);
expect(docs[0].isUpdate).toBe(true);
});
});
describe('splitIntoPhases', () => {
it('should split content with phase headers', () => {
const content = `
# Phase 1: Setup
- [ ] Task 1
# Phase 2: Build
- [ ] Task 2
`;
const docs = splitIntoPhases(content);
expect(docs).toHaveLength(2);
expect(docs[0].phase).toBe(1);
expect(docs[1].phase).toBe(2);
expect(docs[0].isUpdate).toBe(false);
expect(docs[1].isUpdate).toBe(false);
});
it('should treat content without phases as Phase 1', () => {
const content = `
# Some Document
- [ ] Task 1
- [ ] Task 2
`;
const docs = splitIntoPhases(content);
expect(docs).toHaveLength(1);
expect(docs[0].filename).toBe('Phase-01-Initial-Setup.md');
expect(docs[0].phase).toBe(1);
expect(docs[0].isUpdate).toBe(false);
});
it('should handle empty content', () => {
const docs = splitIntoPhases('');
expect(docs).toHaveLength(0);
});
it('should extract description from phase header', () => {
const content = `
# Phase 1: Project Configuration
- [ ] Configure project
# Phase 2: Core Implementation
- [ ] Implement core
`;
const docs = splitIntoPhases(content);
expect(docs).toHaveLength(2);
expect(docs[0].filename).toContain('Phase-01');
expect(docs[0].filename).toContain('Project-Configuration');
expect(docs[1].filename).toContain('Phase-02');
expect(docs[1].filename).toContain('Core-Implementation');
});
});
describe('sanitizeFilename', () => {
it('should remove path separators', () => {
expect(sanitizeFilename('path/to/file.md')).toBe('path-to-file.md');
expect(sanitizeFilename('path\\to\\file.md')).toBe('path-to-file.md');
});
it('should remove directory traversal sequences', () => {
// Path separators become dashes, .. is removed, leading dots are stripped
expect(sanitizeFilename('../../../etc/passwd')).toBe('---etc-passwd');
expect(sanitizeFilename('..file.md')).toBe('file.md');
});
it('should remove leading dots', () => {
expect(sanitizeFilename('.hidden')).toBe('hidden');
expect(sanitizeFilename('...file')).toBe('file');
});
it('should return "document" for empty result', () => {
expect(sanitizeFilename('')).toBe('document');
expect(sanitizeFilename('...')).toBe('document');
// Forward slash becomes dash
expect(sanitizeFilename('/')).toBe('-');
});
it('should trim whitespace', () => {
expect(sanitizeFilename(' file.md ')).toBe('file.md');
});
});
describe('countTasks', () => {
it('should count unchecked tasks', () => {
const content = `
# Tasks
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3
`;
expect(countTasks(content)).toBe(3);
});
it('should count checked tasks', () => {
const content = `
# Tasks
- [x] Done task 1
- [X] Done task 2
`;
expect(countTasks(content)).toBe(2);
});
it('should count mixed tasks', () => {
const content = `
# Tasks
- [ ] Todo 1
- [x] Done 1
- [ ] Todo 2
- [X] Done 2
`;
expect(countTasks(content)).toBe(4);
});
it('should return 0 for content without tasks', () => {
const content = '# Just a heading\n\nSome text.';
expect(countTasks(content)).toBe(0);
});
it('should handle empty content', () => {
expect(countTasks('')).toBe(0);
});
});
});

View File

@@ -17,6 +17,7 @@ export {
wizardInlineSystemPrompt,
wizardInlineIteratePrompt,
wizardInlineNewPrompt,
wizardInlineIterateGenerationPrompt,
// AutoRun
autorunDefaultPrompt,

View File

@@ -0,0 +1,88 @@
You are an expert project planner creating actionable task documents for "{{PROJECT_NAME}}".
## Your Task
Based on the project discovery conversation below, create or update Auto Run documents. The user has existing documents and wants to extend or modify their plans.
## Working Directory
All files will be created or updated in: {{DIRECTORY_PATH}}
The documents folder: {{DIRECTORY_PATH}}/{{AUTO_RUN_FOLDER_NAME}}/
## Existing Documents
The following Auto Run documents already exist:
{{EXISTING_DOCS}}
## User's Goal
{{ITERATE_GOAL}}
## Iterate Mode Guidelines
You can either:
1. **Create new phase files** (e.g., Phase-03-NewFeature.md) when adding entirely new work
2. **Update existing files** when modifying or extending current phases
When deciding:
- Add a NEW phase if the work is independent and follows existing phases
- UPDATE an existing phase if the work extends or modifies that phase's scope
- You can do BOTH: update an existing phase AND create new phases
## Document Format
Each Auto Run document MUST follow this exact format:
```markdown
# Phase XX: [Brief Title]
[One paragraph describing what this phase accomplishes and why it matters]
## Tasks
- [ ] First specific task to complete
- [ ] Second specific task to complete
- [ ] Continue with more tasks...
```
## Task Writing Guidelines
Each task should be:
- **Specific**: Not "set up the project" but "Create package.json with required dependencies"
- **Actionable**: Clear what needs to be done
- **Verifiable**: You can tell when it's complete
- **Autonomous**: Can be done without asking the user questions
## Output Format
For NEW documents, use this format:
---BEGIN DOCUMENT---
FILENAME: Phase-03-[Description].md
CONTENT:
[Full markdown content here]
---END DOCUMENT---
For UPDATED documents, use this format with the exact existing filename:
---BEGIN DOCUMENT---
FILENAME: Phase-01-[ExactExistingName].md
UPDATE: true
CONTENT:
[Complete updated markdown content - include the full document, not just changes]
---END DOCUMENT---
**IMPORTANT**:
- When updating, provide the COMPLETE updated document content, not just the additions
- Use the exact filename of the existing document you're updating
- Write markdown content directly - do NOT wrap it in code fences
- New phases should use the next available phase number
## Project Discovery Conversation
{{CONVERSATION_SUMMARY}}
## Now Generate the Documents
Based on the conversation above and the existing documents, create new phases and/or update existing phases as appropriate for the user's goal.

View File

@@ -12,7 +12,10 @@
import type { ToolType } from '../types';
import type { InlineWizardMessage, InlineGeneratedDocument } from '../hooks/useInlineWizard';
import type { ExistingDocument } from '../utils/existingDocsDetector';
import { wizardDocumentGenerationPrompt } from '../../prompts';
import {
wizardDocumentGenerationPrompt,
wizardInlineIterateGenerationPrompt,
} from '../../prompts';
import { substituteTemplateVariables, type TemplateContext } from '../utils/templateVariables';
/**
@@ -88,6 +91,8 @@ interface ParsedDocument {
filename: string;
content: string;
phase: number;
/** Whether this document updates an existing file (vs creating new) */
isUpdate: boolean;
}
/**
@@ -121,14 +126,36 @@ export function countTasks(content: string): number {
return matches ? matches.length : 0;
}
/**
* Format existing documents for inclusion in the iterate prompt.
*
* @param docs - Array of existing documents with content
* @returns Formatted string for the prompt
*/
function formatExistingDocsForPrompt(docs: ExistingDocument[]): string {
if (!docs || docs.length === 0) {
return '(No existing documents found)';
}
return docs
.map((doc, index) => {
const content = (doc as ExistingDocument & { content?: string }).content || '(Content not loaded)';
return `### ${index + 1}. ${doc.filename}\n\n${content}`;
})
.join('\n\n---\n\n');
}
/**
* Generate the document generation prompt.
*
* Uses the iterate-specific prompt when in iterate mode, which includes
* existing documents and the user's goal for extending/modifying plans.
*
* @param config Configuration for generation
* @returns The complete prompt for the agent
*/
function generateDocumentPrompt(config: DocumentGenerationConfig): string {
const { projectName, directoryPath, conversationHistory } = config;
const { projectName, directoryPath, conversationHistory, mode, goal, existingDocuments } = config;
const projectDisplay = projectName || 'this project';
// Build conversation summary from the wizard conversation
@@ -140,13 +167,28 @@ function generateDocumentPrompt(config: DocumentGenerationConfig): string {
})
.join('\n\n');
// Choose the appropriate prompt template based on mode
const basePrompt = mode === 'iterate'
? wizardInlineIterateGenerationPrompt
: wizardDocumentGenerationPrompt;
// Handle wizard-specific template variables
let prompt = wizardDocumentGenerationPrompt
let prompt = basePrompt
.replace(/\{\{PROJECT_NAME\}\}/gi, projectDisplay)
.replace(/\{\{DIRECTORY_PATH\}\}/gi, directoryPath)
.replace(/\{\{AUTO_RUN_FOLDER_NAME\}\}/gi, AUTO_RUN_FOLDER_NAME)
.replace(/\{\{CONVERSATION_SUMMARY\}\}/gi, conversationSummary);
// Handle iterate-mode specific placeholders
if (mode === 'iterate') {
const existingDocsText = formatExistingDocsForPrompt(existingDocuments || []);
const iterateGoal = goal || '(No specific goal provided)';
prompt = prompt
.replace(/\{\{EXISTING_DOCS\}\}/gi, existingDocsText)
.replace(/\{\{ITERATE_GOAL\}\}/gi, iterateGoal);
}
// Build template context for remaining variables
const templateContext: TemplateContext = {
session: {
@@ -170,20 +212,38 @@ function generateDocumentPrompt(config: DocumentGenerationConfig): string {
* Looks for document blocks with markers:
* ---BEGIN DOCUMENT---
* FILENAME: Phase-01-Setup.md
* UPDATE: true (optional - indicates this updates an existing file)
* CONTENT:
* [markdown content]
* ---END DOCUMENT---
*
* When UPDATE: true is present, the document will overwrite an existing file.
* Otherwise, it creates a new file.
*/
export function parseGeneratedDocuments(output: string): ParsedDocument[] {
const documents: ParsedDocument[] = [];
// Pattern to match document blocks
const docPattern = /---BEGIN DOCUMENT---\s*\nFILENAME:\s*(.+?)\s*\nCONTENT:\s*\n([\s\S]*?)(?=---END DOCUMENT---|$)/g;
// Split by document markers and process each block
const blocks = output.split(/---BEGIN DOCUMENT---/);
let match;
while ((match = docPattern.exec(output)) !== null) {
const filename = match[1].trim();
let content = match[2].trim();
for (const block of blocks) {
if (!block.trim()) continue;
// Extract filename
const filenameMatch = block.match(/FILENAME:\s*(.+?)(?:\n|$)/);
if (!filenameMatch) continue;
const filename = filenameMatch[1].trim();
// Check for UPDATE marker (optional)
const updateMatch = block.match(/UPDATE:\s*(true|false)/i);
const isUpdate = updateMatch ? updateMatch[1].toLowerCase() === 'true' : false;
// Extract content - everything after "CONTENT:" line
const contentMatch = block.match(/CONTENT:\s*\n([\s\S]*?)(?=---END DOCUMENT---|$)/);
if (!contentMatch) continue;
let content = contentMatch[1].trim();
// Remove any trailing ---END DOCUMENT--- marker from content
content = content.replace(/---END DOCUMENT---\s*$/, '').trim();
@@ -197,6 +257,7 @@ export function parseGeneratedDocuments(output: string): ParsedDocument[] {
filename,
content,
phase,
isUpdate,
});
}
}
@@ -212,6 +273,9 @@ export function parseGeneratedDocuments(output: string): ParsedDocument[] {
*
* If the agent generates one large document instead of multiple phases,
* this function attempts to split it intelligently.
*
* Note: Documents created by splitting are always treated as new (isUpdate: false)
* since we can't determine intent from raw content.
*/
export function splitIntoPhases(content: string): ParsedDocument[] {
const documents: ParsedDocument[] = [];
@@ -239,6 +303,7 @@ export function splitIntoPhases(content: string): ParsedDocument[] {
filename: `Phase-${String(phaseNumber).padStart(2, '0')}-${description}.md`,
content: fullContent,
phase: phaseNumber,
isUpdate: false,
});
phaseNumber++;
@@ -250,6 +315,7 @@ export function splitIntoPhases(content: string): ParsedDocument[] {
filename: 'Phase-01-Initial-Setup.md',
content: content.trim(),
phase: 1,
isUpdate: false,
});
}
@@ -355,6 +421,10 @@ function buildArgsForAgent(agent: { id: string; args?: string[] }): string[] {
/**
* Save a single document to the Auto Run folder.
*
* Handles both creating new files and updating existing ones.
* The isUpdate flag is used for logging purposes - both operations
* use writeDoc which will create or overwrite as needed.
*
* @param autoRunFolderPath - The Auto Run folder path
* @param doc - The parsed document to save
* @returns The saved document with path information
@@ -368,9 +438,10 @@ async function saveDocument(
// Ensure filename has .md extension
const filename = sanitized.endsWith('.md') ? sanitized : `${sanitized}.md`;
console.log('[InlineWizardDocGen] Saving document:', filename);
const action = doc.isUpdate ? 'Updating' : 'Creating';
console.log(`[InlineWizardDocGen] ${action} document:`, filename);
// Write the document
// Write the document (creates or overwrites as needed)
const result = await window.maestro.autorun.writeDoc(
autoRunFolderPath,
filename,
@@ -378,7 +449,7 @@ async function saveDocument(
);
if (!result.success) {
throw new Error(result.error || `Failed to save ${filename}`);
throw new Error(result.error || `Failed to ${action.toLowerCase()} ${filename}`);
}
const fullPath = `${autoRunFolderPath}/${filename}`;
@@ -600,6 +671,9 @@ export async function generateInlineDocuments(
*
* This is a fallback for when the agent writes files directly
* instead of outputting them with markers.
*
* Note: Documents read from disk are treated as new (isUpdate: false)
* since they were written directly by the agent.
*/
async function readDocumentsFromDisk(autoRunFolderPath: string): Promise<ParsedDocument[]> {
const documents: ParsedDocument[] = [];
@@ -626,6 +700,7 @@ async function readDocumentsFromDisk(autoRunFolderPath: string): Promise<ParsedD
filename,
content: readResult.content,
phase,
isUpdate: false,
});
}
}