diff --git a/.gitignore b/.gitignore index 2b0d626eab..7c5300ecc7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,6 @@ node_modules/ # Build outputs dist/ release/ -src/generated/ *.log tmp/ scratch/ diff --git a/CLAUDE.md b/CLAUDE.md index 98954f704a..d9265ef1a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,7 +184,9 @@ src/ | Add tab overlay menu | See Tab Hover Overlay Menu pattern in [[CLAUDE-PATTERNS.md]] | | Add setting | `src/shared/settingsMetadata.ts` (metadata), `src/renderer/stores/settingsStore.ts`, `src/main/stores/defaults.ts` | | Add template variable | `src/shared/templateVariables.ts`, `src/renderer/utils/templateVariables.ts` | -| Modify system prompts | `src/prompts/*.md` (wizard, Auto Run, etc.) | +| Modify system prompts | `src/prompts/*.md` (wizard, Auto Run, etc.) or edit via **Maestro Prompts** tab in Settings | +| Customize prompts | Use **Maestro Prompts** tab in Settings, or edit `userData/core-prompts-customizations.json` | +| Add new prompt | `src/prompts/*.md`, `src/shared/promptDefinitions.ts` (add to `CORE_PROMPTS` array and `PROMPT_IDS`) | | Add Spec-Kit command | `src/prompts/speckit/`, `src/main/speckit-manager.ts` | | Add OpenSpec command | `src/prompts/openspec/`, `src/main/openspec-manager.ts` | | Add CLI command | `src/cli/commands/`, `src/cli/index.ts` | diff --git a/docs/cli.md b/docs/cli.md index 72a740beae..34b94a322c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -193,6 +193,47 @@ maestro-cli clean playbooks maestro-cli clean playbooks --dry-run ``` +### Prompt Customization + +The CLI uses the same core system prompts as the desktop app. When you customize prompts via Settings → **Maestro Prompts**, those customizations are stored in `core-prompts-customizations.json` in the Maestro data directory and are automatically picked up by the CLI during playbook runs. + +The prompts most relevant to CLI playbook execution are: + +| Prompt ID | Controls | +| ----------------------- | --------------------------------------------- | +| `autorun-default` | Default Auto Run task execution behavior | +| `autorun-synopsis` | Synopsis generation after task completion | +| `commit-command` | `/commit` command behavior | +| `maestro-system-prompt` | Maestro system context injected into sessions | +| `context-grooming` | Context grooming during transfers | + +To customize these prompts, either use the desktop app's **Maestro Prompts** tab or edit the JSON file directly: + +```text +# macOS +~/Library/Application Support/Maestro/core-prompts-customizations.json + +# Linux +~/.config/Maestro/core-prompts-customizations.json + +# Windows +%APPDATA%\Maestro\core-prompts-customizations.json +``` + +The file format is: + +```json +{ + "prompts": { + "autorun-default": { + "content": "Your customized prompt content...", + "isModified": true, + "modifiedAt": "2026-04-11T..." + } + } +} +``` + ### Managing Settings View and modify any Maestro configuration setting directly from the CLI. Changes take effect immediately in the running desktop app — no restart required. diff --git a/docs/configuration.md b/docs/configuration.md index 4a2b00f6b8..cbde65345d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,10 +18,28 @@ Settings are organized into tabs: | **Themes** | Dark, light, and vibe mode themes, custom theme builder with import/export | | **Notifications** | OS notifications, custom command notifications, toast notification duration | | **AI Commands** | View and edit slash commands, [Spec-Kit](./speckit-commands), [OpenSpec](./openspec-commands), and [BMAD](./bmad-commands) prompts | +| **Maestro Prompts** | Browse and edit the 23 core system prompts (wizard, Auto Run, group chat, context, etc.). Changes take effect immediately; reset to bundled defaults at any time | | **SSH Hosts** | Configure remote hosts for [SSH agent execution](./ssh-remote-execution) | | **Environment** | Global environment variables that cascade to all agents and terminal sessions | | **WakaTime** _(in General tab)_ | WakaTime integration toggle, API key, detailed file tracking | +## Maestro Prompts + +Maestro ships with 23 core system prompts that control wizard conversations, Auto Run behavior, group chat moderation, context management, and more. You can customize any of them via the **Maestro Prompts** tab in Settings. + +**To edit a prompt:** + +1. Open **Settings** (`Cmd+,` / `Ctrl+,`) → **Maestro Prompts** tab +2. Select a prompt from the category list on the left +3. Edit the content in the editor +4. Click **Save** — changes take effect immediately (no restart needed) + +**To reset a prompt:** + +Click **Reset to Default** to restore the bundled version. This also takes effect immediately. + +Customizations are stored separately from bundled prompts and survive app updates. You can also access the four most common prompts directly from **Quick Actions** (`Cmd+K` / `Ctrl+K`): Maestro System Prompt, Auto Run Default, Commit Command, and Group Chat Moderator. + ## Conductor Profile The **Conductor Profile** (Settings → General → **About Me**) is a short description of yourself that gets injected into every AI agent's system prompt. This helps agents understand your background, preferences, and communication style so they can tailor responses accordingly. diff --git a/package.json b/package.json index 4c50692dc8..a6f4e4e7c7 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,13 @@ "dev": "node scripts/dev.mjs", "dev:prod-data": "USE_PROD_DATA=1 node scripts/dev.mjs", "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:prompts": "echo 'No-op: prompts are now loaded from disk at runtime (see prompt-manager.ts)'", "build:main": "tsc -p tsconfig.main.json", "build:preload": "node scripts/build-preload.mjs", "build:cli": "node scripts/build-cli.mjs", @@ -45,7 +45,7 @@ "format:all": "prettier --write .", "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "format:check:all": "prettier --check .", - "validate:push": "npm run build:prompts && npm run format:check:all && npm run lint && npm run lint:eslint && npm run test", + "validate:push": "npm run format:check:all && npm run lint && npm run lint:eslint && npm run test", "test": "NODE_OPTIONS=--max-old-space-size=8192 vitest run", "test:watch": "NODE_OPTIONS=--max-old-space-size=8192 vitest", "test:coverage": "NODE_OPTIONS=--max-old-space-size=8192 vitest run --coverage", @@ -106,6 +106,13 @@ "from": "dist/cli/maestro-cli.js", "to": "maestro-cli.js" }, + { + "from": "src/prompts", + "to": "prompts/core", + "filter": [ + "*.md" + ] + }, { "from": "src/prompts/speckit", "to": "prompts/speckit" @@ -142,6 +149,13 @@ "from": "dist/cli/maestro-cli.js", "to": "maestro-cli.js" }, + { + "from": "src/prompts", + "to": "prompts/core", + "filter": [ + "*.md" + ] + }, { "from": "src/prompts/speckit", "to": "prompts/speckit" @@ -170,6 +184,13 @@ "from": "dist/cli/maestro-cli.js", "to": "maestro-cli.js" }, + { + "from": "src/prompts", + "to": "prompts/core", + "filter": [ + "*.md" + ] + }, { "from": "src/prompts/speckit", "to": "prompts/speckit" diff --git a/scripts/generate-prompts.mjs b/scripts/generate-prompts.mjs deleted file mode 100644 index eb6f6e310a..0000000000 --- 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/src/__tests__/main/group-chat/group-chat-agent.test.ts b/src/__tests__/main/group-chat/group-chat-agent.test.ts index 285b8eb969..d6ec6081bc 100644 --- a/src/__tests__/main/group-chat/group-chat-agent.test.ts +++ b/src/__tests__/main/group-chat/group-chat-agent.test.ts @@ -38,6 +38,23 @@ vi.mock('electron-store', () => { }; }); +vi.mock('../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + const fs = require('fs'); + const path = require('path'); + const promptsDir = path.resolve(__dirname, '..', '..', '..', '..', 'src', 'prompts'); + const filenameMap: Record = { + 'group-chat-participant': 'group-chat-participant.md', + 'group-chat-participant-request': 'group-chat-participant-request.md', + 'group-chat-moderator-system': 'group-chat-moderator-system.md', + 'group-chat-moderator-synthesis': 'group-chat-moderator-synthesis.md', + }; + const filename = filenameMap[id]; + if (!filename) throw new Error(`Unknown prompt ID in test mock: ${id}`); + return fs.readFileSync(path.join(promptsDir, filename), 'utf-8'); + }), +})); + import { addParticipant, sendToParticipant, 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 88a4a36cd2..14b74ce303 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,18 @@ vi.mock('electron-store', () => { }; }); +vi.mock('../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + const prompts: Record = { + 'group-chat-moderator-system': + 'You are a Group Chat Moderator.\n\n{{CONDUCTOR_PROFILE}}\n\nCoordinate multiple AI agents using @mentions.', + 'group-chat-moderator-synthesis': + 'Review the agents responses and synthesize a coherent answer.', + }; + return prompts[id] ?? `mock prompt for ${id}`; + }), +})); + import { spawnModerator, sendToModerator, diff --git a/src/__tests__/main/group-chat/group-chat-router.test.ts b/src/__tests__/main/group-chat/group-chat-router.test.ts index cb949c28bb..c3ba4a68a2 100644 --- a/src/__tests__/main/group-chat/group-chat-router.test.ts +++ b/src/__tests__/main/group-chat/group-chat-router.test.ts @@ -46,6 +46,23 @@ vi.mock('../../../main/utils/ssh-spawn-wrapper', () => ({ wrapSpawnWithSsh: (...args: unknown[]) => mockWrapSpawnWithSsh(...args), })); +vi.mock('../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + const fs = require('fs'); + const path = require('path'); + const promptsDir = path.resolve(__dirname, '..', '..', '..', '..', 'src', 'prompts'); + const filenameMap: Record = { + 'group-chat-participant': 'group-chat-participant.md', + 'group-chat-participant-request': 'group-chat-participant-request.md', + 'group-chat-moderator-system': 'group-chat-moderator-system.md', + 'group-chat-moderator-synthesis': 'group-chat-moderator-synthesis.md', + }; + const filename = filenameMap[id]; + if (!filename) throw new Error(`Unknown prompt ID in test mock: ${id}`); + return fs.readFileSync(path.join(promptsDir, filename), 'utf-8'); + }), +})); + import { extractMentions, extractAllMentions, diff --git a/src/__tests__/main/ipc/handlers/director-notes.test.ts b/src/__tests__/main/ipc/handlers/director-notes.test.ts index b763152db5..9eea095eba 100644 --- a/src/__tests__/main/ipc/handlers/director-notes.test.ts +++ b/src/__tests__/main/ipc/handlers/director-notes.test.ts @@ -55,9 +55,12 @@ vi.mock('../../../../main/utils/context-groomer', () => ({ groomContext: vi.fn(), })); -// Mock the prompts module -vi.mock('../../../../../prompts', () => ({ - directorNotesPrompt: 'Mock director notes prompt', +// Mock prompt-manager so getPrompt() returns mock content without needing disk I/O +vi.mock('../../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + if (id === 'director-notes') return 'Mock director notes prompt'; + return `mock prompt for ${id}`; + }), })); 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 27eb5ac19d..e669cd801e 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 prompt-manager so getPrompt() returns mock content without needing disk I/O +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 prompt for ${id}`; + }), })); // Mock the agent args utilities diff --git a/src/__tests__/renderer/components/BatchRunnerModal.test.tsx b/src/__tests__/renderer/components/BatchRunnerModal.test.tsx index 52ebf2a962..923b263d87 100644 --- a/src/__tests__/renderer/components/BatchRunnerModal.test.tsx +++ b/src/__tests__/renderer/components/BatchRunnerModal.test.tsx @@ -1,6 +1,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react'; import React from 'react'; + +// Mock batchUtils to provide loaded DEFAULT_BATCH_PROMPT and real validation functions +vi.mock('../../../renderer/hooks/batch/batchUtils', async () => { + const actual = await vi.importActual('../../../renderer/hooks/batch/batchUtils'); + const fs = await import('fs'); + const path = await import('path'); + const content = fs.readFileSync( + path.resolve(__dirname, '..', '..', '..', '..', 'src', 'prompts', 'autorun-default.md'), + 'utf-8' + ); + return { + ...actual, + DEFAULT_BATCH_PROMPT: content, + }; +}); + import { BatchRunnerModal, DEFAULT_BATCH_PROMPT, diff --git a/src/__tests__/renderer/components/Wizard/services/wizardPrompts.test.ts b/src/__tests__/renderer/components/Wizard/services/wizardPrompts.test.ts index d2c92d1a2c..3b4fe51a6e 100644 --- a/src/__tests__/renderer/components/Wizard/services/wizardPrompts.test.ts +++ b/src/__tests__/renderer/components/Wizard/services/wizardPrompts.test.ts @@ -5,7 +5,9 @@ * Tests parsing strategies, fallback handling, validation, color generation, and edge cases. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; import { parseStructuredOutput, generateSystemPrompt, @@ -13,6 +15,7 @@ import { isReadyToProceed, getConfidenceColor, getInitialQuestion, + loadWizardPrompts, STRUCTURED_OUTPUT_SCHEMA, STRUCTURED_OUTPUT_SUFFIX, READY_CONFIDENCE_THRESHOLD, @@ -22,7 +25,34 @@ import { } from '../../../../../renderer/components/Wizard/services/wizardPrompts'; import { getAllInitialQuestions } from '../../../../../renderer/components/Wizard/services/fillerPhrases'; +// Load actual prompt files from disk so generateSystemPrompt tests work with real content +const promptsDir = path.resolve(__dirname, '..', '..', '..', '..', '..', '..', 'src', 'prompts'); +const wizardSystemContent = fs.readFileSync(path.join(promptsDir, 'wizard-system.md'), 'utf-8'); +const wizardContinuationContent = fs.readFileSync( + path.join(promptsDir, 'wizard-system-continuation.md'), + 'utf-8' +); + describe('wizardPrompts', () => { + beforeAll(async () => { + // Mock window.maestro.prompts.get to return actual prompt file content + (window as any).maestro = { + ...(window as any).maestro, + prompts: { + get: vi.fn((id: string) => { + if (id === 'wizard-system') { + return Promise.resolve({ success: true, content: wizardSystemContent }); + } + if (id === 'wizard-system-continuation') { + return Promise.resolve({ success: true, content: wizardContinuationContent }); + } + return Promise.resolve({ success: false, error: `Unknown prompt: ${id}` }); + }), + }, + }; + await loadWizardPrompts(true); + }); + describe('Constants', () => { describe('READY_CONFIDENCE_THRESHOLD', () => { it('should be 80', () => { diff --git a/src/__tests__/renderer/hooks/useWizardHandlers.test.ts b/src/__tests__/renderer/hooks/useWizardHandlers.test.ts index be9211f3be..fb725ba005 100644 --- a/src/__tests__/renderer/hooks/useWizardHandlers.test.ts +++ b/src/__tests__/renderer/hooks/useWizardHandlers.test.ts @@ -49,10 +49,8 @@ vi.mock('../../../renderer/constants/app', () => ({ getSlashCommandDescription: vi.fn((cmd: string) => `Description for ${cmd}`), })); -vi.mock('../../../prompts', async () => { - const actual = await vi.importActual('../../../prompts'); - return { ...actual, autorunSynopsisPrompt: 'Generate a synopsis of all work done.' }; -}); +// autorunSynopsisPrompt is now loaded via IPC (window.maestro.prompts.get) +// and cached in the hook's module-level cache via loadWizardHandlersPrompts() vi.mock('../../../shared/synopsis', () => ({ parseSynopsis: vi.fn((response: string) => ({ diff --git a/src/__tests__/renderer/services/contextGroomer.test.ts b/src/__tests__/renderer/services/contextGroomer.test.ts index 32615bbcb7..b146bad855 100644 --- a/src/__tests__/renderer/services/contextGroomer.test.ts +++ b/src/__tests__/renderer/services/contextGroomer.test.ts @@ -2,7 +2,7 @@ * TODO: These tests need to be updated to match the current service implementation. * The IPC API changed from window.maestro.context.* to a different approach. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; import { ContextGroomingService, contextGroomingService, @@ -10,6 +10,7 @@ import { AGENT_TARGET_NOTES, getAgentDisplayName, buildContextTransferPrompt, + loadContextGroomerPrompts, } from '../../../renderer/services/contextGroomer'; import type { MergeRequest, @@ -31,6 +32,25 @@ vi.stubGlobal('window', { sendGroomingPrompt: mockSendGroomingPrompt, cleanupGroomingSession: mockCleanupGroomingSession, }, + prompts: { + get: vi.fn((id: string) => { + const fs = require('fs'); + const path = require('path'); + const promptsDir = path.resolve(__dirname, '..', '..', '..', '..', 'src', 'prompts'); + const filenameMap: Record = { + 'context-grooming': 'context-grooming.md', + 'context-transfer': 'context-transfer.md', + }; + const filename = filenameMap[id]; + if (!filename) return Promise.resolve({ success: false, error: `Unknown prompt: ${id}` }); + try { + const content = fs.readFileSync(path.join(promptsDir, filename), 'utf-8'); + return Promise.resolve({ success: true, content }); + } catch (e: any) { + return Promise.resolve({ success: false, error: e.message }); + } + }), + }, }, }); @@ -561,6 +581,10 @@ describe('getAgentDisplayName', () => { }); describe('buildContextTransferPrompt', () => { + beforeAll(async () => { + await loadContextGroomerPrompts(true); + }); + it('should include source and target agent names', () => { const prompt = buildContextTransferPrompt('claude-code', 'opencode'); diff --git a/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts b/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts index 86baec9005..5bff56b159 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,6 +12,7 @@ import { generateWizardFolderBaseName, countTasks, generateDocumentPrompt, + loadInlineWizardDocGenPrompts, type DocumentGenerationConfig, } from '../../../renderer/services/inlineWizardDocumentGeneration'; @@ -415,6 +416,33 @@ CONTENT: }); describe('generateDocumentPrompt', () => { + beforeAll(async () => { + const fs = require('fs'); + const path = require('path'); + const promptsDir = path.resolve(__dirname, '..', '..', '..', '..', 'src', 'prompts'); + (window as any).maestro = { + ...(window as any).maestro, + prompts: { + get: vi.fn((id: string) => { + const filenameMap: Record = { + 'wizard-document-generation': 'wizard-document-generation.md', + 'wizard-inline-iterate-generation': 'wizard-inline-iterate-generation.md', + }; + const filename = filenameMap[id]; + if (!filename) + return Promise.resolve({ success: false, error: `Unknown prompt: ${id}` }); + try { + const content = fs.readFileSync(path.join(promptsDir, filename), 'utf-8'); + return Promise.resolve({ success: true, content }); + } catch (e: any) { + return Promise.resolve({ success: false, error: e.message }); + } + }), + }, + }; + await loadInlineWizardDocGenPrompts(true); + }); + // Helper to create a minimal config for testing const createTestConfig = ( overrides: Partial = {} diff --git a/src/__tests__/renderer/stores/agentStore.test.ts b/src/__tests__/renderer/stores/agentStore.test.ts index 12269b4924..8fa626ba03 100644 --- a/src/__tests__/renderer/stores/agentStore.test.ts +++ b/src/__tests__/renderer/stores/agentStore.test.ts @@ -13,6 +13,7 @@ import { selectAgentsDetected, getAgentState, getAgentActions, + loadAgentStorePrompts, } from '../../../renderer/stores/agentStore'; import type { ProcessQueuedItemDeps } from '../../../renderer/stores/agentStore'; import { useSessionStore } from '../../../renderer/stores/sessionStore'; @@ -106,6 +107,20 @@ const mockClearError = vi.fn().mockResolvedValue(undefined); agentError: { clearError: mockClearError, }, + prompts: { + get: vi.fn((id: string) => { + const prompts: Record = { + 'maestro-system-prompt': 'Mock system prompt for {{CWD}}', + 'autorun-synopsis': '', + 'image-only-default': 'Describe this image', + 'commit-command': '', + }; + if (id in prompts) { + return Promise.resolve({ success: true, content: prompts[id] }); + } + return Promise.resolve({ success: false, error: `Unknown prompt: ${id}` }); + }), + }, }; // Mock gitService @@ -115,13 +130,8 @@ vi.mock('../../../renderer/services/git', () => ({ }, })); -// Mock prompts -vi.mock('../../../prompts', () => ({ - maestroSystemPrompt: 'Mock system prompt for {{CWD}}', - autorunSynopsisPrompt: '', - imageOnlyDefaultPrompt: 'Describe this image', - commitCommandPrompt: '', -})); +// Prompt content is now loaded via window.maestro.prompts.get() and cached at module level. +// The window.maestro.prompts mock is set up below in the window.maestro block. // Mock substituteTemplateVariables — pass through the template as-is for simplicity vi.mock('../../../renderer/utils/templateVariables', () => ({ @@ -144,9 +154,10 @@ function resetStores() { }); } -beforeEach(() => { +beforeEach(async () => { resetStores(); vi.clearAllMocks(); + await loadAgentStorePrompts(true); }); // ============================================================================ diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 3bdb04a30d..8d2ac0c633 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -581,6 +581,14 @@ const mockMaestro = { validateYaml: vi.fn().mockResolvedValue({ valid: true, errors: [] }), onActivityUpdate: vi.fn().mockReturnValue(() => {}), }, + // Core Prompts API (disk-based prompts loaded at runtime) + prompts: { + get: vi.fn().mockResolvedValue({ success: true, content: '' }), + getAll: vi.fn().mockResolvedValue({ success: true, prompts: [] }), + getAllIds: vi.fn().mockResolvedValue({ success: true, ids: [] }), + save: vi.fn().mockResolvedValue({ success: true }), + reset: vi.fn().mockResolvedValue({ success: true, content: '' }), + }, // Synchronous platform string (replaces async os.getPlatform IPC) platform: 'darwin', }; diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 6018e02daf..dfa4ed0314 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -15,13 +15,83 @@ 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 { parseSynopsis } from '../../shared/synopsis'; import { generateUUID } from '../../shared/uuid'; import { formatElapsedTime } from '../../shared/formatters'; +import { getPromptFilename, PROMPT_IDS } from '../../shared/promptDefinitions'; +import { getConfigDirectory } from './storage'; +import fs from 'fs/promises'; +import path from 'path'; -// Synopsis prompt for batch tasks -const BATCH_SYNOPSIS_PROMPT = autorunSynopsisPrompt; +// CLI prompt cache (loaded once on first use) +const cliPromptCache = new Map(); + +/** + * Try to load a user-customized prompt from core-prompts-customizations.json. + * Returns the customized content if found and marked as modified, else null. + */ +async function getCustomizedPrompt(id: string): Promise { + try { + const customizationsPath = path.join(getConfigDirectory(), 'core-prompts-customizations.json'); + const raw = await fs.readFile(customizationsPath, 'utf-8'); + const data = JSON.parse(raw); + const entry = data?.prompts?.[id]; + if (entry?.isModified && typeof entry?.content === 'string') { + return entry.content; + } + } catch { + // No customizations file or parse error — fall through to bundled + } + return null; +} + +async function getCliPrompt(id: string): Promise { + if (cliPromptCache.has(id)) { + return cliPromptCache.get(id)!; + } + + // Check user customizations first (same precedence as Electron prompt-manager) + const customized = await getCustomizedPrompt(id); + if (customized !== null) { + cliPromptCache.set(id, customized); + return customized; + } + + // Resolve filename from shared definitions (single source of truth) + const filename = getPromptFilename(id); + + // Try multiple resolution strategies since the CLI runs in different contexts: + // 1. Dev mode: __dirname is dist/cli/services/ → project root's src/prompts/ + // 2. Packaged Electron app: process.resourcesPath/prompts/core/ + // 3. Standalone CLI (maestro-cli.js bundled at Resources/): resolve relative to script + const projectRoot = path.resolve(__dirname, '..', '..', '..'); + const candidates = [path.join(projectRoot, 'src', 'prompts', filename)]; + + // process.resourcesPath is Electron-only; guard against standalone Node.js + if (typeof process !== 'undefined' && (process as any).resourcesPath) { + candidates.push(path.join((process as any).resourcesPath, 'prompts', 'core', filename)); + } + + // Standalone CLI bundled alongside Resources/prompts/core/ + candidates.push( + path.join(path.dirname(process.argv[1] || __dirname), 'prompts', 'core', filename) + ); + candidates.push(path.join(__dirname, '..', 'prompts', 'core', filename)); + + for (const candidate of candidates) { + try { + const content = await fs.readFile(candidate, 'utf-8'); + cliPromptCache.set(id, content); + return content; + } catch { + // Try next candidate + } + } + + throw new Error( + `Failed to load prompt "${id}" (${filename}). Searched: ${candidates.join(', ')}` + ); +} /** * Get the current git branch for a directory @@ -88,672 +158,677 @@ export async function* runPlaybook( pid: process.pid, }); - // Emit start event - yield { - type: 'start', - timestamp: Date.now(), - playbook: { id: playbook.id, name: playbook.name }, - session: { id: session.id, name: session.name, cwd: session.cwd }, - }; - - // AUTORUN LOG: Start - logger.autorun(`Auto Run started`, session.name, { - playbook: playbook.name, - documents: playbook.documents.map((d) => d.filename), - loopEnabled: playbook.loopEnabled, - maxLoops: playbook.maxLoops ?? 'unlimited', - }); - - // Emit debug info about playbook configuration - if (debug) { - yield { - type: 'debug', - timestamp: Date.now(), - category: 'config', - message: `Playbook config: loopEnabled=${playbook.loopEnabled}, maxLoops=${playbook.maxLoops ?? 'unlimited'}`, - }; - yield { - type: 'debug', - timestamp: Date.now(), - category: 'config', - message: `Documents (${playbook.documents.length}): ${playbook.documents.map((d) => `${d.filename}${d.resetOnCompletion ? ' [RESET]' : ''}`).join(', ')}`, - }; + try { + // Emit start event yield { - type: 'debug', + type: 'start', timestamp: Date.now(), - category: 'config', - message: `Folder path: ${folderPath}`, + playbook: { id: playbook.id, name: playbook.name }, + session: { id: session.id, name: session.name, cwd: session.cwd }, }; - } - // Calculate initial total tasks - let initialTotalTasks = 0; - for (const doc of playbook.documents) { - const { taskCount } = readDocAndCountTasks(folderPath, doc.filename); + // AUTORUN LOG: Start + logger.autorun(`Auto Run started`, session.name, { + playbook: playbook.name, + documents: playbook.documents.map((d) => d.filename), + loopEnabled: playbook.loopEnabled, + maxLoops: playbook.maxLoops ?? 'unlimited', + }); + + // Emit debug info about playbook configuration if (debug) { yield { type: 'debug', timestamp: Date.now(), - category: 'scan', - message: `${doc.filename}: ${taskCount} unchecked task${taskCount !== 1 ? 's' : ''}`, + category: 'config', + message: `Playbook config: loopEnabled=${playbook.loopEnabled}, maxLoops=${playbook.maxLoops ?? 'unlimited'}`, + }; + yield { + type: 'debug', + timestamp: Date.now(), + category: 'config', + message: `Documents (${playbook.documents.length}): ${playbook.documents.map((d) => `${d.filename}${d.resetOnCompletion ? ' [RESET]' : ''}`).join(', ')}`, + }; + yield { + type: 'debug', + timestamp: Date.now(), + category: 'config', + message: `Folder path: ${folderPath}`, }; } - initialTotalTasks += taskCount; - } - if (debug) { - yield { - type: 'debug', - timestamp: Date.now(), - category: 'scan', - message: `Total unchecked tasks: ${initialTotalTasks}`, - }; - } - - if (initialTotalTasks === 0) { - unregisterCliActivity(session.id); - yield { - type: 'error', - timestamp: Date.now(), - message: 'No unchecked tasks found in any documents', - code: 'NO_TASKS', - }; - return; - } - if (dryRun) { - // Dry run - show detailed breakdown of what would be executed - for (let docIndex = 0; docIndex < playbook.documents.length; docIndex++) { - const docEntry = playbook.documents[docIndex]; - const { tasks } = readDocAndGetTasks(folderPath, docEntry.filename); - - if (tasks.length === 0) { - continue; + // Calculate initial total tasks + let initialTotalTasks = 0; + for (const doc of playbook.documents) { + const { taskCount } = readDocAndCountTasks(folderPath, doc.filename); + if (debug) { + yield { + type: 'debug', + timestamp: Date.now(), + category: 'scan', + message: `${doc.filename}: ${taskCount} unchecked task${taskCount !== 1 ? 's' : ''}`, + }; } + initialTotalTasks += taskCount; + } + if (debug) { + yield { + type: 'debug', + timestamp: Date.now(), + category: 'scan', + message: `Total unchecked tasks: ${initialTotalTasks}`, + }; + } - // Emit document start event + if (initialTotalTasks === 0) { + unregisterCliActivity(session.id); yield { - type: 'document_start', + type: 'error', timestamp: Date.now(), - document: docEntry.filename, - index: docIndex, - taskCount: tasks.length, - dryRun: true, + message: 'No unchecked tasks found in any documents', + code: 'NO_TASKS', }; + return; + } + + if (dryRun) { + // Dry run - show detailed breakdown of what would be executed + for (let docIndex = 0; docIndex < playbook.documents.length; docIndex++) { + const docEntry = playbook.documents[docIndex]; + const { tasks } = readDocAndGetTasks(folderPath, docEntry.filename); - // Emit each task that would be processed - for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) { + if (tasks.length === 0) { + continue; + } + + // Emit document start event + yield { + type: 'document_start', + timestamp: Date.now(), + document: docEntry.filename, + index: docIndex, + taskCount: tasks.length, + dryRun: true, + }; + + // Emit each task that would be processed + for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) { + yield { + type: 'task_preview', + timestamp: Date.now(), + document: docEntry.filename, + taskIndex, + task: tasks[taskIndex], + }; + } + + // Emit document complete event yield { - type: 'task_preview', + type: 'document_complete', timestamp: Date.now(), document: docEntry.filename, - taskIndex, - task: tasks[taskIndex], + tasksCompleted: tasks.length, + dryRun: true, }; } - // Emit document complete event + unregisterCliActivity(session.id); yield { - type: 'document_complete', + type: 'complete', timestamp: Date.now(), - document: docEntry.filename, - tasksCompleted: tasks.length, + success: true, + totalTasksCompleted: 0, + totalElapsedMs: 0, dryRun: true, + wouldProcess: initialTotalTasks, }; + return; } - unregisterCliActivity(session.id); - yield { - type: 'complete', - timestamp: Date.now(), - success: true, - totalTasksCompleted: 0, - totalElapsedMs: 0, - dryRun: true, - wouldProcess: initialTotalTasks, - }; - return; - } + // Track totals + let totalCompletedTasks = 0; + let totalCost = 0; + let loopIteration = 0; + + // Per-loop tracking + let loopStartTime = Date.now(); + let loopTasksCompleted = 0; + let loopTotalInputTokens = 0; + let loopTotalOutputTokens = 0; + let loopTotalCost = 0; + + // Total tracking across all loops + let totalInputTokens = 0; + let totalOutputTokens = 0; + + // Helper to create final loop entry with exit reason + const createFinalLoopEntry = (exitReason: string): void => { + // AUTORUN LOG: Exit + logger.autorun(`Auto Run exiting: ${exitReason}`, session.name, { + reason: exitReason, + totalTasksCompleted: totalCompletedTasks, + loopsCompleted: loopIteration + 1, + }); - // Track totals - let totalCompletedTasks = 0; - let totalCost = 0; - let loopIteration = 0; - - // Per-loop tracking - let loopStartTime = Date.now(); - let loopTasksCompleted = 0; - let loopTotalInputTokens = 0; - let loopTotalOutputTokens = 0; - let loopTotalCost = 0; - - // Total tracking across all loops - let totalInputTokens = 0; - let totalOutputTokens = 0; - - // Helper to create final loop entry with exit reason - const createFinalLoopEntry = (exitReason: string): void => { - // AUTORUN LOG: Exit - logger.autorun(`Auto Run exiting: ${exitReason}`, session.name, { - reason: exitReason, - totalTasksCompleted: totalCompletedTasks, - loopsCompleted: loopIteration + 1, - }); + if (!writeHistory) return; + // Only write if looping was enabled and we did some work + if (!playbook.loopEnabled && loopIteration === 0) return; + if (loopTasksCompleted === 0 && loopIteration === 0) return; + + const loopElapsedMs = Date.now() - loopStartTime; + const loopNumber = loopIteration + 1; + const loopSummary = `Loop ${loopNumber} (final) completed: ${loopTasksCompleted} task${loopTasksCompleted !== 1 ? 's' : ''} accomplished`; + + const loopUsageStats: UsageStats | undefined = + loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 + ? { + inputTokens: loopTotalInputTokens, + outputTokens: loopTotalOutputTokens, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: loopTotalCost, + contextWindow: 0, // Set to 0 for summaries - these are cumulative totals, not per-task context + } + : undefined; + + const loopDetails = [ + `**Loop ${loopNumber} (final) Summary**`, + '', + `- **Tasks Accomplished:** ${loopTasksCompleted}`, + `- **Duration:** ${formatElapsedTime(loopElapsedMs)}`, + loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 + ? `- **Tokens:** ${(loopTotalInputTokens + loopTotalOutputTokens).toLocaleString()} (${loopTotalInputTokens.toLocaleString()} in / ${loopTotalOutputTokens.toLocaleString()} out)` + : '', + loopTotalCost > 0 ? `- **Cost:** $${loopTotalCost.toFixed(4)}` : '', + `- **Exit Reason:** ${exitReason}`, + ] + .filter((line) => line !== '') + .join('\n'); - if (!writeHistory) return; - // Only write if looping was enabled and we did some work - if (!playbook.loopEnabled && loopIteration === 0) return; - if (loopTasksCompleted === 0 && loopIteration === 0) return; - - const loopElapsedMs = Date.now() - loopStartTime; - const loopNumber = loopIteration + 1; - const loopSummary = `Loop ${loopNumber} (final) completed: ${loopTasksCompleted} task${loopTasksCompleted !== 1 ? 's' : ''} accomplished`; - - const loopUsageStats: UsageStats | undefined = - loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 - ? { - inputTokens: loopTotalInputTokens, - outputTokens: loopTotalOutputTokens, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - totalCostUsd: loopTotalCost, - contextWindow: 0, // Set to 0 for summaries - these are cumulative totals, not per-task context - } - : undefined; - - const loopDetails = [ - `**Loop ${loopNumber} (final) Summary**`, - '', - `- **Tasks Accomplished:** ${loopTasksCompleted}`, - `- **Duration:** ${formatElapsedTime(loopElapsedMs)}`, - loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 - ? `- **Tokens:** ${(loopTotalInputTokens + loopTotalOutputTokens).toLocaleString()} (${loopTotalInputTokens.toLocaleString()} in / ${loopTotalOutputTokens.toLocaleString()} out)` - : '', - loopTotalCost > 0 ? `- **Cost:** $${loopTotalCost.toFixed(4)}` : '', - `- **Exit Reason:** ${exitReason}`, - ] - .filter((line) => line !== '') - .join('\n'); - - const historyEntry: HistoryEntry = { - id: generateUUID(), - type: 'AUTO', - timestamp: Date.now(), - summary: loopSummary, - fullResponse: loopDetails, - projectPath: session.cwd, - sessionId: session.id, - success: true, - elapsedTimeMs: loopElapsedMs, - usageStats: loopUsageStats, - }; - addHistoryEntry(historyEntry); - }; - - // Helper to create total Auto Run summary - const createAutoRunSummary = (): void => { - if (!writeHistory) return; - // Only write if we completed multiple loops or if looping was enabled - if (!playbook.loopEnabled && loopIteration === 0) return; - - const totalElapsedMs = Date.now() - batchStartTime; - const loopsCompleted = loopIteration + 1; - const summary = `Auto Run completed: ${totalCompletedTasks} tasks in ${loopsCompleted} loop${loopsCompleted !== 1 ? 's' : ''}`; - - const totalUsageStats: UsageStats | undefined = - totalInputTokens > 0 || totalOutputTokens > 0 - ? { - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - totalCostUsd: totalCost, - contextWindow: 0, // Set to 0 for summaries - these are cumulative totals, not per-task context - } - : undefined; - - const details = [ - `**Auto Run Summary**`, - '', - `- **Total Tasks Completed:** ${totalCompletedTasks}`, - `- **Loops Completed:** ${loopsCompleted}`, - `- **Total Duration:** ${formatElapsedTime(totalElapsedMs)}`, - totalInputTokens > 0 || totalOutputTokens > 0 - ? `- **Total Tokens:** ${(totalInputTokens + totalOutputTokens).toLocaleString()} (${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out)` - : '', - totalCost > 0 ? `- **Total Cost:** $${totalCost.toFixed(4)}` : '', - ] - .filter((line) => line !== '') - .join('\n'); - - const historyEntry: HistoryEntry = { - id: generateUUID(), - type: 'AUTO', - timestamp: Date.now(), - summary, - fullResponse: details, - projectPath: session.cwd, - sessionId: session.id, - success: true, - elapsedTimeMs: totalElapsedMs, - usageStats: totalUsageStats, + const historyEntry: HistoryEntry = { + id: generateUUID(), + type: 'AUTO', + timestamp: Date.now(), + summary: loopSummary, + fullResponse: loopDetails, + projectPath: session.cwd, + sessionId: session.id, + success: true, + elapsedTimeMs: loopElapsedMs, + usageStats: loopUsageStats, + }; + addHistoryEntry(historyEntry); }; - addHistoryEntry(historyEntry); - }; - // Main processing loop - while (true) { - let anyTasksProcessedThisIteration = false; + // Helper to create total Auto Run summary + const createAutoRunSummary = (): void => { + if (!writeHistory) return; + // Only write if we completed multiple loops or if looping was enabled + if (!playbook.loopEnabled && loopIteration === 0) return; + + const totalElapsedMs = Date.now() - batchStartTime; + const loopsCompleted = loopIteration + 1; + const summary = `Auto Run completed: ${totalCompletedTasks} tasks in ${loopsCompleted} loop${loopsCompleted !== 1 ? 's' : ''}`; + + const totalUsageStats: UsageStats | undefined = + totalInputTokens > 0 || totalOutputTokens > 0 + ? { + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: totalCost, + contextWindow: 0, // Set to 0 for summaries - these are cumulative totals, not per-task context + } + : undefined; + + const details = [ + `**Auto Run Summary**`, + '', + `- **Total Tasks Completed:** ${totalCompletedTasks}`, + `- **Loops Completed:** ${loopsCompleted}`, + `- **Total Duration:** ${formatElapsedTime(totalElapsedMs)}`, + totalInputTokens > 0 || totalOutputTokens > 0 + ? `- **Total Tokens:** ${(totalInputTokens + totalOutputTokens).toLocaleString()} (${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out)` + : '', + totalCost > 0 ? `- **Total Cost:** $${totalCost.toFixed(4)}` : '', + ] + .filter((line) => line !== '') + .join('\n'); - // Process each document in order - for (let docIndex = 0; docIndex < playbook.documents.length; docIndex++) { - const docEntry = playbook.documents[docIndex]; - - // Read document and count tasks - let { taskCount: remainingTasks } = readDocAndCountTasks(folderPath, docEntry.filename); - - // Skip documents with no tasks - if (remainingTasks === 0) { - continue; - } - - // Emit document start event - yield { - type: 'document_start', + const historyEntry: HistoryEntry = { + id: generateUUID(), + type: 'AUTO', timestamp: Date.now(), - document: docEntry.filename, - index: docIndex, - taskCount: remainingTasks, + summary, + fullResponse: details, + projectPath: session.cwd, + sessionId: session.id, + success: true, + elapsedTimeMs: totalElapsedMs, + usageStats: totalUsageStats, }; + addHistoryEntry(historyEntry); + }; - // AUTORUN LOG: Document processing - logger.autorun(`Processing document: ${docEntry.filename}`, session.name, { - document: docEntry.filename, - tasksRemaining: remainingTasks, - loopNumber: loopIteration + 1, - }); + // Main processing loop + while (true) { + let anyTasksProcessedThisIteration = false; + + // Process each document in order + for (let docIndex = 0; docIndex < playbook.documents.length; docIndex++) { + const docEntry = playbook.documents[docIndex]; - let docTasksCompleted = 0; - let taskIndex = 0; + // Read document and count tasks + let { taskCount: remainingTasks } = readDocAndCountTasks(folderPath, docEntry.filename); + + // Skip documents with no tasks + if (remainingTasks === 0) { + continue; + } - // Process tasks in this document - while (remainingTasks > 0) { - // Emit task start + // Emit document start event yield { - type: 'task_start', + type: 'document_start', timestamp: Date.now(), document: docEntry.filename, - taskIndex, + index: docIndex, + taskCount: remainingTasks, }; - const taskStartTime = Date.now(); - - const docFilePath = `${folderPath}/${docEntry.filename}.md`; - - // Build template context for this task - const templateContext: TemplateContext = { - session: { - ...session, - isGitRepo: isGit, - }, - gitBranch, - groupName, - groupId: session.groupId, - autoRunFolder: folderPath, - loopNumber: loopIteration + 1, // 1-indexed - documentName: docEntry.filename, - documentPath: docFilePath, - }; - - // Substitute template variables in the prompt - // 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, - templateContext - ); - - // Read document content and expand template variables in it - const { content: docContent } = readDocAndCountTasks(folderPath, docEntry.filename); - const expandedDocContent = docContent - ? substituteTemplateVariables(docContent, templateContext) - : ''; - - // Write expanded content back to document (so agent edits have correct paths) - if (expandedDocContent && expandedDocContent !== docContent) { - writeDoc(folderPath, `${docEntry.filename}.md`, expandedDocContent); - } + // AUTORUN LOG: Document processing + logger.autorun(`Processing document: ${docEntry.filename}`, session.name, { + document: docEntry.filename, + tasksRemaining: remainingTasks, + loopNumber: loopIteration + 1, + }); - // Combine prompt with document content - agent works on what it's given - // Include explicit file path so agent knows where to save changes - const finalPrompt = `${basePrompt}\n\n---\n\n# Current Document: ${docFilePath}\n\nProcess tasks from this document and save changes back to the file above.\n\n${expandedDocContent}`; + let docTasksCompleted = 0; + let taskIndex = 0; - // Emit verbose event with full prompt - if (verbose) { + // Process tasks in this document + while (remainingTasks > 0) { + // Emit task start yield { - type: 'verbose', + type: 'task_start', timestamp: Date.now(), - category: 'prompt', document: docEntry.filename, taskIndex, - prompt: finalPrompt, }; - } - // Spawn agent with combined prompt + document - const result = await spawnAgent(session.toolType, session.cwd, finalPrompt, undefined, { - customModel: session.customModel, - }); - - const elapsedMs = Date.now() - taskStartTime; - - // Re-read document to get new task count - const { taskCount: newRemainingTasks } = readDocAndCountTasks( - folderPath, - docEntry.filename - ); - const tasksCompletedThisRun = remainingTasks - newRemainingTasks; - - // Update counters - docTasksCompleted += tasksCompletedThisRun; - totalCompletedTasks += tasksCompletedThisRun; - loopTasksCompleted += tasksCompletedThisRun; - anyTasksProcessedThisIteration = true; - - // Track usage - if (result.usageStats) { - loopTotalInputTokens += result.usageStats.inputTokens || 0; - loopTotalOutputTokens += result.usageStats.outputTokens || 0; - loopTotalCost += result.usageStats.totalCostUsd || 0; - totalCost += result.usageStats.totalCostUsd || 0; - totalInputTokens += result.usageStats.inputTokens || 0; - totalOutputTokens += result.usageStats.outputTokens || 0; - } + const taskStartTime = Date.now(); + + const docFilePath = `${folderPath}/${docEntry.filename}.md`; + + // Build template context for this task + const templateContext: TemplateContext = { + session: { + ...session, + isGitRepo: isGit, + }, + gitBranch, + groupName, + groupId: session.groupId, + autoRunFolder: folderPath, + loopNumber: loopIteration + 1, // 1-indexed + documentName: docEntry.filename, + documentPath: docFilePath, + }; - // Generate synopsis - let shortSummary = `[${docEntry.filename}] Task completed`; - let fullSynopsis = shortSummary; - - if (result.success && result.agentSessionId) { - // Request synopsis from the agent - const synopsisResult = await spawnAgent( - session.toolType, - session.cwd, - BATCH_SYNOPSIS_PROMPT, - result.agentSessionId, - { customModel: session.customModel } + // Substitute template variables in the prompt + // 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 || (await getCliPrompt(PROMPT_IDS.AUTORUN_DEFAULT)), + templateContext ); - if (synopsisResult.success && synopsisResult.response) { - const parsed = parseSynopsis(synopsisResult.response); - shortSummary = parsed.shortSummary; - fullSynopsis = parsed.fullSynopsis; + // Read document content and expand template variables in it + const { content: docContent } = readDocAndCountTasks(folderPath, docEntry.filename); + const expandedDocContent = docContent + ? substituteTemplateVariables(docContent, templateContext) + : ''; + + // Write expanded content back to document (so agent edits have correct paths) + if (expandedDocContent && expandedDocContent !== docContent) { + writeDoc(folderPath, `${docEntry.filename}.md`, expandedDocContent); } - } else if (!result.success) { - shortSummary = `[${docEntry.filename}] Task failed`; - fullSynopsis = result.error || shortSummary; - } - // Emit task complete event - yield { - type: 'task_complete', - timestamp: Date.now(), - document: docEntry.filename, - taskIndex, - success: result.success, - summary: shortSummary, - fullResponse: fullSynopsis, - elapsedMs, - usageStats: result.usageStats, - agentSessionId: result.agentSessionId, - }; + // Combine prompt with document content - agent works on what it's given + // Include explicit file path so agent knows where to save changes + const finalPrompt = `${basePrompt}\n\n---\n\n# Current Document: ${docFilePath}\n\nProcess tasks from this document and save changes back to the file above.\n\n${expandedDocContent}`; - // Add history entry if enabled - if (writeHistory) { - const historyEntry: HistoryEntry = { - id: generateUUID(), - type: 'AUTO', + // Emit verbose event with full prompt + if (verbose) { + yield { + type: 'verbose', + timestamp: Date.now(), + category: 'prompt', + document: docEntry.filename, + taskIndex, + prompt: finalPrompt, + }; + } + + // Spawn agent with combined prompt + document + const result = await spawnAgent(session.toolType, session.cwd, finalPrompt, undefined, { + customModel: session.customModel, + }); + + const elapsedMs = Date.now() - taskStartTime; + + // Re-read document to get new task count + const { taskCount: newRemainingTasks } = readDocAndCountTasks( + folderPath, + docEntry.filename + ); + const tasksCompletedThisRun = remainingTasks - newRemainingTasks; + + // Update counters + docTasksCompleted += tasksCompletedThisRun; + totalCompletedTasks += tasksCompletedThisRun; + loopTasksCompleted += tasksCompletedThisRun; + anyTasksProcessedThisIteration = true; + + // Track usage + if (result.usageStats) { + loopTotalInputTokens += result.usageStats.inputTokens || 0; + loopTotalOutputTokens += result.usageStats.outputTokens || 0; + loopTotalCost += result.usageStats.totalCostUsd || 0; + totalCost += result.usageStats.totalCostUsd || 0; + totalInputTokens += result.usageStats.inputTokens || 0; + totalOutputTokens += result.usageStats.outputTokens || 0; + } + + // Generate synopsis + let shortSummary = `[${docEntry.filename}] Task completed`; + let fullSynopsis = shortSummary; + + if (result.success && result.agentSessionId) { + // Request synopsis from the agent + const synopsisResult = await spawnAgent( + session.toolType, + session.cwd, + await getCliPrompt(PROMPT_IDS.AUTORUN_SYNOPSIS), + result.agentSessionId, + { customModel: session.customModel } + ); + + if (synopsisResult.success && synopsisResult.response) { + const parsed = parseSynopsis(synopsisResult.response); + shortSummary = parsed.shortSummary; + fullSynopsis = parsed.fullSynopsis; + } + } else if (!result.success) { + shortSummary = `[${docEntry.filename}] Task failed`; + fullSynopsis = result.error || shortSummary; + } + + // Emit task complete event + yield { + type: 'task_complete', timestamp: Date.now(), + document: docEntry.filename, + taskIndex, + success: result.success, summary: shortSummary, fullResponse: fullSynopsis, - agentSessionId: result.agentSessionId, - projectPath: session.cwd, - sessionId: session.id, - success: result.success, + elapsedMs, usageStats: result.usageStats, - elapsedTimeMs: elapsedMs, + agentSessionId: result.agentSessionId, }; - addHistoryEntry(historyEntry); + + // Add history entry if enabled + if (writeHistory) { + const historyEntry: HistoryEntry = { + id: generateUUID(), + type: 'AUTO', + timestamp: Date.now(), + summary: shortSummary, + fullResponse: fullSynopsis, + agentSessionId: result.agentSessionId, + projectPath: session.cwd, + sessionId: session.id, + success: result.success, + usageStats: result.usageStats, + elapsedTimeMs: elapsedMs, + }; + addHistoryEntry(historyEntry); + if (debug) { + yield { + type: 'history_write', + timestamp: Date.now(), + entryId: historyEntry.id, + }; + } + } + + remainingTasks = newRemainingTasks; + taskIndex++; + } + + // Document complete - handle reset-on-completion + if (docEntry.resetOnCompletion && docTasksCompleted > 0) { + // AUTORUN LOG: Document reset + logger.autorun(`Resetting document: ${docEntry.filename}`, session.name, { + document: docEntry.filename, + tasksCompleted: docTasksCompleted, + loopNumber: loopIteration + 1, + }); + + const { content: currentContent } = readDocAndCountTasks(folderPath, docEntry.filename); + const resetContent = uncheckAllTasks(currentContent); + writeDoc(folderPath, docEntry.filename + '.md', resetContent); if (debug) { + const { taskCount: newTaskCount } = readDocAndCountTasks(folderPath, docEntry.filename); yield { - type: 'history_write', + type: 'debug', timestamp: Date.now(), - entryId: historyEntry.id, + category: 'reset', + message: `Reset ${docEntry.filename}: unchecked all tasks (${newTaskCount} tasks now open)`, }; } } - remainingTasks = newRemainingTasks; - taskIndex++; - } - - // Document complete - handle reset-on-completion - if (docEntry.resetOnCompletion && docTasksCompleted > 0) { - // AUTORUN LOG: Document reset - logger.autorun(`Resetting document: ${docEntry.filename}`, session.name, { + // Emit document complete event + yield { + type: 'document_complete', + timestamp: Date.now(), document: docEntry.filename, tasksCompleted: docTasksCompleted, - loopNumber: loopIteration + 1, - }); + }; + } - const { content: currentContent } = readDocAndCountTasks(folderPath, docEntry.filename); - const resetContent = uncheckAllTasks(currentContent); - writeDoc(folderPath, docEntry.filename + '.md', resetContent); + // Check if we should continue looping + if (!playbook.loopEnabled) { if (debug) { - const { taskCount: newTaskCount } = readDocAndCountTasks(folderPath, docEntry.filename); yield { type: 'debug', timestamp: Date.now(), - category: 'reset', - message: `Reset ${docEntry.filename}: unchecked all tasks (${newTaskCount} tasks now open)`, + category: 'loop', + message: 'Exiting: loopEnabled is false', }; } + createFinalLoopEntry('Looping disabled'); + break; } - // Emit document complete event - yield { - type: 'document_complete', - timestamp: Date.now(), - document: docEntry.filename, - tasksCompleted: docTasksCompleted, - }; - } - - // Check if we should continue looping - if (!playbook.loopEnabled) { - if (debug) { - yield { - type: 'debug', - timestamp: Date.now(), - category: 'loop', - message: 'Exiting: loopEnabled is false', - }; + // Check max loop limit + if ( + playbook.maxLoops !== null && + playbook.maxLoops !== undefined && + loopIteration + 1 >= playbook.maxLoops + ) { + if (debug) { + yield { + type: 'debug', + timestamp: Date.now(), + category: 'loop', + message: `Exiting: reached max loops (${playbook.maxLoops})`, + }; + } + createFinalLoopEntry(`Reached max loop limit (${playbook.maxLoops})`); + break; } - createFinalLoopEntry('Looping disabled'); - break; - } - // Check max loop limit - if ( - playbook.maxLoops !== null && - playbook.maxLoops !== undefined && - loopIteration + 1 >= playbook.maxLoops - ) { + // Check if any non-reset documents have remaining tasks + const hasAnyNonResetDocs = playbook.documents.some((doc) => !doc.resetOnCompletion); if (debug) { + const nonResetDocs = playbook.documents + .filter((d) => !d.resetOnCompletion) + .map((d) => d.filename); + const resetDocs = playbook.documents + .filter((d) => d.resetOnCompletion) + .map((d) => d.filename); yield { type: 'debug', timestamp: Date.now(), category: 'loop', - message: `Exiting: reached max loops (${playbook.maxLoops})`, + message: `Checking loop condition: ${nonResetDocs.length} non-reset docs [${nonResetDocs.join(', ')}], ${resetDocs.length} reset docs [${resetDocs.join(', ')}]`, }; } - createFinalLoopEntry(`Reached max loop limit (${playbook.maxLoops})`); - break; - } - // Check if any non-reset documents have remaining tasks - const hasAnyNonResetDocs = playbook.documents.some((doc) => !doc.resetOnCompletion); - if (debug) { - const nonResetDocs = playbook.documents - .filter((d) => !d.resetOnCompletion) - .map((d) => d.filename); - const resetDocs = playbook.documents - .filter((d) => d.resetOnCompletion) - .map((d) => d.filename); - yield { - type: 'debug', - timestamp: Date.now(), - category: 'loop', - message: `Checking loop condition: ${nonResetDocs.length} non-reset docs [${nonResetDocs.join(', ')}], ${resetDocs.length} reset docs [${resetDocs.join(', ')}]`, - }; - } - - if (hasAnyNonResetDocs) { - let anyNonResetDocsHaveTasks = false; - for (const doc of playbook.documents) { - if (doc.resetOnCompletion) continue; - const { taskCount } = readDocAndCountTasks(folderPath, doc.filename); + if (hasAnyNonResetDocs) { + let anyNonResetDocsHaveTasks = false; + for (const doc of playbook.documents) { + if (doc.resetOnCompletion) continue; + const { taskCount } = readDocAndCountTasks(folderPath, doc.filename); + if (debug) { + yield { + type: 'debug', + timestamp: Date.now(), + category: 'loop', + message: `Non-reset doc ${doc.filename}: ${taskCount} unchecked task${taskCount !== 1 ? 's' : ''}`, + }; + } + if (taskCount > 0) { + anyNonResetDocsHaveTasks = true; + break; + } + } + if (!anyNonResetDocsHaveTasks) { + if (debug) { + yield { + type: 'debug', + timestamp: Date.now(), + category: 'loop', + message: 'Exiting: all non-reset documents have 0 remaining tasks', + }; + } + createFinalLoopEntry('All tasks completed'); + break; + } + } else { + // All documents are reset docs - exit after one pass if (debug) { yield { type: 'debug', timestamp: Date.now(), category: 'loop', - message: `Non-reset doc ${doc.filename}: ${taskCount} unchecked task${taskCount !== 1 ? 's' : ''}`, + message: + 'Exiting: ALL documents have resetOnCompletion=true (loop requires at least one non-reset doc to drive iterations)', }; } - if (taskCount > 0) { - anyNonResetDocsHaveTasks = true; - break; - } + createFinalLoopEntry('All documents have reset-on-completion'); + break; } - if (!anyNonResetDocsHaveTasks) { + + // Safety check + if (!anyTasksProcessedThisIteration) { if (debug) { yield { type: 'debug', timestamp: Date.now(), category: 'loop', - message: 'Exiting: all non-reset documents have 0 remaining tasks', + message: 'Exiting: no tasks were processed this iteration (safety check)', }; } - createFinalLoopEntry('All tasks completed'); + createFinalLoopEntry('No tasks processed this iteration'); break; } - } else { - // All documents are reset docs - exit after one pass - if (debug) { - yield { - type: 'debug', - timestamp: Date.now(), - category: 'loop', - message: - 'Exiting: ALL documents have resetOnCompletion=true (loop requires at least one non-reset doc to drive iterations)', - }; - } - createFinalLoopEntry('All documents have reset-on-completion'); - break; - } - // Safety check - if (!anyTasksProcessedThisIteration) { if (debug) { yield { type: 'debug', timestamp: Date.now(), category: 'loop', - message: 'Exiting: no tasks were processed this iteration (safety check)', + message: `Continuing to next loop iteration (current: ${loopIteration + 1})`, }; } - createFinalLoopEntry('No tasks processed this iteration'); - break; - } - if (debug) { + // Emit loop complete event + const loopElapsedMs = Date.now() - loopStartTime; + const loopUsageStats: UsageStats | undefined = + loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 + ? { + inputTokens: loopTotalInputTokens, + outputTokens: loopTotalOutputTokens, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: loopTotalCost, + contextWindow: 0, // Set to 0 for summaries - these are cumulative totals, not per-task context + } + : undefined; + yield { - type: 'debug', + type: 'loop_complete', timestamp: Date.now(), - category: 'loop', - message: `Continuing to next loop iteration (current: ${loopIteration + 1})`, + iteration: loopIteration + 1, + tasksCompleted: loopTasksCompleted, + elapsedMs: loopElapsedMs, + usageStats: loopUsageStats, }; - } - // Emit loop complete event - const loopElapsedMs = Date.now() - loopStartTime; - const loopUsageStats: UsageStats | undefined = - loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 - ? { - inputTokens: loopTotalInputTokens, - outputTokens: loopTotalOutputTokens, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - totalCostUsd: loopTotalCost, - contextWindow: 0, // Set to 0 for summaries - these are cumulative totals, not per-task context - } - : undefined; + // AUTORUN LOG: Loop completion + logger.autorun(`Loop ${loopIteration + 1} completed`, session.name, { + loopNumber: loopIteration + 1, + tasksCompleted: loopTasksCompleted, + }); - yield { - type: 'loop_complete', - timestamp: Date.now(), - iteration: loopIteration + 1, - tasksCompleted: loopTasksCompleted, - elapsedMs: loopElapsedMs, - usageStats: loopUsageStats, - }; + // Add loop summary history entry + if (writeHistory) { + const loopSummary = `Loop ${loopIteration + 1} completed: ${loopTasksCompleted} tasks accomplished`; + const historyEntry: HistoryEntry = { + id: generateUUID(), + type: 'AUTO', + timestamp: Date.now(), + summary: loopSummary, + projectPath: session.cwd, + sessionId: session.id, + success: true, + elapsedTimeMs: loopElapsedMs, + usageStats: loopUsageStats, + }; + addHistoryEntry(historyEntry); + } - // AUTORUN LOG: Loop completion - logger.autorun(`Loop ${loopIteration + 1} completed`, session.name, { - loopNumber: loopIteration + 1, - tasksCompleted: loopTasksCompleted, - }); + // Reset per-loop tracking + loopStartTime = Date.now(); + loopTasksCompleted = 0; + loopTotalInputTokens = 0; + loopTotalOutputTokens = 0; + loopTotalCost = 0; - // Add loop summary history entry - if (writeHistory) { - const loopSummary = `Loop ${loopIteration + 1} completed: ${loopTasksCompleted} tasks accomplished`; - const historyEntry: HistoryEntry = { - id: generateUUID(), - type: 'AUTO', - timestamp: Date.now(), - summary: loopSummary, - projectPath: session.cwd, - sessionId: session.id, - success: true, - elapsedTimeMs: loopElapsedMs, - usageStats: loopUsageStats, - }; - addHistoryEntry(historyEntry); + loopIteration++; } - // Reset per-loop tracking - loopStartTime = Date.now(); - loopTasksCompleted = 0; - loopTotalInputTokens = 0; - loopTotalOutputTokens = 0; - loopTotalCost = 0; + // Unregister CLI activity - session is no longer busy + unregisterCliActivity(session.id); - loopIteration++; - } + // Add total Auto Run summary (only if looping was used) + createAutoRunSummary(); - // Unregister CLI activity - session is no longer busy - unregisterCliActivity(session.id); - - // Add total Auto Run summary (only if looping was used) - createAutoRunSummary(); - - // Emit complete event - yield { - type: 'complete', - timestamp: Date.now(), - success: true, - totalTasksCompleted: totalCompletedTasks, - totalElapsedMs: Date.now() - batchStartTime, - totalCost, - }; + // Emit complete event + yield { + type: 'complete', + timestamp: Date.now(), + success: true, + totalTasksCompleted: totalCompletedTasks, + totalElapsedMs: Date.now() - batchStartTime, + totalCost, + }; + } finally { + // Ensure CLI activity is always unregistered even if the generator throws + unregisterCliActivity(session.id); + } } diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts index 9ad495741a..e041ccdebf 100644 --- a/src/main/group-chat/group-chat-agent.ts +++ b/src/main/group-chat/group-chat-agent.ts @@ -21,7 +21,7 @@ import { } from './group-chat-storage'; import { appendToLog } from './group-chat-log'; import { IProcessManager, isModeratorActive } from './group-chat-moderator'; -import { groupChatParticipantPrompt } from '../../prompts'; +import { getPrompt } from '../prompt-manager'; /** * In-memory store for active participant sessions. @@ -45,7 +45,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-moderator.ts b/src/main/group-chat/group-chat-moderator.ts index 73b40af8e1..a8cee47a32 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'; /** @@ -128,7 +128,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'); } /** @@ -137,7 +137,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/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index 986aa8c50c..e0c15ac3bd 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -45,7 +45,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'; @@ -92,7 +92,6 @@ export type GetSessionsCallback = () => SessionInfo[]; export type GetCustomEnvVarsCallback = (agentId: string) => Record | undefined; export type GetAgentConfigCallback = (agentId: string) => Record | undefined; export type GetModeratorSettingsCallback = () => { - standingInstructions: string; conductorProfile: string; }; @@ -758,22 +757,16 @@ export async function routeUserMessage( // Get moderator settings for prompt customization const moderatorSettings = getModeratorSettingsCallback?.() ?? { - standingInstructions: '', conductorProfile: '', }; - // Substitute {{CONDUCTOR_PROFILE}} template variable + // Substitute {{CONDUCTOR_PROFILE}} template variable (global to catch all occurrences) const baseSystemPrompt = getModeratorSystemPrompt().replace( - '{{CONDUCTOR_PROFILE}}', + /\{\{CONDUCTOR_PROFILE\}\}/g, moderatorSettings.conductorProfile || '(No conductor profile set)' ); - // Build standing instructions section if configured - const standingInstructionsSection = moderatorSettings.standingInstructions - ? `\n\n## Standing Instructions\n\nThe following instructions apply to ALL group chat sessions. Follow them consistently:\n\n${moderatorSettings.standingInstructions}` - : ''; - - const fullPrompt = `${baseSystemPrompt}${standingInstructionsSection} + const fullPrompt = `${baseSystemPrompt} ## Current Participants: ${participantContext}${availableSessionsContext} @@ -1276,7 +1269,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) @@ -1698,18 +1691,14 @@ export async function spawnModeratorSynthesis( // Get moderator settings for prompt customization const synthModeratorSettings = getModeratorSettingsCallback?.() ?? { - standingInstructions: '', conductorProfile: '', }; const synthBasePrompt = getModeratorSystemPrompt().replace( - '{{CONDUCTOR_PROFILE}}', + /\{\{CONDUCTOR_PROFILE\}\}/g, synthModeratorSettings.conductorProfile || '(No conductor profile set)' ); - const synthStandingInstructions = synthModeratorSettings.standingInstructions - ? `\n\n## Standing Instructions\n\nThe following instructions apply to ALL group chat sessions. Follow them consistently:\n\n${synthModeratorSettings.standingInstructions}` - : ''; - const synthesisPrompt = `${synthBasePrompt}${synthStandingInstructions} + const synthesisPrompt = `${synthBasePrompt} ${getModeratorSynthesisPrompt()} @@ -1918,7 +1907,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) diff --git a/src/main/index.ts b/src/main/index.ts index 9924640cef..b84e18016d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,6 +65,7 @@ import { registerCueHandlers, registerWakatimeHandlers, registerFeedbackHandlers, + registerPromptsHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount, @@ -91,6 +92,8 @@ import { createSshRemoteStoreAdapter } from './utils/ssh-remote-resolver'; import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage'; import { stopSessionCleanup } from './group-chat/group-chat-moderator'; import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/session-recovery'; +import { initializePrompts, getPrompt, savePrompt } from './prompt-manager'; +import { captureException } from './utils/sentry'; import { initializeSessionStorages } from './storage'; import { initializeOutputParsers } from './parsers'; import { calculateContextTokens } from './parsers/usage-aggregator'; @@ -372,6 +375,64 @@ app.whenReady().then(async () => { // Note: webServer is created on-demand when user enables web interface (see setupWebServerCallbacks) agentDetector = new AgentDetector(); + // Initialize core prompts from disk (must happen before features that use them) + try { + await initializePrompts(); + } catch (error) { + logger.error(`Critical: Failed to initialize prompts: ${error}`, 'Startup'); + await captureException(error instanceof Error ? error : new Error(String(error)), { + operation: 'startup:initializePrompts', + }); + const { dialog } = await import('electron'); + dialog.showErrorBox( + 'Startup Error', + 'Failed to load system prompts. Please reinstall the application.' + ); + app.quit(); + return; + } + + // One-time migration: bake standing instructions into moderator prompt customization + const standingInstructions = (store.get('moderatorStandingInstructions', '') as string) || ''; + const migratedKey = 'moderatorStandingInstructionsMigrated'; + + if (standingInstructions && !store.get(migratedKey, false)) { + try { + const currentPrompt = getPrompt('group-chat-moderator-system'); + + // Only migrate if the exact standing instructions content isn't already in the prompt + if (!currentPrompt.includes(standingInstructions)) { + const sectionHeader = '## Standing Instructions'; + const newSection = `${sectionHeader}\n\nThe following instructions apply to ALL group chat sessions. Follow them consistently:\n\n${standingInstructions}`; + + let migratedPrompt: string; + if (currentPrompt.includes(sectionHeader)) { + migratedPrompt = currentPrompt.replace( + /## Standing Instructions[\s\S]*?(?=\n## |\s*$)/, + newSection + ); + } else { + migratedPrompt = `${currentPrompt}\n\n${newSection}`; + } + await savePrompt('group-chat-moderator-system', migratedPrompt); + logger.info( + 'Migrated moderator standing instructions into prompt customization', + 'Startup' + ); + } + store.set(migratedKey, true); + } catch (err) { + await captureException(err instanceof Error ? err : new Error(String(err)), { + migratedKey, + standingInstructionsSlice: standingInstructions.slice(0, 200), + }); + logger.warn( + 'Failed to persist migrated moderator standing instructions, will retry next launch', + 'Startup' + ); + } + } + // Load custom agent paths from settings const allAgentConfigs = agentConfigsStore.get('configs', {}); const customPaths: Record = {}; @@ -813,6 +874,9 @@ function setupIpcHandlers() { // Register BMAD handlers (no dependencies needed) registerBmadHandlers(); + // Register Core Prompts handlers (no dependencies needed) + registerPromptsHandlers(); + // Register Context Merge handlers for session context transfer and grooming registerContextHandlers({ getMainWindow: () => mainWindow, @@ -875,9 +939,8 @@ function setupIpcHandlers() { setGetCustomEnvVarsCallback(getCustomEnvVarsForAgent); setGetAgentConfigCallback(getAgentConfigForAgent); - // Set up callback for group chat router to get moderator standing instructions + conductor profile + // Set up callback for group chat router to get moderator conductor profile setGetModeratorSettingsCallback(() => ({ - standingInstructions: (store.get('moderatorStandingInstructions', '') as string) || '', conductorProfile: (store.get('conductorProfile', '') as string) || '', })); diff --git a/src/main/ipc/handlers/director-notes.ts b/src/main/ipc/handlers/director-notes.ts index 8ced75007d..0e746bfaef 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'; @@ -363,7 +363,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen }); const prompt = [ - directorNotesPrompt, + getPrompt('director-notes'), '', '---', '', diff --git a/src/main/ipc/handlers/feedback.ts b/src/main/ipc/handlers/feedback.ts index e922ae5b0a..f9624bfd16 100644 --- a/src/main/ipc/handlers/feedback.ts +++ b/src/main/ipc/handlers/feedback.ts @@ -11,6 +11,7 @@ import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { logger } from '../../utils/logger'; +import { getPrompt } from '../../prompt-manager'; import { withIpcErrorLogging, CreateHandlerOptions } from '../../utils/ipcHandler'; import { isGhInstalled, @@ -790,9 +791,7 @@ export function registerFeedbackHandlers(_deps: FeedbackHandlerDependencies): vo withIpcErrorLogging( handlerOpts('get-conversation-prompt'), async (): Promise<{ prompt: string; environment: string }> => { - // Use the build-time generated prompt constant (no runtime file I/O needed) - const { feedbackConversationPrompt } = await import('../../../generated/prompts'); - const promptTemplate = feedbackConversationPrompt; + const promptTemplate = getPrompt('feedback-conversation'); const platformLabel = getPlatformLabel(process.platform); const osVersion = typeof os.version === 'function' ? os.version() : ''; diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 90b0865255..d12be97509 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -56,6 +56,7 @@ import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from import { registerCueHandlers, CueHandlerDependencies } from './cue'; import { registerWakatimeHandlers } from './wakatime'; import { registerFeedbackHandlers } from './feedback'; +import { registerPromptsHandlers } from './prompts'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -107,6 +108,7 @@ export { registerCueHandlers }; export type { CueHandlerDependencies }; export { registerWakatimeHandlers }; export { registerFeedbackHandlers }; +export { registerPromptsHandlers }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; @@ -303,6 +305,8 @@ export function registerAllHandlers(deps: HandlerDependencies): void { registerFeedbackHandlers({ getProcessManager: deps.getProcessManager, }); + // 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 0000000000..c591f41d74 --- /dev/null +++ b/src/main/ipc/handlers/prompts.ts @@ -0,0 +1,93 @@ +/** + * 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 { + if (!arePromptsInitialized()) { + return { success: false, error: 'Prompts not yet initialized' }; + } + 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 { + if (!arePromptsInitialized()) { + return { success: false, error: 'Prompts not yet initialized' }; + } + 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/ipc/handlers/tabNaming.ts b/src/main/ipc/handlers/tabNaming.ts index 2ee3aa79c5..f366fd9d12 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'; @@ -120,7 +120,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/preload/index.ts b/src/main/preload/index.ts index 1fc1192842..68e908a4ef 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -52,6 +52,7 @@ import { createTabNamingApi } from './tabNaming'; import { createDirectorNotesApi } from './directorNotes'; import { createCueApi } from './cue'; import { createWakatimeApi } from './wakatime'; +import { createPromptsApi } from './prompts'; // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object @@ -200,6 +201,9 @@ contextBridge.exposeInMainWorld('maestro', { // WakaTime API (CLI check, API key validation) wakatime: createWakatimeApi(), + + // Core Prompts API (view, edit, reset system prompts) + prompts: createPromptsApi(), }); // Re-export factory functions for external consumers (e.g., tests) @@ -278,6 +282,8 @@ export { createCueApi, // WakaTime createWakatimeApi, + // Core Prompts + createPromptsApi, }; // Re-export types for TypeScript consumers @@ -502,3 +508,8 @@ export type { // From wakatime WakatimeApi, } from './wakatime'; +export type { + // From prompts + PromptsApi, + CorePromptData, +} from './prompts'; diff --git a/src/main/preload/prompts.ts b/src/main/preload/prompts.ts new file mode 100644 index 0000000000..4098a20802 --- /dev/null +++ b/src/main/preload/prompts.ts @@ -0,0 +1,52 @@ +/** + * Preload API for core prompts + * + * Provides the window.maestro.prompts namespace for: + * - Getting individual prompts by ID + * - Getting all prompts with metadata (for Settings UI) + * - Saving user customizations + * - Resetting prompts to bundled defaults + */ + +import { ipcRenderer } from 'electron'; + +export interface CorePromptData { + 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?: CorePromptData[]; + 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/main/prompt-manager.ts b/src/main/prompt-manager.ts new file mode 100644 index 0000000000..1693410bda --- /dev/null +++ b/src/main/prompt-manager.ts @@ -0,0 +1,261 @@ +/** + * 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'; +import { CORE_PROMPTS } from '../shared/promptDefinitions'; + +const LOG_CONTEXT = '[PromptManager]'; + +// ============================================================================ +// Types +// ============================================================================ + +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; +} + +// ============================================================================ +// State +// ============================================================================ + +const promptCache = new Map(); +let initialized = false; + +// Serialize disk writes to prevent concurrent read-modify-write races +let writeLock: Promise = Promise.resolve(); +function withWriteLock(fn: () => Promise): Promise { + const next = writeLock.then(fn, fn); + writeLock = next.then( + () => {}, + () => {} + ); + return next; +} + +// ============================================================================ +// 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 { + const filePath = getCustomizationsPath(); + try { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content) as StoredData; + } catch (error: unknown) { + // File not existing is expected (no customizations yet) + const err = error as NodeJS.ErrnoException | undefined; + if (err?.code === 'ENOENT') { + return null; + } + // Any other error (malformed JSON, permission denied, disk corruption) + // is a real problem — log it so users know their customizations failed to load + logger.error( + `Failed to load prompt customizations from ${filePath}: ${String(error)}`, + LOG_CONTEXT + ); + throw error; + } +} + +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}`); + } + + await withWriteLock(async () => { + 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}`); + } + + // Read bundled content FIRST — verify it's readable before deleting customization + const promptsPath = getBundledPromptsPath(); + const filePath = path.join(promptsPath, def.filename); + const bundledContent = await fs.readFile(filePath, 'utf-8'); + + // Only remove customization after confirming bundled file is readable + await withWriteLock(async () => { + const customizations = await loadUserCustomizations(); + if (customizations?.prompts?.[id]) { + delete customizations.prompts[id]; + await saveUserCustomizations(customizations); + } + }); + + // 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); +} diff --git a/src/prompts/index.ts b/src/prompts/index.ts index 8ab8c51c43..2615f03984 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -1,54 +1,15 @@ /** - * 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. - * - * The generated file is at src/generated/prompts.ts + * Prompts are loaded from disk at runtime via the prompt-manager. + * This file re-exports shared definitions for convenience. + * The single source of truth is src/shared/promptDefinitions.ts. */ export { - // Wizard - wizardSystemPrompt, - wizardSystemContinuationPrompt, - wizardDocumentGenerationPrompt, - - // Inline Wizard - wizardInlineSystemPrompt, - wizardInlineIteratePrompt, - wizardInlineNewPrompt, - wizardInlineIterateGenerationPrompt, - - // AutoRun - autorunDefaultPrompt, - autorunSynopsisPrompt, - - // Input processing - imageOnlyDefaultPrompt, - - // Commands - commitCommandPrompt, - - // Maestro system prompt - maestroSystemPrompt, - - // Group chat prompts - groupChatModeratorSystemPrompt, - groupChatModeratorSynthesisPrompt, - groupChatParticipantPrompt, - groupChatParticipantRequestPrompt, - - // Context management - contextGroomingPrompt, - contextTransferPrompt, - contextSummarizePrompt, - - // Tab naming - tabNamingPrompt, - - // Director's Notes - directorNotesPrompt, - - // Feedback - feedbackPrompt, -} from '../generated/prompts'; + CORE_PROMPTS, + PROMPT_IDS, + getPromptFilename, + type PromptDefinition, + type PromptId, +} from '../shared/promptDefinitions'; diff --git a/src/prompts/openspec/openspec.implement.md b/src/prompts/openspec/openspec.implement.md index abc4e11a0f..10b8d3c75a 100644 --- a/src/prompts/openspec/openspec.implement.md +++ b/src/prompts/openspec/openspec.implement.md @@ -21,7 +21,7 @@ The user input may contain: 1. **Locate the OpenSpec change** in `openspec/changes//` 2. **Read the `tasks.md`** file (and optionally `proposal.md` for context) 3. **Generate Auto Run documents** using the format below -4. **Save to `.maestro/playbooks/`** folder +4. **Save to `{{AUTORUN_FOLDER}}/`** folder ## Critical Requirements @@ -87,11 +87,11 @@ Preserve any markers from the original tasks.md: ## Output Format -Create each document as a file in the `.maestro/playbooks/` folder with this naming pattern: +Create each document as a file in the `{{AUTORUN_FOLDER}}/` folder with this naming pattern: ``` -.maestro/playbooks/OpenSpec--Phase-01-[Description].md -.maestro/playbooks/OpenSpec--Phase-02-[Description].md +{{AUTORUN_FOLDER}}/OpenSpec--Phase-01-[Description].md +{{AUTORUN_FOLDER}}/OpenSpec--Phase-02-[Description].md ``` ## Execution Steps @@ -116,7 +116,7 @@ Create each document as a file in the `.maestro/playbooks/` folder with this nam - Include OpenSpec context in each document 5. **Save the documents**: - - Files go to `.maestro/playbooks/` folder + - Files go to `{{AUTORUN_FOLDER}}/` folder - Filename pattern: `OpenSpec--Phase-XX-[Description].md` ## Now Execute diff --git a/src/prompts/speckit/speckit.implement.md b/src/prompts/speckit/speckit.implement.md index ab40d1bf42..703a6b2734 100644 --- a/src/prompts/speckit/speckit.implement.md +++ b/src/prompts/speckit/speckit.implement.md @@ -20,7 +20,7 @@ The user input may contain: 1. **Locate the Spec Kit feature** in `specs//` 2. **Read the `tasks.md`** file (and optionally `specification.md` for context) 3. **Generate Auto Run documents** using the format below -4. **Save to `.maestro/playbooks/`** folder +4. **Save to `{{AUTORUN_FOLDER}}/`** folder ## Critical Requirements @@ -87,11 +87,11 @@ Preserve any markers from the original tasks.md: ## Output Format -Create each document as a file in the `.maestro/playbooks/` folder with this naming pattern: +Create each document as a file in the `{{AUTORUN_FOLDER}}/` folder with this naming pattern: ``` -.maestro/playbooks/SpecKit--Phase-01-[Description].md -.maestro/playbooks/SpecKit--Phase-02-[Description].md +{{AUTORUN_FOLDER}}/SpecKit--Phase-01-[Description].md +{{AUTORUN_FOLDER}}/SpecKit--Phase-02-[Description].md ``` ## Execution Steps @@ -116,7 +116,7 @@ Create each document as a file in the `.maestro/playbooks/` folder with this nam - Include Spec Kit context in each document 5. **Save the documents**: - - Files go to `.maestro/playbooks/` folder + - Files go to `{{AUTORUN_FOLDER}}/` folder - Filename pattern: `SpecKit--Phase-XX-[Description].md` ## Now Execute diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index e790aa80f2..63ca0fa56c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,7 +6,7 @@ import { RightPanel, RightPanelHandle } from './components/RightPanel'; import { slashCommands } from './slashCommands'; import { AppModals, type PRDetails, type FlatFileItem } from './components/AppModals'; import { AppStandaloneModals } from './components/AppStandaloneModals'; -// DEFAULT_BATCH_PROMPT moved to useSymphonyContribution hook +import { initializeRendererPrompts } from './services/promptInit'; import { ErrorBoundary } from './components/ErrorBoundary'; import { MainPanel, type MainPanelHandle } from './components/MainPanel'; // AppOverlays, PlaygroundPanel, DebugWizardModal, DebugPackageModal, WindowsWarningModal, @@ -3094,6 +3094,23 @@ function MaestroConsoleInner() { * InlineWizardProvider - inline /wizard command state management */ export default function MaestroConsole() { + const [promptsReady, setPromptsReady] = useState(false); + + useEffect(() => { + initializeRendererPrompts() + .then(() => setPromptsReady(true)) + .catch((err) => { + captureException(err instanceof Error ? err : new Error(String(err)), { + extra: { context: 'MaestroConsole.initializeRendererPrompts' }, + }); + setPromptsReady(true); // Allow app to render; features degrade gracefully + }); + }, []); + + if (!promptsReady) { + return null; + } + return ( diff --git a/src/renderer/components/AppStandaloneModals.tsx b/src/renderer/components/AppStandaloneModals.tsx index bf18868d1e..1120047651 100644 --- a/src/renderer/components/AppStandaloneModals.tsx +++ b/src/renderer/components/AppStandaloneModals.tsx @@ -243,6 +243,7 @@ function AppStandaloneModalsInner({ deleteAgentSession, settingsModalOpen, settingsTab, + settingsPromptId, wizardResumeModalOpen, wizardResumeState, tourOpen, @@ -534,6 +535,7 @@ function AppStandaloneModalsInner({ theme={theme} themes={THEMES} initialTab={settingsTab} + initialSelectedPromptId={settingsPromptId} hasNoAgents={hasNoAgents} onThemeImportError={(msg) => setFlashNotification(msg)} onThemeImportSuccess={(msg) => setFlashNotification(msg)} diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index d8f2bc2d9b..c6d71dd454 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -4,6 +4,8 @@ import type { Session, Group, Theme, Shortcut, RightPanelTab, SettingsTab } from import type { GroupChat } from '../../shared/group-chat-types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { notifyToast } from '../stores/notificationStore'; +import { useModalStore } from '../stores/modalStore'; +import { QUICK_ACTION_PROMPTS } from '../../shared/promptDefinitions'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { gitService } from '../services/git'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; @@ -958,6 +960,14 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct setQuickActionOpen(false); }, }, + ...QUICK_ACTION_PROMPTS.map((p) => ({ + id: `edit-prompt-${p.id}`, + label: `Edit Prompt: ${p.label}`, + action: () => { + useModalStore.getState().openModal('settings', { tab: 'prompts', promptId: p.id }); + setQuickActionOpen(false); + }, + })), { id: 'shortcuts', label: 'View Shortcuts', diff --git a/src/renderer/components/Settings/SettingsModal.tsx b/src/renderer/components/Settings/SettingsModal.tsx index 36a0cf9ab0..8b52aa9b1b 100644 --- a/src/renderer/components/Settings/SettingsModal.tsx +++ b/src/renderer/components/Settings/SettingsModal.tsx @@ -11,13 +11,14 @@ import { Server, Monitor, Globe, - Users, + Wand2, } from 'lucide-react'; import { useSettings } from '../../hooks'; import type { Theme, LLMProvider } from '../../types'; import { useLayerStack } from '../../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; import { AICommandsPanel } from '../AICommandsPanel'; +import { MaestroPromptsTab } from './tabs/MaestroPromptsTab'; import { SpecKitCommandsPanel } from '../SpecKitCommandsPanel'; import { OpenSpecCommandsPanel } from '../OpenSpecCommandsPanel'; import { BmadCommandsPanel } from '../BmadCommandsPanel'; @@ -51,10 +52,11 @@ interface SettingsModalProps { | 'theme' | 'notifications' | 'aicommands' - | 'groupchat' | 'ssh' | 'environment' - | 'encore'; + | 'encore' + | 'prompts'; + initialSelectedPromptId?: string; hasNoAgents?: boolean; onThemeImportError?: (message: string) => void; onThemeImportSuccess?: (message: string) => void; @@ -67,6 +69,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro theme, themes, initialTab, + initialSelectedPromptId, hasNoAgents, onThemeImportError, onThemeImportSuccess, @@ -100,9 +103,6 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro setSshRemoteIgnorePatterns, sshRemoteHonorGitignore, setSshRemoteHonorGitignore, - // Group Chat settings - moderatorStandingInstructions, - setModeratorStandingInstructions, } = useSettings(); const [activeTab, setActiveTab] = useState< @@ -113,10 +113,10 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro | 'theme' | 'notifications' | 'aicommands' - | 'groupchat' | 'ssh' | 'environment' | 'encore' + | 'prompts' >('general'); const [testingLLM, setTestingLLM] = useState(false); const [testResult, setTestResult] = useState<{ @@ -209,10 +209,10 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro | 'theme' | 'notifications' | 'aicommands' - | 'groupchat' | 'ssh' | 'environment' | 'encore' + | 'prompts' > = FEATURE_FLAGS.LLM_SETTINGS ? [ 'general', @@ -222,7 +222,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro 'theme', 'notifications', 'aicommands', - 'groupchat', + 'prompts', 'ssh', 'environment', 'encore', @@ -234,7 +234,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro 'theme', 'notifications', 'aicommands', - 'groupchat', + 'prompts', 'ssh', 'environment', 'encore', @@ -384,7 +384,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro { id: 'theme', label: 'Themes', icon: Palette }, { id: 'notifications', label: 'Notifications', icon: Bell }, { id: 'aicommands', label: 'AI Commands', icon: Cpu }, - { id: 'groupchat', label: 'Group Chat', icon: Users }, + { id: 'prompts', label: 'Maestro Prompts', icon: Wand2 }, { id: 'ssh', label: 'SSH Hosts', icon: Server }, { id: 'environment', label: 'Environment', icon: Globe }, { id: 'encore', label: 'Encore Features', icon: FlaskConical }, @@ -634,37 +634,12 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro )} - {activeTab === 'groupchat' && ( -
-
-

- Moderator Standing Instructions -

-

- These instructions are included in every group chat moderator prompt. Use them - for standing practices like branch workflows, autorun setup, or coding - standards. -

-