From a0e1d0f86784b153ee2ea2e0308166ab034202c4 Mon Sep 17 00:00:00 2001 From: GoyalIshaan Date: Sun, 23 Nov 2025 16:27:28 -0600 Subject: [PATCH 1/2] working plan perf --- .../contrib/chat/browser/gemini/agent.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/gemini/agent.ts b/src/vs/workbench/contrib/chat/browser/gemini/agent.ts index a801c9703d2..562967c5e80 100644 --- a/src/vs/workbench/contrib/chat/browser/gemini/agent.ts +++ b/src/vs/workbench/contrib/chat/browser/gemini/agent.ts @@ -821,10 +821,15 @@ Only call a tool if it is necessary; otherwise respond normally.`, (id) => selected[id] === true ); if (!allowedIds.length) { + // Empty selection - fall back to default behavior this.logService.debug( - `[gemini] tool selection for request ${requestId} contained no enabled entries` + `[gemini] tool selection for request ${requestId} contained no enabled entries, falling back to default` ); - return []; + const allTools = Array.from(this.languageModelToolsService.getTools()); + if (chatMode === ChatModeKind.Plan) { + return allTools.filter((tool) => tool.id !== "create_file"); + } + return allTools; } const allowedSet = new Set(allowedIds); const allowedTools: IToolData[] = []; @@ -833,6 +838,22 @@ Only call a tool if it is necessary; otherwise respond normally.`, allowedTools.push(tool); } } + + // CRITICAL FIX: If filtering resulted in zero tools, fall back to default + // This prevents empty tool arrays from being sent to Gemini, which would prevent tool calls + if (allowedTools.length === 0) { + this.logService.warn( + `[gemini] Tool filtering for request ${requestId} resulted in zero tools (selected IDs: ${allowedIds.join( + ", " + )}), falling back to default behavior` + ); + const allTools = Array.from(this.languageModelToolsService.getTools()); + if (chatMode === ChatModeKind.Plan) { + return allTools.filter((tool) => tool.id !== "create_file"); + } + return allTools; + } + this.logService.debug( `[gemini] resolved ${ allowedTools.length @@ -853,6 +874,11 @@ Only call a tool if it is necessary; otherwise respond normally.`, } { const allowedTools = this.getAllowedToolData(requestId, chatMode); if (!allowedTools.length) { + this.logService.warn( + `[gemini] buildGeminiToolDeclarations: No tools available for request ${requestId} in mode ${ + chatMode || "unknown" + }. This will prevent tool calls.` + ); return { tools: [], nameToToolId: new Map(), summaries: [] }; } From 664c3d7d1c7648156747f2845bd0cb768dfa6b57 Mon Sep 17 00:00:00 2001 From: GoyalIshaan Date: Wed, 10 Dec 2025 15:19:12 -0600 Subject: [PATCH 2/2] merge this shi --- .../preview-src/index.ts | 39 ++ .../src/preview/documentRenderer.ts | 214 +++++++++- .../src/preview/preview.ts | 11 +- .../types/previewMessaging.d.ts | 9 + .../contrib/chat/browser/chat.contribution.ts | 2 + .../chat/browser/planFilePreviewHandler.ts | 380 +++++++++++++++++- .../chat/common/planExecutionTracker.ts | 96 +++++ .../contrib/chat/common/planValidator.ts | 223 ++++++++++ .../chat/common/tools/planTemplates.ts | 334 +++++++++++++++ 9 files changed, 1271 insertions(+), 37 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/planExecutionTracker.ts create mode 100644 src/vs/workbench/contrib/chat/common/planValidator.ts create mode 100644 src/vs/workbench/contrib/chat/common/tools/planTemplates.ts diff --git a/extensions/markdown-language-features/preview-src/index.ts b/extensions/markdown-language-features/preview-src/index.ts index b6200b8ceb9..dbd4adfb24c 100644 --- a/extensions/markdown-language-features/preview-src/index.ts +++ b/extensions/markdown-language-features/preview-src/index.ts @@ -22,6 +22,8 @@ let documentVersion = 0; let documentResource = settings.settings.source; const vscode = acquireVsCodeApi(); +// Expose for injected scripts (like plan execution button) +(window as any).vscodeApi = vscode; // eslint-disable-next-line local/code-no-any-casts const originalState = vscode.getState() ?? {} as any; @@ -221,6 +223,43 @@ window.addEventListener('message', async event => { } return; + case 'updatePlanProgress': { + if (data.source === documentResource) { + const progressText = document.getElementById('plan-progress-text'); + const progressBar = document.getElementById('plan-progress-bar'); + const todoStats = document.getElementById('plan-todo-stats'); + + if (progressText) { + progressText.textContent = `${data.progress}%`; + } + if (progressBar) { + progressBar.style.width = `${data.progress}%`; + } + if (todoStats) { + todoStats.textContent = `${data.completedTodos}/${data.totalTodos} todos`; + } + + // Update execution status if provided + if (data.status) { + const button = document.getElementById('start-execution-btn'); + if (button) { + const statusLabels: Record = { + 'not-started': '▶ Start Execution', + 'starting': '⏳ Starting...', + 'in-progress': '⏸ Pause Execution', + 'completed': '✓ Completed', + 'failed': '✗ Failed' + }; + button.textContent = statusLabels[data.status] || '▶ Start Execution'; + if (data.status === 'completed' || data.status === 'failed') { + button.disabled = true; + } + } + } + } + return; + } + case 'updateContent': { const root = document.querySelector('.markdown-body')!; diff --git a/extensions/markdown-language-features/src/preview/documentRenderer.ts b/extensions/markdown-language-features/src/preview/documentRenderer.ts index 1c8818b5e37..b225c75c90a 100644 --- a/extensions/markdown-language-features/src/preview/documentRenderer.ts +++ b/extensions/markdown-language-features/src/preview/documentRenderer.ts @@ -121,40 +121,220 @@ export class MdDocumentRenderer { ): Promise { const rendered = await this._engine.render(markdownDocument, resourceProvider); - // Inject "Start Execution" button for .plan.md files + // Inject enhanced plan preview for .plan.md files let planFileHeader = ''; + let planProgressScript = ''; if (markdownDocument.uri.fsPath.endsWith('.plan.md')) { const sourceUri = markdownDocument.uri.toString(); const scriptNonce = nonce || getNonce(); + const planContent = markdownDocument.getText(); + const planStats = this.parsePlanStats(planContent); + + // Extract todos for display + const todos = this.extractTodosForDisplay(planContent); + const incompleteTodos = todos.filter(t => !t.completed); + planFileHeader = `
- - -
+ }); + + // Add section progress indicators + const sections = document.querySelectorAll('h2'); + sections.forEach(section => { + const sectionId = section.id || section.textContent?.toLowerCase().replace(/\\s+/g, '-') || ''; + const sectionContent = section.nextElementSibling; + if (sectionContent) { + const todos = sectionContent.querySelectorAll('.plan-todo-item'); + if (todos.length > 0) { + const completed = Array.from(todos).filter(t => t.style.textDecoration === 'line-through').length; + const total = todos.length; + const progress = total > 0 ? Math.round((completed / total) * 100) : 0; + + const progressBadge = document.createElement('span'); + progressBadge.style.cssText = 'margin-left: 8px; font-size: 11px; color: var(--vscode-descriptionForeground);'; + progressBadge.textContent = \`[\${completed}/\${total}]\`; + section.appendChild(progressBadge); + } + } + }); + + // Listen for progress updates + window.addEventListener('message', (event) => { + const data = event.data; + if (data && data.type === 'updatePlanProgress' && data.source === '${sourceUri}') { + const progressText = document.getElementById('plan-progress-text'); + const progressBar = document.getElementById('plan-progress-bar'); + const todoStats = document.getElementById('plan-todo-stats'); + const statusBadge = document.getElementById('execution-status-badge'); + const statusText = document.getElementById('execution-status-text'); + const button = document.getElementById('start-execution-btn'); + + if (progressText) progressText.textContent = data.progress + '%'; + if (progressBar) progressBar.style.width = data.progress + '%'; + if (todoStats) todoStats.textContent = data.completedTodos + '/' + data.totalTodos + ' todos'; + + if (data.status && statusBadge && statusText) { + const statusLabels = { + 'not-started': 'Not Started', + 'starting': 'Starting...', + 'in-progress': 'Executing', + 'completed': 'Completed', + 'failed': 'Failed' + }; + statusText.textContent = statusLabels[data.status] || 'Unknown'; + statusBadge.style.display = 'block'; + + if (button) { + const buttonLabels = { + 'not-started': '▶ Start Execution', + 'starting': '⏳ Starting...', + 'in-progress': '⏸ Pause Execution', + 'completed': '✓ Completed', + 'failed': '✗ Failed' + }; + button.textContent = buttonLabels[data.status] || '▶ Start Execution'; + if (data.status === 'completed' || data.status === 'failed') { + button.disabled = true; + } + } + } + } + }); + })(); + `; } - const html = `${planFileHeader}
${rendered.html}
`; + const html = `${planFileHeader}
${rendered.html}
${planProgressScript}`; return { html, containingImages: rendered.containingImages }; } + + /** + * Parse plan statistics from markdown content + */ + private parsePlanStats(content: string): { totalTodos: number; completedTodos: number; progress: number } { + const todos = this.extractTodosForDisplay(content); + const totalTodos = todos.length; + const completedTodos = todos.filter(t => t.completed).length; + const progress = totalTodos > 0 ? Math.round((completedTodos / totalTodos) * 100) : 0; + + return { totalTodos, completedTodos, progress }; + } + + /** + * Extract todos from plan content for display + */ + private extractTodosForDisplay(content: string): Array<{ text: string; completed: boolean }> { + const todos: Array<{ text: string; completed: boolean }> = []; + const todoPatterns = [ + /^\s*[-*]\s*\[([\sx])\]\s*(.+)$/gim, // Standard: - [ ] or - [x] + /^\s*\d+\.\s*\[([\sx])\]\s*(.+)$/gim, // Numbered: 1. [ ] or 1. [x] + ]; + + for (const pattern of todoPatterns) { + pattern.lastIndex = 0; + let match; + while ((match = pattern.exec(content)) !== null) { + todos.push({ + text: match[2].trim(), + completed: match[1].toLowerCase() === 'x' + }); + } + } + + return todos; + } + + /** + * Escape HTML to prevent XSS + */ + private escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } public renderFileNotFoundDocument(resource: vscode.Uri): string { const resourcePath = uri.Utils.basename(resource); diff --git a/extensions/markdown-language-features/src/preview/preview.ts b/extensions/markdown-language-features/src/preview/preview.ts index 30fe561aa3b..701374f229d 100644 --- a/extensions/markdown-language-features/src/preview/preview.ts +++ b/extensions/markdown-language-features/src/preview/preview.ts @@ -152,7 +152,16 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { break; case 'startExecution': - vscode.commands.executeCommand('workbench.action.chat.startPlanExecution', e.source); + this._logger.trace('Plan Execution', `Message received in preview.ts, source: ${e.source}`); + Promise.resolve(vscode.commands.executeCommand('workbench.action.chat.startPlanExecution', e.source)) + .then((result) => { + this._logger.trace('Plan Execution', `Command executed successfully, result: ${result}`); + }) + .catch((error: unknown) => { + const errorMessage = error instanceof Error ? error.message : String(error); + this._logger.trace('Plan Execution', `Command execution failed: ${errorMessage}`); + vscode.window.showErrorMessage(`Failed to start plan execution: ${errorMessage}`); + }); break; } })); diff --git a/extensions/markdown-language-features/types/previewMessaging.d.ts b/extensions/markdown-language-features/types/previewMessaging.d.ts index e605478c82f..81f998c327b 100644 --- a/extensions/markdown-language-features/types/previewMessaging.d.ts +++ b/extensions/markdown-language-features/types/previewMessaging.d.ts @@ -83,11 +83,20 @@ export namespace ToWebviewMessage { readonly imageSource: string; } + export interface UpdatePlanProgress extends BaseMessage { + readonly type: 'updatePlanProgress'; + readonly progress: number; + readonly completedTodos: number; + readonly totalTodos: number; + readonly status?: 'not-started' | 'starting' | 'in-progress' | 'completed' | 'failed'; + } + export type Type = | OnDidChangeTextEditorSelection | UpdateView | UpdateContent | CopyImageContent | OpenImageContent + | UpdatePlanProgress ; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 54b8f101fa7..0d40624426e 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -45,6 +45,7 @@ import { ChatService } from '../common/chatServiceImpl.js'; import { IChatSessionsService } from '../common/chatSessionsService.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../common/chatSlashCommands.js'; import { ChatTodoListService, IChatTodoListService } from '../common/chatTodoListService.js'; +import { PlanExecutionTracker, IPlanExecutionTracker } from '../common/planExecutionTracker.js'; import { ChatTransferService, IChatTransferService } from '../common/chatTransferService.js'; import { IChatVariablesService } from '../common/chatVariables.js'; import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/chatWidgetHistoryService.js'; @@ -1079,6 +1080,7 @@ registerSingleton(IChatContextPickService, ChatContextPickService, Instantiation registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.Delayed); +registerSingleton(IPlanExecutionTracker, PlanExecutionTracker, InstantiationType.Delayed); registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/planFilePreviewHandler.ts b/src/vs/workbench/contrib/chat/browser/planFilePreviewHandler.ts index dd6b32ecff1..a739a953a18 100644 --- a/src/vs/workbench/contrib/chat/browser/planFilePreviewHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/planFilePreviewHandler.ts @@ -14,11 +14,19 @@ import { ChatMode } from '../common/chatModes.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { handleModeSwitch } from './actions/chatActions.js'; import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { registerAction2, Action2 } from '../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { localize } from '../../../../nls.js'; +import { PlanValidator } from '../common/planValidator.js'; +import { parsePlanMetadata } from '../common/tools/planTemplates.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { Action } from '../../../../base/common/actions.js'; +import { IChatTodoListService, IChatTodo } from '../common/chatTodoListService.js'; +import { IPlanExecutionTracker } from '../common/planExecutionTracker.js'; /** * Handler for automatically opening .plan.md files in preview mode @@ -29,6 +37,8 @@ export class PlanFilePreviewHandler extends Disposable implements IWorkbenchCont private static _instance: PlanFilePreviewHandler | undefined; private readonly _openedPlanFiles = new Set(); + private readonly planValidator: PlanValidator; + constructor( @IEditorService private readonly editorService: IEditorService, @ICommandService private readonly commandService: ICommandService, @@ -38,9 +48,14 @@ export class PlanFilePreviewHandler extends Disposable implements IWorkbenchCont @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IFileService private readonly fileService: IFileService, + @INotificationService private readonly notificationService: INotificationService, + @IDialogService private readonly dialogService: IDialogService, + @IChatTodoListService private readonly todoListService: IChatTodoListService, + @IPlanExecutionTracker private readonly executionTracker: IPlanExecutionTracker, ) { super(); PlanFilePreviewHandler._instance = this; + this.planValidator = this.instantiationService.createInstance(PlanValidator); // Listen for file opens this._register(this.editorService.onDidActiveEditorChange(() => { @@ -49,6 +64,13 @@ export class PlanFilePreviewHandler extends Disposable implements IWorkbenchCont this.handleFileOpen(activeEditor.resource); } })); + + // Listen to execution state changes to update preview + this._register(this.executionTracker.onDidUpdateExecutionState(({ planUri, state }) => { + // Trigger preview refresh by updating the file (if it's open) + // The preview will automatically refresh when the file changes + this.logService.debug(`[PlanFilePreviewHandler] Execution state updated for ${planUri}: ${state.status}`); + })); } private isPlanFile(uri: URI): boolean { @@ -86,20 +108,135 @@ export class PlanFilePreviewHandler extends Disposable implements IWorkbenchCont this.logService.info(`[PlanFilePreviewHandler] Start execution requested for: ${planFileUri.fsPath}`); try { - // Open chat view and get widget - let widget = await showChatView(this.viewsService, this.layoutService); + // Verify file exists + if (!await this.fileService.exists(planFileUri)) { + this.notificationService.error(localize('planExecution.fileNotFound', 'Plan file not found: {0}', planFileUri.fsPath)); + this.logService.error(`[PlanFilePreviewHandler] Plan file does not exist: ${planFileUri.fsPath}`); + return; + } + + // Read plan file content + let planContent = ''; + try { + const fileContent = await this.fileService.readFile(planFileUri); + planContent = fileContent.value.toString(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.notificationService.error(localize('planExecution.readError', 'Failed to read plan file: {0}', errorMessage)); + this.logService.error(`[PlanFilePreviewHandler] Failed to read plan file: ${error}`); + return; + } + + // Validate plan before execution + const validationResult = await this.planValidator.validatePlan(planContent, planFileUri); + + // Extract todos from plan (enhanced extraction) + const todos = this.extractTodosFromPlan(planContent); + const planMetadata = parsePlanMetadata(planContent); + + this.logService.debug(`[PlanFilePreviewHandler] Extracted ${todos.length} todos from plan`); + + // Show execution preview with validation results + const previewMessage = this.buildExecutionPreview(planMetadata, validationResult, todos); + const severity = validationResult.isValid ? Severity.Info : Severity.Warning; - // If no widget from view, try last focused widget + // Get widget first for use in notification action + let widget = await showChatView(this.viewsService, this.layoutService); if (!widget) { widget = this.chatWidgetService.lastFocusedWidget; } + + // Create actions for notification + const executeAction = new Action('execute-plan', localize('executePlan', 'Execute Plan'), undefined, true, async () => { + await this.executePlanInternal(planFileUri, planContent, planMetadata, validationResult, todos, widget); + }); + + const cancelAction = new Action('cancel-execution', localize('cancel', 'Cancel'), undefined, true, () => { + this.logService.info('[PlanFilePreviewHandler] Plan execution cancelled by user'); + }); + + // Show preview notification + const handle = this.notificationService.notify({ + severity, + message: previewMessage, + actions: { + primary: [executeAction], + secondary: [cancelAction] + }, + sticky: !validationResult.isValid + }); + + // Auto-close after 5 seconds if valid, or wait for user action if invalid + if (validationResult.isValid) { + setTimeout(async () => { + handle.close(); + // Auto-execute if valid + await this.executePlanInternal(planFileUri, planContent, planMetadata, validationResult, todos, widget); + }, 5000); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.notificationService.error(localize('planExecution.error', 'Failed to start plan execution: {0}', errorMessage)); + this.logService.error('[PlanFilePreviewHandler] Failed to start execution', error); + } + } + + /** + * Build execution preview message + */ + private buildExecutionPreview( + planMetadata: ReturnType, + validationResult: Awaited>, + todos: Array<{ id: string; text: string; completed: boolean; section?: string; lineNumber?: number }> + ): string { + const parts: string[] = []; + + if (planMetadata?.title) { + parts.push(`Plan: ${planMetadata.title}`); + } + + parts.push(`Quality Score: ${validationResult.score}/100`); + + if (todos.length > 0) { + const incompleteTodos = todos.filter(t => !t.completed).length; + parts.push(`${incompleteTodos} of ${todos.length} todos remaining`); + } + + if (!validationResult.isValid) { + parts.push(`⚠️ ${validationResult.errors.length} error(s) found`); + } else if (validationResult.warnings.length > 0) { + parts.push(`ℹ️ ${validationResult.warnings.length} warning(s)`); + } + + return parts.join(' • '); + } + + /** + * Internal method to execute the plan + */ + private async executePlanInternal( + planFileUri: URI, + planContent: string, + planMetadata: ReturnType, + validationResult: Awaited>, + todos: Array<{ id: string; text: string; completed: boolean; section?: string; lineNumber?: number }>, + widget: any + ): Promise { + try { + // Get or create widget + if (!widget) { + widget = await showChatView(this.viewsService, this.layoutService); + if (!widget) { + widget = this.chatWidgetService.lastFocusedWidget; + } + } if (!widget) { this.logService.warn('[PlanFilePreviewHandler] Could not get chat widget'); return; } - // Switch to Agent mode + // Switch to Agent mode (preserve conversation if possible) const agentMode = ChatMode.Agent; const currentMode = widget.input.currentModeKind; @@ -115,38 +252,225 @@ export class PlanFilePreviewHandler extends Disposable implements IWorkbenchCont ); if (!chatModeCheck) { + this.notificationService.warn(localize('planExecution.modeSwitchCancelled', 'Mode switch was cancelled')); this.logService.warn('[PlanFilePreviewHandler] Mode switch was cancelled'); return; } widget.input.setChatMode(agentMode.id); + // Only clear session if absolutely necessary (e.g., Edit mode conflicts) + // For Plan → Agent switch, typically should NOT require clearing if (chatModeCheck.needToClearSession) { - await this.commandService.executeCommand('workbench.action.chat.newChat'); + // Show confirmation before clearing session + const confirmed = await this.dialogService.confirm({ + type: 'info', + title: localize('planExecution.clearSessionTitle', 'Start new session?'), + message: localize('planExecution.clearSessionPrompt', 'Switching to Agent mode will clear your current session. Continue?'), + primaryButton: localize('yes', 'Yes'), + cancelButton: localize('no', 'No') + }); + + if (confirmed.confirmed) { + await this.commandService.executeCommand('workbench.action.chat.newChat'); + } else { + this.logService.info('[PlanFilePreviewHandler] User cancelled session clear'); + return; + } } } - // Read plan file content - let planContent = ''; - try { - const fileContent = await this.fileService.readFile(planFileUri); - planContent = fileContent.value.toString(); - } catch (error) { - this.logService.warn(`[PlanFilePreviewHandler] Failed to read plan file: ${error}`); - } - - // Prepare message + // Get session ID + const sessionId = widget.viewModel?.model.sessionId || 'default'; + + // Start execution tracking + const incompleteTodos = todos.filter(t => !t.completed); + this.executionTracker.startExecution(planFileUri.toString(), sessionId, incompleteTodos.length); + + // Prepare enhanced message that instructs agent to create todos using ManageTodoListTool const workspaceRelativePath = this.getWorkspaceRelativePath(planFileUri); - const message = `Please implement the plan in ${workspaceRelativePath || planFileUri.fsPath}.\n\n${planContent ? `Plan content:\n\n${planContent}` : ''}`; + const planTitle = planMetadata?.title || workspaceRelativePath || planFileUri.fsPath; + + let message = `Please implement the plan: ${planTitle}\n\n`; + + if (planMetadata?.status) { + message += `Plan Status: ${planMetadata.status}\n\n`; + } + + if (validationResult.score < 70) { + message += `Note: Plan quality score is ${validationResult.score}/100. ${validationResult.suggestions.slice(0, 2).join(' ')}\n\n`; + } + + message += `Plan file: ${workspaceRelativePath || planFileUri.fsPath}\n\n`; + + // Instruct agent to create todos using ManageTodoListTool (more explicit format) + if (incompleteTodos.length > 0) { + message += `=== REQUIRED: Create Todo List First ===\n\n`; + message += `Before starting implementation, you MUST call the 'todos' tool to create the todo list.\n\n`; + message += `Tool: todos\n`; + message += `Operation: write\n`; + message += `TodoList format:\n`; + message += `[\n`; + + incompleteTodos.forEach((todo, index) => { + message += ` {\n`; + message += ` "id": ${index + 1},\n`; + message += ` "title": "${todo.text.replace(/"/g, '\\"')}",\n`; + message += ` "description": "${(todo.section ? `Section: ${todo.section}` : 'From plan file').replace(/"/g, '\\"')}",\n`; + message += ` "status": "not-started"\n`; + message += ` }${index < incompleteTodos.length - 1 ? ',' : ''}\n`; + }); + + message += `]\n\n`; + message += `After creating the todos using the tool, work through each one systematically:\n`; + message += `1. Mark a todo as "in-progress" when you start working on it\n`; + message += `2. Mark it as "completed" when finished\n`; + message += `3. Reference the plan file for exact implementation details\n\n`; + } + + message += `Plan content:\n\n${planContent}`; + + // Listen to todo updates to sync with execution tracker and plan file + this._register(this.todoListService.onDidUpdateTodos((updatedSessionId) => { + if (updatedSessionId === sessionId) { + const currentTodos = this.todoListService.getTodos(updatedSessionId); + const completedCount = currentTodos.filter(t => t.status === 'completed').length; + this.executionTracker.updateProgress(planFileUri.toString(), completedCount); + + // Sync plan file with todo completion + this.syncPlanFileWithTodos(planFileUri, planContent, todos, currentTodos).catch(error => { + this.logService.warn(`[PlanFilePreviewHandler] Failed to sync plan file: ${error}`); + }); + } + })); // Set input and send + this.logService.info(`[PlanFilePreviewHandler] Setting input message (length: ${message.length} chars)`); widget.setInput(message); await widget.waitForReady(); + this.logService.info(`[PlanFilePreviewHandler] Widget ready, accepting input`); await widget.acceptInput(); + this.logService.info(`[PlanFilePreviewHandler] Input accepted, message sent to agent`); + + // Mark execution as in-progress + this.executionTracker.setExecutionState(planFileUri.toString(), { + status: 'in-progress' + }); + + // Fallback: If agent doesn't create todos within 3 seconds, create them directly + if (incompleteTodos.length > 0) { + setTimeout(async () => { + const currentTodos = this.todoListService.getTodos(sessionId); + if (currentTodos.length === 0) { + this.logService.warn(`[PlanFilePreviewHandler] Agent did not create todos, creating them directly as fallback`); + const chatTodos: IChatTodo[] = incompleteTodos.map((todo, index) => ({ + id: index + 1, + title: todo.text, + description: todo.section ? `Section: ${todo.section}` : undefined, + status: 'not-started' as const + })); + this.todoListService.setTodos(sessionId, chatTodos); + this.logService.info(`[PlanFilePreviewHandler] Created ${chatTodos.length} todos directly as fallback`); + } + }, 3000); + } - this.logService.info('[PlanFilePreviewHandler] Start execution message sent'); + this.logService.info(`[PlanFilePreviewHandler] Plan execution started. Validation: ${validationResult.isValid ? 'passed' : 'failed'} (score: ${validationResult.score}/100), Todos: ${incompleteTodos.length}`); } catch (error) { - this.logService.error('[PlanFilePreviewHandler] Failed to start execution', error); + this.logService.error('[PlanFilePreviewHandler] Failed to execute plan', error); + } + } + + /** + * Extract todos from plan content with enhanced regex patterns + */ + private extractTodosFromPlan(planContent: string): Array<{ id: string; text: string; completed: boolean; section?: string; lineNumber?: number }> { + const todos: Array<{ id: string; text: string; completed: boolean; section?: string; lineNumber?: number }> = []; + + // Enhanced regex patterns to match various markdown todo formats + const todoPatterns = [ + /^\s*[-*]\s*\[([\sx])\]\s*(.+)$/gim, // Standard: - [ ] or - [x] + /^\s*\d+\.\s*\[([\sx])\]\s*(.+)$/gim, // Numbered: 1. [ ] or 1. [x] + ]; + + const lines = planContent.split('\n'); + let currentSection: string | undefined; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; + + // Track current section + const sectionMatch = line.match(/^##\s+(.+)$/); + if (sectionMatch) { + currentSection = sectionMatch[1].trim(); + } + + // Try each pattern + for (const pattern of todoPatterns) { + pattern.lastIndex = 0; // Reset regex + const match = pattern.exec(line); + if (match) { + const todoId = `todo-${lineIndex}-${todos.length}`; + todos.push({ + id: todoId, + text: match[2].trim(), + completed: match[1].toLowerCase() === 'x', + section: currentSection, + lineNumber: lineIndex + 1 + }); + break; // Found a match, move to next line + } + } + } + + return todos; + } + + /** + * Sync plan file markdown with todo completion status + */ + private async syncPlanFileWithTodos( + planFileUri: URI, + originalContent: string, + planTodos: Array<{ id: string; text: string; completed: boolean; lineNumber?: number }>, + chatTodos: IChatTodo[] + ): Promise { + try { + // Create a map of chat todos by title for matching + const chatTodoMap = new Map(); + for (const todo of chatTodos) { + chatTodoMap.set(todo.title, todo); + } + + // Update plan content with completed todos + let updatedContent = originalContent; + const lines = updatedContent.split('\n'); + + for (const planTodo of planTodos) { + const chatTodo = chatTodoMap.get(planTodo.text); + if (chatTodo && chatTodo.status === 'completed' && !planTodo.completed) { + // Find the line and mark it as completed + if (planTodo.lineNumber && planTodo.lineNumber <= lines.length) { + const lineIndex = planTodo.lineNumber - 1; + const line = lines[lineIndex]; + // Replace [ ] with [x] (case-insensitive) + const updatedLine = line.replace(/\[\s\]/gi, '[x]'); + if (updatedLine !== line) { + lines[lineIndex] = updatedLine; + } + } + } + } + + const newContent = lines.join('\n'); + if (newContent !== originalContent) { + // Write updated content back to file + const buffer = VSBuffer.fromString(newContent); + await this.fileService.writeFile(planFileUri, buffer); + this.logService.debug(`[PlanFilePreviewHandler] Updated plan file with completed todos`); + } + } catch (error) { + this.logService.warn(`[PlanFilePreviewHandler] Error syncing plan file: ${error}`); } } @@ -182,16 +506,34 @@ class StartPlanExecutionAction extends Action2 { } async run(accessor: ServicesAccessor, uri?: string): Promise { + const logService = accessor.get(ILogService); + const notificationService = accessor.get(INotificationService); + + logService.info(`[StartPlanExecutionAction] Command called with URI: ${uri}`); + if (!uri) { + logService.error('[StartPlanExecutionAction] No URI provided'); + notificationService.error(localize('planExecution.noUri', 'Plan execution failed: No plan file URI provided')); return; } const handler = PlanFilePreviewHandler.getInstance(); if (!handler) { + logService.error('[StartPlanExecutionAction] PlanFilePreviewHandler instance not found'); + notificationService.error(localize('planExecution.noHandler', 'Plan execution failed: Handler not available. Please try again.')); return; } - await handler.handleStartExecution(URI.parse(uri)); + try { + logService.info(`[StartPlanExecutionAction] Parsing URI: ${uri}`); + const planUri = URI.parse(uri); + logService.info(`[StartPlanExecutionAction] Parsed URI successfully: ${planUri.toString()}`); + await handler.handleStartExecution(planUri); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logService.error('[StartPlanExecutionAction] Failed to parse URI or execute plan', error); + notificationService.error(localize('planExecution.parseError', 'Failed to parse plan URI: {0}', errorMessage)); + } } } diff --git a/src/vs/workbench/contrib/chat/common/planExecutionTracker.ts b/src/vs/workbench/contrib/chat/common/planExecutionTracker.ts new file mode 100644 index 00000000000..42ebc973ddf --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/planExecutionTracker.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export type PlanExecutionStatus = 'not-started' | 'starting' | 'in-progress' | 'completed' | 'failed'; + +export interface PlanExecutionState { + planUri: string; + status: PlanExecutionStatus; + startedAt?: number; + completedAt?: number; + todoCount: number; + completedTodos: number; + chatSessionId?: string; +} + +export const IPlanExecutionTracker = createDecorator('planExecutionTracker'); + +export interface IPlanExecutionTracker { + readonly _serviceBrand: undefined; + readonly onDidUpdateExecutionState: Event<{ planUri: string; state: PlanExecutionState }>; + getExecutionState(planUri: string): PlanExecutionState | undefined; + setExecutionState(planUri: string, state: Partial): void; + startExecution(planUri: string, chatSessionId: string, todoCount: number): void; + updateProgress(planUri: string, completedTodos: number): void; + completeExecution(planUri: string, success: boolean): void; +} + +export class PlanExecutionTracker extends Disposable implements IPlanExecutionTracker { + declare readonly _serviceBrand: undefined; + + private readonly _onDidUpdateExecutionState = this._register(new Emitter<{ planUri: string; state: PlanExecutionState }>()); + readonly onDidUpdateExecutionState: Event<{ planUri: string; state: PlanExecutionState }> = this._onDidUpdateExecutionState.event; + + private readonly executionStates = new Map(); + + getExecutionState(planUri: string): PlanExecutionState | undefined { + return this.executionStates.get(planUri); + } + + setExecutionState(planUri: string, state: Partial): void { + const currentState = this.executionStates.get(planUri); + const newState: PlanExecutionState = { + planUri, + status: state.status || currentState?.status || 'not-started', + startedAt: state.startedAt ?? currentState?.startedAt, + completedAt: state.completedAt ?? currentState?.completedAt, + todoCount: state.todoCount ?? currentState?.todoCount ?? 0, + completedTodos: state.completedTodos ?? currentState?.completedTodos ?? 0, + chatSessionId: state.chatSessionId ?? currentState?.chatSessionId + }; + + this.executionStates.set(planUri, newState); + this._onDidUpdateExecutionState.fire({ planUri, state: newState }); + } + + startExecution(planUri: string, chatSessionId: string, todoCount: number): void { + this.setExecutionState(planUri, { + status: 'starting', + startedAt: Date.now(), + chatSessionId, + todoCount, + completedTodos: 0 + }); + + // Transition to in-progress after a short delay + setTimeout(() => { + this.setExecutionState(planUri, { + status: 'in-progress' + }); + }, 100); + } + + updateProgress(planUri: string, completedTodos: number): void { + const currentState = this.executionStates.get(planUri); + if (currentState) { + this.setExecutionState(planUri, { + completedTodos, + status: 'in-progress' + }); + } + } + + completeExecution(planUri: string, success: boolean): void { + this.setExecutionState(planUri, { + status: success ? 'completed' : 'failed', + completedAt: Date.now() + }); + } +} + diff --git a/src/vs/workbench/contrib/chat/common/planValidator.ts b/src/vs/workbench/contrib/chat/common/planValidator.ts new file mode 100644 index 00000000000..24b0a49cfda --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/planValidator.ts @@ -0,0 +1,223 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IFileService } from '../../../../platform/files/common/files.js'; +import { URI } from '../../../../base/common/uri.js'; +import { parsePlanMetadata } from './tools/planTemplates.js'; + +export interface PlanValidationError { + type: 'missing-section' | 'invalid-format' | 'missing-todos' | 'invalid-metadata' | 'file-reference'; + section?: string; + message: string; + severity: 'error' | 'warning'; +} + +export interface PlanValidationResult { + isValid: boolean; + errors: PlanValidationError[]; + warnings: PlanValidationError[]; + score: number; // 0-100 quality score + suggestions: string[]; +} + +export class PlanValidator { + constructor( + @IFileService private readonly fileService: IFileService + ) { } + + /** + * Validate a plan file + */ + async validatePlan(planContent: string, planUri?: URI): Promise { + const errors: PlanValidationError[] = []; + const warnings: PlanValidationError[] = []; + const suggestions: string[] = []; + + // Check for required sections + const requiredSections = ['Goal', 'Implementation Steps']; + for (const section of requiredSections) { + if (!this.hasSection(planContent, section)) { + errors.push({ + type: 'missing-section', + section, + message: `Missing required section: ${section}`, + severity: 'error' + }); + } + } + + // Check for optional but recommended sections + const recommendedSections = ['Requirements', 'Architecture', 'Testing Strategy', 'Verification']; + for (const section of recommendedSections) { + if (!this.hasSection(planContent, section)) { + warnings.push({ + type: 'missing-section', + section, + message: `Missing recommended section: ${section}`, + severity: 'warning' + }); + suggestions.push(`Consider adding a "${section}" section to make your plan more comprehensive.`); + } + } + + // Check for todos in Implementation Steps + const hasTodos = this.hasTodos(planContent); + if (!hasTodos) { + warnings.push({ + type: 'missing-todos', + message: 'No todos found in Implementation Steps. Consider adding actionable todos.', + severity: 'warning' + }); + suggestions.push('Add todos (checkboxes) to your Implementation Steps to track progress.'); + } + + // Validate metadata + const metadata = parsePlanMetadata(planContent); + if (!metadata) { + warnings.push({ + type: 'invalid-metadata', + message: 'No metadata section found. Consider adding metadata for better plan tracking.', + severity: 'warning' + }); + } else { + if (!metadata.status) { + warnings.push({ + type: 'invalid-metadata', + message: 'Metadata missing status field.', + severity: 'warning' + }); + } + } + + // Check for file references (if planUri is provided) + if (planUri) { + const fileReferences = this.extractFileReferences(planContent); + for (const fileRef of fileReferences) { + const fileExists = await this.checkFileExists(planUri, fileRef); + if (!fileExists) { + warnings.push({ + type: 'file-reference', + message: `Referenced file may not exist: ${fileRef}`, + severity: 'warning' + }); + } + } + } + + // Calculate quality score + const score = this.calculateQualityScore(planContent, errors, warnings); + + return { + isValid: errors.length === 0, + errors, + warnings, + score, + suggestions + }; + } + + /** + * Check if plan has a specific section + */ + private hasSection(content: string, sectionName: string): boolean { + const regex = new RegExp(`^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm'); + return regex.test(content); + } + + /** + * Check if plan has todos + */ + private hasTodos(content: string): boolean { + const todoRegex = /-?\s*\[[\sx]\]/i; + return todoRegex.test(content); + } + + /** + * Extract file references from plan content + */ + private extractFileReferences(content: string): string[] { + const fileRefs: string[] = []; + // Match common file reference patterns + const patterns = [ + /`([^`]+\.(ts|js|tsx|jsx|py|java|cpp|h|hpp|md|json|yaml|yml))`/g, + /\[([^\]]+\.(ts|js|tsx|jsx|py|java|cpp|h|hpp|md|json|yaml|yml))\]/g, + /file:\s*([^\s]+)/gi + ]; + + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(content)) !== null) { + fileRefs.push(match[1]); + } + } + + return [...new Set(fileRefs)]; // Remove duplicates + } + + /** + * Check if a file exists relative to plan file + */ + private async checkFileExists(planUri: URI, filePath: string): Promise { + try { + // Resolve relative to plan file directory + const planDir = planUri.with({ path: planUri.path.substring(0, planUri.path.lastIndexOf('/')) }); + const fileUri = planDir.with({ path: `${planDir.path}/${filePath}` }); + return await this.fileService.exists(fileUri); + } catch { + return false; + } + } + + /** + * Calculate quality score (0-100) + */ + private calculateQualityScore(content: string, errors: PlanValidationError[], warnings: PlanValidationError[]): number { + let score = 100; + + // Deduct points for errors + score -= errors.length * 20; + + // Deduct points for warnings + score -= warnings.length * 5; + + // Bonus for having todos + if (this.hasTodos(content)) { + score += 5; + } + + // Bonus for having metadata + if (parsePlanMetadata(content)) { + score += 5; + } + + // Bonus for having recommended sections + const recommendedSections = ['Requirements', 'Architecture', 'Testing Strategy', 'Verification']; + const hasRecommended = recommendedSections.filter(section => this.hasSection(content, section)).length; + score += hasRecommended * 5; + + return Math.max(0, Math.min(100, score)); + } + + /** + * Get validation summary message + */ + getValidationSummary(result: PlanValidationResult): string { + if (result.isValid && result.warnings.length === 0) { + return `Plan is valid (Quality Score: ${result.score}/100)`; + } + + const parts: string[] = []; + if (!result.isValid) { + parts.push(`${result.errors.length} error(s)`); + } + if (result.warnings.length > 0) { + parts.push(`${result.warnings.length} warning(s)`); + } + parts.push(`Quality Score: ${result.score}/100`); + + return parts.join(', '); + } +} + diff --git a/src/vs/workbench/contrib/chat/common/tools/planTemplates.ts b/src/vs/workbench/contrib/chat/common/tools/planTemplates.ts new file mode 100644 index 00000000000..74a65f5be69 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/planTemplates.ts @@ -0,0 +1,334 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; + +export interface PlanTemplate { + id: string; + name: string; + description: string; + category: 'feature' | 'bugfix' | 'refactor' | 'migration' | 'custom'; + content: string; + variables?: Record; +} + +export interface PlanMetadata { + title: string; + author?: string; + created?: string; + lastUpdated?: string; + status?: 'draft' | 'in-progress' | 'review' | 'approved' | 'executing' | 'completed'; + version?: string; +} + +export const DEFAULT_PLAN_TEMPLATES: PlanTemplate[] = [ + { + id: 'feature-development', + name: localize('planTemplate.feature.name', 'Feature Development'), + description: localize('planTemplate.feature.description', 'Template for developing new features'), + category: 'feature', + content: `# {{title}} + +## Metadata +- **Author**: {{author}} +- **Created**: {{created}} +- **Status**: {{status}} +- **Version**: {{version}} + +## Goal +Brief summary of what is being built. + +## Requirements +- Requirement 1 +- Requirement 2 +- Requirement 3 + +## Architecture +High-level description of the architectural approach. + +### Components +- Component 1 +- Component 2 + +## Implementation Steps + +### Step 1: [Description] +- [ ] Task 1 +- [ ] Task 2 + +### Step 2: [Description] +- [ ] Task 1 +- [ ] Task 2 + +## Testing Strategy +- Unit tests +- Integration tests +- Manual testing + +## Verification +How to verify the implementation is complete and correct. + +## Rollout Plan +- Phase 1: [Description] +- Phase 2: [Description] +`, + variables: { + title: 'Feature Name', + author: 'Developer', + created: new Date().toISOString().split('T')[0], + status: 'draft', + version: '1.0.0' + } + }, + { + id: 'bug-fix', + name: localize('planTemplate.bugfix.name', 'Bug Fix'), + description: localize('planTemplate.bugfix.description', 'Template for fixing bugs'), + category: 'bugfix', + content: `# Bug Fix: {{title}} + +## Metadata +- **Author**: {{author}} +- **Created**: {{created}} +- **Status**: {{status}} +- **Version**: {{version}} + +## Problem Description +Detailed description of the bug. + +## Root Cause Analysis +- Investigation findings +- Root cause identified + +## Solution Approach +Description of the fix approach. + +## Implementation Steps +- [ ] Step 1: [Description] +- [ ] Step 2: [Description] +- [ ] Step 3: [Description] + +## Testing +- [ ] Reproduce the bug +- [ ] Verify fix resolves the issue +- [ ] Regression testing +- [ ] Edge case testing + +## Verification +How to verify the bug is fixed. + +## Related Issues +- Issue #1 +- Issue #2 +`, + variables: { + title: 'Bug Description', + author: 'Developer', + created: new Date().toISOString().split('T')[0], + status: 'draft', + version: '1.0.0' + } + }, + { + id: 'refactoring', + name: localize('planTemplate.refactor.name', 'Refactoring'), + description: localize('planTemplate.refactor.description', 'Template for code refactoring'), + category: 'refactor', + content: `# Refactoring: {{title}} + +## Metadata +- **Author**: {{author}} +- **Created**: {{created}} +- **Status**: {{status}} +- **Version**: {{version}} + +## Goal +What we want to achieve with this refactoring. + +## Current State +Description of the current code structure. + +## Target State +Description of the desired code structure. + +## Refactoring Strategy +- Strategy 1 +- Strategy 2 + +## Affected Files +- file1.ts +- file2.ts + +## Implementation Steps +- [ ] Step 1: [Description] +- [ ] Step 2: [Description] +- [ ] Step 3: [Description] + +## Testing Strategy +- [ ] Unit tests pass +- [ ] Integration tests pass +- [ ] No regression + +## Verification +How to verify the refactoring is successful. +`, + variables: { + title: 'Refactoring Description', + author: 'Developer', + created: new Date().toISOString().split('T')[0], + status: 'draft', + version: '1.0.0' + } + }, + { + id: 'migration', + name: localize('planTemplate.migration.name', 'Migration'), + description: localize('planTemplate.migration.description', 'Template for system migrations'), + category: 'migration', + content: `# Migration: {{title}} + +## Metadata +- **Author**: {{author}} +- **Created**: {{created}} +- **Status**: {{status}} +- **Version**: {{version}} + +## Goal +What we are migrating from and to. + +## Migration Scope +- Component 1 +- Component 2 + +## Migration Strategy +- Phase 1: [Description] +- Phase 2: [Description] +- Phase 3: [Description] + +## Implementation Steps +- [ ] Step 1: [Description] +- [ ] Step 2: [Description] +- [ ] Step 3: [Description] + +## Rollback Plan +What to do if migration fails. + +## Testing Strategy +- Pre-migration testing +- Post-migration testing +- Validation + +## Verification +How to verify the migration is successful. +`, + variables: { + title: 'Migration Description', + author: 'Developer', + created: new Date().toISOString().split('T')[0], + status: 'draft', + version: '1.0.0' + } + } +]; + +/** + * Substitute template variables in plan content + */ +export function substituteTemplateVariables(content: string, variables: Record): string { + let result = content; + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); + result = result.replace(regex, value); + } + return result; +} + +/** + * Get template by ID + */ +export function getTemplateById(id: string): PlanTemplate | undefined { + return DEFAULT_PLAN_TEMPLATES.find(t => t.id === id); +} + +/** + * Get templates by category + */ +export function getTemplatesByCategory(category: PlanTemplate['category']): PlanTemplate[] { + return DEFAULT_PLAN_TEMPLATES.filter(t => t.category === category); +} + +/** + * Parse plan metadata from markdown content + */ +export function parsePlanMetadata(content: string): PlanMetadata | null { + const metadataRegex = /## Metadata\s*\n([\s\S]*?)(?=\n## |$)/; + const match = content.match(metadataRegex); + if (!match) { + return null; + } + + const metadata: PlanMetadata = { + title: extractTitle(content) || 'Untitled Plan' + }; + + const metadataSection = match[1]; + const titleMatch = content.match(/^#\s+(.+)$/m); + if (titleMatch) { + metadata.title = titleMatch[1].trim(); + } + + // Extract metadata fields + const authorMatch = metadataSection.match(/\*\*Author\*\*:\s*(.+)/); + if (authorMatch) { + metadata.author = authorMatch[1].trim(); + } + + const createdMatch = metadataSection.match(/\*\*Created\*\*:\s*(.+)/); + if (createdMatch) { + metadata.created = createdMatch[1].trim(); + } + + const lastUpdatedMatch = metadataSection.match(/\*\*Last Updated\*\*:\s*(.+)/); + if (lastUpdatedMatch) { + metadata.lastUpdated = lastUpdatedMatch[1].trim(); + } + + const statusMatch = metadataSection.match(/\*\*Status\*\*:\s*(.+)/); + if (statusMatch) { + const status = statusMatch[1].trim().toLowerCase(); + if (['draft', 'in-progress', 'review', 'approved', 'executing', 'completed'].includes(status)) { + metadata.status = status as PlanMetadata['status']; + } + } + + const versionMatch = metadataSection.match(/\*\*Version\*\*:\s*(.+)/); + if (versionMatch) { + metadata.version = versionMatch[1].trim(); + } + + return metadata; +} + +/** + * Extract title from markdown content + */ +function extractTitle(content: string): string | null { + const titleMatch = content.match(/^#\s+(.+)$/m); + return titleMatch ? titleMatch[1].trim() : null; +} + +/** + * Generate plan content from template + */ +export function generatePlanFromTemplate(templateId: string, variables?: Record): string { + const template = getTemplateById(templateId); + if (!template) { + throw new Error(`Template not found: ${templateId}`); + } + + const mergedVariables = { ...template.variables, ...variables }; + return substituteTemplateVariables(template.content, mergedVariables); +} +