diff --git a/CLAUDE.md b/CLAUDE.md index 861bfb4f..f878b4cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,8 @@ src/ | Add setting | `src/renderer/hooks/useSettings.ts`, `src/main/index.ts` | | Add template variable | `src/shared/templateVariables.ts`, `src/renderer/utils/templateVariables.ts` | | Modify system prompts | `src/prompts/*.md` (wizard, Auto Run, etc.) | +| 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` | | Configure agent | `src/main/agent-detector.ts`, `src/main/agent-capabilities.ts` | | Add agent output parser | `src/main/parsers/`, `src/main/parsers/index.ts` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 192baa03..100678c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -307,6 +307,23 @@ To add a built-in slash command that users see by default, add it to the Custom For commands that need programmatic behavior (not just prompts), handle them in `App.tsx` where slash commands are processed before being sent to the agent. +### Adding Bundled AI Command Sets (Spec-Kit / OpenSpec Pattern) + +Maestro bundles two spec-driven workflow systems. To add a similar bundled command set: + +1. **Create prompts directory**: `src/prompts/my-workflow/` +2. **Add command markdown files**: `my-workflow.command1.md`, `my-workflow.command2.md` +3. **Create index.ts**: Export command definitions with IDs, slash commands, descriptions, and prompts +4. **Create metadata.json**: Track source version, commit SHA, and last refreshed date +5. **Create manager**: `src/main/my-workflow-manager.ts` (handles loading, saving, refreshing) +6. **Add IPC handlers**: In `src/main/index.ts` for get/set/refresh operations +7. **Add preload API**: In `src/main/preload.ts` to expose to renderer +8. **Create UI panel**: Similar to `OpenSpecCommandsPanel.tsx` or `SpecKitCommandsPanel.tsx` +9. **Add to extraResources**: In `package.json` build config for all platforms +10. **Create refresh script**: `scripts/refresh-my-workflow.mjs` + +Reference the existing Spec-Kit (`src/prompts/speckit/`, `src/main/speckit-manager.ts`) and OpenSpec (`src/prompts/openspec/`, `src/main/openspec-manager.ts`) implementations. + ### Adding a New Theme Maestro has 16 themes across 3 modes: dark, light, and vibe. @@ -698,15 +715,23 @@ All PRs must pass these checks before review: ## Building for Release -### 0. Refresh Spec Kit Prompts (Optional) +### 0. Refresh AI Command Prompts (Optional) -Before releasing, check if GitHub's spec-kit has updates: +Before releasing, check if the upstream spec-kit and OpenSpec repositories have updates: ```bash +# Refresh GitHub's spec-kit prompts npm run refresh-speckit + +# Refresh Fission-AI's OpenSpec prompts +npm run refresh-openspec ``` -This fetches the latest prompts from [github/spec-kit](https://github.com/github/spec-kit) and updates the bundled files in `src/prompts/speckit/`. The custom `/speckit.implement` prompt is never overwritten. +These scripts fetch the latest prompts from their respective repositories: +- **Spec-Kit**: [github/spec-kit](https://github.com/github/spec-kit) → `src/prompts/speckit/` +- **OpenSpec**: [Fission-AI/OpenSpec](https://github.com/Fission-AI/OpenSpec) → `src/prompts/openspec/` + +Custom Maestro-specific prompts (`/speckit.implement`, `/openspec.implement`, `/openspec.help`) are never overwritten by the refresh scripts. Review any changes with `git diff` before committing. diff --git a/docs/configuration.md b/docs/configuration.md index ddb54f3d..e91ed3ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,7 +16,7 @@ Settings are organized into tabs: | **Shortcuts** | Customize keyboard shortcuts (see [Keyboard Shortcuts](./keyboard-shortcuts)) | | **Appearance** | Font size, UI density | | **Notifications** | Sound alerts, text-to-speech settings | -| **AI Commands** | View and edit slash commands and Spec-Kit prompts | +| **AI Commands** | View and edit slash commands, [Spec-Kit](./speckit-commands), and [OpenSpec](./openspec-commands) prompts | ## Checking for Updates diff --git a/docs/docs.json b/docs/docs.json index 09937cd7..a7f4a78e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -55,14 +55,16 @@ "group": "Usage", "pages": [ "general-usage", + "keyboard-shortcuts", + "slash-commands", + "speckit-commands", + "openspec-commands", "history", "context-management", "autorun-playbooks", "git-worktrees", "group-chat", "remote-access", - "slash-commands", - "speckit-commands", "configuration" ] }, @@ -77,7 +79,6 @@ "group": "Reference", "pages": [ "achievements", - "keyboard-shortcuts", "troubleshooting" ], "icon": "life-ring" diff --git a/docs/features.md b/docs/features.md index f42dd1bf..cea4509e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -22,7 +22,7 @@ icon: sparkles - šŸ”€ **Git Integration** - Automatic repo detection, branch display, diff viewer, commit logs, and git-aware file completion. Work with git without leaving the app. - šŸ“ **[File Explorer](./general-usage)** - Browse project files with syntax highlighting, markdown preview, and image viewing. Reference files in prompts with `@` mentions. - šŸ” **[Powerful Output Filtering](./general-usage)** - Search and filter AI output with include/exclude modes, regex support, and per-response local filters. -- ⚔ **[Slash Commands](./slash-commands)** - Extensible command system with autocomplete. Create custom commands with template variables for your workflows. +- ⚔ **[Slash Commands](./slash-commands)** - Extensible command system with autocomplete. Create custom commands with template variables for your workflows. Includes bundled [Spec-Kit](./speckit-commands) for feature specifications and [OpenSpec](./openspec-commands) for change proposals. - šŸ’¾ **Draft Auto-Save** - Never lose work. Drafts are automatically saved and restored per session. - šŸ”Š **Speakable Notifications** - Audio alerts with text-to-speech announcements when agents complete tasks. - šŸŽØ **[Beautiful Themes](https://github.com/pedramamini/Maestro/blob/main/THEMES.md)** - 12 themes including Dracula, Monokai, Nord, Tokyo Night, GitHub Light, and more. diff --git a/docs/openspec-commands.md b/docs/openspec-commands.md new file mode 100644 index 00000000..28a174e9 --- /dev/null +++ b/docs/openspec-commands.md @@ -0,0 +1,154 @@ +--- +title: OpenSpec Commands +description: Spec-driven development workflow for managing code changes with AI-assisted proposal, implementation, and archival. +icon: git-pull-request +--- + +OpenSpec is a spec-driven development tool from [Fission-AI/OpenSpec](https://github.com/Fission-AI/OpenSpec) that ensures alignment between humans and AI coding assistants before any code is written. Maestro bundles these workflow commands and keeps them updated automatically. + +## OpenSpec vs. Spec-Kit + +Maestro offers two complementary spec-driven development tools: + +| Feature | OpenSpec | Spec-Kit | +|---------|----------|----------| +| **Focus** | Change management & proposals | Feature specifications | +| **Workflow** | Proposal → Apply → Archive | Constitution → Specify → Plan → Tasks | +| **Best For** | Iterative changes, brownfield projects | New features, greenfield development | +| **Output** | Change proposals with spec deltas | Feature specifications and task lists | +| **Directory** | `openspec/` | Project root or designated folder | + +**Use OpenSpec when:** +- Making iterative changes to existing features +- You need explicit change proposals before implementation +- Working on brownfield projects with existing specifications +- You want a clear archive of completed changes + +**Use Spec-Kit when:** +- Defining new features from scratch +- Establishing project constitutions and principles +- Creating detailed feature specifications +- Breaking down work into implementation tasks + +Both tools integrate with Maestro's Auto Run for autonomous execution. + +## Core Workflow + +OpenSpec follows a three-stage cycle: + +### Stage 1: Proposal (`/openspec.proposal`) + +Create a change proposal before writing any code: + +1. Reviews existing specs and active changes +2. Scaffolds `proposal.md`, `tasks.md`, and optional `design.md` +3. Creates spec deltas showing what will be ADDED, MODIFIED, or REMOVED +4. Validates the proposal structure + +**Creates:** A `openspec/changes//` directory with: +- `proposal.md` - Why and what +- `tasks.md` - Implementation checklist +- `specs//spec.md` - Spec deltas + +### Stage 2: Apply (`/openspec.apply`) + +Implement the approved proposal: + +1. Reads proposal and tasks +2. Implements tasks sequentially +3. Updates task checkboxes as work completes +4. Ensures approval gate is passed before starting + +**Tip:** Only start implementation after the proposal is reviewed and approved. + +### Stage 3: Archive (`/openspec.archive`) + +After deployment, archive the completed change: + +1. Moves `changes//` to `changes/archive/YYYY-MM-DD-/` +2. Updates source-of-truth specs if capabilities changed +3. Validates the archived change + +## Maestro-Specific Commands + +### `/openspec.implement` - Generate Auto Run Documents + +Bridges OpenSpec with Maestro's Auto Run: + +1. Reads the proposal and tasks from a change +2. Converts tasks into Auto Run document format +3. Saves to `Auto Run Docs/` with task checkboxes +4. Supports worktree mode for parallel execution + +### `/openspec.help` - Workflow Overview + +Get help with OpenSpec concepts and Maestro integration. + +## Directory Structure + +OpenSpec uses a clear separation between current truth and proposed changes: + +``` +openspec/ +ā”œā”€ā”€ project.md # Project conventions +ā”œā”€ā”€ specs/ # Current truth - what IS built +│ └── / +│ ā”œā”€ā”€ spec.md # Requirements and scenarios +│ └── design.md # Technical patterns +└── changes/ # Proposals - what SHOULD change + ā”œā”€ā”€ / + │ ā”œā”€ā”€ proposal.md # Why, what, impact + │ ā”œā”€ā”€ tasks.md # Implementation checklist + │ └── specs/ # Spec deltas (ADDED/MODIFIED/REMOVED) + └── archive/ # Completed changes +``` + +## Spec Delta Format + +Changes use explicit operation headers: + +```markdown +## ADDED Requirements +### Requirement: New Feature +The system SHALL provide... + +#### Scenario: Success case +- **WHEN** user performs action +- **THEN** expected result + +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete updated requirement text] + +## REMOVED Requirements +### Requirement: Old Feature +**Reason**: [Why removing] +**Migration**: [How to handle] +``` + +## Viewing & Managing Commands + +Access OpenSpec commands via **Settings → AI Commands** tab. Here you can: + +- **View all commands** with descriptions +- **Check for Updates** to pull the latest workflow from GitHub +- **Expand commands** to see their full prompts +- **Customize prompts** (modifications are preserved across updates) + +## Auto-Updates + +OpenSpec prompts are synced from the [Fission-AI/OpenSpec repository](https://github.com/Fission-AI/OpenSpec): + +1. Open **Settings → AI Commands** +2. Click **Check for Updates** in the OpenSpec section +3. New workflow improvements are downloaded +4. Your custom modifications are preserved + +## Tips for Best Results + +- **Proposal first** - Never start implementation without an approved proposal +- **Keep changes focused** - One logical change per proposal +- **Use meaningful IDs** - `add-user-auth` not `change-1` +- **Include scenarios** - Every requirement needs at least one `#### Scenario:` +- **Validate often** - Run `openspec validate --strict` before sharing +- **Archive promptly** - Archive changes after deployment to keep `changes/` clean diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 5dfbffb1..2d8b8733 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -67,3 +67,23 @@ Commands support **template variables** that are automatically substituted at ru It's {{WEEKDAY}}, {{DATE}}. I'm on branch {{GIT_BRANCH}} at {{AGENT_PATH}}. Summarize what I worked on yesterday and suggest priorities for today. ``` + +## Spec-Kit Commands + +Maestro bundles [GitHub's spec-kit](https://github.com/github/spec-kit) methodology for structured feature development. Commands include `/speckit.constitution`, `/speckit.specify`, `/speckit.clarify`, `/speckit.plan`, `/speckit.tasks`, and `/speckit.implement`. + +See [Spec-Kit Commands](/speckit-commands) for the complete workflow guide. + +## OpenSpec Commands + +Maestro bundles [OpenSpec](https://github.com/Fission-AI/OpenSpec) for spec-driven change management. These commands help you propose, implement, and archive changes systematically: + +| Command | Description | +|---------|-------------| +| `/openspec.proposal` | Create a change proposal with spec deltas before writing code | +| `/openspec.apply` | Implement an approved proposal by following the tasks | +| `/openspec.archive` | Archive completed changes after deployment | +| `/openspec.implement` | Generate Auto Run documents from a proposal (Maestro-specific) | +| `/openspec.help` | Get help with OpenSpec workflow and concepts | + +See [OpenSpec Commands](/openspec-commands) for the complete workflow guide and directory structure. diff --git a/package-lock.json b/package-lock.json index 317fb1c0..dc17284f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.12.2", + "version": "0.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.12.2", + "version": "0.12.3", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { diff --git a/package.json b/package.json index 6c9e0c51..c273dfbb 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "test:integration": "vitest run --config vitest.integration.config.ts", "test:integration:watch": "vitest --config vitest.integration.config.ts", "test:performance": "vitest run --config vitest.performance.config.mts", - "refresh-speckit": "node scripts/refresh-speckit.mjs" + "refresh-speckit": "node scripts/refresh-speckit.mjs", + "refresh-openspec": "node scripts/refresh-openspec.mjs" }, "build": { "npmRebuild": false, @@ -101,6 +102,10 @@ { "from": "src/prompts/speckit", "to": "prompts/speckit" + }, + { + "from": "src/prompts/openspec", + "to": "prompts/openspec" } ] }, @@ -129,6 +134,10 @@ { "from": "src/prompts/speckit", "to": "prompts/speckit" + }, + { + "from": "src/prompts/openspec", + "to": "prompts/openspec" } ] }, @@ -166,6 +175,10 @@ { "from": "src/prompts/speckit", "to": "prompts/speckit" + }, + { + "from": "src/prompts/openspec", + "to": "prompts/openspec" } ] }, diff --git a/scripts/refresh-openspec.mjs b/scripts/refresh-openspec.mjs new file mode 100644 index 00000000..2a3c9089 --- /dev/null +++ b/scripts/refresh-openspec.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +/** + * Refresh OpenSpec Prompts + * + * Fetches the latest OpenSpec prompts from GitHub by parsing AGENTS.md + * and extracts the three workflow stages (proposal, apply, archive). + * + * Unlike spec-kit which uses ZIP releases, OpenSpec bundles all workflow + * instructions in a single AGENTS.md file that we parse into sections. + * + * Usage: npm run refresh-openspec + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import https from 'https'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OPENSPEC_DIR = path.join(__dirname, '..', 'src', 'prompts', 'openspec'); +const METADATA_PATH = path.join(OPENSPEC_DIR, 'metadata.json'); + +// GitHub OpenSpec repository info +const GITHUB_API = 'https://api.github.com'; +const REPO_OWNER = 'Fission-AI'; +const REPO_NAME = 'OpenSpec'; +const AGENTS_MD_URL = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main/openspec/AGENTS.md`; + +// Commands to extract from AGENTS.md (we skip custom commands like 'help' and 'implement') +const UPSTREAM_COMMANDS = ['proposal', 'apply', 'archive']; + +// Section markers for parsing AGENTS.md +// Stage headers are formatted as: ### Stage N: Title +const SECTION_MARKERS = { + proposal: { + start: /^###\s*Stage\s*1[:\s]+Creating\s+Changes/i, + end: /^###\s*Stage\s*2[:\s]+/i, + }, + apply: { + start: /^###\s*Stage\s*2[:\s]+Implementing\s+Changes/i, + end: /^###\s*Stage\s*3[:\s]+/i, + }, + archive: { + start: /^###\s*Stage\s*3[:\s]+Archiving\s+Changes/i, + end: /^##[^#]/, // End at next level-2 heading or end of file + }, +}; + +/** + * Make an HTTPS GET request + */ +function httpsGet(url, options = {}) { + return new Promise((resolve, reject) => { + const headers = { + 'User-Agent': 'Maestro-OpenSpec-Refresher', + ...options.headers, + }; + + https.get(url, { headers }, (res) => { + // Handle redirects + if (res.statusCode === 301 || res.statusCode === 302) { + return resolve(httpsGet(res.headers.location, options)); + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${url}`)); + return; + } + + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve({ data, headers: res.headers })); + res.on('error', reject); + }).on('error', reject); + }); +} + +/** + * Parse AGENTS.md and extract workflow sections as prompts + */ +function parseAgentsMd(content) { + const result = {}; + const lines = content.split('\n'); + + for (const [sectionId, markers] of Object.entries(SECTION_MARKERS)) { + let inSection = false; + let sectionLines = []; + + for (const line of lines) { + if (!inSection && markers.start.test(line)) { + inSection = true; + sectionLines.push(line); + continue; + } + + if (inSection) { + // Check if we've hit the end marker (next stage or next major section) + if (markers.end.test(line) && line.trim() !== '') { + // Don't include the end marker line, it belongs to the next section + break; + } + sectionLines.push(line); + } + } + + if (sectionLines.length > 0) { + // Clean up trailing empty lines + while (sectionLines.length > 0 && sectionLines[sectionLines.length - 1].trim() === '') { + sectionLines.pop(); + } + result[sectionId] = sectionLines.join('\n').trim(); + } + } + + return result; +} + +/** + * Get the latest commit SHA from the main branch + */ +async function getLatestCommitSha() { + try { + const url = `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/commits/main`; + const { data } = await httpsGet(url); + const commit = JSON.parse(data); + return commit.sha.substring(0, 7); + } catch (error) { + console.warn(' Warning: Could not fetch commit SHA, using "main"'); + return 'main'; + } +} + +/** + * Main refresh function + */ +async function refreshOpenSpec() { + console.log('šŸ”„ Refreshing OpenSpec prompts from GitHub...\n'); + + // Ensure openspec directory exists + if (!fs.existsSync(OPENSPEC_DIR)) { + console.error('āŒ OpenSpec directory not found:', OPENSPEC_DIR); + process.exit(1); + } + + try { + // Fetch AGENTS.md + console.log('šŸ“” Fetching AGENTS.md from OpenSpec repository...'); + const { data: agentsMdContent } = await httpsGet(AGENTS_MD_URL); + console.log(` Downloaded AGENTS.md (${agentsMdContent.length} bytes)`); + + // Parse sections + console.log('\nšŸ“¦ Parsing workflow sections...'); + const extractedPrompts = parseAgentsMd(agentsMdContent); + const extractedCount = Object.keys(extractedPrompts).length; + console.log(` Extracted ${extractedCount} sections from AGENTS.md`); + + if (extractedCount === 0) { + console.error('āŒ Failed to extract any sections from AGENTS.md'); + console.error(' Check that the section markers match the current format'); + process.exit(1); + } + + // Get commit SHA for version tracking + console.log('\nšŸ“‹ Getting version info...'); + const commitSha = await getLatestCommitSha(); + console.log(` Commit: ${commitSha}`); + + // Update prompt files + console.log('\nāœļø Updating prompt files...'); + let updatedCount = 0; + for (const commandName of UPSTREAM_COMMANDS) { + const content = extractedPrompts[commandName]; + if (!content) { + console.log(` ⚠ Missing: openspec.${commandName}.md (section not found)`); + continue; + } + + const promptFile = path.join(OPENSPEC_DIR, `openspec.${commandName}.md`); + const existingContent = fs.existsSync(promptFile) + ? fs.readFileSync(promptFile, 'utf8') + : ''; + + if (content !== existingContent) { + fs.writeFileSync(promptFile, content); + console.log(` āœ“ Updated: openspec.${commandName}.md`); + updatedCount++; + } else { + console.log(` - Unchanged: openspec.${commandName}.md`); + } + } + + // Update metadata + const metadata = { + lastRefreshed: new Date().toISOString(), + commitSha, + sourceVersion: '0.1.0', + sourceUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}`, + }; + + fs.writeFileSync(METADATA_PATH, JSON.stringify(metadata, null, 2)); + console.log('\nšŸ“„ Updated metadata.json'); + + // Summary + console.log('\nāœ… Refresh complete!'); + console.log(` Commit: ${commitSha}`); + console.log(` Updated: ${updatedCount} files`); + console.log(` Skipped: help, implement (custom Maestro prompts)`); + + } catch (error) { + console.error('\nāŒ Refresh failed:', error.message); + process.exit(1); + } +} + +// Run +refreshOpenSpec(); diff --git a/src/__tests__/main/ipc/handlers/openspec.test.ts b/src/__tests__/main/ipc/handlers/openspec.test.ts new file mode 100644 index 00000000..dc5ed791 --- /dev/null +++ b/src/__tests__/main/ipc/handlers/openspec.test.ts @@ -0,0 +1,259 @@ +/** + * Tests for the OpenSpec IPC handlers + * + * These tests verify the IPC handlers for managing OpenSpec commands: + * - Getting metadata + * - Getting all prompts + * - Getting individual commands + * - Saving user customizations + * - Resetting to defaults + * - Refreshing from GitHub + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ipcMain } from 'electron'; +import { registerOpenSpecHandlers } from '../../../../main/ipc/handlers/openspec'; +import * as openspecManager from '../../../../main/openspec-manager'; + +// Mock electron's ipcMain +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +// Mock the openspec-manager module +vi.mock('../../../../main/openspec-manager', () => ({ + getOpenSpecMetadata: vi.fn(), + getOpenSpecPrompts: vi.fn(), + getOpenSpecCommandBySlash: vi.fn(), + saveOpenSpecPrompt: vi.fn(), + resetOpenSpecPrompt: vi.fn(), + refreshOpenSpecPrompts: vi.fn(), +})); + +// Mock the logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('openspec IPC handlers', () => { + let handlers: Map; + + beforeEach(() => { + vi.clearAllMocks(); + + // Capture all registered handlers + handlers = new Map(); + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler); + }); + + // Register handlers + registerOpenSpecHandlers(); + }); + + afterEach(() => { + handlers.clear(); + }); + + describe('registration', () => { + it('should register all openspec handlers', () => { + const expectedChannels = [ + 'openspec:getMetadata', + 'openspec:getPrompts', + 'openspec:getCommand', + 'openspec:savePrompt', + 'openspec:resetPrompt', + 'openspec:refresh', + ]; + + for (const channel of expectedChannels) { + expect(handlers.has(channel)).toBe(true); + } + }); + }); + + describe('openspec:getMetadata', () => { + it('should return metadata from manager', async () => { + const mockMetadata = { + lastRefreshed: '2025-01-01T00:00:00Z', + commitSha: 'abc1234', + sourceVersion: '0.1.0', + sourceUrl: 'https://github.com/Fission-AI/OpenSpec', + }; + + vi.mocked(openspecManager.getOpenSpecMetadata).mockResolvedValue(mockMetadata); + + const handler = handlers.get('openspec:getMetadata'); + const result = await handler!({} as any); + + expect(openspecManager.getOpenSpecMetadata).toHaveBeenCalled(); + expect(result).toEqual({ success: true, metadata: mockMetadata }); + }); + + it('should handle errors gracefully', async () => { + vi.mocked(openspecManager.getOpenSpecMetadata).mockRejectedValue(new Error('Failed to read')); + + const handler = handlers.get('openspec:getMetadata'); + const result = await handler!({} as any); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to read'); + }); + }); + + describe('openspec:getPrompts', () => { + it('should return all commands from manager', async () => { + const mockCommands = [ + { + id: 'proposal', + command: '/openspec.proposal', + description: 'Create a change proposal', + prompt: '# Proposal', + isCustom: false, + isModified: false, + }, + { + id: 'help', + command: '/openspec.help', + description: 'Get help', + prompt: '# Help', + isCustom: true, + isModified: false, + }, + ]; + + vi.mocked(openspecManager.getOpenSpecPrompts).mockResolvedValue(mockCommands); + + const handler = handlers.get('openspec:getPrompts'); + const result = await handler!({} as any); + + expect(openspecManager.getOpenSpecPrompts).toHaveBeenCalled(); + expect(result).toEqual({ success: true, commands: mockCommands }); + }); + + it('should handle errors gracefully', async () => { + vi.mocked(openspecManager.getOpenSpecPrompts).mockRejectedValue(new Error('Failed')); + + const handler = handlers.get('openspec:getPrompts'); + const result = await handler!({} as any); + + expect(result.success).toBe(false); + }); + }); + + describe('openspec:getCommand', () => { + it('should return command by slash command string', async () => { + const mockCommand = { + id: 'proposal', + command: '/openspec.proposal', + description: 'Create a change proposal', + prompt: '# Proposal', + isCustom: false, + isModified: false, + }; + + vi.mocked(openspecManager.getOpenSpecCommandBySlash).mockResolvedValue(mockCommand); + + const handler = handlers.get('openspec:getCommand'); + const result = await handler!({} as any, '/openspec.proposal'); + + expect(openspecManager.getOpenSpecCommandBySlash).toHaveBeenCalledWith('/openspec.proposal'); + expect(result).toEqual({ success: true, command: mockCommand }); + }); + + it('should return null for unknown command', async () => { + vi.mocked(openspecManager.getOpenSpecCommandBySlash).mockResolvedValue(null); + + const handler = handlers.get('openspec:getCommand'); + const result = await handler!({} as any, '/openspec.unknown'); + + expect(result).toEqual({ success: true, command: null }); + }); + }); + + describe('openspec:savePrompt', () => { + it('should save prompt customization', async () => { + vi.mocked(openspecManager.saveOpenSpecPrompt).mockResolvedValue(undefined); + + const handler = handlers.get('openspec:savePrompt'); + const result = await handler!({} as any, 'proposal', '# Custom Proposal'); + + expect(openspecManager.saveOpenSpecPrompt).toHaveBeenCalledWith('proposal', '# Custom Proposal'); + expect(result).toEqual({ success: true }); + }); + + it('should handle save errors', async () => { + vi.mocked(openspecManager.saveOpenSpecPrompt).mockRejectedValue(new Error('Write failed')); + + const handler = handlers.get('openspec:savePrompt'); + const result = await handler!({} as any, 'proposal', '# Custom'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Write failed'); + }); + }); + + describe('openspec:resetPrompt', () => { + it('should reset prompt to default', async () => { + const defaultPrompt = '# Default Proposal'; + vi.mocked(openspecManager.resetOpenSpecPrompt).mockResolvedValue(defaultPrompt); + + const handler = handlers.get('openspec:resetPrompt'); + const result = await handler!({} as any, 'proposal'); + + expect(openspecManager.resetOpenSpecPrompt).toHaveBeenCalledWith('proposal'); + expect(result).toEqual({ success: true, prompt: defaultPrompt }); + }); + + it('should handle unknown command error', async () => { + vi.mocked(openspecManager.resetOpenSpecPrompt).mockRejectedValue( + new Error('Unknown openspec command: nonexistent') + ); + + const handler = handlers.get('openspec:resetPrompt'); + const result = await handler!({} as any, 'nonexistent'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unknown openspec command'); + }); + }); + + describe('openspec:refresh', () => { + it('should refresh prompts from GitHub', async () => { + const newMetadata = { + lastRefreshed: '2025-06-15T12:00:00Z', + commitSha: 'def5678', + sourceVersion: '0.1.0', + sourceUrl: 'https://github.com/Fission-AI/OpenSpec', + }; + + vi.mocked(openspecManager.refreshOpenSpecPrompts).mockResolvedValue(newMetadata); + + const handler = handlers.get('openspec:refresh'); + const result = await handler!({} as any); + + expect(openspecManager.refreshOpenSpecPrompts).toHaveBeenCalled(); + expect(result).toEqual({ success: true, metadata: newMetadata }); + }); + + it('should handle network errors', async () => { + vi.mocked(openspecManager.refreshOpenSpecPrompts).mockRejectedValue( + new Error('Failed to fetch AGENTS.md: Not Found') + ); + + const handler = handlers.get('openspec:refresh'); + const result = await handler!({} as any); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to fetch'); + }); + }); +}); diff --git a/src/__tests__/main/openspec-manager.test.ts b/src/__tests__/main/openspec-manager.test.ts new file mode 100644 index 00000000..5f44f76d --- /dev/null +++ b/src/__tests__/main/openspec-manager.test.ts @@ -0,0 +1,493 @@ +/** + * Tests for the OpenSpec Manager + * + * Tests the core functionality for managing bundled OpenSpec prompts including: + * - Loading bundled prompts from disk + * - User customization persistence + * - Resetting to defaults + * - Parsing AGENTS.md for upstream command extraction + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; + +// Mock electron app module +vi.mock('electron', () => ({ + app: { + getPath: vi.fn().mockReturnValue('/mock/userData'), + isPackaged: false, + }, +})); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, +})); + +// Mock the logger +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Import the module after mocks are set up +import { + getOpenSpecMetadata, + getOpenSpecPrompts, + saveOpenSpecPrompt, + resetOpenSpecPrompt, + getOpenSpecCommand, + getOpenSpecCommandBySlash, + OpenSpecCommand, + OpenSpecMetadata, +} from '../../main/openspec-manager'; + +describe('openspec-manager', () => { + const mockBundledPrompt = '# Test Prompt\n\nThis is a test prompt.'; + const mockMetadata: OpenSpecMetadata = { + lastRefreshed: '2025-01-01T00:00:00Z', + commitSha: 'abc1234', + sourceVersion: '0.1.0', + sourceUrl: 'https://github.com/Fission-AI/OpenSpec', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getOpenSpecMetadata', () => { + it('should return bundled metadata when no customizations exist', async () => { + // No user customizations file + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('metadata.json')) { + return JSON.stringify(mockMetadata); + } + throw new Error('ENOENT'); + }); + + const metadata = await getOpenSpecMetadata(); + + expect(metadata).toEqual(mockMetadata); + }); + + it('should return customized metadata when available', async () => { + const customMetadata: OpenSpecMetadata = { + lastRefreshed: '2025-06-15T12:00:00Z', + commitSha: 'def5678', + sourceVersion: '0.2.0', + sourceUrl: 'https://github.com/Fission-AI/OpenSpec', + }; + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + return JSON.stringify({ + metadata: customMetadata, + prompts: {}, + }); + } + throw new Error('ENOENT'); + }); + + const metadata = await getOpenSpecMetadata(); + + expect(metadata).toEqual(customMetadata); + }); + + it('should return default metadata when no files exist', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + + const metadata = await getOpenSpecMetadata(); + + expect(metadata.sourceUrl).toBe('https://github.com/Fission-AI/OpenSpec'); + expect(metadata.sourceVersion).toBe('0.1.0'); + }); + }); + + describe('getOpenSpecPrompts', () => { + it('should return all bundled commands', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const commands = await getOpenSpecPrompts(); + + expect(commands.length).toBeGreaterThan(0); + expect(commands.some((cmd) => cmd.command === '/openspec.help')).toBe(true); + expect(commands.some((cmd) => cmd.command === '/openspec.proposal')).toBe(true); + expect(commands.some((cmd) => cmd.command === '/openspec.apply')).toBe(true); + expect(commands.some((cmd) => cmd.command === '/openspec.archive')).toBe(true); + expect(commands.some((cmd) => cmd.command === '/openspec.implement')).toBe(true); + }); + + it('should return commands with correct structure', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const commands = await getOpenSpecPrompts(); + + for (const cmd of commands) { + expect(cmd).toHaveProperty('id'); + expect(cmd).toHaveProperty('command'); + expect(cmd).toHaveProperty('description'); + expect(cmd).toHaveProperty('prompt'); + expect(cmd).toHaveProperty('isCustom'); + expect(cmd).toHaveProperty('isModified'); + expect(cmd.command.startsWith('/openspec.')).toBe(true); + } + }); + + it('should mark custom commands correctly', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const commands = await getOpenSpecPrompts(); + + const helpCmd = commands.find((cmd) => cmd.id === 'help'); + const implementCmd = commands.find((cmd) => cmd.id === 'implement'); + const proposalCmd = commands.find((cmd) => cmd.id === 'proposal'); + + expect(helpCmd?.isCustom).toBe(true); + expect(implementCmd?.isCustom).toBe(true); + expect(proposalCmd?.isCustom).toBe(false); + }); + + it('should use customized prompt when available', async () => { + const customContent = '# Custom Proposal\n\nThis is my custom prompt.'; + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + return JSON.stringify({ + metadata: mockMetadata, + prompts: { + proposal: { + content: customContent, + isModified: true, + modifiedAt: '2025-06-15T12:00:00Z', + }, + }, + }); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const commands = await getOpenSpecPrompts(); + const proposalCmd = commands.find((cmd) => cmd.id === 'proposal'); + + expect(proposalCmd?.prompt).toBe(customContent); + expect(proposalCmd?.isModified).toBe(true); + }); + }); + + describe('saveOpenSpecPrompt', () => { + it('should save customization to disk', async () => { + const customContent = '# My Custom Prompt'; + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('metadata.json')) { + return JSON.stringify(mockMetadata); + } + throw new Error('ENOENT'); + }); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await saveOpenSpecPrompt('proposal', customContent); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenContent = JSON.parse(writeCall[1] as string); + + expect(writtenContent.prompts.proposal.content).toBe(customContent); + expect(writtenContent.prompts.proposal.isModified).toBe(true); + expect(writtenContent.prompts.proposal.modifiedAt).toBeDefined(); + }); + + it('should preserve existing customizations', async () => { + const existingCustomizations = { + metadata: mockMetadata, + prompts: { + apply: { + content: '# Existing Apply', + isModified: true, + modifiedAt: '2025-01-01T00:00:00Z', + }, + }, + }; + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + return JSON.stringify(existingCustomizations); + } + throw new Error('ENOENT'); + }); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await saveOpenSpecPrompt('proposal', '# New Proposal'); + + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenContent = JSON.parse(writeCall[1] as string); + + expect(writtenContent.prompts.apply.content).toBe('# Existing Apply'); + expect(writtenContent.prompts.proposal.content).toBe('# New Proposal'); + }); + }); + + describe('resetOpenSpecPrompt', () => { + it('should reset prompt to bundled default', async () => { + const customizations = { + metadata: mockMetadata, + prompts: { + proposal: { + content: '# Custom', + isModified: true, + }, + }, + }; + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + return JSON.stringify(customizations); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const result = await resetOpenSpecPrompt('proposal'); + + expect(result).toBe(mockBundledPrompt); + expect(fs.writeFile).toHaveBeenCalled(); + + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenContent = JSON.parse(writeCall[1] as string); + expect(writtenContent.prompts.proposal).toBeUndefined(); + }); + + it('should throw for unknown command', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + throw new Error('ENOENT'); + } + throw new Error('ENOENT'); + }); + + await expect(resetOpenSpecPrompt('nonexistent')).rejects.toThrow('Unknown openspec command'); + }); + }); + + describe('getOpenSpecCommand', () => { + it('should return command by ID', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const command = await getOpenSpecCommand('proposal'); + + expect(command).not.toBeNull(); + expect(command?.id).toBe('proposal'); + expect(command?.command).toBe('/openspec.proposal'); + }); + + it('should return null for unknown ID', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const command = await getOpenSpecCommand('nonexistent'); + + expect(command).toBeNull(); + }); + }); + + describe('getOpenSpecCommandBySlash', () => { + it('should return command by slash command string', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const command = await getOpenSpecCommandBySlash('/openspec.proposal'); + + expect(command).not.toBeNull(); + expect(command?.id).toBe('proposal'); + expect(command?.command).toBe('/openspec.proposal'); + }); + + it('should return null for unknown slash command', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const command = await getOpenSpecCommandBySlash('/openspec.nonexistent'); + + expect(command).toBeNull(); + }); + }); + + describe('user prompts directory priority', () => { + it('should prefer user prompts directory over bundled for upstream commands', async () => { + const userPromptContent = '# User Updated Proposal'; + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + // User prompts directory (downloaded updates) + if (pathStr.includes('openspec-prompts') && pathStr.includes('openspec.proposal.md')) { + return userPromptContent; + } + if (pathStr.includes('openspec-prompts')) { + throw new Error('ENOENT'); + } + // Bundled prompts (fallback) + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const commands = await getOpenSpecPrompts(); + const proposalCmd = commands.find((cmd) => cmd.id === 'proposal'); + + expect(proposalCmd?.prompt).toBe(userPromptContent); + }); + + it('should always use bundled for custom commands (help, implement)', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('openspec-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('openspec-prompts')) { + return '# Should not be used for custom commands'; + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + throw new Error('ENOENT'); + }); + + const commands = await getOpenSpecPrompts(); + const helpCmd = commands.find((cmd) => cmd.id === 'help'); + const implementCmd = commands.find((cmd) => cmd.id === 'implement'); + + // Custom commands should use bundled, not user prompts directory + expect(helpCmd?.prompt).toBe(mockBundledPrompt); + expect(implementCmd?.prompt).toBe(mockBundledPrompt); + }); + }); +}); diff --git a/src/__tests__/renderer/services/openspec.test.ts b/src/__tests__/renderer/services/openspec.test.ts new file mode 100644 index 00000000..7d04c277 --- /dev/null +++ b/src/__tests__/renderer/services/openspec.test.ts @@ -0,0 +1,224 @@ +/** + * Tests for src/renderer/services/openspec.ts + * OpenSpec service that wraps IPC calls to main process + */ + +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { + getOpenSpecCommands, + getOpenSpecMetadata, + getOpenSpecCommand, +} from '../../../renderer/services/openspec'; + +// Mock the window.maestro.openspec object +const mockOpenspec = { + getPrompts: vi.fn(), + getMetadata: vi.fn(), + getCommand: vi.fn(), +}; + +// Setup mock before each test +beforeEach(() => { + vi.clearAllMocks(); + + // Ensure window.maestro.openspec is mocked + (window as any).maestro = { + ...(window as any).maestro, + openspec: mockOpenspec, + }; + + // Mock console.error to prevent noise in test output + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('openspec service', () => { + describe('getOpenSpecCommands', () => { + test('returns commands when API succeeds', async () => { + const mockCommands = [ + { + id: 'proposal', + command: '/openspec.proposal', + description: 'Create a change proposal', + prompt: '# Proposal', + isCustom: false, + isModified: false, + }, + { + id: 'help', + command: '/openspec.help', + description: 'Get help', + prompt: '# Help', + isCustom: true, + isModified: false, + }, + ]; + + mockOpenspec.getPrompts.mockResolvedValue({ + success: true, + commands: mockCommands, + }); + + const result = await getOpenSpecCommands(); + + expect(result).toEqual(mockCommands); + expect(mockOpenspec.getPrompts).toHaveBeenCalled(); + }); + + test('returns empty array when API returns success false', async () => { + mockOpenspec.getPrompts.mockResolvedValue({ + success: false, + error: 'Something went wrong', + }); + + const result = await getOpenSpecCommands(); + + expect(result).toEqual([]); + }); + + test('returns empty array when API throws', async () => { + mockOpenspec.getPrompts.mockRejectedValue(new Error('IPC error')); + + const result = await getOpenSpecCommands(); + + expect(result).toEqual([]); + expect(console.error).toHaveBeenCalledWith( + '[OpenSpec] Failed to get commands:', + expect.any(Error) + ); + }); + + test('returns empty array when commands is undefined', async () => { + mockOpenspec.getPrompts.mockResolvedValue({ + success: true, + commands: undefined, + }); + + const result = await getOpenSpecCommands(); + + expect(result).toEqual([]); + }); + }); + + describe('getOpenSpecMetadata', () => { + test('returns metadata when API succeeds', async () => { + const mockMetadata = { + lastRefreshed: '2025-01-01T00:00:00Z', + commitSha: 'abc1234', + sourceVersion: '0.1.0', + sourceUrl: 'https://github.com/Fission-AI/OpenSpec', + }; + + mockOpenspec.getMetadata.mockResolvedValue({ + success: true, + metadata: mockMetadata, + }); + + const result = await getOpenSpecMetadata(); + + expect(result).toEqual(mockMetadata); + expect(mockOpenspec.getMetadata).toHaveBeenCalled(); + }); + + test('returns null when API returns success false', async () => { + mockOpenspec.getMetadata.mockResolvedValue({ + success: false, + error: 'Something went wrong', + }); + + const result = await getOpenSpecMetadata(); + + expect(result).toBeNull(); + }); + + test('returns null when API throws', async () => { + mockOpenspec.getMetadata.mockRejectedValue(new Error('IPC error')); + + const result = await getOpenSpecMetadata(); + + expect(result).toBeNull(); + expect(console.error).toHaveBeenCalledWith( + '[OpenSpec] Failed to get metadata:', + expect.any(Error) + ); + }); + + test('returns null when metadata is undefined', async () => { + mockOpenspec.getMetadata.mockResolvedValue({ + success: true, + metadata: undefined, + }); + + const result = await getOpenSpecMetadata(); + + expect(result).toBeNull(); + }); + }); + + describe('getOpenSpecCommand', () => { + test('returns command when API succeeds', async () => { + const mockCommand = { + id: 'proposal', + command: '/openspec.proposal', + description: 'Create a change proposal', + prompt: '# Proposal', + isCustom: false, + isModified: false, + }; + + mockOpenspec.getCommand.mockResolvedValue({ + success: true, + command: mockCommand, + }); + + const result = await getOpenSpecCommand('/openspec.proposal'); + + expect(result).toEqual(mockCommand); + expect(mockOpenspec.getCommand).toHaveBeenCalledWith('/openspec.proposal'); + }); + + test('returns null when command not found', async () => { + mockOpenspec.getCommand.mockResolvedValue({ + success: true, + command: null, + }); + + const result = await getOpenSpecCommand('/openspec.nonexistent'); + + expect(result).toBeNull(); + }); + + test('returns null when API returns success false', async () => { + mockOpenspec.getCommand.mockResolvedValue({ + success: false, + error: 'Something went wrong', + }); + + const result = await getOpenSpecCommand('/openspec.proposal'); + + expect(result).toBeNull(); + }); + + test('returns null when API throws', async () => { + mockOpenspec.getCommand.mockRejectedValue(new Error('IPC error')); + + const result = await getOpenSpecCommand('/openspec.proposal'); + + expect(result).toBeNull(); + expect(console.error).toHaveBeenCalledWith( + '[OpenSpec] Failed to get command:', + expect.any(Error) + ); + }); + + test('returns null when command is undefined', async () => { + mockOpenspec.getCommand.mockResolvedValue({ + success: true, + command: undefined, + }); + + const result = await getOpenSpecCommand('/openspec.proposal'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index c4c7ecf7..da182ae2 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -22,6 +22,7 @@ import { registerAgentSessionsHandlers, AgentSessionsHandlerDependencies } from import { registerGroupChatHandlers, GroupChatHandlerDependencies } from './groupChat'; import { registerDebugHandlers, DebugHandlerDependencies } from './debug'; import { registerSpeckitHandlers } from './speckit'; +import { registerOpenSpecHandlers } from './openspec'; import { registerContextHandlers, ContextHandlerDependencies, cleanupAllGroomingSessions, getActiveGroomingSessionCount } from './context'; import { AgentDetector } from '../../agent-detector'; import { ProcessManager } from '../../process-manager'; @@ -45,6 +46,7 @@ export { registerAgentSessionsHandlers }; export { registerGroupChatHandlers }; export { registerDebugHandlers }; export { registerSpeckitHandlers }; +export { registerOpenSpecHandlers }; export { registerContextHandlers, cleanupAllGroomingSessions, getActiveGroomingSessionCount }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; @@ -154,6 +156,8 @@ export function registerAllHandlers(deps: HandlerDependencies): void { }); // Register spec-kit handlers (no dependencies needed) registerSpeckitHandlers(); + // Register OpenSpec handlers (no dependencies needed) + registerOpenSpecHandlers(); registerContextHandlers({ getMainWindow: deps.getMainWindow, getProcessManager: deps.getProcessManager, diff --git a/src/main/ipc/handlers/openspec.ts b/src/main/ipc/handlers/openspec.ts new file mode 100644 index 00000000..8d669b04 --- /dev/null +++ b/src/main/ipc/handlers/openspec.ts @@ -0,0 +1,100 @@ +/** + * OpenSpec IPC Handlers + * + * Provides IPC handlers for managing OpenSpec commands: + * - Get metadata (version, last refresh date) + * - Get all commands with prompts + * - Save user edits to prompts + * - Reset prompts to bundled defaults + * - Refresh prompts from GitHub + */ + +import { ipcMain } from 'electron'; +import { logger } from '../../utils/logger'; +import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler'; +import { + getOpenSpecMetadata, + getOpenSpecPrompts, + saveOpenSpecPrompt, + resetOpenSpecPrompt, + refreshOpenSpecPrompts, + getOpenSpecCommandBySlash, + OpenSpecCommand, + OpenSpecMetadata, +} from '../../openspec-manager'; + +const LOG_CONTEXT = '[OpenSpec]'; + +// Helper to create handler options with consistent context +const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions => ({ + context: LOG_CONTEXT, + operation, + logSuccess, +}); + +/** + * Register all OpenSpec IPC handlers. + */ +export function registerOpenSpecHandlers(): void { + // Get metadata (version info, last refresh date) + ipcMain.handle( + 'openspec:getMetadata', + createIpcHandler(handlerOpts('getMetadata', false), async () => { + const metadata = await getOpenSpecMetadata(); + return { metadata }; + }) + ); + + // Get all openspec prompts + ipcMain.handle( + 'openspec:getPrompts', + createIpcHandler(handlerOpts('getPrompts', false), async () => { + const commands = await getOpenSpecPrompts(); + return { commands }; + }) + ); + + // Get a single command by slash command string + ipcMain.handle( + 'openspec:getCommand', + createIpcHandler(handlerOpts('getCommand', false), async (slashCommand: string) => { + const command = await getOpenSpecCommandBySlash(slashCommand); + return { command }; + }) + ); + + // Save user's edit to a prompt + ipcMain.handle( + 'openspec:savePrompt', + createIpcHandler(handlerOpts('savePrompt'), async (id: string, content: string) => { + await saveOpenSpecPrompt(id, content); + logger.info(`Saved custom prompt for openspec.${id}`, LOG_CONTEXT); + return {}; + }) + ); + + // Reset a prompt to bundled default + ipcMain.handle( + 'openspec:resetPrompt', + createIpcHandler(handlerOpts('resetPrompt'), async (id: string) => { + const prompt = await resetOpenSpecPrompt(id); + logger.info(`Reset openspec.${id} to bundled default`, LOG_CONTEXT); + return { prompt }; + }) + ); + + // Refresh prompts from GitHub + ipcMain.handle( + 'openspec:refresh', + createIpcHandler(handlerOpts('refresh'), async () => { + const metadata = await refreshOpenSpecPrompts(); + logger.info(`Refreshed OpenSpec prompts to commit ${metadata.commitSha}`, LOG_CONTEXT); + return { metadata }; + }) + ); + + logger.debug(`${LOG_CONTEXT} OpenSpec IPC handlers registered`); +} + +// Export types for preload +export type { OpenSpecCommand, OpenSpecMetadata }; diff --git a/src/main/openspec-manager.ts b/src/main/openspec-manager.ts new file mode 100644 index 00000000..297ca061 --- /dev/null +++ b/src/main/openspec-manager.ts @@ -0,0 +1,435 @@ +/** + * OpenSpec Manager + * + * Manages bundled OpenSpec prompts with support for: + * - Loading bundled prompts from src/prompts/openspec/ + * - Fetching updates from GitHub's OpenSpec repository + * - User customization with ability to reset to defaults + * + * OpenSpec provides a structured change management workflow: + * - Proposal → Draft change specifications before coding + * - Apply → Implement tasks referencing agreed specs + * - Archive → Move completed work to archive after deployment + * + * Source: https://github.com/Fission-AI/OpenSpec + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { app } from 'electron'; +import { logger } from './utils/logger'; + +const LOG_CONTEXT = '[OpenSpec]'; + +// All bundled OpenSpec commands with their metadata +const OPENSPEC_COMMANDS = [ + { id: 'help', command: '/openspec.help', description: 'Learn how to use OpenSpec with Maestro', isCustom: true }, + { id: 'proposal', command: '/openspec.proposal', description: 'Create a change proposal with specs, tasks, and optional design docs', isCustom: false }, + { id: 'apply', command: '/openspec.apply', description: 'Implement an approved change proposal by executing tasks', isCustom: false }, + { id: 'archive', command: '/openspec.archive', description: 'Archive a completed change after deployment', isCustom: false }, + { id: 'implement', command: '/openspec.implement', description: 'Convert OpenSpec tasks to Maestro Auto Run documents', isCustom: true }, +] as const; + +export interface OpenSpecCommand { + id: string; + command: string; + description: string; + prompt: string; + isCustom: boolean; + isModified: boolean; +} + +export interface OpenSpecMetadata { + lastRefreshed: string; + commitSha: string; + sourceVersion: string; + sourceUrl: string; +} + +interface StoredPrompt { + content: string; + isModified: boolean; + modifiedAt?: string; +} + +interface StoredData { + metadata: OpenSpecMetadata; + prompts: Record; +} + +/** + * Get path to user's OpenSpec customizations file + */ +function getUserDataPath(): string { + return path.join(app.getPath('userData'), 'openspec-customizations.json'); +} + +/** + * Load user customizations from disk + */ +async function loadUserCustomizations(): Promise { + try { + const content = await fs.readFile(getUserDataPath(), 'utf-8'); + return JSON.parse(content); + } catch { + return null; + } +} + +/** + * Save user customizations to disk + */ +async function saveUserCustomizations(data: StoredData): Promise { + await fs.writeFile(getUserDataPath(), JSON.stringify(data, null, 2), 'utf-8'); +} + +/** + * Get the path to bundled prompts directory + * In development, this is src/prompts/openspec + * In production, this is in the app resources + */ +function getBundledPromptsPath(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, 'prompts', 'openspec'); + } + // In development, use the source directory + return path.join(__dirname, '..', '..', 'src', 'prompts', 'openspec'); +} + +/** + * Get the user data directory for storing downloaded OpenSpec prompts + */ +function getUserPromptsPath(): string { + return path.join(app.getPath('userData'), 'openspec-prompts'); +} + +/** + * Get bundled prompts by reading from disk + * Checks user prompts directory first (for downloaded updates), then falls back to bundled + */ +async function getBundledPrompts(): Promise> { + const bundledPromptsDir = getBundledPromptsPath(); + const userPromptsDir = getUserPromptsPath(); + const result: Record = {}; + + for (const cmd of OPENSPEC_COMMANDS) { + // For custom commands, always use bundled + if (cmd.isCustom) { + try { + const promptPath = path.join(bundledPromptsDir, `openspec.${cmd.id}.md`); + const prompt = await fs.readFile(promptPath, 'utf-8'); + result[cmd.id] = { + prompt, + description: cmd.description, + isCustom: cmd.isCustom, + }; + } catch (error) { + logger.warn(`Failed to load bundled prompt for ${cmd.id}: ${error}`, LOG_CONTEXT); + result[cmd.id] = { + prompt: `# ${cmd.id}\n\nPrompt not available.`, + description: cmd.description, + isCustom: cmd.isCustom, + }; + } + continue; + } + + // For upstream commands, check user prompts directory first (downloaded updates) + try { + const userPromptPath = path.join(userPromptsDir, `openspec.${cmd.id}.md`); + const prompt = await fs.readFile(userPromptPath, 'utf-8'); + result[cmd.id] = { + prompt, + description: cmd.description, + isCustom: cmd.isCustom, + }; + continue; + } catch { + // User prompt not found, try bundled + } + + // Fall back to bundled prompts + try { + const promptPath = path.join(bundledPromptsDir, `openspec.${cmd.id}.md`); + const prompt = await fs.readFile(promptPath, 'utf-8'); + result[cmd.id] = { + prompt, + description: cmd.description, + isCustom: cmd.isCustom, + }; + } catch (error) { + logger.warn(`Failed to load bundled prompt for ${cmd.id}: ${error}`, LOG_CONTEXT); + result[cmd.id] = { + prompt: `# ${cmd.id}\n\nPrompt not available.`, + description: cmd.description, + isCustom: cmd.isCustom, + }; + } + } + + return result; +} + +/** + * Get bundled metadata by reading from disk + * Checks user prompts directory first (for downloaded updates), then falls back to bundled + */ +async function getBundledMetadata(): Promise { + const bundledPromptsDir = getBundledPromptsPath(); + const userPromptsDir = getUserPromptsPath(); + + // Check user prompts directory first (downloaded updates) + try { + const userMetadataPath = path.join(userPromptsDir, 'metadata.json'); + const content = await fs.readFile(userMetadataPath, 'utf-8'); + return JSON.parse(content); + } catch { + // User metadata not found, try bundled + } + + // Fall back to bundled metadata + try { + const metadataPath = path.join(bundledPromptsDir, 'metadata.json'); + const content = await fs.readFile(metadataPath, 'utf-8'); + return JSON.parse(content); + } catch { + // Return default metadata if file doesn't exist + return { + lastRefreshed: '2025-01-01T00:00:00Z', + commitSha: 'main', + sourceVersion: '0.1.0', + sourceUrl: 'https://github.com/Fission-AI/OpenSpec', + }; + } +} + +/** + * Get current OpenSpec metadata + */ +export async function getOpenSpecMetadata(): Promise { + const customizations = await loadUserCustomizations(); + if (customizations?.metadata) { + return customizations.metadata; + } + return getBundledMetadata(); +} + +/** + * Get all OpenSpec prompts (bundled defaults merged with user customizations) + */ +export async function getOpenSpecPrompts(): Promise { + const bundled = await getBundledPrompts(); + const customizations = await loadUserCustomizations(); + + const commands: OpenSpecCommand[] = []; + + for (const [id, data] of Object.entries(bundled)) { + const customPrompt = customizations?.prompts?.[id]; + const isModified = customPrompt?.isModified ?? false; + const prompt = isModified && customPrompt ? customPrompt.content : data.prompt; + + commands.push({ + id, + command: `/openspec.${id}`, + description: data.description, + prompt, + isCustom: data.isCustom, + isModified, + }); + } + + return commands; +} + +/** + * Save user's edit to an OpenSpec prompt + */ +export async function saveOpenSpecPrompt(id: string, content: string): Promise { + const customizations = await loadUserCustomizations() ?? { + metadata: await getBundledMetadata(), + prompts: {}, + }; + + customizations.prompts[id] = { + content, + isModified: true, + modifiedAt: new Date().toISOString(), + }; + + await saveUserCustomizations(customizations); + logger.info(`Saved customization for openspec.${id}`, LOG_CONTEXT); +} + +/** + * Reset an OpenSpec prompt to its bundled default + */ +export async function resetOpenSpecPrompt(id: string): Promise { + const bundled = await getBundledPrompts(); + const defaultPrompt = bundled[id]; + + if (!defaultPrompt) { + throw new Error(`Unknown openspec command: ${id}`); + } + + const customizations = await loadUserCustomizations(); + if (customizations?.prompts?.[id]) { + delete customizations.prompts[id]; + await saveUserCustomizations(customizations); + logger.info(`Reset openspec.${id} to bundled default`, LOG_CONTEXT); + } + + return defaultPrompt.prompt; +} + +/** + * Upstream commands to fetch (we skip custom commands like 'help' and 'implement') + */ +const UPSTREAM_COMMANDS = ['proposal', 'apply', 'archive']; + +/** + * Section markers in AGENTS.md for extracting workflow prompts + */ +const SECTION_MARKERS: Record = { + proposal: { + start: /^#+\s*Stage\s*1[:\s]+Creating\s+Changes/i, + end: /^#+\s*Stage\s*2[:\s]+/i, + }, + apply: { + start: /^#+\s*Stage\s*2[:\s]+Implementing\s+Changes/i, + end: /^#+\s*Stage\s*3[:\s]+/i, + }, + archive: { + start: /^#+\s*Stage\s*3[:\s]+Archiving\s+Changes/i, + end: /^$/, // End of file or next major section + }, +}; + +/** + * Parse AGENTS.md and extract workflow sections as prompts + */ +function parseAgentsMd(content: string): Record { + const result: Record = {}; + const lines = content.split('\n'); + + for (const [sectionId, markers] of Object.entries(SECTION_MARKERS)) { + let inSection = false; + const sectionLines: string[] = []; + + for (const line of lines) { + if (!inSection && markers.start.test(line)) { + inSection = true; + sectionLines.push(line); + continue; + } + + if (inSection) { + // Check if we've hit the end marker (next stage or end of content) + if (markers.end.test(line) && line.trim() !== '') { + // Don't include the end marker line, it belongs to the next section + break; + } + sectionLines.push(line); + } + } + + if (sectionLines.length > 0) { + result[sectionId] = sectionLines.join('\n').trim(); + } + } + + return result; +} + +/** + * Fetch latest prompts from GitHub OpenSpec repository + * Updates all upstream commands by parsing AGENTS.md + */ +export async function refreshOpenSpecPrompts(): Promise { + logger.info('Refreshing OpenSpec prompts from GitHub...', LOG_CONTEXT); + + // Fetch AGENTS.md from the repository + const agentsMdUrl = 'https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/openspec/AGENTS.md'; + const agentsResponse = await fetch(agentsMdUrl); + if (!agentsResponse.ok) { + throw new Error(`Failed to fetch AGENTS.md: ${agentsResponse.statusText}`); + } + const agentsMdContent = await agentsResponse.text(); + logger.info('Downloaded AGENTS.md', LOG_CONTEXT); + + // Parse the AGENTS.md content to extract sections + const extractedPrompts = parseAgentsMd(agentsMdContent); + logger.info(`Extracted ${Object.keys(extractedPrompts).length} sections from AGENTS.md`, LOG_CONTEXT); + + // Get the latest commit SHA for version tracking + let commitSha = 'main'; + try { + const commitsResponse = await fetch('https://api.github.com/repos/Fission-AI/OpenSpec/commits/main', { + headers: { 'User-Agent': 'Maestro-OpenSpec-Refresher' }, + }); + if (commitsResponse.ok) { + const commitInfo = await commitsResponse.json() as { sha: string }; + commitSha = commitInfo.sha.substring(0, 7); + } + } catch { + logger.warn('Could not fetch commit SHA, using "main"', LOG_CONTEXT); + } + + // Create user prompts directory + const userPromptsDir = getUserPromptsPath(); + await fs.mkdir(userPromptsDir, { recursive: true }); + + // Save extracted prompts + for (const cmdId of UPSTREAM_COMMANDS) { + const promptContent = extractedPrompts[cmdId]; + if (promptContent) { + const destPath = path.join(userPromptsDir, `openspec.${cmdId}.md`); + await fs.writeFile(destPath, promptContent, 'utf8'); + logger.info(`Updated: openspec.${cmdId}.md`, LOG_CONTEXT); + } else { + logger.warn(`Could not extract ${cmdId} section from AGENTS.md`, LOG_CONTEXT); + } + } + + // Update metadata with new version info + const newMetadata: OpenSpecMetadata = { + lastRefreshed: new Date().toISOString(), + commitSha, + sourceVersion: '0.1.0', + sourceUrl: 'https://github.com/Fission-AI/OpenSpec', + }; + + // Save metadata to user prompts directory + await fs.writeFile( + path.join(userPromptsDir, 'metadata.json'), + JSON.stringify(newMetadata, null, 2), + 'utf8' + ); + + // Also save to customizations file for compatibility + const customizations = await loadUserCustomizations() ?? { + metadata: newMetadata, + prompts: {}, + }; + customizations.metadata = newMetadata; + await saveUserCustomizations(customizations); + + logger.info(`Refreshed OpenSpec prompts (commit: ${commitSha})`, LOG_CONTEXT); + + return newMetadata; +} + +/** + * Get a single OpenSpec command by ID + */ +export async function getOpenSpecCommand(id: string): Promise { + const commands = await getOpenSpecPrompts(); + return commands.find((cmd) => cmd.id === id) ?? null; +} + +/** + * Get an OpenSpec command by its slash command string (e.g., "/openspec.proposal") + */ +export async function getOpenSpecCommandBySlash(slashCommand: string): Promise { + const commands = await getOpenSpecPrompts(); + return commands.find((cmd) => cmd.command === slashCommand) ?? null; +} diff --git a/src/main/preload.ts b/src/main/preload.ts index 4e0a8161..a556e2cc 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -992,6 +992,75 @@ contextBridge.exposeInMainWorld('maestro', { }>, }, + // OpenSpec API (bundled OpenSpec slash commands) + openspec: { + // Get metadata (version, last refresh date) + getMetadata: () => + ipcRenderer.invoke('openspec:getMetadata') as Promise<{ + success: boolean; + metadata?: { + lastRefreshed: string; + commitSha: string; + sourceVersion: string; + sourceUrl: string; + }; + error?: string; + }>, + // Get all openspec prompts + getPrompts: () => + ipcRenderer.invoke('openspec:getPrompts') as Promise<{ + success: boolean; + commands?: Array<{ + id: string; + command: string; + description: string; + prompt: string; + isCustom: boolean; + isModified: boolean; + }>; + error?: string; + }>, + // Get a single command by slash command string + getCommand: (slashCommand: string) => + ipcRenderer.invoke('openspec:getCommand', slashCommand) as Promise<{ + success: boolean; + command?: { + id: string; + command: string; + description: string; + prompt: string; + isCustom: boolean; + isModified: boolean; + } | null; + error?: string; + }>, + // Save user's edit to a prompt + savePrompt: (id: string, content: string) => + ipcRenderer.invoke('openspec:savePrompt', id, content) as Promise<{ + success: boolean; + error?: string; + }>, + // Reset a prompt to bundled default + resetPrompt: (id: string) => + ipcRenderer.invoke('openspec:resetPrompt', id) as Promise<{ + success: boolean; + prompt?: string; + error?: string; + }>, + // Refresh prompts from GitHub + refresh: () => + ipcRenderer.invoke('openspec:refresh') as Promise<{ + success: boolean; + metadata?: { + lastRefreshed: string; + commitSha: string; + sourceVersion: string; + sourceUrl: string; + }; + error?: string; + }>, + }, + // Notification API notification: { show: (title: string, body: string) => @@ -2373,6 +2442,61 @@ export interface MaestroAPI { error?: string; }>; }; + openspec: { + getMetadata: () => Promise<{ + success: boolean; + metadata?: { + lastRefreshed: string; + commitSha: string; + sourceVersion: string; + sourceUrl: string; + }; + error?: string; + }>; + getPrompts: () => Promise<{ + success: boolean; + commands?: Array<{ + id: string; + command: string; + description: string; + prompt: string; + isCustom: boolean; + isModified: boolean; + }>; + error?: string; + }>; + getCommand: (slashCommand: string) => Promise<{ + success: boolean; + command?: { + id: string; + command: string; + description: string; + prompt: string; + isCustom: boolean; + isModified: boolean; + } | null; + error?: string; + }>; + savePrompt: (id: string, content: string) => Promise<{ + success: boolean; + error?: string; + }>; + resetPrompt: (id: string) => Promise<{ + success: boolean; + prompt?: string; + error?: string; + }>; + refresh: () => Promise<{ + success: boolean; + metadata?: { + lastRefreshed: string; + commitSha: string; + sourceVersion: string; + sourceUrl: string; + }; + error?: string; + }>; + }; } declare global { diff --git a/src/prompts/openspec/index.ts b/src/prompts/openspec/index.ts new file mode 100644 index 00000000..1ce7d123 --- /dev/null +++ b/src/prompts/openspec/index.ts @@ -0,0 +1,117 @@ +/** + * OpenSpec prompts module + * + * Bundled prompts for the OpenSpec workflow from Fission-AI with our custom Maestro integration. + * These prompts are imported at build time using Vite's ?raw suffix. + * + * OpenSpec provides a structured change management workflow: + * - Proposal → Draft change specifications before coding + * - Apply → Implement tasks referencing agreed specs + * - Archive → Move completed work to archive after deployment + * + * Source: https://github.com/Fission-AI/OpenSpec + * Version: 0.1.0 + */ + +// Bundled OpenSpec prompts (extracted from upstream AGENTS.md) +import proposalPrompt from './openspec.proposal.md?raw'; +import applyPrompt from './openspec.apply.md?raw'; +import archivePrompt from './openspec.archive.md?raw'; + +// Custom Maestro prompts +import helpPrompt from './openspec.help.md?raw'; +import implementPrompt from './openspec.implement.md?raw'; + +// Metadata +import metadataJson from './metadata.json'; + +export interface OpenSpecCommandDefinition { + id: string; + command: string; + description: string; + prompt: string; + isCustom: boolean; +} + +export interface OpenSpecMetadata { + lastRefreshed: string; + commitSha: string; + sourceVersion: string; + sourceUrl: string; +} + +/** + * All bundled OpenSpec commands + */ +export const openspecCommands: OpenSpecCommandDefinition[] = [ + { + id: 'help', + command: '/openspec.help', + description: 'Learn how to use OpenSpec with Maestro', + prompt: helpPrompt, + isCustom: true, + }, + { + id: 'proposal', + command: '/openspec.proposal', + description: 'Create a change proposal with specs, tasks, and optional design docs', + prompt: proposalPrompt, + isCustom: false, + }, + { + id: 'apply', + command: '/openspec.apply', + description: 'Implement an approved change proposal by executing tasks', + prompt: applyPrompt, + isCustom: false, + }, + { + id: 'archive', + command: '/openspec.archive', + description: 'Archive a completed change after deployment', + prompt: archivePrompt, + isCustom: false, + }, + { + id: 'implement', + command: '/openspec.implement', + description: 'Convert OpenSpec tasks to Maestro Auto Run documents', + prompt: implementPrompt, + isCustom: true, + }, +]; + +/** + * Get an OpenSpec command by ID + */ +export function getOpenSpecCommand(id: string): OpenSpecCommandDefinition | undefined { + return openspecCommands.find((cmd) => cmd.id === id); +} + +/** + * Get an OpenSpec command by slash command string + */ +export function getOpenSpecCommandBySlash(command: string): OpenSpecCommandDefinition | undefined { + return openspecCommands.find((cmd) => cmd.command === command); +} + +/** + * Get the metadata for bundled OpenSpec prompts + */ +export function getOpenSpecMetadata(): OpenSpecMetadata { + return { + lastRefreshed: metadataJson.lastRefreshed, + commitSha: metadataJson.commitSha, + sourceVersion: metadataJson.sourceVersion, + sourceUrl: metadataJson.sourceUrl, + }; +} + +// Export individual prompts for direct access +export { + helpPrompt, + proposalPrompt, + applyPrompt, + archivePrompt, + implementPrompt, +}; diff --git a/src/prompts/openspec/metadata.json b/src/prompts/openspec/metadata.json new file mode 100644 index 00000000..765495e7 --- /dev/null +++ b/src/prompts/openspec/metadata.json @@ -0,0 +1,6 @@ +{ + "lastRefreshed": "2025-12-27T17:46:57.133Z", + "commitSha": "8dcd170", + "sourceVersion": "0.1.0", + "sourceUrl": "https://github.com/Fission-AI/OpenSpec" +} \ No newline at end of file diff --git a/src/prompts/openspec/openspec.apply.md b/src/prompts/openspec/openspec.apply.md new file mode 100644 index 00000000..0a43fc8b --- /dev/null +++ b/src/prompts/openspec/openspec.apply.md @@ -0,0 +1,9 @@ +### Stage 2: Implementing Changes +Track these steps as TODOs and complete them one by one. +1. **Read proposal.md** - Understand what's being built +2. **Read design.md** (if exists) - Review technical decisions +3. **Read tasks.md** - Get implementation checklist +4. **Implement tasks sequentially** - Complete in order +5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved \ No newline at end of file diff --git a/src/prompts/openspec/openspec.archive.md b/src/prompts/openspec/openspec.archive.md new file mode 100644 index 00000000..760cd26f --- /dev/null +++ b/src/prompts/openspec/openspec.archive.md @@ -0,0 +1,6 @@ +### Stage 3: Archiving Changes +After deployment, create separate PR to: +- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` +- Update `specs/` if capabilities changed +- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) +- Run `openspec validate --strict` to confirm the archived change passes checks \ No newline at end of file diff --git a/src/prompts/openspec/openspec.help.md b/src/prompts/openspec/openspec.help.md new file mode 100644 index 00000000..3b94f286 --- /dev/null +++ b/src/prompts/openspec/openspec.help.md @@ -0,0 +1,147 @@ +# OpenSpec Help + +You are explaining how to use **OpenSpec** within Maestro. OpenSpec is a spec-driven development tool from [Fission-AI/OpenSpec](https://github.com/Fission-AI/OpenSpec) that provides a structured workflow for managing code changes through specifications. + +## What is OpenSpec? + +OpenSpec implements a three-stage workflow for managing code changes: + +1. **Proposal** - Draft change specifications before coding begins +2. **Apply** - Implement tasks referencing agreed specs +3. **Archive** - Move completed work to archive after deployment + +Unlike spec-kit (which focuses on creating feature specifications), OpenSpec specializes in **change management** - tracking modifications to an existing system through detailed spec deltas. + +## Key Differences from Spec-Kit + +| Aspect | Spec-Kit | OpenSpec | +|--------|----------|----------| +| Focus | Feature specification | Change management | +| Output | Feature specs, plans, tasks | Proposals, spec deltas | +| Workflow | Constitution → Specify → Plan | Proposal → Apply → Archive | +| Artifact | `specs/[feature]/` | `openspec/changes/[id]/` | +| When to use | New features | Modifying existing features | + +**Use spec-kit for**: New features, greenfield development, establishing project foundations + +**Use OpenSpec for**: Feature modifications, breaking changes, refactoring, migrations + +## Directory Structure + +OpenSpec uses this directory layout: + +``` +openspec/ +ā”œā”€ā”€ project.md # Project conventions and context +ā”œā”€ā”€ specs/ # Deployed specifications (truth) +│ └── [capability]/ +│ └── spec.md +└── changes/ # Proposed modifications (in progress) + ā”œā”€ā”€ [change-id]/ + │ ā”œā”€ā”€ proposal.md # What and why + │ ā”œā”€ā”€ tasks.md # Implementation checklist + │ ā”œā”€ā”€ design.md # Optional technical decisions + │ └── specs/ # Spec deltas + │ └── [capability]/ + │ └── spec.md + └── archive/ # Completed changes + └── YYYY-MM-DD-[change-id]/ +``` + +## Core Commands + +### `/openspec.proposal` - Create Change Proposal +Start here when modifying existing functionality. This command helps you: +- Review existing specs and active changes +- Choose a unique change-id (kebab-case, verb-led like `add-`, `update-`, `remove-`) +- Scaffold `proposal.md`, `tasks.md`, and spec deltas +- Validate your proposal before sharing + +### `/openspec.apply` - Implement Changes +Use after your proposal is approved. This command guides you through: +- Reading the proposal and design documents +- Following the tasks checklist sequentially +- Marking tasks complete as you work +- Ensuring all items are finished before deployment + +### `/openspec.archive` - Archive Completed Changes +Use after deployment to finalize the change: +- Move change directory to archive with date prefix +- Update main specs if capabilities changed +- Validate the archived change passes all checks + +### `/openspec.implement` - Execute with Maestro Auto Run +**Maestro-specific command.** Converts your OpenSpec tasks into Auto Run documents: +- Read proposal and tasks from a specified change +- Convert to Auto Run document format with checkboxes +- Support worktree mode for parallel execution +- Group related tasks into phases + +## Spec Delta Format + +When modifying existing specs, OpenSpec uses operation headers: + +```markdown +## ADDED Requirements +New standalone capabilities + +## MODIFIED Requirements +Changed behavior of existing requirements + +## REMOVED Requirements +Deprecated features (include Reason and Migration) + +## RENAMED Requirements +Name-only changes (no behavior change) +``` + +Each requirement needs at least one scenario: + +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` + +## Validation Commands + +Always validate before sharing your proposal: + +```bash +openspec validate --strict # Comprehensive validation +openspec list # View active changes +openspec list --specs # List existing specs +openspec show # Display change details +``` + +## Integration with Maestro Auto Run + +OpenSpec works seamlessly with Maestro's Auto Run feature: + +1. **Create proposal** with `/openspec.proposal` +2. **Get approval** from stakeholders +3. **Use `/openspec.implement`** to generate Auto Run documents +4. Documents are saved to `Auto Run Docs/` in your project +5. Each task becomes a checkbox item that Auto Run executes +6. Complete tasks are marked with implementation notes +7. **Archive** with `/openspec.archive` after deployment + +## Tips for Best Results + +- **Always review `project.md`** - Understand project conventions first +- **Check existing changes** - Run `openspec list` to avoid conflicts +- **Use verb-led IDs** - `add-auth`, `update-api`, `remove-legacy` +- **Include scenarios** - Every requirement needs at least one test scenario +- **Validate early** - Run validation before sharing proposals +- **Respect the approval gate** - Don't implement until proposal is approved +- **Archive promptly** - Clean up after deployment to keep changes directory focused + +## Learn More + +- [OpenSpec Repository](https://github.com/Fission-AI/OpenSpec) - Official documentation +- OpenSpec prompts update automatically when you click "Check for Updates" in Maestro settings +- Custom modifications to prompts are preserved across updates + +--- + +*This help command is a Maestro-specific addition to the OpenSpec workflow.* diff --git a/src/prompts/openspec/openspec.implement.md b/src/prompts/openspec/openspec.implement.md new file mode 100644 index 00000000..e3bd8faa --- /dev/null +++ b/src/prompts/openspec/openspec.implement.md @@ -0,0 +1,122 @@ +--- +description: Convert OpenSpec tasks to Maestro Auto Run documents for automated implementation. +--- + +You are an expert at converting OpenSpec change proposals into actionable Maestro Auto Run documents. + +## User Input + +```text +$ARGUMENTS +``` + +The user input may contain: +- A change ID (e.g., `001-add-auth`, `feature-search`) +- A path to an OpenSpec change directory +- Empty (you should scan for changes in `openspec/changes/`) + +## Your Task + +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 `Auto Run Docs/`** folder + +## Critical Requirements + +Each Auto Run document MUST: + +1. **Be Completely Self-Contained**: Each phase must be executable without ANY user input during execution. The AI should be able to start and complete each phase entirely on its own. + +2. **Deliver Working Progress**: By the end of each phase, there should be something tangible that works - testable code, runnable features, or verifiable changes. + +3. **Reference OpenSpec Context**: Include links to the proposal and relevant spec files so the executing AI understands the full context. + +4. **Preserve Task IDs**: Keep the original task identifiers (T001, T002, etc.) from OpenSpec for traceability. + +## Document Format + +Each Auto Run document MUST follow this exact format: + +```markdown +# Phase XX: [Brief Title] + +[One paragraph describing what this phase accomplishes and why it matters] + +## OpenSpec Context + +- **Change ID:** +- **Proposal:** openspec/changes//proposal.md +- **Design:** openspec/changes//design.md (if exists) + +## Tasks + +- [ ] T001 First specific task to complete +- [ ] T002 Second specific task to complete +- [ ] Continue with more tasks... + +## Completion + +- [ ] Verify all changes work as expected +- [ ] Run `openspec validate ` (if available) +``` + +## Task Writing Guidelines + +Each task should be: +- **Specific**: Not "set up the feature" but "Create UserAuthService class with login/logout methods" +- **Actionable**: Clear what needs to be done +- **Verifiable**: You can tell when it's complete +- **Autonomous**: Can be done without asking the user questions + +Preserve any markers from the original tasks.md: +- `[P]` = Parallelizable (can run with other `[P]` tasks) +- Task IDs (T001, T002, etc.) for traceability + +## Phase Guidelines + +- **Phase 1**: Foundation + Setup (dependencies, configuration, scaffolding) +- **Phase 2-N**: Feature implementation by logical grouping +- Each phase should build on the previous +- Keep phases focused (5-15 tasks typically) +- Group related tasks that share context + +## Output Format + +Create each document as a file in the `Auto Run Docs/` folder with this naming pattern: + +``` +Auto Run Docs/OpenSpec--Phase-01-[Description].md +Auto Run Docs/OpenSpec--Phase-02-[Description].md +``` + +## Execution Steps + +1. **Find the OpenSpec change**: + - If change ID provided, look in `openspec/changes//` + - If no ID, list available changes in `openspec/changes/` and ask user to select + +2. **Read the source files**: + - `tasks.md` - The implementation checklist (REQUIRED) + - `proposal.md` - Context about what and why (recommended) + - `design.md` - Technical decisions if exists (optional) + +3. **Analyze and group tasks**: + - Identify logical phases (setup, core features, testing, etc.) + - Preserve task dependencies (non-`[P]` tasks run sequentially) + - Keep related tasks together in the same phase + +4. **Generate Auto Run documents**: + - One document per phase + - Use the exact format with BEGIN/END markers + - Include OpenSpec context in each document + +5. **Save the documents**: + - Files go to `Auto Run Docs/` folder + - Filename pattern: `OpenSpec--Phase-XX-[Description].md` + +## Now Execute + +Read the OpenSpec change (from user input or by scanning `openspec/changes/`) and generate the Auto Run documents. Start with Phase 1 (setup/foundation), then create additional phases as needed. + +If no change ID is provided and multiple changes exist, list them and ask which one to implement. diff --git a/src/prompts/openspec/openspec.proposal.md b/src/prompts/openspec/openspec.proposal.md new file mode 100644 index 00000000..bd23c427 --- /dev/null +++ b/src/prompts/openspec/openspec.proposal.md @@ -0,0 +1,31 @@ +### Stage 1: Creating Changes +Create proposal when you need to: +- Add features or functionality +- Make breaking changes (API, schema) +- Change architecture or patterns +- Optimize performance (changes behavior) +- Update security patterns + +Triggers (examples): +- "Help me create a change proposal" +- "Help me plan a change" +- "Help me create a proposal" +- "I want to create a spec proposal" +- "I want to create a spec" + +Loose matching guidance: +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` + +Skip proposal for: +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior + +**Workflow** +1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. \ No newline at end of file diff --git a/src/prompts/speckit/speckit.implement.md b/src/prompts/speckit/speckit.implement.md index e01e0852..40cf1c95 100644 --- a/src/prompts/speckit/speckit.implement.md +++ b/src/prompts/speckit/speckit.implement.md @@ -1,126 +1,122 @@ --- -description: Execute spec-kit tasks using Maestro's Auto Run feature with optional git worktree support for parallel implementation. +description: Convert Spec Kit tasks to Maestro Auto Run documents for automated implementation. --- +You are an expert at converting Spec Kit feature specifications into actionable Maestro Auto Run documents. + ## User Input ```text $ARGUMENTS ``` -You **MUST** consider the user input before proceeding (if not empty). +The user input may contain: +- A feature name or spec directory path (e.g., `user-auth`, `specs/1-my-feature`) +- Empty (you should scan for specs in `specs/` directory) -## Overview +## Your Task -This command guides you through implementing your spec-kit feature using Maestro's powerful automation capabilities. The `tasks.md` file generated by `/speckit.tasks` is designed to work seamlessly with Maestro's Auto Run feature. +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 `Auto Run Docs/`** folder -## Implementation Workflow +## Critical Requirements -### Step 1: Locate Your Tasks File +Each Auto Run document MUST: -Your `tasks.md` file is located in your feature's spec directory (e.g., `specs/1-my-feature/tasks.md`). This file contains all implementation tasks in a checkbox format that Auto Run can process automatically. +1. **Be Completely Self-Contained**: Each phase must be executable without ANY user input during execution. The AI should be able to start and complete each phase entirely on its own. -### Step 2: Configure Auto Run +2. **Deliver Working Progress**: By the end of each phase, there should be something tangible that works - testable code, runnable features, or verifiable changes. -1. **Open the Right Bar** in Maestro (press `Cmd/Ctrl + B` or click the sidebar toggle) -2. **Select the "Auto Run" tab** -3. **Set the Auto Run folder** to your spec-kit documents directory: - - Click the folder icon or use the folder selector - - Navigate to your feature's spec directory (e.g., `specs/1-my-feature/`) -4. **Select your `tasks.md` file** from the document list +3. **Reference Spec Kit Context**: Include links to the specification and relevant planning docs so the executing AI understands the full context. -### Step 3: Start Automated Implementation +4. **Preserve Task IDs**: Keep the original task identifiers (T001, T002, etc.) and user story markers ([US1], [US2]) from Spec Kit for traceability. -Once configured, Auto Run will: -- Read each task from `tasks.md` -- Execute tasks sequentially (respecting dependencies) -- Mark tasks as completed (`[X]`) with implementation notes -- Handle parallel tasks (`[P]` marker) when possible +## Document Format -**To start**: Click the "Run" button or press `Cmd/Ctrl + Enter` in the Auto Run panel. - -## Advanced: Parallel Implementation with Git Worktrees - -For larger features with independent components, you can use git worktrees to implement multiple parts in parallel across different Maestro sessions. - -### What are Worktrees? - -Git worktrees allow you to have multiple working directories for the same repository, each on a different branch. This enables: -- Multiple AI agents working on different feature branches simultaneously -- Isolated changes that won't conflict during development -- Easy merging when components are complete - -### Setting Up Parallel Implementation - -1. **Identify Independent Tasks**: Look for tasks marked with `[P]` in your `tasks.md` that can run in parallel. - -2. **Create Worktrees in Maestro**: - - In each session, Maestro can automatically create a worktree for the feature branch - - Use the worktree toggle in Auto Run to enable worktree mode - - Each session gets its own isolated working directory - -3. **Assign Tasks to Sessions**: - - Session 1: Phase 1 (Setup) + User Story 1 tasks - - Session 2: User Story 2 tasks (if independent) - - Session 3: User Story 3 tasks (if independent) - -4. **Merge When Complete**: - - Each session commits to its feature branch - - Use Maestro's git integration to merge branches - - Or merge manually: `git merge session-branch` - -### Worktree Commands - -Maestro handles worktrees automatically, but for manual setup: - -```bash -# Create a worktree for a feature branch -git worktree add ../my-feature-worktree feature-branch - -# List existing worktrees -git worktree list - -# Remove a worktree when done -git worktree remove ../my-feature-worktree -``` - -## Best Practices - -1. **Complete Setup Phase First**: Always complete Phase 1 (Setup) before parallelizing user stories. - -2. **Respect Dependencies**: Tasks without the `[P]` marker should run sequentially. - -3. **Review Before Merging**: Use `/speckit.analyze` after implementation to verify consistency. - -4. **Incremental Testing**: Each user story phase should be independently testable. Verify before moving to the next. - -5. **Use Checklists**: If you created checklists with `/speckit.checklist`, verify them before marking the feature complete. - -## Task Format Reference - -Your `tasks.md` uses this format that Auto Run understands: +Each Auto Run document MUST follow this exact format: ```markdown -- [ ] T001 Setup project structure -- [ ] T002 [P] Configure database connection -- [ ] T003 [P] [US1] Create User model in src/models/user.py -- [ ] T004 [US1] Implement UserService +# Phase XX: [Brief Title] + +[One paragraph describing what this phase accomplishes and why it matters] + +## Spec Kit Context + +- **Feature:** +- **Specification:** specs//specification.md +- **Plan:** specs//plan.md (if exists) + +## Tasks + +- [ ] T001 First specific task to complete +- [ ] T002 Second specific task to complete +- [ ] Continue with more tasks... + +## Completion + +- [ ] Verify all changes work as expected +- [ ] Run `/speckit.analyze` to verify consistency ``` -- `- [ ]` = Incomplete task (Auto Run will process) -- `- [X]` = Completed task (Auto Run will skip) -- `[P]` = Parallelizable (can run with other [P] tasks) -- `[US1]` = User Story 1 (groups related tasks) +## Task Writing Guidelines -## Getting Help +Each task should be: +- **Specific**: Not "set up the feature" but "Create UserAuthService class with login/logout methods" +- **Actionable**: Clear what needs to be done +- **Verifiable**: You can tell when it's complete +- **Autonomous**: Can be done without asking the user questions -- **View task progress**: Check the Auto Run panel in the Right Bar -- **See implementation details**: Each completed task includes notes about what was done -- **Troubleshoot issues**: Check the session logs in the main terminal view +Preserve any markers from the original tasks.md: +- `[P]` = Parallelizable (can run with other `[P]` tasks) +- `[US1]`, `[US2]` = User Story groupings +- Task IDs (T001, T002, etc.) for traceability -## Next Steps After Implementation +## Phase Guidelines -1. Run `/speckit.analyze` to verify implementation consistency -2. Complete any remaining checklists -3. Run tests to ensure everything works -4. Create a pull request with your changes +- **Phase 1**: Foundation + Setup (dependencies, configuration, scaffolding) +- **Phase 2-N**: Feature implementation by user story or logical grouping +- Each phase should build on the previous +- Keep phases focused (5-15 tasks typically) +- Group related tasks (same user story) together + +## Output Format + +Create each document as a file in the `Auto Run Docs/` folder with this naming pattern: + +``` +Auto Run Docs/SpecKit--Phase-01-[Description].md +Auto Run Docs/SpecKit--Phase-02-[Description].md +``` + +## Execution Steps + +1. **Find the Spec Kit feature**: + - If feature name provided, look in `specs//` + - If no name, list available specs in `specs/` and ask user to select + +2. **Read the source files**: + - `tasks.md` - The implementation checklist (REQUIRED) + - `specification.md` - Feature specification (recommended) + - `plan.md` - Implementation plan if exists (optional) + +3. **Analyze and group tasks**: + - Identify logical phases (setup, user stories, testing, etc.) + - Preserve task dependencies (non-`[P]` tasks run sequentially) + - Keep related tasks (same `[US]` marker) together in the same phase + +4. **Generate Auto Run documents**: + - One document per phase + - Use the exact format with BEGIN/END markers + - Include Spec Kit context in each document + +5. **Save the documents**: + - Files go to `Auto Run Docs/` folder + - Filename pattern: `SpecKit--Phase-XX-[Description].md` + +## Now Execute + +Read the Spec Kit feature (from user input or by scanning `specs/`) and generate the Auto Run documents. Start with Phase 1 (setup/foundation), then create additional phases as needed. + +If no feature name is provided and multiple specs exist, list them and ask which one to implement. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3805381e..bcb5b917 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -90,6 +90,7 @@ import { ToastContainer } from './components/Toast'; // Import services import { gitService } from './services/git'; import { getSpeckitCommands } from './services/speckit'; +import { getOpenSpecCommands } from './services/openspec'; // Import prompts and synopsis parsing import { autorunSynopsisPrompt, maestroSystemPrompt } from '../prompts'; @@ -101,7 +102,7 @@ import type { ToolType, SessionState, RightPanelTab, FocusArea, LogEntry, Session, AITab, UsageStats, QueuedItem, BatchRunConfig, AgentError, BatchRunState, GroupChatMessage, - SpecKitCommand, LeaderboardRegistration, CustomAICommand + SpecKitCommand, OpenSpecCommand, LeaderboardRegistration, CustomAICommand } from './types'; import { THEMES } from './constants/themes'; import { generateId } from './utils/ids'; @@ -315,6 +316,9 @@ function MaestroConsoleInner() { // Spec Kit commands (loaded from bundled prompts) const [speckitCommands, setSpeckitCommands] = useState([]); + // OpenSpec commands (loaded from bundled prompts) + const [openspecCommands, setOpenspecCommands] = useState([]); + // --- GROUP CHAT STATE (Phase 4: extracted to GroupChatContext) --- // Note: groupChatsExpanded remains here as it's a UI layout concern (already in UILayoutContext) const [groupChatsExpanded, setGroupChatsExpanded] = useState(true); @@ -1141,6 +1145,19 @@ function MaestroConsoleInner() { loadSpeckitCommands(); }, []); + // Load OpenSpec commands on startup + useEffect(() => { + const loadOpenspecCommands = async () => { + try { + const commands = await getOpenSpecCommands(); + setOpenspecCommands(commands); + } catch (error) { + console.error('[OpenSpec] Failed to load commands:', error); + } + }; + loadOpenspecCommands(); + }, []); + // Set up process event listeners for real-time output useEffect(() => { // Copy ref value to local variable for cleanup (React ESLint rule) @@ -2372,10 +2389,12 @@ function MaestroConsoleInner() { const updateGlobalStatsRef = useRef(updateGlobalStats); const customAICommandsRef = useRef(customAICommands); const speckitCommandsRef = useRef(speckitCommands); + const openspecCommandsRef = useRef(openspecCommands); addToastRef.current = addToast; updateGlobalStatsRef.current = updateGlobalStats; customAICommandsRef.current = customAICommands; speckitCommandsRef.current = speckitCommands; + openspecCommandsRef.current = openspecCommands; // Note: spawnBackgroundSynopsisRef and spawnAgentWithPromptRef are now provided by useAgentExecution hook // Note: addHistoryEntryRef is now provided by useAgentSessionManagement hook @@ -3131,8 +3150,8 @@ function MaestroConsoleInner() { }); }, [activeSession, canSummarize, minContextUsagePercent, startSummarize, setSessions, addToast, clearTabState]); - // Combine custom AI commands with spec-kit commands for input processing (slash command execution) - // This ensures speckit commands are processed the same way as custom commands + // Combine custom AI commands with spec-kit and openspec commands for input processing (slash command execution) + // This ensures speckit and openspec commands are processed the same way as custom commands const allCustomCommands = useMemo((): CustomAICommand[] => { // Convert speckit commands to CustomAICommand format const speckitAsCustom: CustomAICommand[] = speckitCommands.map(cmd => ({ @@ -3142,10 +3161,18 @@ function MaestroConsoleInner() { prompt: cmd.prompt, isBuiltIn: true, // Speckit commands are built-in (bundled) })); - return [...customAICommands, ...speckitAsCustom]; - }, [customAICommands, speckitCommands]); + // Convert openspec commands to CustomAICommand format + const openspecAsCustom: CustomAICommand[] = openspecCommands.map(cmd => ({ + id: `openspec-${cmd.id}`, + command: cmd.command, + description: cmd.description, + prompt: cmd.prompt, + isBuiltIn: true, // OpenSpec commands are built-in (bundled) + })); + return [...customAICommands, ...speckitAsCustom, ...openspecAsCustom]; + }, [customAICommands, speckitCommands, openspecCommands]); - // Combine built-in slash commands with custom AI commands, spec-kit commands, AND agent-specific commands for autocomplete + // Combine built-in slash commands with custom AI commands, spec-kit commands, openspec commands, AND agent-specific commands for autocomplete const allSlashCommands = useMemo(() => { const customCommandsAsSlash = customAICommands .map(cmd => ({ @@ -3163,6 +3190,15 @@ function MaestroConsoleInner() { prompt: cmd.prompt, // Include prompt for execution isSpeckit: true, // Mark as spec-kit command for special handling })); + // OpenSpec commands (bundled from Fission-AI/OpenSpec) + const openspecCommandsAsSlash = openspecCommands + .map(cmd => ({ + command: cmd.command, + description: cmd.description, + aiOnly: true, // OpenSpec commands are only available in AI mode + prompt: cmd.prompt, // Include prompt for execution + isOpenspec: true, // Mark as openspec command for special handling + })); // Only include agent-specific commands if the agent supports slash commands // This allows built-in and custom commands to be shown for all agents (Codex, OpenCode, etc.) const agentCommands = hasActiveSessionCapability('supportsSlashCommands') @@ -3172,8 +3208,8 @@ function MaestroConsoleInner() { aiOnly: true, // Agent commands are only available in AI mode })) : []; - return [...slashCommands, ...customCommandsAsSlash, ...speckitCommandsAsSlash, ...agentCommands]; - }, [customAICommands, speckitCommands, activeSession?.agentCommands, hasActiveSessionCapability]); + return [...slashCommands, ...customCommandsAsSlash, ...speckitCommandsAsSlash, ...openspecCommandsAsSlash, ...agentCommands]; + }, [customAICommands, speckitCommands, openspecCommands, activeSession?.agentCommands, hasActiveSessionCapability]); // Derive current input value and setter based on active session mode // For AI mode: use active tab's inputValue (stored per-tab) @@ -5945,10 +5981,15 @@ function MaestroConsoleInner() { cmd => cmd.command === commandText ); - const matchingCommand = matchingCustomCommand || matchingSpeckitCommand; + // Look up in openspec commands + const matchingOpenspecCommand = openspecCommandsRef.current.find( + cmd => cmd.command === commandText + ); + + const matchingCommand = matchingCustomCommand || matchingSpeckitCommand || matchingOpenspecCommand; if (matchingCommand) { - console.log('[Remote] Found matching command:', matchingCommand.command, matchingSpeckitCommand ? '(spec-kit)' : '(custom)'); + console.log('[Remote] Found matching command:', matchingCommand.command, matchingSpeckitCommand ? '(spec-kit)' : matchingOpenspecCommand ? '(openspec)' : '(custom)'); // Get git branch for template substitution let gitBranch: string | undefined; @@ -6269,10 +6310,11 @@ function MaestroConsoleInner() { sessionCustomContextWindow: session.customContextWindow, }); } else if (item.type === 'command' && item.command) { - // Process a slash command - find the matching custom AI command or speckit command + // Process a slash command - find the matching custom AI command, speckit command, or openspec command // Use refs to get latest values and avoid stale closure const matchingCommand = customAICommandsRef.current.find(cmd => cmd.command === item.command) - || speckitCommandsRef.current.find(cmd => cmd.command === item.command); + || speckitCommandsRef.current.find(cmd => cmd.command === item.command) + || openspecCommandsRef.current.find(cmd => cmd.command === item.command); if (matchingCommand) { // Substitute template variables let gitBranch: string | undefined; @@ -7702,6 +7744,19 @@ function MaestroConsoleInner() { })); }, [activeSession, getActiveTab]); const handlePromptToggleEnterToSend = useCallback(() => setEnterToSendAI(!enterToSendAI), [enterToSendAI]); + // OpenSpec command injection - sets prompt content into input field + const handleInjectOpenSpecPrompt = useCallback((prompt: string) => { + if (activeGroupChatId) { + // Update group chat draft + setGroupChats(prev => prev.map(c => + c.id === activeGroupChatId ? { ...c, draftMessage: prompt } : c + )); + } else { + setInputValue(prompt); + } + // Focus the input so user can edit/send the injected prompt + setTimeout(() => inputRef.current?.focus(), 0); + }, [activeGroupChatId, setInputValue]); // QuickActionsModal stable callbacks const handleQuickActionsRenameTab = useCallback(() => { @@ -8291,6 +8346,7 @@ function MaestroConsoleInner() { isFilePreviewOpen={previewFile !== null} ghCliAvailable={ghCliAvailable} onPublishGist={() => setGistPublishModalOpen(true)} + onInjectOpenSpecPrompt={handleInjectOpenSpecPrompt} lightboxImage={lightboxImage} lightboxImages={lightboxImages} stagedImages={stagedImages} diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 5f5ff254..3c717200 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -749,6 +749,8 @@ export interface AppUtilityModalsProps { autoRunSelectedDocument: string | null; autoRunCompletedTaskCount: number; onAutoRunResetTasks: () => void; + // OpenSpec commands + onInjectOpenSpecPrompt?: (prompt: string) => void; // Gist publishing (for QuickActionsModal) isFilePreviewOpen: boolean; @@ -920,6 +922,8 @@ export function AppUtilityModals({ isFilePreviewOpen, ghCliAvailable, onPublishGist, + // OpenSpec commands + onInjectOpenSpecPrompt, // LightboxModal lightboxImage, lightboxImages, @@ -1061,6 +1065,7 @@ export function AppUtilityModals({ isFilePreviewOpen={isFilePreviewOpen} ghCliAvailable={ghCliAvailable} onPublishGist={onPublishGist} + onInjectOpenSpecPrompt={onInjectOpenSpecPrompt} /> )} @@ -1740,6 +1745,8 @@ export interface AppModalsProps { isFilePreviewOpen: boolean; ghCliAvailable: boolean; onPublishGist?: () => void; + // OpenSpec commands + onInjectOpenSpecPrompt?: (prompt: string) => void; lightboxImage: string | null; lightboxImages: string[]; stagedImages: string[]; @@ -2007,6 +2014,8 @@ export function AppModals(props: AppModalsProps) { isFilePreviewOpen, ghCliAvailable, onPublishGist, + // OpenSpec commands + onInjectOpenSpecPrompt, lightboxImage, lightboxImages, stagedImages, @@ -2291,6 +2300,7 @@ export function AppModals(props: AppModalsProps) { isFilePreviewOpen={isFilePreviewOpen} ghCliAvailable={ghCliAvailable} onPublishGist={onPublishGist} + onInjectOpenSpecPrompt={onInjectOpenSpecPrompt} lightboxImage={lightboxImage} lightboxImages={lightboxImages} stagedImages={stagedImages} diff --git a/src/renderer/components/OpenSpecCommandsPanel.tsx b/src/renderer/components/OpenSpecCommandsPanel.tsx new file mode 100644 index 00000000..17a7612e --- /dev/null +++ b/src/renderer/components/OpenSpecCommandsPanel.tsx @@ -0,0 +1,376 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Edit2, Save, X, RotateCcw, RefreshCw, ExternalLink, ChevronDown, ChevronRight, GitBranch } from 'lucide-react'; +import type { Theme, OpenSpecCommand, OpenSpecMetadata } from '../types'; +import { useTemplateAutocomplete } from '../hooks'; +import { TemplateAutocompleteDropdown } from './TemplateAutocompleteDropdown'; + +interface OpenSpecCommandsPanelProps { + theme: Theme; +} + +interface EditingCommand { + id: string; + prompt: string; +} + +export function OpenSpecCommandsPanel({ theme }: OpenSpecCommandsPanelProps) { + const [commands, setCommands] = useState([]); + const [metadata, setMetadata] = useState(null); + const [editingCommand, setEditingCommand] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [expandedCommands, setExpandedCommands] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(true); + + const editCommandTextareaRef = useRef(null); + + // Template autocomplete for edit command prompt + const { + autocompleteState: editAutocompleteState, + handleKeyDown: handleEditAutocompleteKeyDown, + handleChange: handleEditAutocompleteChange, + selectVariable: selectEditVariable, + autocompleteRef: editAutocompleteRef, + } = useTemplateAutocomplete({ + textareaRef: editCommandTextareaRef, + value: editingCommand?.prompt || '', + onChange: (value) => editingCommand && setEditingCommand({ ...editingCommand, prompt: value }), + }); + + // Load commands and metadata on mount + useEffect(() => { + const loadData = async () => { + setIsLoading(true); + try { + const [promptsResult, metadataResult] = await Promise.all([ + window.maestro.openspec.getPrompts(), + window.maestro.openspec.getMetadata(), + ]); + + if (promptsResult.success && promptsResult.commands) { + setCommands(promptsResult.commands); + } + if (metadataResult.success && metadataResult.metadata) { + setMetadata(metadataResult.metadata); + } + } catch (error) { + console.error('Failed to load openspec commands:', error); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, []); + + const handleSaveEdit = async () => { + if (!editingCommand) return; + + try { + const result = await window.maestro.openspec.savePrompt(editingCommand.id, editingCommand.prompt); + if (result.success) { + setCommands(commands.map(cmd => + cmd.id === editingCommand.id + ? { ...cmd, prompt: editingCommand.prompt, isModified: true } + : cmd + )); + setEditingCommand(null); + } + } catch (error) { + console.error('Failed to save prompt:', error); + } + }; + + const handleReset = async (id: string) => { + try { + const result = await window.maestro.openspec.resetPrompt(id); + if (result.success && result.prompt) { + setCommands(commands.map(cmd => + cmd.id === id + ? { ...cmd, prompt: result.prompt!, isModified: false } + : cmd + )); + } + } catch (error) { + console.error('Failed to reset prompt:', error); + } + }; + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + const result = await window.maestro.openspec.refresh(); + if (result.success && result.metadata) { + setMetadata(result.metadata); + // Reload prompts after refresh + const promptsResult = await window.maestro.openspec.getPrompts(); + if (promptsResult.success && promptsResult.commands) { + setCommands(promptsResult.commands); + } + } + } catch (error) { + console.error('Failed to refresh openspec prompts:', error); + } finally { + setIsRefreshing(false); + } + }; + + const handleCancelEdit = () => { + setEditingCommand(null); + }; + + const toggleExpanded = (id: string) => { + const newExpanded = new Set(expandedCommands); + if (newExpanded.has(id)) { + newExpanded.delete(id); + } else { + newExpanded.add(id); + } + setExpandedCommands(newExpanded); + }; + + const formatDate = (isoDate: string) => { + try { + return new Date(isoDate).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return isoDate; + } + }; + + if (isLoading) { + return ( +
+
+ +

+ Loading OpenSpec commands... +

+
+
+ ); + } + + return ( +
+
+ +

+ Change management commands from{' '} + + {' '}for structured change proposals. +

+
+ + {/* Metadata and refresh */} + {metadata && ( +
+
+ Version: + + {metadata.sourceVersion} + + • + Updated: + + {formatDate(metadata.lastRefreshed)} + +
+ +
+ )} + + {/* Commands list */} +
+ {commands.map((cmd) => ( +
+ {editingCommand?.id === cmd.id ? ( + // Editing mode +
+
+ + {cmd.command} + +
+ + +
+
+
+