From 56c84cb4d72f68ebfaa1af8475b1d40974b03e47 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:17:54 -0700 Subject: [PATCH 01/23] [Symphony] Start contribution for #197 From 6fcc0c4782458d0901acb3b91954d344df5a50e2 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:21:45 -0700 Subject: [PATCH 02/23] MAESTRO: Add prompt-manager.ts for disk-based prompt loading Creates the core PromptManager that loads all 19 system prompts from disk at startup, with support for user customizations stored in a separate JSON file. Follows the same architecture pattern as SpecKit/OpenSpec managers. Co-Authored-By: Claude Opus 4.6 --- src/main/prompt-manager.ts | 274 +++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 src/main/prompt-manager.ts diff --git a/src/main/prompt-manager.ts b/src/main/prompt-manager.ts new file mode 100644 index 000000000..fe5963bb4 --- /dev/null +++ b/src/main/prompt-manager.ts @@ -0,0 +1,274 @@ +// ABOUTME: Prompt Manager - Loads core system prompts from disk at startup. +// ABOUTME: Supports user customizations stored separately, with immediate in-memory updates on save/reset. + +/** + * Prompt Manager - Core System Prompts + * + * Loads all core prompts from disk exactly once at application startup. + * User customizations are stored separately and take precedence over bundled defaults. + * + * Architecture (same as SpecKit/OpenSpec): + * - Bundled prompts: Resources/prompts/core/*.md (read-only) + * - User customizations: userData/core-prompts-customizations.json + * - On load: User customization wins if isModified=true, else bundled + * - On save: Writes to customizations JSON AND updates in-memory cache immediately + * - On reset: Removes from customizations JSON AND updates in-memory cache immediately + */ + +import { app } from 'electron'; +import fs from 'fs/promises'; +import path from 'path'; +import { logger } from './utils/logger'; + +const LOG_CONTEXT = '[PromptManager]'; + +// ============================================================================ +// Types +// ============================================================================ + +interface PromptDefinition { + id: string; + filename: string; + description: string; + category: string; +} + +export interface CorePrompt { + id: string; + filename: string; + description: string; + category: string; + content: string; + isModified: boolean; +} + +interface StoredPrompt { + content: string; + isModified: boolean; + modifiedAt?: string; +} + +interface StoredData { + prompts: Record; +} + +// ============================================================================ +// Prompt Definitions +// ============================================================================ + +const CORE_PROMPTS: PromptDefinition[] = [ + // Wizard + { id: 'wizard-system', filename: 'wizard-system.md', description: 'Main wizard conversation system prompt', category: 'wizard' }, + { id: 'wizard-system-continuation', filename: 'wizard-system-continuation.md', description: 'Wizard continuation prompt', category: 'wizard' }, + { id: 'wizard-document-generation', filename: 'wizard-document-generation.md', description: 'Wizard document generation prompt', category: 'wizard' }, + // Inline Wizard + { id: 'wizard-inline-system', filename: 'wizard-inline-system.md', description: 'Inline wizard system prompt', category: 'inline-wizard' }, + { id: 'wizard-inline-iterate', filename: 'wizard-inline-iterate.md', description: 'Inline wizard iteration prompt', category: 'inline-wizard' }, + { id: 'wizard-inline-new', filename: 'wizard-inline-new.md', description: 'Inline wizard new session prompt', category: 'inline-wizard' }, + { id: 'wizard-inline-iterate-generation', filename: 'wizard-inline-iterate-generation.md', description: 'Inline wizard iteration generation', category: 'inline-wizard' }, + // AutoRun + { id: 'autorun-default', filename: 'autorun-default.md', description: 'Default Auto Run behavior prompt', category: 'autorun' }, + { id: 'autorun-synopsis', filename: 'autorun-synopsis.md', description: 'Auto Run synopsis generation prompt', category: 'autorun' }, + // Commands + { id: 'image-only-default', filename: 'image-only-default.md', description: 'Default prompt for image-only messages', category: 'commands' }, + { id: 'commit-command', filename: 'commit-command.md', description: 'Git commit command prompt', category: 'commands' }, + // System + { id: 'maestro-system-prompt', filename: 'maestro-system-prompt.md', description: 'Maestro system context prompt', category: 'system' }, + // Group Chat + { id: 'group-chat-moderator-system', filename: 'group-chat-moderator-system.md', description: 'Group chat moderator system prompt', category: 'group-chat' }, + { id: 'group-chat-moderator-synthesis', filename: 'group-chat-moderator-synthesis.md', description: 'Group chat synthesis prompt', category: 'group-chat' }, + { id: 'group-chat-participant', filename: 'group-chat-participant.md', description: 'Group chat participant prompt', category: 'group-chat' }, + { id: 'group-chat-participant-request', filename: 'group-chat-participant-request.md', description: 'Group chat participant request prompt', category: 'group-chat' }, + // Context + { id: 'context-grooming', filename: 'context-grooming.md', description: 'Context grooming prompt', category: 'context' }, + { id: 'context-transfer', filename: 'context-transfer.md', description: 'Context transfer prompt', category: 'context' }, + { id: 'context-summarize', filename: 'context-summarize.md', description: 'Context summarization prompt', category: 'context' }, +]; + +// ============================================================================ +// State +// ============================================================================ + +const promptCache = new Map(); +let initialized = false; + +// ============================================================================ +// Path Helpers +// ============================================================================ + +function getBundledPromptsPath(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, 'prompts', 'core'); + } + return path.join(__dirname, '..', '..', 'src', 'prompts'); +} + +function getCustomizationsPath(): string { + return path.join(app.getPath('userData'), 'core-prompts-customizations.json'); +} + +// ============================================================================ +// Customizations Storage +// ============================================================================ + +async function loadUserCustomizations(): Promise { + try { + const content = await fs.readFile(getCustomizationsPath(), 'utf-8'); + return JSON.parse(content) as StoredData; + } catch { + return null; + } +} + +async function saveUserCustomizations(data: StoredData): Promise { + await fs.writeFile(getCustomizationsPath(), JSON.stringify(data, null, 2), 'utf-8'); +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Initialize all prompts from disk. Called once at app startup. + * Loads bundled prompts, then overlays user customizations. + */ +export async function initializePrompts(): Promise { + if (initialized) { + logger.warn('Prompts already initialized, skipping', LOG_CONTEXT); + return; + } + + const promptsPath = getBundledPromptsPath(); + const customizations = await loadUserCustomizations(); + + logger.info(`Loading ${CORE_PROMPTS.length} prompts from: ${promptsPath}`, LOG_CONTEXT); + + let customizedCount = 0; + for (const prompt of CORE_PROMPTS) { + const filePath = path.join(promptsPath, prompt.filename); + + // Load bundled content + let bundledContent: string; + try { + bundledContent = await fs.readFile(filePath, 'utf-8'); + } catch (error) { + logger.error(`Failed to load prompt ${prompt.id} from ${filePath}: ${error}`, LOG_CONTEXT); + throw new Error(`Failed to load required prompt: ${prompt.id}`); + } + + // Check for user customization + const customPrompt = customizations?.prompts?.[prompt.id]; + const isModified = customPrompt?.isModified ?? false; + const content = isModified && customPrompt ? customPrompt.content : bundledContent; + + if (isModified) customizedCount++; + promptCache.set(prompt.id, { content, isModified }); + } + + initialized = true; + logger.info(`Successfully loaded ${promptCache.size} prompts (${customizedCount} customized)`, LOG_CONTEXT); +} + +/** + * Get a prompt by ID. Returns cached value (prompts are loaded once at startup). + */ +export function getPrompt(id: string): string { + if (!initialized) { + throw new Error('Prompts not initialized. Call initializePrompts() first.'); + } + + const cached = promptCache.get(id); + if (!cached) { + throw new Error(`Unknown prompt ID: ${id}`); + } + + return cached.content; +} + +/** + * Get all prompts with metadata (for UI display). + */ +export function getAllPrompts(): CorePrompt[] { + if (!initialized) { + throw new Error('Prompts not initialized. Call initializePrompts() first.'); + } + + return CORE_PROMPTS.map((def) => { + const cached = promptCache.get(def.id)!; + return { + id: def.id, + filename: def.filename, + description: def.description, + category: def.category, + content: cached.content, + isModified: cached.isModified, + }; + }); +} + +/** + * Save user's edit to a prompt. Updates both disk and in-memory cache immediately. + */ +export async function savePrompt(id: string, content: string): Promise { + const def = CORE_PROMPTS.find((p) => p.id === id); + if (!def) { + throw new Error(`Unknown prompt ID: ${id}`); + } + + // Update disk + const customizations = (await loadUserCustomizations()) || { prompts: {} }; + customizations.prompts[id] = { + content, + isModified: true, + modifiedAt: new Date().toISOString(), + }; + await saveUserCustomizations(customizations); + + // Update in-memory cache immediately + promptCache.set(id, { content, isModified: true }); + + logger.info(`Saved and applied customization for ${id}`, LOG_CONTEXT); +} + +/** + * Reset a prompt to bundled default. Updates both disk and in-memory cache immediately. + * Returns the bundled content for UI confirmation. + */ +export async function resetPrompt(id: string): Promise { + const def = CORE_PROMPTS.find((p) => p.id === id); + if (!def) { + throw new Error(`Unknown prompt ID: ${id}`); + } + + // Remove from customizations on disk + const customizations = await loadUserCustomizations(); + if (customizations?.prompts?.[id]) { + delete customizations.prompts[id]; + await saveUserCustomizations(customizations); + } + + // Read bundled content + const promptsPath = getBundledPromptsPath(); + const filePath = path.join(promptsPath, def.filename); + const bundledContent = await fs.readFile(filePath, 'utf-8'); + + // Update in-memory cache immediately + promptCache.set(id, { content: bundledContent, isModified: false }); + + logger.info(`Reset and applied bundled default for ${id}`, LOG_CONTEXT); + return bundledContent; +} + +/** + * Check if prompts have been initialized. + */ +export function arePromptsInitialized(): boolean { + return initialized; +} + +/** + * Get all prompt IDs. + */ +export function getAllPromptIds(): string[] { + return CORE_PROMPTS.map((p) => p.id); +} From fc1860899f704ea5e3dff31d593e03c0b53981c2 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:23:20 -0700 Subject: [PATCH 03/23] MAESTRO: Add extraResources config to bundle core prompts for all platforms Adds electron-builder extraResources entries for mac, win, and linux to copy src/prompts/*.md files to prompts/core/ in the packaged app, enabling the disk-based prompt manager to load prompts from Resources at runtime. Co-Authored-By: Claude Opus 4.6 --- package.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/package.json b/package.json index f12544e11..5d2a13dbd 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,11 @@ { "from": "src/prompts/openspec", "to": "prompts/openspec" + }, + { + "from": "src/prompts", + "to": "prompts/core", + "filter": ["*.md"] } ] }, @@ -147,6 +152,11 @@ { "from": "src/prompts/openspec", "to": "prompts/openspec" + }, + { + "from": "src/prompts", + "to": "prompts/core", + "filter": ["*.md"] } ] }, @@ -171,6 +181,11 @@ { "from": "src/prompts/openspec", "to": "prompts/openspec" + }, + { + "from": "src/prompts", + "to": "prompts/core", + "filter": ["*.md"] } ] }, From 675c2469047550b47bc90bd2079b08d66bd40b23 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:29:45 -0700 Subject: [PATCH 04/23] MAESTRO: Add IPC handlers and preload bridge for core prompts API Exposes window.maestro.prompts namespace with get, getAll, getAllIds, save, and reset methods so the renderer can access, edit, and reset system prompts with immediate in-memory effect. Co-Authored-By: Claude Opus 4.6 --- .../main/ipc/handlers/prompts.test.ts | 215 ++++++++++++++++++ src/__tests__/main/preload/prompts.test.ts | 117 ++++++++++ src/main/ipc/handlers/index.ts | 4 + src/main/ipc/handlers/prompts.ts | 90 ++++++++ src/main/preload/index.ts | 11 + src/main/preload/prompts.ts | 69 ++++++ src/renderer/global.d.ts | 34 +++ 7 files changed, 540 insertions(+) create mode 100644 src/__tests__/main/ipc/handlers/prompts.test.ts create mode 100644 src/__tests__/main/preload/prompts.test.ts create mode 100644 src/main/ipc/handlers/prompts.ts create mode 100644 src/main/preload/prompts.ts diff --git a/src/__tests__/main/ipc/handlers/prompts.test.ts b/src/__tests__/main/ipc/handlers/prompts.test.ts new file mode 100644 index 000000000..aa984ac65 --- /dev/null +++ b/src/__tests__/main/ipc/handlers/prompts.test.ts @@ -0,0 +1,215 @@ +// ABOUTME: Tests for the core prompts IPC handlers. +// ABOUTME: Verifies get, getAll, getAllIds, save, and reset handler registration and behavior. + +/** + * Tests for the Core Prompts IPC handlers + * + * These tests verify the IPC handlers for managing core system prompts: + * - Getting a single prompt by ID + * - Getting all prompts with metadata + * - Getting all prompt IDs + * - Saving user customizations + * - Resetting to bundled defaults + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ipcMain } from 'electron'; +import { registerPromptsHandlers } from '../../../../main/ipc/handlers/prompts'; +import * as promptManager from '../../../../main/prompt-manager'; + +// Mock electron's ipcMain +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +// Mock the prompt-manager module +vi.mock('../../../../main/prompt-manager', () => ({ + getPrompt: vi.fn(), + getAllPrompts: vi.fn(), + getAllPromptIds: vi.fn(), + savePrompt: vi.fn(), + resetPrompt: vi.fn(), + arePromptsInitialized: vi.fn(), +})); + +// Mock the logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('prompts IPC handlers', () => { + let handlers: Map; + + beforeEach(() => { + vi.clearAllMocks(); + + // Capture all registered handlers + handlers = new Map(); + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler); + }); + + // Register handlers + registerPromptsHandlers(); + }); + + afterEach(() => { + handlers.clear(); + }); + + describe('registration', () => { + it('should register all prompts handlers', () => { + const expectedChannels = [ + 'prompts:get', + 'prompts:getAll', + 'prompts:getAllIds', + 'prompts:save', + 'prompts:reset', + ]; + + for (const channel of expectedChannels) { + expect(handlers.has(channel)).toBe(true); + } + }); + }); + + describe('prompts:get', () => { + it('should return prompt content when initialized', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(true); + vi.mocked(promptManager.getPrompt).mockReturnValue('prompt content'); + + const handler = handlers.get('prompts:get')!; + const result = await handler({}, 'wizard-system'); + + expect(result).toEqual({ success: true, content: 'prompt content' }); + expect(promptManager.getPrompt).toHaveBeenCalledWith('wizard-system'); + }); + + it('should return error when not initialized', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(false); + + const handler = handlers.get('prompts:get')!; + const result = await handler({}, 'wizard-system'); + + expect(result).toEqual({ success: false, error: 'Prompts not yet initialized' }); + }); + + it('should return error on exception', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(true); + vi.mocked(promptManager.getPrompt).mockImplementation(() => { + throw new Error('Unknown prompt ID: invalid'); + }); + + const handler = handlers.get('prompts:get')!; + const result = await handler({}, 'invalid'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unknown prompt ID'); + }); + }); + + describe('prompts:getAll', () => { + it('should return all prompts when initialized', async () => { + const mockPrompts = [ + { + id: 'wizard-system', + filename: 'wizard-system.md', + description: 'Main wizard system prompt', + category: 'wizard', + content: 'wizard content', + isModified: false, + }, + ]; + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(true); + vi.mocked(promptManager.getAllPrompts).mockReturnValue(mockPrompts); + + const handler = handlers.get('prompts:getAll')!; + const result = await handler({}); + + expect(result).toEqual({ success: true, prompts: mockPrompts }); + }); + + it('should return error when not initialized', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(false); + + const handler = handlers.get('prompts:getAll')!; + const result = await handler({}); + + expect(result).toEqual({ success: false, error: 'Prompts not yet initialized' }); + }); + }); + + describe('prompts:getAllIds', () => { + it('should return all prompt IDs when initialized', async () => { + const mockIds = ['wizard-system', 'autorun-default']; + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(true); + vi.mocked(promptManager.getAllPromptIds).mockReturnValue(mockIds); + + const handler = handlers.get('prompts:getAllIds')!; + const result = await handler({}); + + expect(result).toEqual({ success: true, ids: mockIds }); + }); + + it('should return error when not initialized', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(false); + + const handler = handlers.get('prompts:getAllIds')!; + const result = await handler({}); + + expect(result).toEqual({ success: false, error: 'Prompts not yet initialized' }); + }); + }); + + describe('prompts:save', () => { + it('should save prompt successfully', async () => { + vi.mocked(promptManager.savePrompt).mockResolvedValue(undefined); + + const handler = handlers.get('prompts:save')!; + const result = await handler({}, 'wizard-system', 'new content'); + + expect(result).toEqual({ success: true }); + expect(promptManager.savePrompt).toHaveBeenCalledWith('wizard-system', 'new content'); + }); + + it('should return error on save failure', async () => { + vi.mocked(promptManager.savePrompt).mockRejectedValue(new Error('Write failed')); + + const handler = handlers.get('prompts:save')!; + const result = await handler({}, 'wizard-system', 'new content'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Write failed'); + }); + }); + + describe('prompts:reset', () => { + it('should reset prompt and return bundled content', async () => { + vi.mocked(promptManager.resetPrompt).mockResolvedValue('bundled default'); + + const handler = handlers.get('prompts:reset')!; + const result = await handler({}, 'wizard-system'); + + expect(result).toEqual({ success: true, content: 'bundled default' }); + expect(promptManager.resetPrompt).toHaveBeenCalledWith('wizard-system'); + }); + + it('should return error on reset failure', async () => { + vi.mocked(promptManager.resetPrompt).mockRejectedValue(new Error('Reset failed')); + + const handler = handlers.get('prompts:reset')!; + const result = await handler({}, 'wizard-system'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Reset failed'); + }); + }); +}); diff --git a/src/__tests__/main/preload/prompts.test.ts b/src/__tests__/main/preload/prompts.test.ts new file mode 100644 index 000000000..fb45387c5 --- /dev/null +++ b/src/__tests__/main/preload/prompts.test.ts @@ -0,0 +1,117 @@ +// ABOUTME: Tests for the core prompts preload API. +// ABOUTME: Verifies that createPromptsApi correctly invokes IPC channels for get, getAll, getAllIds, save, and reset. + +/** + * Tests for prompts preload API + * + * Coverage: + * - createPromptsApi: get, getAll, getAllIds, save, reset + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createPromptsApi } from '../../../main/preload/prompts'; + +describe('Prompts Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createPromptsApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createPromptsApi(); + }); + + describe('get', () => { + it('should invoke prompts:get with id', async () => { + const response = { success: true, content: 'prompt content' }; + mockInvoke.mockResolvedValue(response); + + const result = await api.get('wizard-system'); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:get', 'wizard-system'); + expect(result).toEqual(response); + }); + + it('should handle error response', async () => { + const response = { success: false, error: 'Prompts not yet initialized' }; + mockInvoke.mockResolvedValue(response); + + const result = await api.get('invalid'); + + expect(result).toEqual(response); + }); + }); + + describe('getAll', () => { + it('should invoke prompts:getAll', async () => { + const response = { + success: true, + prompts: [ + { + id: 'wizard-system', + filename: 'wizard-system.md', + description: 'Main wizard system prompt', + category: 'wizard', + content: 'wizard content', + isModified: false, + }, + ], + }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getAll(); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:getAll'); + expect(result).toEqual(response); + }); + }); + + describe('getAllIds', () => { + it('should invoke prompts:getAllIds', async () => { + const response = { success: true, ids: ['wizard-system', 'autorun-default'] }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getAllIds(); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:getAllIds'); + expect(result).toEqual(response); + }); + }); + + describe('save', () => { + it('should invoke prompts:save with id and content', async () => { + const response = { success: true }; + mockInvoke.mockResolvedValue(response); + + const result = await api.save('wizard-system', 'new content'); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:save', 'wizard-system', 'new content'); + expect(result).toEqual(response); + }); + }); + + describe('reset', () => { + it('should invoke prompts:reset with id', async () => { + const response = { success: true, content: 'bundled default' }; + mockInvoke.mockResolvedValue(response); + + const result = await api.reset('wizard-system'); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:reset', 'wizard-system'); + expect(result).toEqual(response); + }); + }); + }); +}); diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 1dfd2e805..df633b627 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -53,6 +53,7 @@ import { registerAgentErrorHandlers } from './agent-error'; import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes'; import { registerWakatimeHandlers } from './wakatime'; +import { registerPromptsHandlers } from './prompts'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -97,6 +98,7 @@ export type { TabNamingHandlerDependencies }; export { registerDirectorNotesHandlers }; export type { DirectorNotesHandlerDependencies }; export { registerWakatimeHandlers }; +export { registerPromptsHandlers }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; @@ -282,6 +284,8 @@ export function registerAllHandlers(deps: HandlerDependencies): void { getAgentDetector: deps.getAgentDetector, agentConfigsStore: deps.agentConfigsStore, }); + // Register core prompts handlers (no dependencies needed) + registerPromptsHandlers(); // Setup logger event forwarding to renderer setupLoggerEventForwarding(deps.getMainWindow); } diff --git a/src/main/ipc/handlers/prompts.ts b/src/main/ipc/handlers/prompts.ts new file mode 100644 index 000000000..c6a039644 --- /dev/null +++ b/src/main/ipc/handlers/prompts.ts @@ -0,0 +1,90 @@ +// ABOUTME: IPC handlers for core system prompts. +// ABOUTME: Provides full CRUD for the Maestro Prompts UI tab with immediate in-memory updates. + +/** + * IPC handlers for core prompts + * + * Provides full CRUD for the Maestro Prompts UI tab. + * Changes are saved to customizations file AND applied immediately in memory. + */ + +import { ipcMain } from 'electron'; +import { + getPrompt, + getAllPrompts, + getAllPromptIds, + savePrompt, + resetPrompt, + arePromptsInitialized, +} from '../../prompt-manager'; +import { logger } from '../../utils/logger'; + +const LOG_CONTEXT = '[IPC:Prompts]'; + +export function registerPromptsHandlers(): void { + // Get a single prompt by ID + ipcMain.handle('prompts:get', async (_, id: string) => { + try { + if (!arePromptsInitialized()) { + return { success: false, error: 'Prompts not yet initialized' }; + } + const content = getPrompt(id); + return { success: true, content }; + } catch (error) { + logger.error(`Failed to get prompt ${id}: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + // Get all prompts with metadata (for UI) + ipcMain.handle('prompts:getAll', async () => { + try { + if (!arePromptsInitialized()) { + return { success: false, error: 'Prompts not yet initialized' }; + } + const prompts = getAllPrompts(); + return { success: true, prompts }; + } catch (error) { + logger.error(`Failed to get all prompts: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + // Get all prompt IDs (for reference) + ipcMain.handle('prompts:getAllIds', async () => { + try { + if (!arePromptsInitialized()) { + return { success: false, error: 'Prompts not yet initialized' }; + } + const ids = getAllPromptIds(); + return { success: true, ids }; + } catch (error) { + logger.error(`Failed to get prompt IDs: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + // Save user's edit to a prompt (immediate effect) + ipcMain.handle('prompts:save', async (_, id: string, content: string) => { + try { + await savePrompt(id, content); + return { success: true }; + } catch (error) { + logger.error(`Failed to save prompt ${id}: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + // Reset a prompt to bundled default (immediate effect) + ipcMain.handle('prompts:reset', async (_, id: string) => { + try { + const content = await resetPrompt(id); + return { success: true, content }; + } catch (error) { + logger.error(`Failed to reset prompt ${id}: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + logger.info('Prompts IPC handlers registered', LOG_CONTEXT); +} diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f8..0123c4984 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -35,6 +35,7 @@ import { createLoggerApi } from './logger'; import { createClaudeApi, createAgentSessionsApi } from './sessions'; import { createTempfileApi, createHistoryApi, createCliApi } from './files'; import { createSpeckitApi, createOpenspecApi } from './commands'; +import { createPromptsApi } from './prompts'; import { createAutorunApi, createPlaybooksApi, createMarketplaceApi } from './autorun'; import { createDebugApi, createDocumentGraphApi } from './debug'; import { createGroupChatApi } from './groupChat'; @@ -144,6 +145,9 @@ contextBridge.exposeInMainWorld('maestro', { // OpenSpec API openspec: createOpenspecApi(), + // Core Prompts API (Maestro system prompts) + prompts: createPromptsApi(), + // Notification API notification: createNotificationApi(), @@ -231,6 +235,8 @@ export { // Commands createSpeckitApi, createOpenspecApi, + // Prompts + createPromptsApi, // Auto Run createAutorunApi, createPlaybooksApi, @@ -335,6 +341,11 @@ export type { CommandMetadata, CommandDefinition, } from './commands'; +export type { + // From prompts + PromptsApi, + CorePromptEntry, +} from './prompts'; export type { // From autorun AutorunApi, diff --git a/src/main/preload/prompts.ts b/src/main/preload/prompts.ts new file mode 100644 index 000000000..0d369f01a --- /dev/null +++ b/src/main/preload/prompts.ts @@ -0,0 +1,69 @@ +// ABOUTME: Preload API for core system prompts. +// ABOUTME: Provides window.maestro.prompts namespace for get, getAll, save, and reset operations. + +/** + * Preload API for core system prompts + * + * Provides the window.maestro.prompts namespace for: + * - Getting individual prompts by ID + * - Getting all prompts with metadata (for the Prompts UI tab) + * - Saving user edits (immediate in-memory + disk) + * - Resetting prompts to bundled defaults + */ + +import { ipcRenderer } from 'electron'; + +/** + * Core prompt definition returned by the prompts API + */ +export interface CorePromptEntry { + id: string; + filename: string; + description: string; + category: string; + content: string; + isModified: boolean; +} + +/** + * Creates the Prompts API object for preload exposure + */ +export function createPromptsApi() { + return { + // Get a single prompt by ID + get: (id: string): Promise<{ + success: boolean; + content?: string; + error?: string; + }> => ipcRenderer.invoke('prompts:get', id), + + // Get all prompts with metadata (for UI) + getAll: (): Promise<{ + success: boolean; + prompts?: CorePromptEntry[]; + error?: string; + }> => ipcRenderer.invoke('prompts:getAll'), + + // Get all prompt IDs + getAllIds: (): Promise<{ + success: boolean; + ids?: string[]; + error?: string; + }> => ipcRenderer.invoke('prompts:getAllIds'), + + // Save user's edit (immediate effect) + save: (id: string, content: string): Promise<{ + success: boolean; + error?: string; + }> => ipcRenderer.invoke('prompts:save', id, content), + + // Reset to bundled default (immediate effect) + reset: (id: string): Promise<{ + success: boolean; + content?: string; + error?: string; + }> => ipcRenderer.invoke('prompts:reset', id), + }; +} + +export type PromptsApi = ReturnType; diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index b0b64c7c4..b587421fa 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -2106,6 +2106,40 @@ interface MaestroAPI { error?: string; }>; }; + // Core Prompts API (Maestro system prompts) + prompts: { + get: (id: string) => Promise<{ + success: boolean; + content?: string; + error?: string; + }>; + getAll: () => Promise<{ + success: boolean; + prompts?: Array<{ + id: string; + filename: string; + description: string; + category: string; + content: string; + isModified: boolean; + }>; + error?: string; + }>; + getAllIds: () => Promise<{ + success: boolean; + ids?: string[]; + error?: string; + }>; + save: (id: string, content: string) => Promise<{ + success: boolean; + error?: string; + }>; + reset: (id: string) => Promise<{ + success: boolean; + content?: string; + error?: string; + }>; + }; // Stats tracking API (global AI interaction statistics) stats: { // Record a query event (interactive conversation turn) From 51152bcf64a104db60e48b99170307017926b2a5 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:31:42 -0700 Subject: [PATCH 05/23] MAESTRO: Initialize core prompts at app startup before IPC handlers Calls initializePrompts() in app.whenReady() after stats DB init but before setupIpcHandlers() and window creation. Includes error handling that shows a dialog and quits if prompts fail to load. Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index 577f81530..54c479471 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, powerMonitor } from 'electron'; +import { app, BrowserWindow, dialog, Menu, powerMonitor } from 'electron'; import { isMacOS } from '../shared/platformDetection'; import path from 'path'; import os from 'os'; @@ -77,6 +77,7 @@ import { createSshRemoteStoreAdapter } from './utils/ssh-remote-resolver'; import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage'; import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/session-recovery'; import { initializeSessionStorages } from './storage'; +import { initializePrompts } from './prompt-manager'; import { initializeOutputParsers } from './parsers'; import { calculateContextTokens } from './parsers/usage-aggregator'; import { @@ -364,6 +365,21 @@ app.whenReady().then(async () => { logger.warn('Continuing without stats - usage tracking will be unavailable', 'Startup'); } + // Initialize prompts from disk (must happen before features that use them) + logger.info('Initializing core prompts', 'Startup'); + try { + await initializePrompts(); + logger.info('Core prompts initialized', 'Startup'); + } catch (error) { + logger.error(`Critical: Failed to initialize prompts: ${error}`, 'Startup'); + dialog.showErrorBox( + 'Startup Error', + 'Failed to load system prompts. Please reinstall the application.' + ); + app.quit(); + return; + } + // Set up IPC handlers logger.debug('Setting up IPC handlers', 'Startup'); setupIpcHandlers(); From 53ee6d7004e7bb3eca62465fd854df6b47d3c86f Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:34:04 -0700 Subject: [PATCH 06/23] MAESTRO: Migrate main process and CLI imports to disk-based prompts Replace compiled prompt imports with getPrompt() in group-chat-router.ts and group-chat-agent.ts. Add CLI-specific async getCliPrompt() loader in batch-processor.ts since CLI doesn't use Electron's startup sequence. Co-Authored-By: Claude Opus 4.6 --- src/cli/services/batch-processor.ts | 42 +++++++++++++++++++++--- src/main/group-chat/group-chat-agent.ts | 4 +-- src/main/group-chat/group-chat-router.ts | 6 ++-- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 3127cac6a..bc51f922d 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -15,13 +15,45 @@ import { addHistoryEntry, readGroups } from './storage'; import { substituteTemplateVariables, TemplateContext } from '../../shared/templateVariables'; import { registerCliActivity, unregisterCliActivity } from '../../shared/cli-activity'; import { logger } from '../../main/utils/logger'; -import { autorunSynopsisPrompt, autorunDefaultPrompt } from '../../prompts'; +import fs from 'fs/promises'; +import path from 'path'; import { parseSynopsis } from '../../shared/synopsis'; import { generateUUID } from '../../shared/uuid'; import { formatElapsedTime } from '../../shared/formatters'; -// Synopsis prompt for batch tasks -const BATCH_SYNOPSIS_PROMPT = autorunSynopsisPrompt; +// CLI prompt cache (loaded once on first use) +const cliPromptCache = new Map(); + +async function getCliPrompt(id: string): Promise { + if (cliPromptCache.has(id)) { + return cliPromptCache.get(id)!; + } + + // Map ID to filename + const filenameMap: Record = { + 'autorun-synopsis': 'autorun-synopsis.md', + 'autorun-default': 'autorun-default.md', + }; + + const filename = filenameMap[id]; + if (!filename) { + throw new Error(`Unknown prompt ID: ${id}`); + } + + // Try development path first, then bundled + const devPath = path.join(__dirname, '..', '..', 'prompts', filename); + try { + const content = await fs.readFile(devPath, 'utf-8'); + cliPromptCache.set(id, content); + return content; + } catch { + // Try bundled path (when running from packaged app) + const bundledPath = path.join(process.resourcesPath || '', 'prompts', 'core', filename); + const content = await fs.readFile(bundledPath, 'utf-8'); + cliPromptCache.set(id, content); + return content; + } +} /** * Get the current git branch for a directory @@ -407,7 +439,7 @@ export async function* runPlaybook( // Use default Auto Run prompt if playbook.prompt is empty/null // Marketplace playbooks with prompt: null will use the default const basePrompt = substituteTemplateVariables( - playbook.prompt || autorunDefaultPrompt, + playbook.prompt || await getCliPrompt('autorun-default'), templateContext ); @@ -475,7 +507,7 @@ export async function* runPlaybook( const synopsisResult = await spawnAgent( session.toolType, session.cwd, - BATCH_SYNOPSIS_PROMPT, + await getCliPrompt('autorun-synopsis'), result.agentSessionId ); diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts index 8e42179a4..9e6a6a526 100644 --- a/src/main/group-chat/group-chat-agent.ts +++ b/src/main/group-chat/group-chat-agent.ts @@ -25,7 +25,7 @@ import { applyAgentConfigOverrides, getContextWindowValue, } from '../utils/agent-args'; -import { groupChatParticipantPrompt } from '../../prompts'; +import { getPrompt } from '../prompt-manager'; import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; import { getWindowsSpawnConfig } from './group-chat-config'; @@ -52,7 +52,7 @@ export function getParticipantSystemPrompt( groupChatName: string, logPath: string ): string { - return groupChatParticipantPrompt + return getPrompt('group-chat-participant') .replace(/\{\{GROUP_CHAT_NAME\}\}/g, groupChatName) .replace(/\{\{PARTICIPANT_NAME\}\}/g, participantName) .replace(/\{\{LOG_PATH\}\}/g, logPath); diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index 42dcc5718..c7f24b0e5 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -40,7 +40,7 @@ import { applyAgentConfigOverrides, getContextWindowValue, } from '../utils/agent-args'; -import { groupChatParticipantRequestPrompt } from '../../prompts'; +import { getPrompt } from '../prompt-manager'; import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; import { setGetCustomShellPathCallback, getWindowsSpawnConfig } from './group-chat-config'; @@ -827,7 +827,7 @@ export async function routeModeratorResponse( // Get the group chat folder path for file access permissions const groupChatFolder = getGroupChatDir(groupChatId); - const participantPrompt = groupChatParticipantRequestPrompt + const participantPrompt = getPrompt('group-chat-participant-request') .replace(/\{\{PARTICIPANT_NAME\}\}/g, participantName) .replace(/\{\{GROUP_CHAT_NAME\}\}/g, updatedChat.name) .replace(/\{\{READ_ONLY_NOTE\}\}/g, readOnlyNote) @@ -1371,7 +1371,7 @@ export async function respawnParticipantWithRecovery( const groupChatFolder = getGroupChatDir(groupChatId); // Build the recovery prompt - includes standard prompt plus recovery context - const basePrompt = groupChatParticipantRequestPrompt + const basePrompt = getPrompt('group-chat-participant-request') .replace(/\{\{PARTICIPANT_NAME\}\}/g, participantName) .replace(/\{\{GROUP_CHAT_NAME\}\}/g, chat.name) .replace(/\{\{READ_ONLY_NOTE\}\}/g, readOnlyNote) From 645022db10778e94a700c17aedc72a61a0ea74b5 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:40:27 -0700 Subject: [PATCH 07/23] MAESTRO: Migrate renderer prompt imports to disk-based IPC loading Replace direct imports from compiled prompts module with IPC-based loading via window.maestro.prompts.get() in useInputProcessing.ts, settingsStore.ts, and agentStore.ts. Prompts are cached in module-level variables and loaded once at app startup via useAppInitialization. Co-Authored-By: Claude Opus 4.6 --- src/renderer/hooks/input/index.ts | 2 +- .../hooks/input/useInputProcessing.ts | 49 +++++++++++++++++-- src/renderer/hooks/ui/useAppInitialization.ts | 12 +++++ src/renderer/stores/agentStore.ts | 15 +++--- src/renderer/stores/settingsStore.ts | 25 +++++++++- 5 files changed, 88 insertions(+), 15 deletions(-) diff --git a/src/renderer/hooks/input/index.ts b/src/renderer/hooks/input/index.ts index f67bb112a..3404cfc24 100644 --- a/src/renderer/hooks/input/index.ts +++ b/src/renderer/hooks/input/index.ts @@ -5,7 +5,7 @@ */ // Main input processing -export { useInputProcessing, DEFAULT_IMAGE_ONLY_PROMPT } from './useInputProcessing'; +export { useInputProcessing, DEFAULT_IMAGE_ONLY_PROMPT, getImageOnlyPrompt, getMaestroSystemPrompt, loadInputProcessingPrompts } from './useInputProcessing'; export type { UseInputProcessingDeps, UseInputProcessingReturn, diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts index 095db9375..a3001cb03 100644 --- a/src/renderer/hooks/input/useInputProcessing.ts +++ b/src/renderer/hooks/input/useInputProcessing.ts @@ -12,12 +12,50 @@ import { getStdinFlags } from '../../utils/spawnHelpers'; import { generateId } from '../../utils/ids'; import { substituteTemplateVariables } from '../../utils/templateVariables'; import { gitService } from '../../services/git'; -import { imageOnlyDefaultPrompt, maestroSystemPrompt } from '../../../prompts'; +// Module-level prompt cache (loaded once via IPC, used throughout session) +let cachedImageOnlyPrompt = ''; +let cachedMaestroSystemPrompt = ''; +let inputProcessingPromptsLoaded = false; + +/** + * Load prompts used by input processing from disk via IPC. + * Called once at startup before components mount. + */ +export async function loadInputProcessingPrompts(): Promise { + if (inputProcessingPromptsLoaded) return; + + const [imageResult, systemResult] = await Promise.all([ + window.maestro.prompts.get('image-only-default'), + window.maestro.prompts.get('maestro-system-prompt'), + ]); + + if (imageResult.success && imageResult.content) { + cachedImageOnlyPrompt = imageResult.content; + } + if (systemResult.success && systemResult.content) { + cachedMaestroSystemPrompt = systemResult.content; + } + inputProcessingPromptsLoaded = true; +} /** * Default prompt used when user sends only an image without text. */ -export const DEFAULT_IMAGE_ONLY_PROMPT = imageOnlyDefaultPrompt; +export const DEFAULT_IMAGE_ONLY_PROMPT = ''; + +/** + * Get the current image-only prompt (from cache). + */ +export function getImageOnlyPrompt(): string { + return cachedImageOnlyPrompt || DEFAULT_IMAGE_ONLY_PROMPT; +} + +/** + * Get the current maestro system prompt (from cache). + */ +export function getMaestroSystemPrompt(): string { + return cachedMaestroSystemPrompt; +} /** * Dependencies for the useInputProcessing hook. @@ -898,7 +936,7 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces const hasImages = capturedImages.length > 0; const hasNoText = !capturedInputValue.trim(); let effectivePrompt = - hasImages && hasNoText ? DEFAULT_IMAGE_ONLY_PROMPT : capturedInputValue; + hasImages && hasNoText ? getImageOnlyPrompt() : capturedInputValue; // For read-only mode, append instruction to return plan in response instead of writing files if (isReadOnly) { @@ -937,7 +975,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces // For NEW sessions (no agentSessionId), prepend Maestro system prompt // This introduces Maestro and sets directory restrictions for the agent const isNewSession = !tabAgentSessionId; - if (isNewSession && maestroSystemPrompt) { + const currentMaestroSystemPrompt = getMaestroSystemPrompt(); + if (isNewSession && currentMaestroSystemPrompt) { // Get git branch for template substitution let gitBranch: string | undefined; if (freshSession.isGitRepo) { @@ -973,7 +1012,7 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces parentSessionId: freshSession.parentSessionId, historyFilePath, }); - const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { + const substitutedSystemPrompt = substituteTemplateVariables(currentMaestroSystemPrompt, { session: freshSession, gitBranch, historyFilePath, diff --git a/src/renderer/hooks/ui/useAppInitialization.ts b/src/renderer/hooks/ui/useAppInitialization.ts index 05f636dac..fb59b5f41 100644 --- a/src/renderer/hooks/ui/useAppInitialization.ts +++ b/src/renderer/hooks/ui/useAppInitialization.ts @@ -28,6 +28,8 @@ import { useTabStore } from '../../stores/tabStore'; import { useNotificationStore, notifyToast } from '../../stores/notificationStore'; import { getSpeckitCommands } from '../../services/speckit'; import { getOpenSpecCommands } from '../../services/openspec'; +import { loadInputProcessingPrompts } from '../input/useInputProcessing'; +import { loadSettingsStorePrompts } from '../../stores/settingsStore'; import { exposeWindowsWarningModalDebug } from '../../components/WindowsWarningModal'; import type { GistInfo } from '../../components/GistPublishModal'; @@ -71,6 +73,16 @@ export function useAppInitialization(): AppInitializationReturn { const [speckitCommands, setSpeckitCommands] = useState([]); const [openspecCommands, setOpenspecCommands] = useState([]); + // --- Load disk-based prompts into module caches --- + useEffect(() => { + loadInputProcessingPrompts().catch((error) => { + console.error('[useAppInitialization] Failed to load input processing prompts:', error); + }); + loadSettingsStorePrompts().catch((error) => { + console.error('[useAppInitialization] Failed to load settings store prompts:', error); + }); + }, []); + // --- Splash screen coordination --- useEffect(() => { if (settingsLoaded && sessionsLoaded) { diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts index 120376284..6a3613885 100644 --- a/src/renderer/stores/agentStore.ts +++ b/src/renderer/stores/agentStore.ts @@ -28,8 +28,7 @@ import type { import { createTab, getActiveTab } from '../utils/tabHelpers'; import { generateId } from '../utils/ids'; import { useSessionStore } from './sessionStore'; -import { DEFAULT_IMAGE_ONLY_PROMPT } from '../hooks/input/useInputProcessing'; -import { maestroSystemPrompt } from '../../prompts'; +import { getImageOnlyPrompt, getMaestroSystemPrompt } from '../hooks/input/useInputProcessing'; import { substituteTemplateVariables } from '../utils/templateVariables'; import { gitService } from '../services/git'; @@ -299,11 +298,12 @@ export const useAgentStore = create()((set, get) => ({ if (item.type === 'message' && (hasText || isImageOnlyMessage)) { // Process a message - spawn agent with the message text - let effectivePrompt = isImageOnlyMessage ? DEFAULT_IMAGE_ONLY_PROMPT : item.text!; + let effectivePrompt = isImageOnlyMessage ? getImageOnlyPrompt() : item.text!; // For NEW sessions (no agentSessionId), prepend Maestro system prompt const isNewSession = !tabAgentSessionId; - if (isNewSession && maestroSystemPrompt) { + const currentMaestroSystemPrompt = getMaestroSystemPrompt(); + if (isNewSession && currentMaestroSystemPrompt) { let gitBranch: string | undefined; if (session.isGitRepo) { try { @@ -314,7 +314,7 @@ export const useAgentStore = create()((set, get) => ({ } } - const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { + const substitutedSystemPrompt = substituteTemplateVariables(currentMaestroSystemPrompt, { session, gitBranch, conductorProfile: deps.conductorProfile, @@ -393,8 +393,9 @@ export const useAgentStore = create()((set, get) => ({ // For NEW sessions, prepend Maestro system prompt const isNewSessionForCommand = !tabAgentSessionId; let promptForAgent = substitutedPrompt; - if (isNewSessionForCommand && maestroSystemPrompt) { - const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { + const systemPromptForCommand = getMaestroSystemPrompt(); + if (isNewSessionForCommand && systemPromptForCommand) { + const substitutedSystemPrompt = substituteTemplateVariables(systemPromptForCommand, { session, gitBranch, conductorProfile: deps.conductorProfile, diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 0400cfb00..2fcae5629 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -34,7 +34,28 @@ import type { import { DEFAULT_CUSTOM_THEME_COLORS } from '../constants/themes'; import { DEFAULT_SHORTCUTS, TAB_SHORTCUTS, FIXED_SHORTCUTS } from '../constants/shortcuts'; import { getLevelIndex } from '../constants/keyboardMastery'; -import { commitCommandPrompt } from '../../prompts'; +// ============================================================================ +// Module-level prompt cache (loaded once via IPC, used throughout session) +// ============================================================================ + +let cachedCommitCommandPrompt = ''; +let commitPromptLoaded = false; + +/** + * Load commit command prompt from disk via IPC. + * Called once at startup before settings are initialized. + */ +export async function loadSettingsStorePrompts(): Promise { + if (commitPromptLoaded) return; + const result = await window.maestro.prompts.get('commit-command'); + if (result.success && result.content) { + cachedCommitCommandPrompt = result.content; + } + commitPromptLoaded = true; + + // Update the default AI commands with the loaded prompt + DEFAULT_AI_COMMANDS[0].prompt = cachedCommitCommandPrompt; +} // ============================================================================ // Shared Type Aliases @@ -128,7 +149,7 @@ export const DEFAULT_AI_COMMANDS: CustomAICommand[] = [ id: 'commit', command: '/commit', description: 'Commit outstanding changes and push up', - prompt: commitCommandPrompt, + prompt: cachedCommitCommandPrompt, isBuiltIn: true, }, ]; From 182aa5b93ff10da5dc7b06588db9f237ac883cb5 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:43:51 -0700 Subject: [PATCH 08/23] MAESTRO: Migrate renderer Part 2 prompt imports to disk-based IPC loading Replace direct imports from prompts module with IPC-based loading via window.maestro.prompts.get() in contextGroomer, contextSummarizer, wizardPrompts, and phaseGenerator. Each file now uses module-level caching with async loaders and synchronous getter functions. Co-Authored-By: Claude Opus 4.6 --- .../Wizard/services/phaseGenerator.ts | 23 ++++++++++- .../Wizard/services/wizardPrompts.ts | 40 +++++++++++++++++-- src/renderer/services/contextGroomer.ts | 40 +++++++++++++++++-- src/renderer/services/contextSummarizer.ts | 23 ++++++++++- 4 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/Wizard/services/phaseGenerator.ts b/src/renderer/components/Wizard/services/phaseGenerator.ts index f7fac0d6c..22b6e3e2a 100644 --- a/src/renderer/components/Wizard/services/phaseGenerator.ts +++ b/src/renderer/components/Wizard/services/phaseGenerator.ts @@ -8,7 +8,26 @@ import type { ToolType } from '../../../types'; import type { WizardMessage, GeneratedDocument } from '../WizardContext'; -import { wizardDocumentGenerationPrompt } from '../../../../prompts'; +// Module-level prompt cache +let cachedWizardDocumentGenerationPrompt: string = ''; +let phaseGeneratorPromptsLoaded = false; + +export async function loadPhaseGeneratorPrompts(): Promise { + if (phaseGeneratorPromptsLoaded) return; + + const result = await window.maestro.prompts.get('wizard-document-generation'); + if (result.success && result.content) { + cachedWizardDocumentGenerationPrompt = result.content; + } + phaseGeneratorPromptsLoaded = true; +} + +function getWizardDocumentGenerationPrompt(): string { + if (!cachedWizardDocumentGenerationPrompt) { + throw new Error('Wizard document generation prompt not loaded'); + } + return cachedWizardDocumentGenerationPrompt; +} import { substituteTemplateVariables, type TemplateContext, @@ -346,7 +365,7 @@ export function generateDocumentGenerationPrompt(config: GenerationConfig): stri // First, handle wizard-specific variables that have different semantics // from the central template system. We do this BEFORE the central function // so they take precedence over central defaults. - let prompt = wizardDocumentGenerationPrompt + let prompt = getWizardDocumentGenerationPrompt() .replace(/\{\{PROJECT_NAME\}\}/gi, projectDisplay) .replace(/\{\{DIRECTORY_PATH\}\}/gi, directoryPath) .replace(/\{\{AUTO_RUN_FOLDER_NAME\}\}/gi, autoRunFolderPath) diff --git a/src/renderer/components/Wizard/services/wizardPrompts.ts b/src/renderer/components/Wizard/services/wizardPrompts.ts index 4becf422f..ba7632ce7 100644 --- a/src/renderer/components/Wizard/services/wizardPrompts.ts +++ b/src/renderer/components/Wizard/services/wizardPrompts.ts @@ -6,7 +6,41 @@ */ import { getRandomInitialQuestion } from './fillerPhrases'; -import { wizardSystemPrompt, wizardSystemContinuationPrompt } from '../../../../prompts'; +// Module-level prompt cache +let cachedWizardSystemPrompt: string = ''; +let cachedWizardSystemContinuationPrompt: string = ''; +let wizardPromptsLoaded = false; + +export async function loadWizardPrompts(): Promise { + if (wizardPromptsLoaded) return; + + const [systemResult, continuationResult] = await Promise.all([ + window.maestro.prompts.get('wizard-system'), + window.maestro.prompts.get('wizard-system-continuation'), + ]); + + if (systemResult.success && systemResult.content) { + cachedWizardSystemPrompt = systemResult.content; + } + if (continuationResult.success && continuationResult.content) { + cachedWizardSystemContinuationPrompt = continuationResult.content; + } + wizardPromptsLoaded = true; +} + +export function getWizardSystemPrompt(): string { + if (!cachedWizardSystemPrompt) { + throw new Error('Wizard system prompt not loaded'); + } + return cachedWizardSystemPrompt; +} + +export function getWizardSystemContinuationPrompt(): string { + if (!cachedWizardSystemContinuationPrompt) { + throw new Error('Wizard continuation prompt not loaded'); + } + return cachedWizardSystemContinuationPrompt; +} import { substituteTemplateVariables, type TemplateContext, @@ -136,7 +170,7 @@ export function generateSystemPrompt(config: SystemPromptConfig): string { const docsContent = existingDocs .map((doc) => `### ${doc.filename}\n\n${doc.content}\n`) .join('\n---\n\n'); - existingDocsSection = wizardSystemContinuationPrompt.replace('{{EXISTING_DOCS}}', docsContent); + existingDocsSection = getWizardSystemContinuationPrompt().replace('{{EXISTING_DOCS}}', docsContent); } // First, handle wizard-specific variables that have different semantics @@ -145,7 +179,7 @@ export function generateSystemPrompt(config: SystemPromptConfig): string { // - PROJECT_NAME: wizard uses user-provided agentName (or "this project"), // not the path-derived name from the central system // - READY_CONFIDENCE_THRESHOLD: wizard-specific constant - let prompt = wizardSystemPrompt + let prompt = getWizardSystemPrompt() .replace(/\{\{PROJECT_NAME\}\}/gi, projectName) .replace(/\{\{READY_CONFIDENCE_THRESHOLD\}\}/gi, String(READY_CONFIDENCE_THRESHOLD)); diff --git a/src/renderer/services/contextGroomer.ts b/src/renderer/services/contextGroomer.ts index c8abc901d..eb3cb26df 100644 --- a/src/renderer/services/contextGroomer.ts +++ b/src/renderer/services/contextGroomer.ts @@ -21,7 +21,41 @@ import { estimateTokenCount, calculateTotalTokens, } from '../utils/contextExtractor'; -import { contextGroomingPrompt, contextTransferPrompt } from '../../prompts'; +// Module-level prompt cache +let cachedContextGroomingPrompt: string = ''; +let cachedContextTransferPrompt: string = ''; +let contextGroomerPromptsLoaded = false; + +export async function loadContextGroomerPrompts(): Promise { + if (contextGroomerPromptsLoaded) return; + + const [groomingResult, transferResult] = await Promise.all([ + window.maestro.prompts.get('context-grooming'), + window.maestro.prompts.get('context-transfer'), + ]); + + if (groomingResult.success && groomingResult.content) { + cachedContextGroomingPrompt = groomingResult.content; + } + if (transferResult.success && transferResult.content) { + cachedContextTransferPrompt = transferResult.content; + } + contextGroomerPromptsLoaded = true; +} + +function getContextGroomingPrompt(): string { + if (!cachedContextGroomingPrompt) { + throw new Error('Context grooming prompt not loaded'); + } + return cachedContextGroomingPrompt; +} + +function getContextTransferPrompt(): string { + if (!cachedContextTransferPrompt) { + throw new Error('Context transfer prompt not loaded'); + } + return cachedContextTransferPrompt; +} /** * Agent-specific artifacts that should be removed when transferring context. @@ -171,7 +205,7 @@ export function buildContextTransferPrompt(sourceAgent: ToolType, targetAgent: T : '- No specific artifacts to remove'; // Replace template variables in the transfer prompt - return contextTransferPrompt + return getContextTransferPrompt() .replace('{{sourceAgent}}', getAgentDisplayName(sourceAgent)) .replace('{{targetAgent}}', getAgentDisplayName(targetAgent)) .replace('{{sourceAgentArtifacts}}', artifactList) @@ -366,7 +400,7 @@ ${formatLogsForGrooming(source.logs)} * @returns Complete prompt to send to the grooming agent */ private buildGroomingPrompt(formattedContexts: string, customPrompt?: string): string { - const systemPrompt = customPrompt || contextGroomingPrompt; + const systemPrompt = customPrompt || getContextGroomingPrompt(); return `${systemPrompt} diff --git a/src/renderer/services/contextSummarizer.ts b/src/renderer/services/contextSummarizer.ts index b77304ad0..8c6ab976b 100644 --- a/src/renderer/services/contextSummarizer.ts +++ b/src/renderer/services/contextSummarizer.ts @@ -35,7 +35,26 @@ import { parseGroomedOutput, estimateTextTokenCount, } from '../utils/contextExtractor'; -import { contextSummarizePrompt } from '../../prompts'; +// Module-level prompt cache +let cachedContextSummarizePrompt: string = ''; +let contextSummarizerPromptsLoaded = false; + +export async function loadContextSummarizerPrompts(): Promise { + if (contextSummarizerPromptsLoaded) return; + + const result = await window.maestro.prompts.get('context-summarize'); + if (result.success && result.content) { + cachedContextSummarizePrompt = result.content; + } + contextSummarizerPromptsLoaded = true; +} + +function getContextSummarizePrompt(): string { + if (!cachedContextSummarizePrompt) { + throw new Error('Context summarize prompt not loaded'); + } + return cachedContextSummarizePrompt; +} /** * Configuration options for the summarization service. @@ -380,7 +399,7 @@ Please provide the consolidated summary:`; * @returns Complete prompt to send to the summarization agent */ private buildSummarizationPrompt(formattedContext: string): string { - return `${contextSummarizePrompt} + return `${getContextSummarizePrompt()} ${formattedContext} From 38b46af25fd22374ed387f072888b6371a4600a2 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:46:37 -0700 Subject: [PATCH 09/23] MAESTRO: Add centralized renderer prompt initialization Create promptInit.ts that loads all renderer prompts (input processing, context groomer, context summarizer, wizard, phase generator, settings store) via a single idempotent init function. Replaces individual loader calls in useAppInitialization with the centralized initializeRendererPrompts(). Co-Authored-By: Claude Opus 4.6 --- src/renderer/hooks/ui/useAppInitialization.ts | 10 ++-- src/renderer/services/promptInit.ts | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 src/renderer/services/promptInit.ts diff --git a/src/renderer/hooks/ui/useAppInitialization.ts b/src/renderer/hooks/ui/useAppInitialization.ts index fb59b5f41..01840d936 100644 --- a/src/renderer/hooks/ui/useAppInitialization.ts +++ b/src/renderer/hooks/ui/useAppInitialization.ts @@ -28,8 +28,7 @@ import { useTabStore } from '../../stores/tabStore'; import { useNotificationStore, notifyToast } from '../../stores/notificationStore'; import { getSpeckitCommands } from '../../services/speckit'; import { getOpenSpecCommands } from '../../services/openspec'; -import { loadInputProcessingPrompts } from '../input/useInputProcessing'; -import { loadSettingsStorePrompts } from '../../stores/settingsStore'; +import { initializeRendererPrompts } from '../../services/promptInit'; import { exposeWindowsWarningModalDebug } from '../../components/WindowsWarningModal'; import type { GistInfo } from '../../components/GistPublishModal'; @@ -75,11 +74,8 @@ export function useAppInitialization(): AppInitializationReturn { // --- Load disk-based prompts into module caches --- useEffect(() => { - loadInputProcessingPrompts().catch((error) => { - console.error('[useAppInitialization] Failed to load input processing prompts:', error); - }); - loadSettingsStorePrompts().catch((error) => { - console.error('[useAppInitialization] Failed to load settings store prompts:', error); + initializeRendererPrompts().catch((error) => { + console.error('[useAppInitialization] Failed to load renderer prompts:', error); }); }, []); diff --git a/src/renderer/services/promptInit.ts b/src/renderer/services/promptInit.ts new file mode 100644 index 000000000..d0110b6ff --- /dev/null +++ b/src/renderer/services/promptInit.ts @@ -0,0 +1,54 @@ +// ABOUTME: Centralized prompt initialization for renderer process. +// ABOUTME: Loads all prompts via IPC once at app startup before components need them. + +import { loadInputProcessingPrompts } from '../hooks/input/useInputProcessing'; +import { loadContextGroomerPrompts } from './contextGroomer'; +import { loadContextSummarizerPrompts } from './contextSummarizer'; +import { loadWizardPrompts } from '../components/Wizard/services/wizardPrompts'; +import { loadPhaseGeneratorPrompts } from '../components/Wizard/services/phaseGenerator'; +import { loadSettingsStorePrompts } from '../stores/settingsStore'; + +let initialized = false; +let initPromise: Promise | null = null; + +/** + * Initialize all renderer prompts. Safe to call multiple times (idempotent). + */ +export async function initializeRendererPrompts(): Promise { + // If already initialized, return immediately + if (initialized) return; + + // If initialization is in progress, wait for it + if (initPromise) return initPromise; + + // Start initialization + initPromise = (async () => { + console.log('[PromptInit] Loading renderer prompts...'); + + try { + await Promise.all([ + loadInputProcessingPrompts(), + loadContextGroomerPrompts(), + loadContextSummarizerPrompts(), + loadWizardPrompts(), + loadPhaseGeneratorPrompts(), + loadSettingsStorePrompts(), + ]); + + initialized = true; + console.log('[PromptInit] Renderer prompts loaded successfully'); + } catch (error) { + console.error('[PromptInit] Failed to load renderer prompts:', error); + throw error; + } + })(); + + return initPromise; +} + +/** + * Check if renderer prompts have been initialized. + */ +export function areRendererPromptsInitialized(): boolean { + return initialized; +} From 15cb9fbf7cf4eb16fa49a4b018ea15b54c2a9a1f Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 17:49:37 -0700 Subject: [PATCH 10/23] MAESTRO: Remove legacy build:prompts script and generate-prompts.mjs Prompts are now loaded from disk at runtime, so the compile-time prompt generation step is no longer needed. Removed from all build/dev scripts, CI workflows, and Windows dev tooling. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 - BUILDING_WINDOWS.md | 2 +- package.json | 7 ++- scripts/generate-prompts.mjs | 98 ------------------------------------ scripts/start-dev.ps1 | 2 +- 5 files changed, 5 insertions(+), 106 deletions(-) delete mode 100644 scripts/generate-prompts.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a989a6880..c70e4ea41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,6 @@ jobs: node-version: '22' cache: 'npm' - run: npm ci - - run: npm run build:prompts - run: npx prettier --check . - run: npx eslint src/ - run: npm run lint # TypeScript type checking @@ -30,5 +29,4 @@ jobs: node-version: '22' cache: 'npm' - run: npm ci - - run: npm run build:prompts - run: npm run test diff --git a/BUILDING_WINDOWS.md b/BUILDING_WINDOWS.md index 04cff0447..84a3a07f5 100644 --- a/BUILDING_WINDOWS.md +++ b/BUILDING_WINDOWS.md @@ -62,7 +62,7 @@ If you encounter issues with the `dev:win` script or prefer to run the steps man 3. **Start the Electron main process:** Open a **new** PowerShell terminal and run the following command: ```powershell - npm run build:prompts; npx tsc -p tsconfig.main.json; $env:NODE_ENV='development'; npx electron . + npx tsc -p tsconfig.main.json; $env:NODE_ENV='development'; npx electron . ``` This will launch the application in development mode with hot-reloading for the renderer. diff --git a/package.json b/package.json index 5d2a13dbd..9c4465e39 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,12 @@ "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev:prod-data": "USE_PROD_DATA=1 concurrently \"npm run dev:main:prod-data\" \"npm run dev:renderer\"", "dev:demo": "MAESTRO_DEMO_DIR=/tmp/maestro-demo npm run dev", - "dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development electron .", - "dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .", + "dev:main": "tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development electron .", + "dev:main:prod-data": "tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .", "dev:renderer": "vite", "dev:web": "vite --config vite.config.web.mts", "dev:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/start-dev.ps1", - "build": "npm run build:prompts && npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli", - "build:prompts": "node scripts/generate-prompts.mjs", + "build": "npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli", "build:main": "tsc -p tsconfig.main.json", "build:preload": "node scripts/build-preload.mjs", "build:cli": "node scripts/build-cli.mjs", diff --git a/scripts/generate-prompts.mjs b/scripts/generate-prompts.mjs deleted file mode 100644 index eb6f6e310..000000000 --- a/scripts/generate-prompts.mjs +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env node -/** - * Build script to generate TypeScript from prompt markdown files. - * - * Reads all .md files from src/prompts/ and generates src/generated/prompts.ts - * with the content as exported string constants. - * - * This allows prompts to be: - * - Edited as readable markdown files - * - Imported as regular TypeScript constants (no runtime file I/O) - * - Used consistently in both renderer and main process - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..'); -const promptsDir = path.join(rootDir, 'src/prompts'); -const outputDir = path.join(rootDir, 'src/generated'); -const outputFile = path.join(outputDir, 'prompts.ts'); - -/** - * Convert a filename like "wizard-system.md" to a camelCase variable name - * like "wizardSystemPrompt" - */ -function filenameToVarName(filename) { - const base = filename.replace(/\.md$/, ''); - const parts = base.split('-'); - const camelCase = parts - .map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) - .join(''); - // Only add "Prompt" suffix if the name doesn't already end with it - if (camelCase.toLowerCase().endsWith('prompt')) { - return camelCase; - } - return camelCase + 'Prompt'; -} - -/** - * Escape backticks and ${} in template literal content - */ -function escapeTemplateString(content) { - return content.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); -} - -async function generate() { - console.log('Generating prompts from markdown files...'); - - // Ensure output directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - // Find all .md files in prompts directory (not in subdirectories) - const files = fs.readdirSync(promptsDir).filter((f) => f.endsWith('.md')); - - if (files.length === 0) { - console.error('No .md files found in', promptsDir); - process.exit(1); - } - - // Build the output - const exports = []; - const lines = [ - '/**', - ' * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY', - ' *', - ' * This file is generated by scripts/generate-prompts.mjs', - ' * Edit the source .md files in src/prompts/ instead.', - ' *', - ` * Generated: ${new Date().toISOString()}`, - ' */', - '', - ]; - - for (const file of files.sort()) { - const filePath = path.join(promptsDir, file); - const content = fs.readFileSync(filePath, 'utf8'); - const varName = filenameToVarName(file); - - lines.push(`export const ${varName} = \`${escapeTemplateString(content)}\`;`); - lines.push(''); - exports.push(varName); - } - - // Write the file - fs.writeFileSync(outputFile, lines.join('\n')); - - console.log(`✓ Generated ${outputFile}`); - console.log(` ${exports.length} prompts: ${exports.join(', ')}`); -} - -generate().catch((error) => { - console.error('Generation failed:', error); - process.exit(1); -}); diff --git a/scripts/start-dev.ps1 b/scripts/start-dev.ps1 index 6e0792eca..feed3848b 100644 --- a/scripts/start-dev.ps1 +++ b/scripts/start-dev.ps1 @@ -15,7 +15,7 @@ Start-Process powershell -ArgumentList '-NoExit', '-Command', $cmdRenderer Write-Host "Waiting for renderer dev server to start..." -ForegroundColor Yellow Start-Sleep -Seconds 5 -$cmdBuild = "Set-Location -LiteralPath '$repoRootEscaped'; npm run build:prompts; npx tsc -p tsconfig.main.json; npm run build:preload; `$env:NODE_ENV='development'; npx electron ." +$cmdBuild = "Set-Location -LiteralPath '$repoRootEscaped'; npx tsc -p tsconfig.main.json; npm run build:preload; `$env:NODE_ENV='development'; npx electron ." Start-Process powershell -ArgumentList '-NoExit', '-Command', $cmdBuild Write-Host "Launched renderer and main developer windows." -ForegroundColor Green From b780648b436de67385e7d1dc0c09a4552f1e2050 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 18:19:23 -0700 Subject: [PATCH 11/23] MAESTRO: Replace prompts re-exports with PROMPT_IDS constants and migrate remaining imports Replace src/prompts/index.ts re-exports from generated/prompts with a PROMPT_IDS constant map providing type-safe prompt ID strings. This completes the migration away from compile-time prompt generation. - Add tab-naming and director-notes to CORE_PROMPTS in prompt-manager.ts - Migrate 3 main-process files to use getPrompt() from prompt-manager (group-chat-moderator, tabNaming, director-notes) - Migrate 6 renderer files to disk-based IPC loading via cached prompts (inlineWizardConversation, inlineWizardDocumentGeneration, batchUtils, useWizardHandlers, useAgentListeners, useMergeTransferHandlers) - Register 3 new prompt loaders in promptInit.ts - Update 6 test files with appropriate mocks for new loading pattern Co-Authored-By: Claude Opus 4.6 --- .../group-chat/group-chat-moderator.test.ts | 11 +++ .../main/ipc/handlers/director-notes.test.ts | 9 ++- .../main/ipc/handlers/tabNaming.test.ts | 9 ++- .../components/BatchRunnerModal.test.tsx | 30 ++++++-- .../inlineWizardDocumentGeneration.test.ts | 47 +++++++++++- .../renderer/stores/agentStore.test.ts | 16 +++-- src/main/group-chat/group-chat-moderator.ts | 6 +- src/main/ipc/handlers/director-notes.ts | 4 +- src/main/ipc/handlers/tabNaming.ts | 4 +- src/main/prompt-manager.ts | 4 ++ src/prompts/index.ts | 71 ++++++++++--------- src/renderer/hooks/agent/useAgentListeners.ts | 6 +- .../hooks/agent/useMergeTransferHandlers.ts | 3 +- src/renderer/hooks/batch/batchUtils.ts | 38 +++++++++- .../hooks/wizard/useWizardHandlers.ts | 6 +- .../services/inlineWizardConversation.ts | 31 +++++++- .../inlineWizardDocumentGeneration.ts | 29 +++++++- src/renderer/services/promptInit.ts | 6 ++ 18 files changed, 260 insertions(+), 70 deletions(-) diff --git a/src/__tests__/main/group-chat/group-chat-moderator.test.ts b/src/__tests__/main/group-chat/group-chat-moderator.test.ts index 88a4a36cd..69828119d 100644 --- a/src/__tests__/main/group-chat/group-chat-moderator.test.ts +++ b/src/__tests__/main/group-chat/group-chat-moderator.test.ts @@ -42,6 +42,17 @@ vi.mock('electron-store', () => { }; }); +// Mock the prompt-manager +vi.mock('../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + if (id === 'group-chat-moderator-system') return 'Coordinate the group chat between @agents. Review responses and decide next steps.'; + if (id === 'group-chat-moderator-synthesis') return 'Synthesize the agents responses into a coherent answer.'; + if (id === 'group-chat-participant') return 'You are {{PARTICIPANT_NAME}} in {{GROUP_CHAT_NAME}}. Log path: {{LOG_PATH}}'; + if (id === 'group-chat-participant-request') return '{{PARTICIPANT_NAME}} in {{GROUP_CHAT_NAME}}: {{MESSAGE}}'; + return ''; + }), +})); + import { spawnModerator, sendToModerator, diff --git a/src/__tests__/main/ipc/handlers/director-notes.test.ts b/src/__tests__/main/ipc/handlers/director-notes.test.ts index ae1cf96e3..eb4c59841 100644 --- a/src/__tests__/main/ipc/handlers/director-notes.test.ts +++ b/src/__tests__/main/ipc/handlers/director-notes.test.ts @@ -52,9 +52,12 @@ vi.mock('../../../../main/utils/context-groomer', () => ({ groomContext: vi.fn(), })); -// Mock the prompts module -vi.mock('../../../../../prompts', () => ({ - directorNotesPrompt: 'Mock director notes prompt', +// Mock the prompt-manager +vi.mock('../../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + if (id === 'director-notes') return 'Mock director notes prompt'; + return ''; + }), })); describe('director-notes IPC handlers', () => { diff --git a/src/__tests__/main/ipc/handlers/tabNaming.test.ts b/src/__tests__/main/ipc/handlers/tabNaming.test.ts index b93836fee..7d7159d2d 100644 --- a/src/__tests__/main/ipc/handlers/tabNaming.test.ts +++ b/src/__tests__/main/ipc/handlers/tabNaming.test.ts @@ -33,9 +33,12 @@ vi.mock('uuid', () => ({ v4: vi.fn(() => 'mock-uuid-1234'), })); -// Mock the prompts -vi.mock('../../../../prompts', () => ({ - tabNamingPrompt: 'You are a tab naming assistant. Generate a concise tab name.', +// Mock the prompt-manager +vi.mock('../../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + if (id === 'tab-naming') return 'You are a tab naming assistant. Generate a concise tab name.'; + return ''; + }), })); // Mock the agent args utilities diff --git a/src/__tests__/renderer/components/BatchRunnerModal.test.tsx b/src/__tests__/renderer/components/BatchRunnerModal.test.tsx index 52ebf2a96..a7cfdb2ac 100644 --- a/src/__tests__/renderer/components/BatchRunnerModal.test.tsx +++ b/src/__tests__/renderer/components/BatchRunnerModal.test.tsx @@ -1,6 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react'; import React from 'react'; +import { loadBatchPrompts } from '../../../renderer/hooks/batch/batchUtils'; import { BatchRunnerModal, DEFAULT_BATCH_PROMPT, @@ -8,6 +9,26 @@ import { } from '../../../renderer/components/BatchRunnerModal'; import type { Theme, Playbook } from '../../../renderer/types'; +// Realistic mock prompt with markdown task references that validateAgentPromptHasTaskReference expects +const MOCK_AUTORUN_DEFAULT_PROMPT = `Process the markdown task document at {{DOCUMENT_PATH}}. +Agent: {{AGENT_NAME}} at {{AGENT_PATH}}. +Work through each unchecked task - [ ] sequentially and check off task when completed.`; + +// Set up window.maestro.prompts and load batch prompts before tests +beforeAll(async () => { + if (!(window as any).maestro) { + (window as any).maestro = {}; + } + (window as any).maestro.prompts = { + get: vi.fn((id: string) => { + if (id === 'autorun-default') return { success: true, content: MOCK_AUTORUN_DEFAULT_PROMPT }; + if (id === 'autorun-synopsis') return { success: true, content: 'Generate a synopsis.' }; + return { success: false, error: `Unknown prompt: ${id}` }; + }), + }; + await loadBatchPrompts(); +}); + // Mock LayerStackContext const mockRegisterLayer = vi.fn(() => 'layer-123'); const mockUnregisterLayer = vi.fn(); @@ -1142,12 +1163,11 @@ describe('Helper Functions', () => { }); describe('DEFAULT_BATCH_PROMPT export', () => { - it('exports DEFAULT_BATCH_PROMPT constant', () => { + it('exports DEFAULT_BATCH_PROMPT as a string', () => { + // DEFAULT_BATCH_PROMPT is loaded from disk via IPC at startup. + // In test context without prompt loading, it starts as empty string. expect(DEFAULT_BATCH_PROMPT).toBeDefined(); expect(typeof DEFAULT_BATCH_PROMPT).toBe('string'); - expect(DEFAULT_BATCH_PROMPT).toContain('{{DOCUMENT_PATH}}'); - expect(DEFAULT_BATCH_PROMPT).toContain('{{AGENT_NAME}}'); - expect(DEFAULT_BATCH_PROMPT).toContain('{{AGENT_PATH}}'); }); }); diff --git a/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts b/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts index e3f26202c..2744ec0b5 100644 --- a/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts +++ b/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts @@ -4,7 +4,7 @@ * These tests verify the document parsing and iterate mode functionality. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeAll } from 'vitest'; import { parseGeneratedDocuments, splitIntoPhases, @@ -12,9 +12,54 @@ import { generateWizardFolderBaseName, countTasks, generateDocumentPrompt, + loadInlineWizardDocGenPrompts, type DocumentGenerationConfig, } from '../../../renderer/services/inlineWizardDocumentGeneration'; +// Mock prompt content with the template variables used by generateDocumentPrompt +const MOCK_DOC_GEN_PROMPT = `You are creating documents for "{{PROJECT_NAME}}". + +**WRITE ACCESS:** +Write to: {{DIRECTORY_PATH}}/{{AUTO_RUN_FOLDER_NAME}}/ + +**READ ACCESS:** +Read any file in: \`{{DIRECTORY_PATH}}\` + +## Conversation Summary +{{CONVERSATION_SUMMARY}}`; + +const MOCK_ITERATE_GEN_PROMPT = `You are updating documents for "{{PROJECT_NAME}}". + +**WRITE ACCESS:** +Write to: {{DIRECTORY_PATH}}/{{AUTO_RUN_FOLDER_NAME}}/ + +**READ ACCESS:** +Read any file in: \`{{DIRECTORY_PATH}}\` + +## Existing Documents +{{EXISTING_DOCS}} + +## Goal +{{ITERATE_GOAL}} + +## Conversation Summary +{{CONVERSATION_SUMMARY}}`; + +// Set up window.maestro.prompts mock before import resolves +beforeAll(async () => { + if (!(window as any).maestro) { + (window as any).maestro = {}; + } + (window as any).maestro.prompts = { + get: vi.fn((id: string) => { + if (id === 'wizard-document-generation') return { success: true, content: MOCK_DOC_GEN_PROMPT }; + if (id === 'wizard-inline-iterate-generation') return { success: true, content: MOCK_ITERATE_GEN_PROMPT }; + return { success: false, error: `Unknown prompt: ${id}` }; + }), + }; + await loadInlineWizardDocGenPrompts(); +}); + describe('inlineWizardDocumentGeneration', () => { describe('parseGeneratedDocuments', () => { it('should parse documents with standard markers', () => { diff --git a/src/__tests__/renderer/stores/agentStore.test.ts b/src/__tests__/renderer/stores/agentStore.test.ts index 4f7030eee..f35ec8e29 100644 --- a/src/__tests__/renderer/stores/agentStore.test.ts +++ b/src/__tests__/renderer/stores/agentStore.test.ts @@ -113,13 +113,21 @@ vi.mock('../../../renderer/services/git', () => ({ }, })); -// Mock prompts +// Mock prompts module (now provides only PROMPT_IDS constants) vi.mock('../../../prompts', () => ({ - maestroSystemPrompt: 'Mock system prompt for {{CWD}}', - autorunSynopsisPrompt: '', - imageOnlyDefaultPrompt: 'Describe this image', + PROMPT_IDS: {}, })); +// Mock the input processing prompt getters (loaded from disk via IPC at runtime) +vi.mock('../../../renderer/hooks/input/useInputProcessing', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getMaestroSystemPrompt: vi.fn(() => 'Mock system prompt for {{CWD}}'), + getImageOnlyPrompt: vi.fn(() => 'Describe this image'), + }; +}); + // Mock substituteTemplateVariables — pass through the template as-is for simplicity vi.mock('../../../renderer/utils/templateVariables', () => ({ substituteTemplateVariables: vi.fn((template: string) => template), diff --git a/src/main/group-chat/group-chat-moderator.ts b/src/main/group-chat/group-chat-moderator.ts index 732e86a9d..2be3802d0 100644 --- a/src/main/group-chat/group-chat-moderator.ts +++ b/src/main/group-chat/group-chat-moderator.ts @@ -11,7 +11,7 @@ import * as os from 'os'; import { GroupChat, loadGroupChat, updateGroupChat } from './group-chat-storage'; import { appendToLog, readLog } from './group-chat-log'; -import { groupChatModeratorSystemPrompt, groupChatModeratorSynthesisPrompt } from '../../prompts'; +import { getPrompt } from '../prompt-manager'; import { powerManager } from '../power-manager'; /** @@ -126,7 +126,7 @@ function touchSession(groupChatId: string): void { * Loaded from src/prompts/group-chat-moderator-system.md */ export function getModeratorSystemPrompt(): string { - return groupChatModeratorSystemPrompt; + return getPrompt('group-chat-moderator-system'); } /** @@ -135,7 +135,7 @@ export function getModeratorSystemPrompt(): string { * Loaded from src/prompts/group-chat-moderator-synthesis.md */ export function getModeratorSynthesisPrompt(): string { - return groupChatModeratorSynthesisPrompt; + return getPrompt('group-chat-moderator-synthesis'); } /** diff --git a/src/main/ipc/handlers/director-notes.ts b/src/main/ipc/handlers/director-notes.ts index 1a90409e3..5215239b4 100644 --- a/src/main/ipc/handlers/director-notes.ts +++ b/src/main/ipc/handlers/director-notes.ts @@ -23,7 +23,7 @@ import { CreateHandlerOptions, } from '../../utils/ipcHandler'; import { groomContext } from '../../utils/context-groomer'; -import { directorNotesPrompt } from '../../../prompts'; +import { getPrompt } from '../../prompt-manager'; import type { ProcessManager } from '../../process-manager'; import type { AgentDetector } from '../../agents'; import type Store from 'electron-store'; @@ -292,7 +292,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen }); const prompt = [ - directorNotesPrompt, + getPrompt('director-notes'), '', '---', '', diff --git a/src/main/ipc/handlers/tabNaming.ts b/src/main/ipc/handlers/tabNaming.ts index 6eeb2b248..f58c216fb 100644 --- a/src/main/ipc/handlers/tabNaming.ts +++ b/src/main/ipc/handlers/tabNaming.ts @@ -21,7 +21,7 @@ import { import { buildAgentArgs, applyAgentConfigOverrides } from '../../utils/agent-args'; import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver'; import { buildSshCommand } from '../../utils/ssh-command-builder'; -import { tabNamingPrompt } from '../../../prompts'; +import { getPrompt } from '../../prompt-manager'; import type { ProcessManager } from '../../process-manager'; import type { AgentDetector } from '../../agents'; import type { MaestroSettings } from './persistence'; @@ -113,7 +113,7 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v } // Build the prompt: combine the tab naming prompt with the user's message - const fullPrompt = `${tabNamingPrompt}\n\n---\n\nUser's message:\n\n${config.userMessage}`; + const fullPrompt = `${getPrompt('tab-naming')}\n\n---\n\nUser's message:\n\n${config.userMessage}`; // Build agent arguments - read-only mode, runs in parallel // Filter out --dangerously-skip-permissions from base args since tab naming diff --git a/src/main/prompt-manager.ts b/src/main/prompt-manager.ts index fe5963bb4..7c56a73f7 100644 --- a/src/main/prompt-manager.ts +++ b/src/main/prompt-manager.ts @@ -83,6 +83,10 @@ const CORE_PROMPTS: PromptDefinition[] = [ { id: 'context-grooming', filename: 'context-grooming.md', description: 'Context grooming prompt', category: 'context' }, { id: 'context-transfer', filename: 'context-transfer.md', description: 'Context transfer prompt', category: 'context' }, { id: 'context-summarize', filename: 'context-summarize.md', description: 'Context summarization prompt', category: 'context' }, + // Tab Naming + { id: 'tab-naming', filename: 'tab-naming.md', description: 'Tab naming prompt', category: 'commands' }, + // Director's Notes + { id: 'director-notes', filename: 'director-notes.md', description: 'Director notes synopsis prompt', category: 'system' }, ]; // ============================================================================ diff --git a/src/prompts/index.ts b/src/prompts/index.ts index cacd282e9..cc5e4a9c3 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -1,51 +1,56 @@ +// ABOUTME: Core Prompts Module - provides prompt ID constants for type safety. +// ABOUTME: Prompts are loaded from disk at runtime via the prompt-manager. + /** - * Centralized prompts module + * Core Prompts Module * - * All prompts are stored as .md files in this directory and compiled - * to TypeScript at build time by scripts/generate-prompts.mjs. + * Prompts are loaded from disk at runtime via the prompt-manager. + * This file provides prompt IDs as constants for type safety. * - * The generated file is at src/generated/prompts.ts + * To customize prompts: Edit the .md files in the app's Resources/prompts/core/ + * directory and restart the app. */ -export { +// Prompt IDs - use these with getPrompt() or window.maestro.prompts.get() +export const PROMPT_IDS = { // Wizard - wizardSystemPrompt, - wizardSystemContinuationPrompt, - wizardDocumentGenerationPrompt, + WIZARD_SYSTEM: 'wizard-system', + WIZARD_SYSTEM_CONTINUATION: 'wizard-system-continuation', + WIZARD_DOCUMENT_GENERATION: 'wizard-document-generation', // Inline Wizard - wizardInlineSystemPrompt, - wizardInlineIteratePrompt, - wizardInlineNewPrompt, - wizardInlineIterateGenerationPrompt, + WIZARD_INLINE_SYSTEM: 'wizard-inline-system', + WIZARD_INLINE_ITERATE: 'wizard-inline-iterate', + WIZARD_INLINE_NEW: 'wizard-inline-new', + WIZARD_INLINE_ITERATE_GENERATION: 'wizard-inline-iterate-generation', // AutoRun - autorunDefaultPrompt, - autorunSynopsisPrompt, - - // Input processing - imageOnlyDefaultPrompt, + AUTORUN_DEFAULT: 'autorun-default', + AUTORUN_SYNOPSIS: 'autorun-synopsis', // Commands - commitCommandPrompt, + IMAGE_ONLY_DEFAULT: 'image-only-default', + COMMIT_COMMAND: 'commit-command', - // Maestro system prompt - maestroSystemPrompt, + // System + MAESTRO_SYSTEM_PROMPT: 'maestro-system-prompt', - // Group chat prompts - groupChatModeratorSystemPrompt, - groupChatModeratorSynthesisPrompt, - groupChatParticipantPrompt, - groupChatParticipantRequestPrompt, + // Group Chat + GROUP_CHAT_MODERATOR_SYSTEM: 'group-chat-moderator-system', + GROUP_CHAT_MODERATOR_SYNTHESIS: 'group-chat-moderator-synthesis', + GROUP_CHAT_PARTICIPANT: 'group-chat-participant', + GROUP_CHAT_PARTICIPANT_REQUEST: 'group-chat-participant-request', - // Context management - contextGroomingPrompt, - contextTransferPrompt, - contextSummarizePrompt, + // Context + CONTEXT_GROOMING: 'context-grooming', + CONTEXT_TRANSFER: 'context-transfer', + CONTEXT_SUMMARIZE: 'context-summarize', - // Tab naming - tabNamingPrompt, + // Tab Naming + TAB_NAMING: 'tab-naming', // Director's Notes - directorNotesPrompt, -} from '../generated/prompts'; + DIRECTOR_NOTES: 'director-notes', +} as const; + +export type PromptId = (typeof PROMPT_IDS)[keyof typeof PROMPT_IDS]; diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts index cd9d00254..ef3e08a5e 100644 --- a/src/renderer/hooks/agent/useAgentListeners.ts +++ b/src/renderer/hooks/agent/useAgentListeners.ts @@ -47,7 +47,7 @@ import { isLikelyConcatenatedToolNames, getSlashCommandDescription } from '../.. import { getActiveTab, getWriteModeTab } from '../../utils/tabHelpers'; import { formatRelativeTime } from '../../../shared/formatters'; import { parseSynopsis } from '../../../shared/synopsis'; -import { autorunSynopsisPrompt } from '../../../prompts'; +import { getAutorunSynopsisPrompt } from '../batch/batchUtils'; import type { RightPanelHandle } from '../../components/RightPanel'; import { useGroupChatStore } from '../../stores/groupChatStore'; @@ -804,9 +804,9 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { let SYNOPSIS_PROMPT: string; if (synopsisData.lastSynopsisTime) { const timeAgo = formatRelativeTime(synopsisData.lastSynopsisTime); - SYNOPSIS_PROMPT = `${autorunSynopsisPrompt}\n\nIMPORTANT: Only synopsize work done since the last synopsis (${timeAgo}). Do not repeat previous work.`; + SYNOPSIS_PROMPT = `${getAutorunSynopsisPrompt()}\n\nIMPORTANT: Only synopsize work done since the last synopsis (${timeAgo}). Do not repeat previous work.`; } else { - SYNOPSIS_PROMPT = autorunSynopsisPrompt; + SYNOPSIS_PROMPT = getAutorunSynopsisPrompt(); } const startTime = Date.now(); const synopsisTime = Date.now(); diff --git a/src/renderer/hooks/agent/useMergeTransferHandlers.ts b/src/renderer/hooks/agent/useMergeTransferHandlers.ts index d3eb80d9a..d72094253 100644 --- a/src/renderer/hooks/agent/useMergeTransferHandlers.ts +++ b/src/renderer/hooks/agent/useMergeTransferHandlers.ts @@ -23,7 +23,7 @@ import { getModalActions } from '../../stores/modalStore'; import { notifyToast } from '../../stores/notificationStore'; import { substituteTemplateVariables } from '../../utils/templateVariables'; import { gitService } from '../../services/git'; -import { maestroSystemPrompt } from '../../../prompts'; +import { getMaestroSystemPrompt } from '../input/useInputProcessing'; import { useSettingsStore } from '../../stores/settingsStore'; import { useMergeSessionWithSessions } from './useMergeSession'; import { useSendToAgentWithSessions } from './useSendToAgent'; @@ -470,6 +470,7 @@ You are taking over this conversation. Based on the context above, provide a bri const conductorProfile = useSettingsStore.getState().conductorProfile; // Prepend Maestro system prompt since this is a new session + const maestroSystemPrompt = getMaestroSystemPrompt(); if (maestroSystemPrompt) { const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session: targetSession, diff --git a/src/renderer/hooks/batch/batchUtils.ts b/src/renderer/hooks/batch/batchUtils.ts index 3bf36d729..d5d359580 100644 --- a/src/renderer/hooks/batch/batchUtils.ts +++ b/src/renderer/hooks/batch/batchUtils.ts @@ -3,10 +3,44 @@ * Extracted from useBatchProcessor.ts for reusability. */ -import { autorunDefaultPrompt } from '../../../prompts'; +// Module-level prompt cache (loaded once via IPC) +let cachedAutorunDefaultPrompt = ''; +let cachedAutorunSynopsisPrompt = ''; +let batchPromptsLoaded = false; + +/** + * Load batch/autorun prompts from disk via IPC. + * Called once at startup before components mount. + */ +export async function loadBatchPrompts(): Promise { + if (batchPromptsLoaded) return; + + const [defaultResult, synopsisResult] = await Promise.all([ + window.maestro.prompts.get('autorun-default'), + window.maestro.prompts.get('autorun-synopsis'), + ]); + + if (defaultResult.success && defaultResult.content) { + cachedAutorunDefaultPrompt = defaultResult.content; + DEFAULT_BATCH_PROMPT = defaultResult.content; + } + if (synopsisResult.success && synopsisResult.content) { + cachedAutorunSynopsisPrompt = synopsisResult.content; + } + batchPromptsLoaded = true; +} + +/** + * Get the autorun synopsis prompt (from cache). + */ +export function getAutorunSynopsisPrompt(): string { + return cachedAutorunSynopsisPrompt; +} // Default batch processing prompt (exported for use by BatchRunnerModal and playbook management) -export const DEFAULT_BATCH_PROMPT = autorunDefaultPrompt; +// Updated via loadBatchPrompts() at startup; live ES module binding ensures importers see the value. +// eslint-disable-next-line import/no-mutable-exports +export let DEFAULT_BATCH_PROMPT = ''; // Regex to count unchecked markdown checkboxes: - [ ] task (also * [ ]) const UNCHECKED_TASK_REGEX = /^[\s]*[-*]\s*\[\s*\]\s*.+$/gm; diff --git a/src/renderer/hooks/wizard/useWizardHandlers.ts b/src/renderer/hooks/wizard/useWizardHandlers.ts index abb26cc1d..6b1a5fd95 100644 --- a/src/renderer/hooks/wizard/useWizardHandlers.ts +++ b/src/renderer/hooks/wizard/useWizardHandlers.ts @@ -35,7 +35,7 @@ import { getActiveTab, createTab } from '../../utils/tabHelpers'; import { generateId } from '../../utils/ids'; import { getSlashCommandDescription } from '../../constants/app'; import { validateNewSession } from '../../utils/sessionValidation'; -import { autorunSynopsisPrompt } from '../../../prompts'; +import { getAutorunSynopsisPrompt } from '../batch/batchUtils'; import { parseSynopsis } from '../../../shared/synopsis'; import { formatRelativeTime } from '../../../shared/formatters'; import { gitService } from '../../services/git'; @@ -524,9 +524,9 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler let synopsisPrompt: string; if (activeTab.lastSynopsisTime) { const timeAgo = formatRelativeTime(activeTab.lastSynopsisTime); - synopsisPrompt = `${autorunSynopsisPrompt}\n\nIMPORTANT: Only synopsize work done since the last synopsis (${timeAgo}). Do not repeat previous work.`; + synopsisPrompt = `${getAutorunSynopsisPrompt()}\n\nIMPORTANT: Only synopsize work done since the last synopsis (${timeAgo}). Do not repeat previous work.`; } else { - synopsisPrompt = autorunSynopsisPrompt; + synopsisPrompt = getAutorunSynopsisPrompt(); } const synopsisTime = Date.now(); diff --git a/src/renderer/services/inlineWizardConversation.ts b/src/renderer/services/inlineWizardConversation.ts index 40f584505..82d952fe8 100644 --- a/src/renderer/services/inlineWizardConversation.ts +++ b/src/renderer/services/inlineWizardConversation.ts @@ -14,7 +14,32 @@ import type { InlineWizardMessage } from '../hooks/batch/useInlineWizard'; import type { ExistingDocument as BaseExistingDocument } from '../utils/existingDocsDetector'; import { logger } from '../utils/logger'; import { getStdinFlags } from '../utils/spawnHelpers'; -import { wizardInlineIteratePrompt, wizardInlineNewPrompt } from '../../prompts'; + +// Module-level prompt cache (loaded once via IPC) +let cachedWizardInlineIteratePrompt = ''; +let cachedWizardInlineNewPrompt = ''; +let inlineWizardConversationPromptsLoaded = false; + +/** + * Load inline wizard conversation prompts from disk via IPC. + * Called once at startup before components mount. + */ +export async function loadInlineWizardConversationPrompts(): Promise { + if (inlineWizardConversationPromptsLoaded) return; + + const [iterateResult, newResult] = await Promise.all([ + window.maestro.prompts.get('wizard-inline-iterate'), + window.maestro.prompts.get('wizard-inline-new'), + ]); + + if (iterateResult.success && iterateResult.content) { + cachedWizardInlineIteratePrompt = iterateResult.content; + } + if (newResult.success && newResult.content) { + cachedWizardInlineNewPrompt = newResult.content; + } + inlineWizardConversationPromptsLoaded = true; +} import { parseStructuredOutput, getConfidenceColor, @@ -201,10 +226,10 @@ export function generateInlineWizardPrompt(config: InlineWizardConversationConfi // Select the base prompt based on mode let basePrompt: string; if (mode === 'iterate') { - basePrompt = wizardInlineIteratePrompt; + basePrompt = cachedWizardInlineIteratePrompt; } else { // 'new' mode uses the new plan prompt - basePrompt = wizardInlineNewPrompt; + basePrompt = cachedWizardInlineNewPrompt; } // Handle wizard-specific variables that have different semantics from the central template system diff --git a/src/renderer/services/inlineWizardDocumentGeneration.ts b/src/renderer/services/inlineWizardDocumentGeneration.ts index 5f84ab8e5..4b6fffb28 100644 --- a/src/renderer/services/inlineWizardDocumentGeneration.ts +++ b/src/renderer/services/inlineWizardDocumentGeneration.ts @@ -13,7 +13,32 @@ import type { ToolType } from '../types'; import type { InlineWizardMessage, InlineGeneratedDocument } from '../hooks/batch/useInlineWizard'; import type { ExistingDocument } from '../utils/existingDocsDetector'; import { logger } from '../utils/logger'; -import { wizardDocumentGenerationPrompt, wizardInlineIterateGenerationPrompt } from '../../prompts'; + +// Module-level prompt cache (loaded once via IPC) +let cachedWizardDocumentGenerationPrompt = ''; +let cachedWizardInlineIterateGenerationPrompt = ''; +let inlineWizardDocGenPromptsLoaded = false; + +/** + * Load inline wizard document generation prompts from disk via IPC. + * Called once at startup before components mount. + */ +export async function loadInlineWizardDocGenPrompts(): Promise { + if (inlineWizardDocGenPromptsLoaded) return; + + const [docGenResult, iterateGenResult] = await Promise.all([ + window.maestro.prompts.get('wizard-document-generation'), + window.maestro.prompts.get('wizard-inline-iterate-generation'), + ]); + + if (docGenResult.success && docGenResult.content) { + cachedWizardDocumentGenerationPrompt = docGenResult.content; + } + if (iterateGenResult.success && iterateGenResult.content) { + cachedWizardInlineIterateGenerationPrompt = iterateGenResult.content; + } + inlineWizardDocGenPromptsLoaded = true; +} import { substituteTemplateVariables, type TemplateContext } from '../utils/templateVariables'; /** @@ -360,7 +385,7 @@ export function generateDocumentPrompt( // Choose the appropriate prompt template based on mode const basePrompt = - mode === 'iterate' ? wizardInlineIterateGenerationPrompt : wizardDocumentGenerationPrompt; + mode === 'iterate' ? cachedWizardInlineIterateGenerationPrompt : cachedWizardDocumentGenerationPrompt; // Build the full Auto Run folder path (including subfolder if specified) // Use the user-configured autoRunFolderPath (which may be external to directoryPath) diff --git a/src/renderer/services/promptInit.ts b/src/renderer/services/promptInit.ts index d0110b6ff..1c722ecd1 100644 --- a/src/renderer/services/promptInit.ts +++ b/src/renderer/services/promptInit.ts @@ -7,6 +7,9 @@ import { loadContextSummarizerPrompts } from './contextSummarizer'; import { loadWizardPrompts } from '../components/Wizard/services/wizardPrompts'; import { loadPhaseGeneratorPrompts } from '../components/Wizard/services/phaseGenerator'; import { loadSettingsStorePrompts } from '../stores/settingsStore'; +import { loadInlineWizardConversationPrompts } from './inlineWizardConversation'; +import { loadInlineWizardDocGenPrompts } from './inlineWizardDocumentGeneration'; +import { loadBatchPrompts } from '../hooks/batch/batchUtils'; let initialized = false; let initPromise: Promise | null = null; @@ -33,6 +36,9 @@ export async function initializeRendererPrompts(): Promise { loadWizardPrompts(), loadPhaseGeneratorPrompts(), loadSettingsStorePrompts(), + loadInlineWizardConversationPrompts(), + loadInlineWizardDocGenPrompts(), + loadBatchPrompts(), ]); initialized = true; From 9407fae5244613db08a25915f7037e5df68bd120 Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 18:28:26 -0700 Subject: [PATCH 12/23] MAESTRO: Remove src/generated/ from .gitignore after disk-based prompts migration The generated prompts build system has been fully replaced by disk-based loading. The src/generated/ directory is no longer produced, so its gitignore entry is dead weight. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8c136325b..4a15c6e0b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,6 @@ node_modules/ # Build outputs dist/ release/ -src/generated/ *.log tmp/ scratch/ From fdead522c8cac4b963b84e457de0953c8badbbcb Mon Sep 17 00:00:00 2001 From: pmorgan Date: Sun, 8 Mar 2026 18:38:28 -0700 Subject: [PATCH 13/23] MAESTRO: Add Maestro Prompts tab to Right Bar for browsing and editing core system prompts Creates MaestroPromptsTab component following SpecKit panel patterns with category grouping, inline editing, save/reset functionality, and modified indicators. Integrates into RightPanel as a fourth tab. Co-Authored-By: Claude Opus 4.6 --- .../components/MaestroPromptsTab.test.tsx | 349 ++++++++++++++ src/renderer/components/MaestroPromptsTab.tsx | 444 ++++++++++++++++++ src/renderer/components/RightPanel.tsx | 15 +- .../hooks/batch/useAutoRunHandlers.ts | 4 +- src/renderer/types/index.ts | 2 +- 5 files changed, 809 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/renderer/components/MaestroPromptsTab.test.tsx create mode 100644 src/renderer/components/MaestroPromptsTab.tsx diff --git a/src/__tests__/renderer/components/MaestroPromptsTab.test.tsx b/src/__tests__/renderer/components/MaestroPromptsTab.test.tsx new file mode 100644 index 000000000..d3fde29e8 --- /dev/null +++ b/src/__tests__/renderer/components/MaestroPromptsTab.test.tsx @@ -0,0 +1,349 @@ +// ABOUTME: Unit tests for the MaestroPromptsTab component. +// ABOUTME: Tests prompt loading, selection, editing, saving, resetting, and category grouping. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; +import { MaestroPromptsTab } from '../../../renderer/components/MaestroPromptsTab'; +import type { Theme } from '../../../renderer/types'; + +const mockTheme: Theme = { + id: 'dark', + name: 'Dark', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#2d2d30', + textMain: '#cccccc', + textDim: '#808080', + textFaint: '#555555', + accent: '#007acc', + border: '#3c3c3c', + error: '#f44747', + warning: '#cca700', + success: '#4caf50', + info: '#3794ff', + selection: '#264f78', + }, +}; + +const mockPrompts = [ + { + id: 'wizard-system', + filename: 'wizard-system.md', + description: 'Main wizard system prompt', + category: 'wizard', + content: 'Wizard system content', + isModified: false, + }, + { + id: 'wizard-iterate', + filename: 'wizard-iterate.md', + description: 'Wizard iterate prompt', + category: 'wizard', + content: 'Wizard iterate content', + isModified: true, + }, + { + id: 'autorun-default', + filename: 'autorun-default.md', + description: 'Default Auto Run prompt', + category: 'autorun', + content: 'Auto run content', + isModified: false, + }, +]; + +const mockPromptsApi = { + getAll: vi.fn(), + get: vi.fn(), + getAllIds: vi.fn(), + save: vi.fn(), + reset: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Add prompts namespace to existing window.maestro mock + (window as any).maestro.prompts = mockPromptsApi; +}); + +afterEach(() => { + cleanup(); +}); + +describe('MaestroPromptsTab', () => { + describe('loading state', () => { + it('should show loading text while prompts are being fetched', () => { + mockPromptsApi.getAll.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText('Loading prompts...')).toBeTruthy(); + }); + }); + + describe('error handling', () => { + it('should display error when getAll fails', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: false, + error: 'Failed to load', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to load')).toBeTruthy(); + }); + }); + + it('should display error when getAll throws', async () => { + mockPromptsApi.getAll.mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Error: Network error')).toBeTruthy(); + }); + }); + }); + + describe('prompt list', () => { + it('should render prompts grouped by category', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Wizard')).toBeTruthy(); + expect(screen.getByText('Auto Run')).toBeTruthy(); + }); + }); + + it('should display prompt IDs in the list', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + // Wait for prompts to load by checking for category header first + await waitFor(() => { + expect(screen.getByText('Wizard')).toBeTruthy(); + }); + + // The selected prompt ID appears both in list and editor header, + // so use getAllByText. The non-selected ones appear once. + expect(screen.getAllByText('wizard-system').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + expect(screen.getByText('autorun-default')).toBeTruthy(); + }); + + it('should select first prompt by default', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Main wizard system prompt')).toBeTruthy(); + }); + }); + }); + + describe('prompt selection', () => { + it('should display selected prompt content in editor', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('autorun-default')).toBeTruthy(); + }); + + fireEvent.click(screen.getByText('autorun-default')); + + expect(screen.getByText('Default Auto Run prompt')).toBeTruthy(); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + expect(textarea.value).toBe('Auto run content'); + }); + }); + + describe('editing', () => { + it('should enable Save button when content changes', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeTruthy(); + }); + + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + const saveButton = screen.getByText('Save').closest('button')!; + + expect(saveButton.disabled).toBe(true); + + fireEvent.change(textarea, { target: { value: 'Modified content' } }); + + expect(saveButton.disabled).toBe(false); + }); + }); + + describe('saving', () => { + it('should call prompts.save and show success message', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + mockPromptsApi.save.mockResolvedValue({ success: true }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeTruthy(); + }); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'Modified content' }, + }); + fireEvent.click(screen.getByText('Save')); + + expect(mockPromptsApi.save).toHaveBeenCalledWith('wizard-system', 'Modified content'); + + await waitFor(() => { + expect(screen.getByText('Changes saved and applied')).toBeTruthy(); + }); + }); + + it('should show error when save fails', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + mockPromptsApi.save.mockResolvedValue({ success: false, error: 'Write failed' }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeTruthy(); + }); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } }); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(screen.getByText('Write failed')).toBeTruthy(); + }); + }); + }); + + describe('resetting', () => { + it('should call prompts.reset and update content', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + mockPromptsApi.reset.mockResolvedValue({ + success: true, + content: 'Original wizard iterate content', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + }); + + // Select the modified prompt + fireEvent.click(screen.getByText('wizard-iterate')); + + expect(screen.getByText('Modified')).toBeTruthy(); + + const resetButton = screen.getByText('Reset to Default').closest('button')!; + expect(resetButton.disabled).toBe(false); + + fireEvent.click(resetButton); + + expect(mockPromptsApi.reset).toHaveBeenCalledWith('wizard-iterate'); + + await waitFor(() => { + expect(screen.getByText('Prompt reset to default')).toBeTruthy(); + }); + }); + + it('should disable Reset button for unmodified prompts', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeTruthy(); + }); + + const resetButton = screen.getByText('Reset to Default').closest('button')!; + expect(resetButton.disabled).toBe(true); + }); + }); + + describe('modified indicator', () => { + it('should show modified indicator dot for customized prompts', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + }); + + const modifiedItem = screen.getByText('wizard-iterate').closest('button')!; + expect(modifiedItem.innerHTML).toContain('•'); + }); + }); + + describe('category collapsing', () => { + it('should toggle category visibility when clicking category header', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + // wizard-iterate only shows in the list (not editor header since wizard-system is selected) + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + }); + + // Click on "Wizard" category header to collapse + fireEvent.click(screen.getByText('Wizard')); + + // wizard-iterate should be hidden from the list + expect(screen.queryByText('wizard-iterate')).toBeNull(); + + // Other category still visible + expect(screen.getByText('autorun-default')).toBeTruthy(); + + // Click again to expand + fireEvent.click(screen.getByText('Wizard')); + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + }); + }); +}); diff --git a/src/renderer/components/MaestroPromptsTab.tsx b/src/renderer/components/MaestroPromptsTab.tsx new file mode 100644 index 000000000..632ac9dd0 --- /dev/null +++ b/src/renderer/components/MaestroPromptsTab.tsx @@ -0,0 +1,444 @@ +// ABOUTME: Maestro Prompts Tab - browse and edit core system prompts in the Right Bar. +// ABOUTME: Follows the SpecKitCommandsPanel UI pattern with category grouping and inline editing. + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Save, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react'; +import type { Theme } from '../types'; +import type { CorePromptEntry } from '../../main/preload/prompts'; + +interface MaestroPromptsTabProps { + theme: Theme; +} + +// Category display names and sort order +const CATEGORY_INFO: Record = { + wizard: { label: 'Wizard', order: 1 }, + 'inline-wizard': { label: 'Inline Wizard', order: 2 }, + autorun: { label: 'Auto Run', order: 3 }, + 'group-chat': { label: 'Group Chat', order: 4 }, + context: { label: 'Context', order: 5 }, + commands: { label: 'Commands', order: 6 }, + system: { label: 'System', order: 7 }, +}; + +export function MaestroPromptsTab({ theme }: MaestroPromptsTabProps) { + const [prompts, setPrompts] = useState([]); + const [selectedPromptId, setSelectedPromptId] = useState(null); + const [editedContent, setEditedContent] = useState(''); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [successMessage, setSuccessMessage] = useState(null); + const [error, setError] = useState(null); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); + + const selectedPrompt = useMemo( + () => prompts.find((p) => p.id === selectedPromptId) ?? null, + [prompts, selectedPromptId] + ); + + // Load prompts on mount + useEffect(() => { + const loadPrompts = async () => { + setIsLoading(true); + try { + const result = await window.maestro.prompts.getAll(); + if (result.success && result.prompts) { + setPrompts(result.prompts); + // Expand all categories by default + const categories = new Set(result.prompts.map((p) => p.category)); + setExpandedCategories(categories); + // Select first prompt if none selected + if (result.prompts.length > 0) { + const first = result.prompts[0]; + setSelectedPromptId(first.id); + setEditedContent(first.content); + } + } else { + setError(result.error || 'Failed to load prompts'); + } + } catch (err) { + setError(String(err)); + } finally { + setIsLoading(false); + } + }; + + loadPrompts(); + }, []); + + // Group prompts by category, sorted + const groupedPrompts = useMemo(() => { + const groups: Record = {}; + for (const prompt of prompts) { + if (!groups[prompt.category]) { + groups[prompt.category] = []; + } + groups[prompt.category].push(prompt); + } + return Object.entries(groups).sort(([a], [b]) => { + const orderA = CATEGORY_INFO[a]?.order ?? 99; + const orderB = CATEGORY_INFO[b]?.order ?? 99; + return orderA - orderB; + }); + }, [prompts]); + + const toggleCategory = useCallback((category: string) => { + setExpandedCategories((prev) => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }, []); + + const handleSelectPrompt = useCallback( + (prompt: CorePromptEntry) => { + if (hasUnsavedChanges) { + // Discard unsaved changes on switch + } + setSelectedPromptId(prompt.id); + setEditedContent(prompt.content); + setHasUnsavedChanges(false); + setSuccessMessage(null); + setError(null); + }, + [hasUnsavedChanges] + ); + + const handleContentChange = useCallback( + (e: React.ChangeEvent) => { + setEditedContent(e.target.value); + setHasUnsavedChanges(e.target.value !== selectedPrompt?.content); + setSuccessMessage(null); + }, + [selectedPrompt] + ); + + const handleSave = useCallback(async () => { + if (!selectedPrompt || !hasUnsavedChanges) return; + + setIsSaving(true); + setError(null); + setSuccessMessage(null); + try { + const result = await window.maestro.prompts.save(selectedPrompt.id, editedContent); + if (result.success) { + setPrompts((prev) => + prev.map((p) => + p.id === selectedPrompt.id + ? { ...p, content: editedContent, isModified: true } + : p + ) + ); + setHasUnsavedChanges(false); + setSuccessMessage('Changes saved and applied'); + } else { + setError(result.error || 'Failed to save prompt'); + } + } catch (err) { + setError(String(err)); + } finally { + setIsSaving(false); + } + }, [selectedPrompt, editedContent, hasUnsavedChanges]); + + const handleReset = useCallback(async () => { + if (!selectedPrompt) return; + + setIsResetting(true); + setError(null); + setSuccessMessage(null); + try { + const result = await window.maestro.prompts.reset(selectedPrompt.id); + if (result.success && result.content) { + setPrompts((prev) => + prev.map((p) => + p.id === selectedPrompt.id + ? { ...p, content: result.content!, isModified: false } + : p + ) + ); + setEditedContent(result.content); + setHasUnsavedChanges(false); + setSuccessMessage('Prompt reset to default'); + } else { + setError(result.error || 'Failed to reset prompt'); + } + } catch (err) { + setError(String(err)); + } finally { + setIsResetting(false); + } + }, [selectedPrompt]); + + if (isLoading) { + return ( +
+ Loading prompts... +
+ ); + } + + if (error && prompts.length === 0) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {/* Prompt List - Left side */} +
+ {groupedPrompts.map(([category, categoryPrompts]) => ( +
+ + {expandedCategories.has(category) && + categoryPrompts.map((prompt) => ( + + ))} +
+ ))} +
+ + {/* Editor Panel - Right side */} +
+ {selectedPrompt ? ( + <> + {/* Header */} +
+

+ {selectedPrompt.id} +

+

+ {selectedPrompt.description} +

+ {selectedPrompt.isModified && ( + + Modified + + )} +
+ + {/* Success / Error messages */} + {successMessage && ( +
+ {successMessage} +
+ )} + {error && ( +
+ {error} +
+ )} + + {/* Textarea editor */} +