Merge pull request #563 from AutoMaker-Org/v0.12.0rc

V0.12.0rc
This commit is contained in:
Web Dev Cody
2026-01-17 18:50:55 -05:00
committed by GitHub
257 changed files with 21543 additions and 5679 deletions

View File

@@ -41,7 +41,8 @@ runs:
# Use npm install instead of npm ci to correctly resolve platform-specific # Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
# Skip scripts to avoid electron-builder install-app-deps which uses too much memory # Skip scripts to avoid electron-builder install-app-deps which uses too much memory
run: npm install --ignore-scripts # Use --force to allow platform-specific dev dependencies like dmg-license on non-darwin platforms
run: npm install --ignore-scripts --force
- name: Install Linux native bindings - name: Install Linux native bindings
shell: bash shell: bash

View File

@@ -25,7 +25,7 @@ jobs:
cache-dependency-path: package-lock.json cache-dependency-path: package-lock.json
- name: Install dependencies - name: Install dependencies
run: npm install --ignore-scripts run: npm install --ignore-scripts --force
- name: Check formatting - name: Check formatting
run: npm run format:check run: npm run format:check

View File

@@ -35,6 +35,11 @@ jobs:
with: with:
check-lockfile: 'true' check-lockfile: 'true'
- name: Install RPM build tools (Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Build Electron app (macOS) - name: Build Electron app (macOS)
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
shell: bash shell: bash
@@ -73,7 +78,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: linux-builds name: linux-builds
path: apps/ui/release/*.{AppImage,deb} path: apps/ui/release/*.{AppImage,deb,rpm}
retention-days: 30 retention-days: 30
upload: upload:
@@ -104,8 +109,8 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: | files: |
artifacts/macos-builds/* artifacts/macos-builds/*.{dmg,zip,blockmap}
artifacts/windows-builds/* artifacts/windows-builds/*.{exe,blockmap}
artifacts/linux-builds/* artifacts/linux-builds/*.{AppImage,deb,rpm,blockmap}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -166,7 +166,10 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
## Environment Variables ## Environment Variables
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth) - `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
- `HOST` - Host to bind server to (default: 0.0.0.0)
- `HOSTNAME` - Hostname for user-facing URLs (default: localhost)
- `PORT` - Server port (default: 3008) - `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data) - `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory - `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing - `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost)

159
README.md
View File

@@ -28,6 +28,7 @@
- [Quick Start](#quick-start) - [Quick Start](#quick-start)
- [How to Run](#how-to-run) - [How to Run](#how-to-run)
- [Development Mode](#development-mode) - [Development Mode](#development-mode)
- [Interactive TUI Launcher](#interactive-tui-launcher-recommended-for-new-users)
- [Building for Production](#building-for-production) - [Building for Production](#building-for-production)
- [Testing](#testing) - [Testing](#testing)
- [Linting](#linting) - [Linting](#linting)
@@ -101,11 +102,9 @@ In the Discord, you can:
### Prerequisites ### Prerequisites
- **Node.js 18+** (tested with Node.js 22) - **Node.js 22+** (required: >=22.0.0 <23.0.0)
- **npm** (comes with Node.js) - **npm** (comes with Node.js)
- **Authentication** (choose one): - **[Claude Code CLI](https://code.claude.com/docs/en/overview)** - Install and authenticate with your Anthropic subscription. Automaker integrates with your authenticated Claude Code CLI to access Claude models.
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically
- **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/))
### Quick Start ### Quick Start
@@ -117,30 +116,14 @@ cd automaker
# 2. Install dependencies # 2. Install dependencies
npm install npm install
# 3. Build shared packages (can be skipped - npm run dev does it automatically) # 3. Start Automaker
npm run build:packages
# 4. Start Automaker
npm run dev npm run dev
# Choose between: # Choose between:
# 1. Web Application (browser at localhost:3007) # 1. Web Application (browser at localhost:3007)
# 2. Desktop Application (Electron - recommended) # 2. Desktop Application (Electron - recommended)
``` ```
**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to: **Authentication:** Automaker integrates with your authenticated Claude Code CLI. Make sure you have [installed and authenticated](https://code.claude.com/docs/en/quickstart) the Claude Code CLI before running Automaker. Your CLI credentials will be detected automatically.
- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically
- Enter an **API key** directly in the wizard
If you prefer to set up authentication before running (e.g., for headless deployments or CI/CD), you can set it manually:
```bash
# Option A: Environment variable
export ANTHROPIC_API_KEY="sk-ant-..."
# Option B: Create .env file in project root
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
```
**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes. **For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes.
@@ -179,6 +162,40 @@ npm run dev:electron:wsl:gpu
npm run dev:web npm run dev:web
``` ```
### Interactive TUI Launcher (Recommended for New Users)
For a user-friendly interactive menu, use the built-in TUI launcher script:
```bash
# Show interactive menu with all launch options
./start-automaker.sh
# Or launch directly without menu
./start-automaker.sh web # Web browser
./start-automaker.sh electron # Desktop app
./start-automaker.sh electron-debug # Desktop + DevTools
# Additional options
./start-automaker.sh --help # Show all available options
./start-automaker.sh --version # Show version information
./start-automaker.sh --check-deps # Verify project dependencies
./start-automaker.sh --no-colors # Disable colored output
./start-automaker.sh --no-history # Don't remember last choice
```
**Features:**
- 🎨 Beautiful terminal UI with gradient colors and ASCII art
- Interactive menu (press 1-3 to select, Q to exit)
- 💾 Remembers your last choice
- Pre-flight checks (validates Node.js, npm, dependencies)
- 📏 Responsive layout (adapts to terminal size)
- 30-second timeout for hands-free selection
- 🌐 Cross-shell compatible (bash/zsh)
**History File:**
Your last selected mode is saved in `~/.automaker_launcher_history` for quick re-runs.
### Building for Production ### Building for Production
#### Web Application #### Web Application
@@ -197,11 +214,30 @@ npm run build:electron
# Platform-specific builds # Platform-specific builds
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64) npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
npm run build:electron:win # Windows (NSIS installer, x64) npm run build:electron:win # Windows (NSIS installer, x64)
npm run build:electron:linux # Linux (AppImage + DEB, x64) npm run build:electron:linux # Linux (AppImage + DEB + RPM, x64)
# Output directory: apps/ui/release/ # Output directory: apps/ui/release/
``` ```
**Linux Distribution Packages:**
- **AppImage**: Universal format, works on any Linux distribution
- **DEB**: Ubuntu, Debian, Linux Mint, Pop!\_OS
- **RPM**: Fedora, RHEL, Rocky Linux, AlmaLinux, openSUSE
**Installing on Fedora/RHEL:**
```bash
# Download the RPM package
wget https://github.com/AutoMaker-Org/automaker/releases/latest/download/Automaker-<version>-x86_64.rpm
# Install with dnf (Fedora)
sudo dnf install ./Automaker-<version>-x86_64.rpm
# Or with yum (RHEL/CentOS)
sudo yum localinstall ./Automaker-<version>-x86_64.rpm
```
#### Docker Deployment #### Docker Deployment
Docker provides the most secure way to run Automaker by isolating it from your host filesystem. Docker provides the most secure way to run Automaker by isolating it from your host filesystem.
@@ -220,16 +256,9 @@ docker-compose logs -f
docker-compose down docker-compose down
``` ```
##### Configuration ##### Authentication
Create a `.env` file in the project root if using API key authentication: Automaker integrates with your authenticated Claude Code CLI. To use CLI authentication in Docker, mount your Claude CLI config directory (see [Claude CLI Authentication](#claude-cli-authentication) below).
```bash
# Optional: Anthropic API key (not needed if using Claude CLI authentication)
ANTHROPIC_API_KEY=sk-ant-...
```
**Note:** Most users authenticate via Claude CLI instead of API keys. See [Claude CLI Authentication](#claude-cli-authentication-optional) below.
##### Working with Projects (Host Directory Access) ##### Working with Projects (Host Directory Access)
@@ -243,9 +272,9 @@ services:
- /path/to/your/project:/projects/your-project - /path/to/your/project:/projects/your-project
``` ```
##### Claude CLI Authentication (Optional) ##### Claude CLI Authentication
To use Claude Code CLI authentication instead of an API key, mount your Claude CLI config directory: Mount your Claude CLI config directory to use your authenticated CLI credentials:
```yaml ```yaml
services: services:
@@ -343,10 +372,6 @@ npm run lint
### Environment Configuration ### Environment Configuration
#### Authentication (if not using Claude Code CLI)
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
#### Optional - Server #### Optional - Server
- `PORT` - Server port (default: 3008) - `PORT` - Server port (default: 3008)
@@ -357,49 +382,22 @@ npm run lint
- `AUTOMAKER_API_KEY` - Optional API authentication for the server - `AUTOMAKER_API_KEY` - Optional API authentication for the server
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory - `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `CORS_ORIGIN` - CORS policy (default: \*) - `CORS_ORIGIN` - CORS allowed origins (comma-separated list; defaults to localhost only)
#### Optional - Development #### Optional - Development
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode - `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron - `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI)
### Authentication Setup ### Authentication Setup
#### Option 1: Claude Code CLI (Recommended) Automaker integrates with your authenticated Claude Code CLI and uses your Anthropic subscription.
Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart). Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed! Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
#### Option 2: Direct API Key
If you prefer not to use the CLI, you can provide an Anthropic API key directly using one of these methods:
##### 2a. Shell Configuration
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
```
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`).
##### 2b. .env File
Create a `.env` file in the project root (gitignored):
```bash
ANTHROPIC_API_KEY=sk-ant-...
PORT=3008
DATA_DIR=./data
```
##### 2c. In-App Storage
The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory.
## Features ## Features
### Core Workflow ### Core Workflow
@@ -508,20 +506,24 @@ Automaker provides several specialized views accessible via the sidebar or keybo
| **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions | | **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions |
| **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions | | **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions |
| **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference | | **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference |
| **Profiles** | `M` | Create and manage AI agent profiles with custom prompts and configurations |
| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more | | **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more |
| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions | | **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
| **GitHub Issues** | - | Import and validate GitHub issues, convert to tasks | | **Graph** | `H` | Visualize feature dependencies with interactive graph visualization |
| **Ideation** | `I` | Brainstorm and generate ideas with AI assistance |
| **Memory** | `Y` | View and manage agent memory and conversation history |
| **GitHub Issues** | `G` | Import and validate GitHub issues, convert to tasks |
| **GitHub PRs** | `R` | View and manage GitHub pull requests |
| **Running Agents** | - | View all active agents across projects with status and progress | | **Running Agents** | - | View all active agents across projects with status and progress |
### Keyboard Navigation ### Keyboard Navigation
All shortcuts are customizable in Settings. Default shortcuts: All shortcuts are customizable in Settings. Default shortcuts:
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `M` (Profiles), `T` (Terminal) - **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `T` (Terminal), `H` (Graph), `I` (Ideation), `Y` (Memory), `G` (GitHub Issues), `R` (GitHub PRs)
- **UI:** `` ` `` (Toggle sidebar) - **UI:** `` ` `` (Toggle sidebar)
- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker) - **Actions:** `N` (New item in current view), `O` (Open project), `P` (Project picker)
- **Projects:** `Q`/`E` (Cycle previous/next project) - **Projects:** `Q`/`E` (Cycle previous/next project)
- **Terminal:** `Alt+D` (Split right), `Alt+S` (Split down), `Alt+W` (Close), `Alt+T` (New tab)
## Architecture ## Architecture
@@ -586,10 +588,16 @@ Stored in `{projectPath}/.automaker/`:
│ ├── agent-output.md # AI agent output log │ ├── agent-output.md # AI agent output log
│ └── images/ # Attached images │ └── images/ # Attached images
├── context/ # Context files for AI agents ├── context/ # Context files for AI agents
├── worktrees/ # Git worktree metadata
├── validations/ # GitHub issue validation results
├── ideation/ # Brainstorming and analysis data
│ └── analysis.json # Project structure analysis
├── board/ # Board-related data
├── images/ # Project-level images
├── settings.json # Project-specific settings ├── settings.json # Project-specific settings
├── spec.md # Project specification ├── app_spec.txt # Project specification (XML format)
├── analysis.json # Project structure analysis ├── active-branches.json # Active git branches tracking
└── feature-suggestions.json # AI-generated suggestions └── execution-state.json # Auto-mode execution state
``` ```
#### Global Data #### Global Data
@@ -627,7 +635,6 @@ data/
- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker - [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs - [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages - [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
### Community ### Community

View File

@@ -44,6 +44,11 @@ CORS_ORIGIN=http://localhost:3007
# OPTIONAL - Server # OPTIONAL - Server
# ============================================ # ============================================
# Host to bind the server to (default: 0.0.0.0)
# Use 0.0.0.0 to listen on all interfaces (recommended for Docker/remote access)
# Use 127.0.0.1 or localhost to restrict to local connections only
HOST=0.0.0.0
# Port to run the server on # Port to run the server on
PORT=3008 PORT=3008
@@ -63,6 +68,14 @@ TERMINAL_PASSWORD=
ENABLE_REQUEST_LOGGING=false ENABLE_REQUEST_LOGGING=false
# ============================================
# OPTIONAL - UI Behavior
# ============================================
# Skip the sandbox warning dialog on startup (default: false)
# Set to "true" to disable the warning entirely (useful for dev/CI environments)
AUTOMAKER_SKIP_SANDBOX_WARNING=false
# ============================================ # ============================================
# OPTIONAL - Debugging # OPTIONAL - Debugging
# ============================================ # ============================================

View File

@@ -1,6 +1,6 @@
{ {
"name": "@automaker/server", "name": "@automaker/server",
"version": "0.11.0", "version": "0.12.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes", "description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team", "author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",

View File

@@ -17,9 +17,19 @@ import dotenv from 'dotenv';
import { createEventEmitter, type EventEmitter } from './lib/events.js'; import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths } from '@automaker/platform'; import { initAllowedPaths } from '@automaker/platform';
import { createLogger } from '@automaker/utils'; import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
const logger = createLogger('Server'); const logger = createLogger('Server');
/**
* Map server log level string to LogLevel enum
*/
const LOG_LEVEL_MAP: Record<string, LogLevel> = {
error: LogLevel.ERROR,
warn: LogLevel.WARN,
info: LogLevel.INFO,
debug: LogLevel.DEBUG,
};
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js'; import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
import { requireJsonContentType } from './middleware/require-json-content-type.js'; import { requireJsonContentType } from './middleware/require-json-content-type.js';
import { createAuthRoutes } from './routes/auth/index.js'; import { createAuthRoutes } from './routes/auth/index.js';
@@ -68,13 +78,37 @@ import { pipelineService } from './services/pipeline-service.js';
import { createIdeationRoutes } from './routes/ideation/index.js'; import { createIdeationRoutes } from './routes/ideation/index.js';
import { IdeationService } from './services/ideation-service.js'; import { IdeationService } from './services/ideation-service.js';
import { getDevServerService } from './services/dev-server-service.js'; import { getDevServerService } from './services/dev-server-service.js';
import { eventHookService } from './services/event-hook-service.js';
import { createNotificationsRoutes } from './routes/notifications/index.js';
import { getNotificationService } from './services/notification-service.js';
import { createEventHistoryRoutes } from './routes/event-history/index.js';
import { getEventHistoryService } from './services/event-history-service.js';
// Load environment variables // Load environment variables
dotenv.config(); dotenv.config();
const PORT = parseInt(process.env.PORT || '3008', 10); const PORT = parseInt(process.env.PORT || '3008', 10);
const HOST = process.env.HOST || '0.0.0.0';
const HOSTNAME = process.env.HOSTNAME || 'localhost';
const DATA_DIR = process.env.DATA_DIR || './data'; const DATA_DIR = process.env.DATA_DIR || './data';
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
// Runtime-configurable request logging flag (can be changed via settings)
let requestLoggingEnabled = ENABLE_REQUEST_LOGGING_DEFAULT;
/**
* Enable or disable HTTP request logging at runtime
*/
export function setRequestLoggingEnabled(enabled: boolean): void {
requestLoggingEnabled = enabled;
}
/**
* Get current request logging state
*/
export function isRequestLoggingEnabled(): boolean {
return requestLoggingEnabled;
}
// Check for required environment variables // Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
@@ -103,22 +137,21 @@ initAllowedPaths();
const app = express(); const app = express();
// Middleware // Middleware
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var) // Custom colored logger showing only endpoint and status code (dynamically configurable)
if (ENABLE_REQUEST_LOGGING) { morgan.token('status-colored', (_req, res) => {
morgan.token('status-colored', (_req, res) => { const status = res.statusCode;
const status = res.statusCode; if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects
if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects return `\x1b[32m${status}\x1b[0m`; // Green for success
return `\x1b[32m${status}\x1b[0m`; // Green for success });
});
app.use( app.use(
morgan(':method :url :status-colored', { morgan(':method :url :status-colored', {
skip: (req) => req.url === '/api/health', // Skip health check logs // Skip when request logging is disabled or for health check endpoints
}) skip: (req) => !requestLoggingEnabled || req.url === '/api/health',
); })
} );
// CORS configuration // CORS configuration
// When using credentials (cookies), origin cannot be '*' // When using credentials (cookies), origin cannot be '*'
// We dynamically allow the requesting origin for local development // We dynamically allow the requesting origin for local development
@@ -181,8 +214,33 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
const devServerService = getDevServerService(); const devServerService = getDevServerService();
devServerService.setEventEmitter(events); devServerService.setEventEmitter(events);
// Initialize Notification Service with event emitter for real-time updates
const notificationService = getNotificationService();
notificationService.setEventEmitter(events);
// Initialize Event History Service
const eventHistoryService = getEventHistoryService();
// Initialize Event Hook Service for custom event triggers (with history storage)
eventHookService.initialize(events, settingsService, eventHistoryService);
// Initialize services // Initialize services
(async () => { (async () => {
// Apply logging settings from saved settings
try {
const settings = await settingsService.getGlobalSettings();
if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) {
setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]);
logger.info(`Server log level set to: ${settings.serverLogLevel}`);
}
// Apply request logging setting (default true if not set)
const enableRequestLog = settings.enableRequestLogging ?? true;
setRequestLoggingEnabled(enableRequestLog);
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
} catch (err) {
logger.warn('Failed to load logging settings, using defaults');
}
await agentService.initialize(); await agentService.initialize();
logger.info('Agent service initialized'); logger.info('Agent service initialized');
@@ -219,7 +277,7 @@ app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events)); app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events)); app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader)); app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService)); app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
@@ -240,6 +298,8 @@ app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/mcp', createMCPRoutes(mcpTestService)); app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService)); app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
// Create HTTP server // Create HTTP server
const server = createServer(app); const server = createServer(app);
@@ -551,8 +611,8 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
}); });
// Start server with error handling for port conflicts // Start server with error handling for port conflicts
const startServer = (port: number) => { const startServer = (port: number, host: string) => {
server.listen(port, () => { server.listen(port, host, () => {
const terminalStatus = isTerminalEnabled() const terminalStatus = isTerminalEnabled()
? isTerminalPasswordRequired() ? isTerminalPasswordRequired()
? 'enabled (password protected)' ? 'enabled (password protected)'
@@ -563,10 +623,11 @@ const startServer = (port: number) => {
╔═══════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════╗
║ Automaker Backend Server ║ ║ Automaker Backend Server ║
╠═══════════════════════════════════════════════════════╣ ╠═══════════════════════════════════════════════════════╣
HTTP API: http://localhost:${portStr} Listening: ${host}:${port}${' '.repeat(Math.max(0, 34 - host.length - port.toString().length))}
WebSocket: ws://localhost:${portStr}/api/events HTTP API: http://${HOSTNAME}:${portStr}
Terminal: ws://localhost:${portStr}/api/terminal/ws WebSocket: ws://${HOSTNAME}:${portStr}/api/events
Health: http://localhost:${portStr}/api/health Terminal: ws://${HOSTNAME}:${portStr}/api/terminal/ws
║ Health: http://${HOSTNAME}:${portStr}/api/health ║
║ Terminal: ${terminalStatus.padEnd(37)} ║ Terminal: ${terminalStatus.padEnd(37)}
╚═══════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════╝
`); `);
@@ -600,7 +661,7 @@ const startServer = (port: number) => {
}); });
}; };
startServer(PORT); startServer(PORT, HOST);
// Global error handlers to prevent crashes from uncaught errors // Global error handlers to prevent crashes from uncaught errors
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => { process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {

View File

@@ -11,8 +11,12 @@ export { specOutputSchema } from '@automaker/types';
/** /**
* Escape special XML characters * Escape special XML characters
* Handles undefined/null values by converting them to empty strings
*/ */
function escapeXml(str: string): string { export function escapeXml(str: string | undefined | null): string {
if (str == null) {
return '';
}
return str return str
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')

View File

@@ -142,6 +142,8 @@ if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
${API_KEY} ${API_KEY}
║ ║ ║ ║
║ In Electron mode, authentication is handled automatically. ║ ║ In Electron mode, authentication is handled automatically. ║
║ ║
║ 💡 Tip: Set AUTOMAKER_API_KEY env var to use a fixed key for dev ║
╚═══════════════════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════════════════╝
`); `);
} else { } else {

View File

@@ -11,6 +11,14 @@ import {
mergeAgentPrompts, mergeAgentPrompts,
mergeBacklogPlanPrompts, mergeBacklogPlanPrompts,
mergeEnhancementPrompts, mergeEnhancementPrompts,
mergeCommitMessagePrompts,
mergeTitleGenerationPrompts,
mergeIssueValidationPrompts,
mergeIdeationPrompts,
mergeAppSpecPrompts,
mergeContextDescriptionPrompts,
mergeSuggestionsPrompts,
mergeTaskExecutionPrompts,
} from '@automaker/prompts'; } from '@automaker/prompts';
const logger = createLogger('SettingsHelper'); const logger = createLogger('SettingsHelper');
@@ -218,6 +226,14 @@ export async function getPromptCustomization(
agent: ReturnType<typeof mergeAgentPrompts>; agent: ReturnType<typeof mergeAgentPrompts>;
backlogPlan: ReturnType<typeof mergeBacklogPlanPrompts>; backlogPlan: ReturnType<typeof mergeBacklogPlanPrompts>;
enhancement: ReturnType<typeof mergeEnhancementPrompts>; enhancement: ReturnType<typeof mergeEnhancementPrompts>;
commitMessage: ReturnType<typeof mergeCommitMessagePrompts>;
titleGeneration: ReturnType<typeof mergeTitleGenerationPrompts>;
issueValidation: ReturnType<typeof mergeIssueValidationPrompts>;
ideation: ReturnType<typeof mergeIdeationPrompts>;
appSpec: ReturnType<typeof mergeAppSpecPrompts>;
contextDescription: ReturnType<typeof mergeContextDescriptionPrompts>;
suggestions: ReturnType<typeof mergeSuggestionsPrompts>;
taskExecution: ReturnType<typeof mergeTaskExecutionPrompts>;
}> { }> {
let customization: PromptCustomization = {}; let customization: PromptCustomization = {};
@@ -239,6 +255,14 @@ export async function getPromptCustomization(
agent: mergeAgentPrompts(customization.agent), agent: mergeAgentPrompts(customization.agent),
backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan), backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan),
enhancement: mergeEnhancementPrompts(customization.enhancement), enhancement: mergeEnhancementPrompts(customization.enhancement),
commitMessage: mergeCommitMessagePrompts(customization.commitMessage),
titleGeneration: mergeTitleGenerationPrompts(customization.titleGeneration),
issueValidation: mergeIssueValidationPrompts(customization.issueValidation),
ideation: mergeIdeationPrompts(customization.ideation),
appSpec: mergeAppSpecPrompts(customization.appSpec),
contextDescription: mergeContextDescriptionPrompts(customization.contextDescription),
suggestions: mergeSuggestionsPrompts(customization.suggestions),
taskExecution: mergeTaskExecutionPrompts(customization.taskExecution),
}; };
} }

View File

@@ -0,0 +1,611 @@
/**
* XML Extraction Utilities
*
* Robust XML parsing utilities for extracting and updating sections
* from app_spec.txt XML content. Uses regex-based parsing which is
* sufficient for our controlled XML structure.
*
* Note: If more complex XML parsing is needed in the future, consider
* using a library like 'fast-xml-parser' or 'xml2js'.
*/
import { createLogger } from '@automaker/utils';
import type { SpecOutput } from '@automaker/types';
const logger = createLogger('XmlExtractor');
/**
* Represents an implemented feature extracted from XML
*/
export interface ImplementedFeature {
name: string;
description: string;
file_locations?: string[];
}
/**
* Logger interface for optional custom logging
*/
export interface XmlExtractorLogger {
debug: (message: string, ...args: unknown[]) => void;
warn?: (message: string, ...args: unknown[]) => void;
}
/**
* Options for XML extraction operations
*/
export interface ExtractXmlOptions {
/** Custom logger (defaults to internal logger) */
logger?: XmlExtractorLogger;
}
/**
* Escape special XML characters
* Handles undefined/null values by converting them to empty strings
*/
export function escapeXml(str: string | undefined | null): string {
if (str == null) {
return '';
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Unescape XML entities back to regular characters
*/
export function unescapeXml(str: string): string {
return str
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
/**
* Extract the content of a specific XML section
*
* @param xmlContent - The full XML content
* @param tagName - The tag name to extract (e.g., 'implemented_features')
* @param options - Optional extraction options
* @returns The content between the tags, or null if not found
*/
export function extractXmlSection(
xmlContent: string,
tagName: string,
options: ExtractXmlOptions = {}
): string | null {
const log = options.logger || logger;
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i');
const match = xmlContent.match(regex);
if (match) {
log.debug(`Extracted <${tagName}> section`);
return match[1];
}
log.debug(`Section <${tagName}> not found`);
return null;
}
/**
* Extract all values from repeated XML elements
*
* @param xmlContent - The XML content to search
* @param tagName - The tag name to extract values from
* @param options - Optional extraction options
* @returns Array of extracted values (unescaped)
*/
export function extractXmlElements(
xmlContent: string,
tagName: string,
options: ExtractXmlOptions = {}
): string[] {
const log = options.logger || logger;
const values: string[] = [];
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g');
const matches = xmlContent.matchAll(regex);
for (const match of matches) {
values.push(unescapeXml(match[1].trim()));
}
log.debug(`Extracted ${values.length} <${tagName}> elements`);
return values;
}
/**
* Extract implemented features from app_spec.txt XML content
*
* @param specContent - The full XML content of app_spec.txt
* @param options - Optional extraction options
* @returns Array of implemented features with name, description, and optional file_locations
*/
export function extractImplementedFeatures(
specContent: string,
options: ExtractXmlOptions = {}
): ImplementedFeature[] {
const log = options.logger || logger;
const features: ImplementedFeature[] = [];
// Match <implemented_features>...</implemented_features> section
const implementedSection = extractXmlSection(specContent, 'implemented_features', options);
if (!implementedSection) {
log.debug('No implemented_features section found');
return features;
}
// Extract individual feature blocks
const featureRegex = /<feature>([\s\S]*?)<\/feature>/g;
const featureMatches = implementedSection.matchAll(featureRegex);
for (const featureMatch of featureMatches) {
const featureContent = featureMatch[1];
// Extract name
const nameMatch = featureContent.match(/<name>([\s\S]*?)<\/name>/);
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
// Extract description
const descMatch = featureContent.match(/<description>([\s\S]*?)<\/description>/);
const description = descMatch ? unescapeXml(descMatch[1].trim()) : '';
// Extract file_locations if present
const locationsSection = extractXmlSection(featureContent, 'file_locations', options);
const file_locations = locationsSection
? extractXmlElements(locationsSection, 'location', options)
: undefined;
if (name) {
features.push({
name,
description,
...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
});
}
}
log.debug(`Extracted ${features.length} implemented features`);
return features;
}
/**
* Extract only the feature names from implemented_features section
*
* @param specContent - The full XML content of app_spec.txt
* @param options - Optional extraction options
* @returns Array of feature names
*/
export function extractImplementedFeatureNames(
specContent: string,
options: ExtractXmlOptions = {}
): string[] {
const features = extractImplementedFeatures(specContent, options);
return features.map((f) => f.name);
}
/**
* Generate XML for a single implemented feature
*
* @param feature - The feature to convert to XML
* @param indent - The base indentation level (default: 2 spaces)
* @returns XML string for the feature
*/
export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string {
const i2 = indent.repeat(2);
const i3 = indent.repeat(3);
const i4 = indent.repeat(4);
let xml = `${i2}<feature>
${i3}<name>${escapeXml(feature.name)}</name>
${i3}<description>${escapeXml(feature.description)}</description>`;
if (feature.file_locations && feature.file_locations.length > 0) {
xml += `
${i3}<file_locations>
${feature.file_locations.map((loc) => `${i4}<location>${escapeXml(loc)}</location>`).join('\n')}
${i3}</file_locations>`;
}
xml += `
${i2}</feature>`;
return xml;
}
/**
* Generate XML for an array of implemented features
*
* @param features - Array of features to convert to XML
* @param indent - The base indentation level (default: 2 spaces)
* @returns XML string for the implemented_features section content
*/
export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string {
return features.map((f) => featureToXml(f, indent)).join('\n');
}
/**
* Update the implemented_features section in XML content
*
* @param specContent - The full XML content
* @param newFeatures - The new features to set
* @param options - Optional extraction options
* @returns Updated XML content with the new implemented_features section
*/
export function updateImplementedFeaturesSection(
specContent: string,
newFeatures: ImplementedFeature[],
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
const indent = ' ';
// Generate new section content
const newSectionContent = featuresToXml(newFeatures, indent);
// Build the new section
const newSection = `<implemented_features>
${newSectionContent}
${indent}</implemented_features>`;
// Check if section exists
const sectionRegex = /<implemented_features>[\s\S]*?<\/implemented_features>/;
if (sectionRegex.test(specContent)) {
log.debug('Replacing existing implemented_features section');
return specContent.replace(sectionRegex, newSection);
}
// If section doesn't exist, try to insert after core_capabilities
const coreCapabilitiesEnd = '</core_capabilities>';
const insertIndex = specContent.indexOf(coreCapabilitiesEnd);
if (insertIndex !== -1) {
const insertPosition = insertIndex + coreCapabilitiesEnd.length;
log.debug('Inserting implemented_features after core_capabilities');
return (
specContent.slice(0, insertPosition) +
'\n\n' +
indent +
newSection +
specContent.slice(insertPosition)
);
}
// As a fallback, insert before </project_specification>
const projectSpecEnd = '</project_specification>';
const fallbackIndex = specContent.indexOf(projectSpecEnd);
if (fallbackIndex !== -1) {
log.debug('Inserting implemented_features before </project_specification>');
return (
specContent.slice(0, fallbackIndex) +
indent +
newSection +
'\n' +
specContent.slice(fallbackIndex)
);
}
log.warn?.('Could not find appropriate insertion point for implemented_features');
log.debug('Could not find appropriate insertion point for implemented_features');
return specContent;
}
/**
* Add a new feature to the implemented_features section
*
* @param specContent - The full XML content
* @param newFeature - The feature to add
* @param options - Optional extraction options
* @returns Updated XML content with the new feature added
*/
export function addImplementedFeature(
specContent: string,
newFeature: ImplementedFeature,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Extract existing features
const existingFeatures = extractImplementedFeatures(specContent, options);
// Check for duplicates by name
const isDuplicate = existingFeatures.some(
(f) => f.name.toLowerCase() === newFeature.name.toLowerCase()
);
if (isDuplicate) {
log.debug(`Feature "${newFeature.name}" already exists, skipping`);
return specContent;
}
// Add the new feature
const updatedFeatures = [...existingFeatures, newFeature];
log.debug(`Adding feature "${newFeature.name}"`);
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
}
/**
* Remove a feature from the implemented_features section by name
*
* @param specContent - The full XML content
* @param featureName - The name of the feature to remove
* @param options - Optional extraction options
* @returns Updated XML content with the feature removed
*/
export function removeImplementedFeature(
specContent: string,
featureName: string,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Extract existing features
const existingFeatures = extractImplementedFeatures(specContent, options);
// Filter out the feature to remove
const updatedFeatures = existingFeatures.filter(
(f) => f.name.toLowerCase() !== featureName.toLowerCase()
);
if (updatedFeatures.length === existingFeatures.length) {
log.debug(`Feature "${featureName}" not found, no changes made`);
return specContent;
}
log.debug(`Removing feature "${featureName}"`);
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
}
/**
* Update an existing feature in the implemented_features section
*
* @param specContent - The full XML content
* @param featureName - The name of the feature to update
* @param updates - Partial updates to apply to the feature
* @param options - Optional extraction options
* @returns Updated XML content with the feature modified
*/
export function updateImplementedFeature(
specContent: string,
featureName: string,
updates: Partial<ImplementedFeature>,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Extract existing features
const existingFeatures = extractImplementedFeatures(specContent, options);
// Find and update the feature
let found = false;
const updatedFeatures = existingFeatures.map((f) => {
if (f.name.toLowerCase() === featureName.toLowerCase()) {
found = true;
return {
...f,
...updates,
// Preserve the original name if not explicitly updated
name: updates.name ?? f.name,
};
}
return f;
});
if (!found) {
log.debug(`Feature "${featureName}" not found, no changes made`);
return specContent;
}
log.debug(`Updating feature "${featureName}"`);
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
}
/**
* Check if a feature exists in the implemented_features section
*
* @param specContent - The full XML content
* @param featureName - The name of the feature to check
* @param options - Optional extraction options
* @returns True if the feature exists
*/
export function hasImplementedFeature(
specContent: string,
featureName: string,
options: ExtractXmlOptions = {}
): boolean {
const features = extractImplementedFeatures(specContent, options);
return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase());
}
/**
* Convert extracted features to SpecOutput.implemented_features format
*
* @param features - Array of extracted features
* @returns Features in SpecOutput format
*/
export function toSpecOutputFeatures(
features: ImplementedFeature[]
): SpecOutput['implemented_features'] {
return features.map((f) => ({
name: f.name,
description: f.description,
...(f.file_locations && f.file_locations.length > 0
? { file_locations: f.file_locations }
: {}),
}));
}
/**
* Convert SpecOutput.implemented_features to ImplementedFeature format
*
* @param specFeatures - Features from SpecOutput
* @returns Features in ImplementedFeature format
*/
export function fromSpecOutputFeatures(
specFeatures: SpecOutput['implemented_features']
): ImplementedFeature[] {
return specFeatures.map((f) => ({
name: f.name,
description: f.description,
...(f.file_locations && f.file_locations.length > 0
? { file_locations: f.file_locations }
: {}),
}));
}
/**
* Represents a roadmap phase extracted from XML
*/
export interface RoadmapPhase {
name: string;
status: string;
description?: string;
}
/**
* Extract the technology stack from app_spec.txt XML content
*
* @param specContent - The full XML content
* @param options - Optional extraction options
* @returns Array of technology names
*/
export function extractTechnologyStack(
specContent: string,
options: ExtractXmlOptions = {}
): string[] {
const log = options.logger || logger;
const techSection = extractXmlSection(specContent, 'technology_stack', options);
if (!techSection) {
log.debug('No technology_stack section found');
return [];
}
const technologies = extractXmlElements(techSection, 'technology', options);
log.debug(`Extracted ${technologies.length} technologies`);
return technologies;
}
/**
* Update the technology_stack section in XML content
*
* @param specContent - The full XML content
* @param technologies - The new technology list
* @param options - Optional extraction options
* @returns Updated XML content
*/
export function updateTechnologyStack(
specContent: string,
technologies: string[],
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
const indent = ' ';
const i2 = indent.repeat(2);
// Generate new section content
const techXml = technologies
.map((t) => `${i2}<technology>${escapeXml(t)}</technology>`)
.join('\n');
const newSection = `<technology_stack>\n${techXml}\n${indent}</technology_stack>`;
// Check if section exists
const sectionRegex = /<technology_stack>[\s\S]*?<\/technology_stack>/;
if (sectionRegex.test(specContent)) {
log.debug('Replacing existing technology_stack section');
return specContent.replace(sectionRegex, newSection);
}
log.debug('No technology_stack section found to update');
return specContent;
}
/**
* Extract roadmap phases from app_spec.txt XML content
*
* @param specContent - The full XML content
* @param options - Optional extraction options
* @returns Array of roadmap phases
*/
export function extractRoadmapPhases(
specContent: string,
options: ExtractXmlOptions = {}
): RoadmapPhase[] {
const log = options.logger || logger;
const phases: RoadmapPhase[] = [];
const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options);
if (!roadmapSection) {
log.debug('No implementation_roadmap section found');
return phases;
}
// Extract individual phase blocks
const phaseRegex = /<phase>([\s\S]*?)<\/phase>/g;
const phaseMatches = roadmapSection.matchAll(phaseRegex);
for (const phaseMatch of phaseMatches) {
const phaseContent = phaseMatch[1];
const nameMatch = phaseContent.match(/<name>([\s\S]*?)<\/name>/);
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
const statusMatch = phaseContent.match(/<status>([\s\S]*?)<\/status>/);
const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending';
const descMatch = phaseContent.match(/<description>([\s\S]*?)<\/description>/);
const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined;
if (name) {
phases.push({ name, status, description });
}
}
log.debug(`Extracted ${phases.length} roadmap phases`);
return phases;
}
/**
* Update a roadmap phase status in XML content
*
* @param specContent - The full XML content
* @param phaseName - The name of the phase to update
* @param newStatus - The new status value
* @param options - Optional extraction options
* @returns Updated XML content
*/
export function updateRoadmapPhaseStatus(
specContent: string,
phaseName: string,
newStatus: string,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Find the phase and update its status
// Match the phase block containing the specific name
const phaseRegex = new RegExp(
`(<phase>\\s*<name>\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*<status>)[\\s\\S]*?(<\\/status>)`,
'i'
);
if (phaseRegex.test(specContent)) {
log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`);
return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`);
}
log.debug(`Phase "${phaseName}" not found`);
return specContent;
}

View File

@@ -35,6 +35,7 @@ import {
type SubprocessOptions, type SubprocessOptions,
type WslCliResult, type WslCliResult,
} from '@automaker/platform'; } from '@automaker/platform';
import { calculateReasoningTimeout } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils'; import { createLogger, isAbortError } from '@automaker/utils';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -107,6 +108,15 @@ export interface CliDetectionResult {
// Create logger for CLI operations // Create logger for CLI operations
const cliLogger = createLogger('CliProvider'); const cliLogger = createLogger('CliProvider');
/**
* Base timeout for CLI operations in milliseconds.
* CLI tools have longer startup and processing times compared to direct API calls,
* so we use a higher base timeout (120s) than the default provider timeout (30s).
* This is multiplied by reasoning effort multipliers when applicable.
* @see calculateReasoningTimeout from @automaker/types
*/
const CLI_BASE_TIMEOUT_MS = 120000;
/** /**
* Abstract base class for CLI-based providers * Abstract base class for CLI-based providers
* *
@@ -450,6 +460,10 @@ export abstract class CliProvider extends BaseProvider {
} }
} }
// Calculate dynamic timeout based on reasoning effort.
// This addresses GitHub issue #530 where reasoning models with 'xhigh' effort would timeout.
const timeout = calculateReasoningTimeout(options.reasoningEffort, CLI_BASE_TIMEOUT_MS);
// WSL strategy // WSL strategy
if (this.useWsl && this.wslCliPath) { if (this.useWsl && this.wslCliPath) {
const wslCwd = windowsToWslPath(cwd); const wslCwd = windowsToWslPath(cwd);
@@ -473,7 +487,7 @@ export abstract class CliProvider extends BaseProvider {
cwd, // Windows cwd for spawn cwd, // Windows cwd for spawn
env: filteredEnv, env: filteredEnv,
abortController: options.abortController, abortController: options.abortController,
timeout: 120000, // CLI operations may take longer timeout,
}; };
} }
@@ -488,7 +502,7 @@ export abstract class CliProvider extends BaseProvider {
cwd, cwd,
env: filteredEnv, env: filteredEnv,
abortController: options.abortController, abortController: options.abortController,
timeout: 120000, timeout,
}; };
} }
@@ -501,7 +515,7 @@ export abstract class CliProvider extends BaseProvider {
cwd, cwd,
env: filteredEnv, env: filteredEnv,
abortController: options.abortController, abortController: options.abortController,
timeout: 120000, timeout,
}; };
} }

View File

@@ -33,6 +33,8 @@ import {
CODEX_MODEL_MAP, CODEX_MODEL_MAP,
supportsReasoningEffort, supportsReasoningEffort,
validateBareModelId, validateBareModelId,
calculateReasoningTimeout,
DEFAULT_TIMEOUT_MS,
type CodexApprovalPolicy, type CodexApprovalPolicy,
type CodexSandboxMode, type CodexSandboxMode,
type CodexAuthStatus, type CodexAuthStatus,
@@ -91,7 +93,14 @@ const CODEX_ITEM_TYPES = {
const SYSTEM_PROMPT_LABEL = 'System instructions'; const SYSTEM_PROMPT_LABEL = 'System instructions';
const HISTORY_HEADER = 'Current request:\n'; const HISTORY_HEADER = 'Current request:\n';
const TEXT_ENCODING = 'utf-8'; const TEXT_ENCODING = 'utf-8';
const DEFAULT_TIMEOUT_MS = 30000; /**
* Default timeout for Codex CLI operations in milliseconds.
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
* for this duration, the process is killed. For reasoning models with high
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CONTEXT_WINDOW_256K = 256000; const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000; const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000; const MAX_OUTPUT_16K = 16000;
@@ -814,13 +823,19 @@ export class CodexProvider extends BaseProvider {
envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey; envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
} }
// Calculate dynamic timeout based on reasoning effort.
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
// for the model to generate reasoning tokens before producing output.
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
const stream = spawnJSONLProcess({ const stream = spawnJSONLProcess({
command: commandPath, command: commandPath,
args, args,
cwd: options.cwd, cwd: options.cwd,
env: envOverrides, env: envOverrides,
abortController: options.abortController, abortController: options.abortController,
timeout: DEFAULT_TIMEOUT_MS, timeout,
stdinData: promptText, // Pass prompt via stdin stdinData: promptText, // Pass prompt via stdin
}); });

View File

@@ -6,8 +6,17 @@ import { createLogger } from '@automaker/utils';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
// Types for running generation
export type GenerationType = 'spec_regeneration' | 'feature_generation' | 'sync';
interface RunningGeneration {
isRunning: boolean;
type: GenerationType;
startedAt: string;
}
// Shared state for tracking generation status - scoped by project path // Shared state for tracking generation status - scoped by project path
const runningProjects = new Map<string, boolean>(); const runningProjects = new Map<string, RunningGeneration>();
const abortControllers = new Map<string, AbortController>(); const abortControllers = new Map<string, AbortController>();
/** /**
@@ -17,16 +26,21 @@ export function getSpecRegenerationStatus(projectPath?: string): {
isRunning: boolean; isRunning: boolean;
currentAbortController: AbortController | null; currentAbortController: AbortController | null;
projectPath?: string; projectPath?: string;
type?: GenerationType;
startedAt?: string;
} { } {
if (projectPath) { if (projectPath) {
const generation = runningProjects.get(projectPath);
return { return {
isRunning: runningProjects.get(projectPath) || false, isRunning: generation?.isRunning || false,
currentAbortController: abortControllers.get(projectPath) || null, currentAbortController: abortControllers.get(projectPath) || null,
projectPath, projectPath,
type: generation?.type,
startedAt: generation?.startedAt,
}; };
} }
// Fallback: check if any project is running (for backward compatibility) // Fallback: check if any project is running (for backward compatibility)
const isAnyRunning = Array.from(runningProjects.values()).some((running) => running); const isAnyRunning = Array.from(runningProjects.values()).some((g) => g.isRunning);
return { isRunning: isAnyRunning, currentAbortController: null }; return { isRunning: isAnyRunning, currentAbortController: null };
} }
@@ -46,10 +60,15 @@ export function getRunningProjectPath(): string | null {
export function setRunningState( export function setRunningState(
projectPath: string, projectPath: string,
running: boolean, running: boolean,
controller: AbortController | null = null controller: AbortController | null = null,
type: GenerationType = 'spec_regeneration'
): void { ): void {
if (running) { if (running) {
runningProjects.set(projectPath, true); runningProjects.set(projectPath, {
isRunning: true,
type,
startedAt: new Date().toISOString(),
});
if (controller) { if (controller) {
abortControllers.set(projectPath, controller); abortControllers.set(projectPath, controller);
} }
@@ -59,6 +78,33 @@ export function setRunningState(
} }
} }
/**
* Get all running spec/feature generations for the running agents view
*/
export function getAllRunningGenerations(): Array<{
projectPath: string;
type: GenerationType;
startedAt: string;
}> {
const results: Array<{
projectPath: string;
type: GenerationType;
startedAt: string;
}> = [];
for (const [projectPath, generation] of runningProjects.entries()) {
if (generation.isRunning) {
results.push({
projectPath,
type: generation.type,
startedAt: generation.startedAt,
});
}
}
return results;
}
/** /**
* Helper to log authentication status * Helper to log authentication status
*/ */

View File

@@ -14,7 +14,8 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js'; import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform'; import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -53,38 +54,48 @@ export async function generateFeaturesFromSpec(
return; return;
} }
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]');
// Load existing features to prevent duplicates
const featureLoader = new FeatureLoader();
const existingFeatures = await featureLoader.getAll(projectPath);
logger.info(`Found ${existingFeatures.length} existing features to exclude from generation`);
// Build existing features context for the prompt
let existingFeaturesContext = '';
if (existingFeatures.length > 0) {
const featuresList = existingFeatures
.map(
(f) =>
`- "${f.title}" (ID: ${f.id}): ${f.description?.substring(0, 100) || 'No description'}`
)
.join('\n');
existingFeaturesContext = `
## EXISTING FEATURES (DO NOT REGENERATE THESE)
The following ${existingFeatures.length} features already exist in the project. You MUST NOT generate features that duplicate or overlap with these:
${featuresList}
CRITICAL INSTRUCTIONS:
- DO NOT generate any features with the same or similar titles as the existing features listed above
- DO NOT generate features that cover the same functionality as existing features
- ONLY generate NEW features that are not yet in the system
- If a feature from the roadmap already exists, skip it entirely
- Generate unique feature IDs that do not conflict with existing IDs: ${existingFeatures.map((f) => f.id).join(', ')}
`;
}
const prompt = `Based on this project specification: const prompt = `Based on this project specification:
${spec} ${spec}
${existingFeaturesContext}
${prompts.appSpec.generateFeaturesFromSpecPrompt}
Generate a prioritized list of implementable features. For each feature provide: Generate ${featureCount} NEW features that build on each other logically. Remember: ONLY generate features that DO NOT already exist.`;
1. **id**: A unique lowercase-hyphenated identifier
2. **category**: Functional category (e.g., "Core", "UI", "API", "Authentication", "Database")
3. **title**: Short descriptive title
4. **description**: What this feature does (2-3 sentences)
5. **priority**: 1 (high), 2 (medium), or 3 (low)
6. **complexity**: "simple", "moderate", or "complex"
7. **dependencies**: Array of feature IDs this depends on (can be empty)
Format as JSON:
{
"features": [
{
"id": "feature-id",
"category": "Feature Category",
"title": "Feature Title",
"description": "What it does",
"priority": 1,
"complexity": "moderate",
"dependencies": []
}
]
}
Generate ${featureCount} features that build on each other logically.
IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`;
logger.info('========== PROMPT BEING SENT =========='); logger.info('========== PROMPT BEING SENT ==========');
logger.info(`Prompt length: ${prompt.length} chars`); logger.info(`Prompt length: ${prompt.length} chars`);

View File

@@ -7,12 +7,7 @@
import * as secureFs from '../../lib/secure-fs.js'; import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js'; import type { EventEmitter } from '../../lib/events.js';
import { import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
specOutputSchema,
specToXml,
getStructuredSpecPromptInstruction,
type SpecOutput,
} from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver'; import { resolvePhaseModel } from '@automaker/model-resolver';
@@ -21,7 +16,7 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -43,6 +38,9 @@ export async function generateSpec(
logger.info('analyzeProject:', analyzeProject); logger.info('analyzeProject:', analyzeProject);
logger.info('maxFeatures:', maxFeatures); logger.info('maxFeatures:', maxFeatures);
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[SpecRegeneration]');
// Build the prompt based on whether we should analyze the project // Build the prompt based on whether we should analyze the project
let analysisInstructions = ''; let analysisInstructions = '';
let techStackDefaults = ''; let techStackDefaults = '';
@@ -66,9 +64,7 @@ export async function generateSpec(
Use these technologies as the foundation for the specification.`; Use these technologies as the foundation for the specification.`;
} }
const prompt = `You are helping to define a software project specification. const prompt = `${prompts.appSpec.generateSpecSystemPrompt}
IMPORTANT: Never ask for clarification or additional information. Use the information provided and make reasonable assumptions to create the best possible specification. If details are missing, infer them based on common patterns and best practices.
Project Overview: Project Overview:
${projectOverview} ${projectOverview}
@@ -77,7 +73,7 @@ ${techStackDefaults}
${analysisInstructions} ${analysisInstructions}
${getStructuredSpecPromptInstruction()}`; ${prompts.appSpec.structuredSpecInstructions}`;
logger.info('========== PROMPT BEING SENT =========='); logger.info('========== PROMPT BEING SENT ==========');
logger.info(`Prompt length: ${prompt.length} chars`); logger.info(`Prompt length: ${prompt.length} chars`);
@@ -205,19 +201,33 @@ Your entire response should be valid JSON starting with { and ending with }. No
xmlContent = responseText.substring(xmlStart, xmlEnd + '</project_specification>'.length); xmlContent = responseText.substring(xmlStart, xmlEnd + '</project_specification>'.length);
logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`); logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
} else { } else {
// No valid XML structure found in the response text // No XML found, try JSON extraction
// This happens when structured output was expected but not received, and the agent logger.warn('⚠️ No XML tags found, attempting JSON extraction...');
// output conversational text instead of XML (e.g., "The project directory appears to be empty...") const extractedJson = extractJson<SpecOutput>(responseText, { logger });
// We should NOT save this conversational text as it's not a valid spec
logger.error('❌ Response does not contain valid <project_specification> XML structure'); if (
logger.error( extractedJson &&
'This typically happens when structured output failed and the agent produced conversational text instead of XML' typeof extractedJson.project_name === 'string' &&
); typeof extractedJson.overview === 'string' &&
throw new Error( Array.isArray(extractedJson.technology_stack) &&
'Failed to generate spec: No valid XML structure found in response. ' + Array.isArray(extractedJson.core_capabilities) &&
'The response contained conversational text but no <project_specification> tags. ' + Array.isArray(extractedJson.implemented_features)
'Please try again.' ) {
); logger.info('✅ Successfully extracted JSON from response text');
xmlContent = specToXml(extractedJson);
logger.info(`✅ Converted extracted JSON to XML: ${xmlContent.length} chars`);
} else {
// Neither XML nor valid JSON found
logger.error('❌ Response does not contain valid XML or JSON structure');
logger.error(
'This typically happens when structured output failed and the agent produced conversational text instead of structured output'
);
throw new Error(
'Failed to generate spec: No valid XML or JSON structure found in response. ' +
'The response contained conversational text but no <project_specification> tags or valid JSON. ' +
'Please try again.'
);
}
} }
} }

View File

@@ -7,6 +7,7 @@ import type { EventEmitter } from '../../lib/events.js';
import { createCreateHandler } from './routes/create.js'; import { createCreateHandler } from './routes/create.js';
import { createGenerateHandler } from './routes/generate.js'; import { createGenerateHandler } from './routes/generate.js';
import { createGenerateFeaturesHandler } from './routes/generate-features.js'; import { createGenerateFeaturesHandler } from './routes/generate-features.js';
import { createSyncHandler } from './routes/sync.js';
import { createStopHandler } from './routes/stop.js'; import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js'; import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
@@ -20,6 +21,7 @@ export function createSpecRegenerationRoutes(
router.post('/create', createCreateHandler(events)); router.post('/create', createCreateHandler(events));
router.post('/generate', createGenerateHandler(events, settingsService)); router.post('/generate', createGenerateHandler(events, settingsService));
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService)); router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
router.post('/sync', createSyncHandler(events, settingsService));
router.post('/stop', createStopHandler()); router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler()); router.get('/status', createStatusHandler());

View File

@@ -5,9 +5,10 @@
import path from 'path'; import path from 'path';
import * as secureFs from '../../lib/secure-fs.js'; import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js'; import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils'; import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
import { getFeaturesDir } from '@automaker/platform'; import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -73,10 +74,10 @@ export async function parseAndCreateFeatures(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await secureFs.writeFile( // Use atomic write with backup support for crash protection
path.join(featureDir, 'feature.json'), await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
JSON.stringify(featureData, null, 2) backupCount: DEFAULT_BACKUP_COUNT,
); });
createdFeatures.push({ id: feature.id, title: feature.title }); createdFeatures.push({ id: feature.id, title: feature.title });
} }
@@ -88,6 +89,15 @@ export async function parseAndCreateFeatures(
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`, message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
projectPath: projectPath, projectPath: projectPath,
}); });
// Create notification for spec generation completion
const notificationService = getNotificationService();
await notificationService.createNotification({
type: 'spec_regeneration_complete',
title: 'Spec Generation Complete',
message: `Created ${createdFeatures.length} features from the project specification.`,
projectPath: projectPath,
});
} catch (error) { } catch (error) {
logger.error('❌ parseAndCreateFeatures() failed:'); logger.error('❌ parseAndCreateFeatures() failed:');
logger.error('Error:', error); logger.error('Error:', error);

View File

@@ -50,7 +50,7 @@ export function createGenerateFeaturesHandler(
logAuthStatus('Before starting feature generation'); logAuthStatus('Before starting feature generation');
const abortController = new AbortController(); const abortController = new AbortController();
setRunningState(projectPath, true, abortController); setRunningState(projectPath, true, abortController, 'feature_generation');
logger.info('Starting background feature generation task...'); logger.info('Starting background feature generation task...');
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)

View File

@@ -0,0 +1,76 @@
/**
* POST /sync endpoint - Sync spec with codebase and features
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils';
import {
getSpecRegenerationStatus,
setRunningState,
logAuthStatus,
logError,
getErrorMessage,
} from '../common.js';
import { syncSpec } from '../sync-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('SpecSync');
export function createSyncHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /sync endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
try {
const { projectPath } = req.body as {
projectPath: string;
};
logger.debug('projectPath:', projectPath);
if (!projectPath) {
logger.error('Missing projectPath parameter');
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
const { isRunning } = getSpecRegenerationStatus(projectPath);
if (isRunning) {
logger.warn('Generation/sync already running for project:', projectPath);
res.json({ success: false, error: 'Operation already running for this project' });
return;
}
logAuthStatus('Before starting spec sync');
const abortController = new AbortController();
setRunningState(projectPath, true, abortController, 'sync');
logger.info('Starting background spec sync task...');
syncSpec(projectPath, events, abortController, settingsService)
.then((result) => {
logger.info('Spec sync completed successfully');
logger.info('Result:', JSON.stringify(result, null, 2));
})
.catch((error) => {
logError(error, 'Spec sync failed with error');
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: getErrorMessage(error),
projectPath,
});
})
.finally(() => {
logger.info('Spec sync task finished (success or error)');
setRunningState(projectPath, false, null);
});
logger.info('Returning success response (sync running in background)');
res.json({ success: true });
} catch (error) {
logError(error, 'Sync route handler failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,307 @@
/**
* Sync spec with current codebase and feature state
*
* Updates the spec file based on:
* - Completed Automaker features
* - Code analysis for tech stack and implementations
* - Roadmap phase status updates
*/
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import {
extractImplementedFeatures,
extractTechnologyStack,
extractRoadmapPhases,
updateImplementedFeaturesSection,
updateTechnologyStack,
updateRoadmapPhaseStatus,
type ImplementedFeature,
type RoadmapPhase,
} from '../../lib/xml-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecSync');
/**
* Result of a sync operation
*/
export interface SyncResult {
techStackUpdates: {
added: string[];
removed: string[];
};
implementedFeaturesUpdates: {
addedFromFeatures: string[];
removed: string[];
};
roadmapUpdates: Array<{ phaseName: string; newStatus: string }>;
summary: string;
}
/**
* Sync the spec with current codebase and feature state
*/
export async function syncSpec(
projectPath: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService
): Promise<SyncResult> {
logger.info('========== syncSpec() started ==========');
logger.info('projectPath:', projectPath);
const result: SyncResult = {
techStackUpdates: { added: [], removed: [] },
implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] },
roadmapUpdates: [],
summary: '',
};
// Read existing spec
const specPath = getAppSpecPath(projectPath);
let specContent: string;
try {
specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
logger.info(`Spec loaded successfully (${specContent.length} chars)`);
} catch (readError) {
logger.error('Failed to read spec file:', readError);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: 'No project spec found. Create or regenerate spec first.',
projectPath,
});
throw new Error('No project spec found');
}
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: '[Phase: sync] Starting spec sync...\n',
projectPath,
});
// Extract current state from spec
const currentImplementedFeatures = extractImplementedFeatures(specContent);
const currentTechStack = extractTechnologyStack(specContent);
const currentRoadmapPhases = extractRoadmapPhases(specContent);
logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`);
logger.info(`Current spec has ${currentTechStack.length} technologies`);
logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`);
// Load completed Automaker features
const featureLoader = new FeatureLoader();
const allFeatures = await featureLoader.getAll(projectPath);
const completedFeatures = allFeatures.filter(
(f) => f.status === 'completed' || f.status === 'verified'
);
logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: `Found ${completedFeatures.length} completed features to sync...\n`,
projectPath,
});
// Build new implemented features list from completed Automaker features
const newImplementedFeatures: ImplementedFeature[] = [];
const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase()));
for (const feature of completedFeatures) {
const name = feature.title || `Feature: ${feature.id}`;
if (!existingNames.has(name.toLowerCase())) {
newImplementedFeatures.push({
name,
description: feature.description || '',
});
result.implementedFeaturesUpdates.addedFromFeatures.push(name);
}
}
// Merge: keep existing + add new from completed features
const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures];
// Update spec with merged features
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
specContent = updateImplementedFeaturesSection(specContent, mergedFeatures);
logger.info(
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec`
);
}
// Analyze codebase for tech stack updates using AI
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: 'Analyzing codebase for technology updates...\n',
projectPath,
});
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[SpecSync]'
);
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
// Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
Current known technologies: ${currentTechStack.join(', ')}
Look at package.json, config files, and source code to identify:
- Frameworks (React, Vue, Express, etc.)
- Languages (TypeScript, JavaScript, Python, etc.)
- Build tools (Vite, Webpack, etc.)
- Databases (PostgreSQL, MongoDB, etc.)
- Key libraries and tools
Return ONLY this JSON format, no other text:
{
"technologies": ["Technology 1", "Technology 2", ...]
}`;
try {
const techResult = await streamingQuery({
prompt: techAnalysisPrompt,
model,
cwd: projectPath,
maxTurns: 10,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
readOnly: true,
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},
});
// Parse tech stack from response
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (Array.isArray(parsed.technologies)) {
const newTechStack = parsed.technologies as string[];
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
for (const tech of newTechStack) {
if (!currentSet.has(tech.toLowerCase())) {
result.techStackUpdates.added.push(tech);
}
}
for (const tech of currentTechStack) {
if (!newSet.has(tech.toLowerCase())) {
result.techStackUpdates.removed.push(tech);
}
}
// Update spec with new tech stack if there are changes
if (
result.techStackUpdates.added.length > 0 ||
result.techStackUpdates.removed.length > 0
) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
}
}
}
} catch (error) {
logger.warn('Failed to analyze tech stack:', error);
// Continue with other sync operations
}
// Update roadmap phase statuses based on completed features
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: 'Checking roadmap phase statuses...\n',
projectPath,
});
// For each phase, check if all its features are completed
// This is a heuristic - we check if the phase name appears in any feature titles/descriptions
for (const phase of currentRoadmapPhases) {
if (phase.status === 'completed') continue; // Already completed
// Check if this phase should be marked as completed
// A phase is considered complete if we have completed features that mention it
const phaseNameLower = phase.name.toLowerCase();
const relatedCompletedFeatures = completedFeatures.filter(
(f) =>
f.title?.toLowerCase().includes(phaseNameLower) ||
f.description?.toLowerCase().includes(phaseNameLower) ||
f.category?.toLowerCase().includes(phaseNameLower)
);
// If we have related completed features and the phase is still pending/in_progress,
// update it to in_progress or completed based on feature count
if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') {
const newStatus = 'in_progress';
specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus);
result.roadmapUpdates.push({ phaseName: phase.name, newStatus });
logger.info(`Updated phase "${phase.name}" to ${newStatus}`);
}
}
// Save updated spec
await secureFs.writeFile(specPath, specContent, 'utf-8');
logger.info('Spec saved successfully');
// Build summary
const summaryParts: string[] = [];
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
summaryParts.push(
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features`
);
}
if (result.techStackUpdates.added.length > 0) {
summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`);
}
if (result.techStackUpdates.removed.length > 0) {
summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`);
}
if (result.roadmapUpdates.length > 0) {
summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`);
}
result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date';
// Create notification
const notificationService = getNotificationService();
await notificationService.createNotification({
type: 'spec_regeneration_complete',
title: 'Spec Sync Complete',
message: result.summary,
projectPath,
});
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_complete',
message: `Spec sync complete! ${result.summary}`,
projectPath,
});
logger.info('========== syncSpec() completed ==========');
logger.info('Summary:', result.summary);
return result;
}

View File

@@ -3,12 +3,31 @@
*/ */
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { ensureAutomakerDir, getAutomakerDir } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import path from 'path';
import type { BacklogPlanResult } from '@automaker/types';
const logger = createLogger('BacklogPlan'); const logger = createLogger('BacklogPlan');
// State for tracking running generation // State for tracking running generation
let isRunning = false; let isRunning = false;
let currentAbortController: AbortController | null = null; let currentAbortController: AbortController | null = null;
let runningDetails: {
projectPath: string;
prompt: string;
model?: string;
startedAt: string;
} | null = null;
const BACKLOG_PLAN_FILENAME = 'backlog-plan.json';
export interface StoredBacklogPlan {
savedAt: string;
prompt: string;
model?: string;
result: BacklogPlanResult;
}
export function getBacklogPlanStatus(): { isRunning: boolean } { export function getBacklogPlanStatus(): { isRunning: boolean } {
return { isRunning }; return { isRunning };
@@ -16,11 +35,67 @@ export function getBacklogPlanStatus(): { isRunning: boolean } {
export function setRunningState(running: boolean, abortController?: AbortController | null): void { export function setRunningState(running: boolean, abortController?: AbortController | null): void {
isRunning = running; isRunning = running;
if (!running) {
runningDetails = null;
}
if (abortController !== undefined) { if (abortController !== undefined) {
currentAbortController = abortController; currentAbortController = abortController;
} }
} }
export function setRunningDetails(
details: {
projectPath: string;
prompt: string;
model?: string;
startedAt: string;
} | null
): void {
runningDetails = details;
}
export function getRunningDetails(): {
projectPath: string;
prompt: string;
model?: string;
startedAt: string;
} | null {
return runningDetails;
}
function getBacklogPlanPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), BACKLOG_PLAN_FILENAME);
}
export async function saveBacklogPlan(projectPath: string, plan: StoredBacklogPlan): Promise<void> {
await ensureAutomakerDir(projectPath);
const filePath = getBacklogPlanPath(projectPath);
await secureFs.writeFile(filePath, JSON.stringify(plan, null, 2), 'utf-8');
}
export async function loadBacklogPlan(projectPath: string): Promise<StoredBacklogPlan | null> {
try {
const filePath = getBacklogPlanPath(projectPath);
const raw = await secureFs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw as string) as StoredBacklogPlan;
if (!Array.isArray(parsed?.result?.changes)) {
return null;
}
return parsed;
} catch {
return null;
}
}
export async function clearBacklogPlan(projectPath: string): Promise<void> {
try {
const filePath = getBacklogPlanPath(projectPath);
await secureFs.unlink(filePath);
} catch {
// ignore missing file
}
}
export function getAbortController(): AbortController | null { export function getAbortController(): AbortController | null {
return currentAbortController; return currentAbortController;
} }

View File

@@ -17,7 +17,13 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
import { FeatureLoader } from '../../services/feature-loader.js'; import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js'; import { ProviderFactory } from '../../providers/provider-factory.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { logger, setRunningState, getErrorMessage } from './common.js'; import {
logger,
setRunningState,
setRunningDetails,
getErrorMessage,
saveBacklogPlan,
} from './common.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
@@ -200,6 +206,13 @@ ${userPrompt}`;
// Parse the response // Parse the response
const result = parsePlanResponse(responseText); const result = parsePlanResponse(responseText);
await saveBacklogPlan(projectPath, {
savedAt: new Date().toISOString(),
prompt,
model: effectiveModel,
result,
});
events.emit('backlog-plan:event', { events.emit('backlog-plan:event', {
type: 'backlog_plan_complete', type: 'backlog_plan_complete',
result, result,
@@ -218,5 +231,6 @@ ${userPrompt}`;
throw error; throw error;
} finally { } finally {
setRunningState(false, null); setRunningState(false, null);
setRunningDetails(null);
} }
} }

View File

@@ -9,6 +9,7 @@ import { createGenerateHandler } from './routes/generate.js';
import { createStopHandler } from './routes/stop.js'; import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js'; import { createStatusHandler } from './routes/status.js';
import { createApplyHandler } from './routes/apply.js'; import { createApplyHandler } from './routes/apply.js';
import { createClearHandler } from './routes/clear.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
export function createBacklogPlanRoutes( export function createBacklogPlanRoutes(
@@ -23,8 +24,9 @@ export function createBacklogPlanRoutes(
createGenerateHandler(events, settingsService) createGenerateHandler(events, settingsService)
); );
router.post('/stop', createStopHandler()); router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler()); router.get('/status', validatePathParams('projectPath'), createStatusHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler()); router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
router.post('/clear', validatePathParams('projectPath'), createClearHandler());
return router; return router;
} }

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types'; import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
import { FeatureLoader } from '../../../services/feature-loader.js'; import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError, logger } from '../common.js'; import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
const featureLoader = new FeatureLoader(); const featureLoader = new FeatureLoader();
@@ -147,6 +147,17 @@ export function createApplyHandler() {
} }
} }
// Clear the plan before responding
try {
await clearBacklogPlan(projectPath);
} catch (error) {
logger.warn(
`[BacklogPlan] Failed to clear backlog plan after apply:`,
getErrorMessage(error)
);
// Don't throw - operation succeeded, just cleanup failed
}
res.json({ res.json({
success: true, success: true,
appliedChanges, appliedChanges,

View File

@@ -0,0 +1,25 @@
/**
* POST /clear endpoint - Clear saved backlog plan
*/
import type { Request, Response } from 'express';
import { clearBacklogPlan, getErrorMessage, logError } from '../common.js';
export function createClearHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
await clearBacklogPlan(projectPath);
res.json({ success: true });
} catch (error) {
logError(error, 'Clear backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -4,7 +4,13 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js'; import type { EventEmitter } from '../../../lib/events.js';
import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js'; import {
getBacklogPlanStatus,
setRunningState,
setRunningDetails,
getErrorMessage,
logError,
} from '../common.js';
import { generateBacklogPlan } from '../generate-plan.js'; import { generateBacklogPlan } from '../generate-plan.js';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
@@ -37,6 +43,12 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
} }
setRunningState(true); setRunningState(true);
setRunningDetails({
projectPath,
prompt,
model,
startedAt: new Date().toISOString(),
});
const abortController = new AbortController(); const abortController = new AbortController();
setRunningState(true, abortController); setRunningState(true, abortController);
@@ -51,6 +63,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
}) })
.finally(() => { .finally(() => {
setRunningState(false, null); setRunningState(false, null);
setRunningDetails(null);
}); });
res.json({ success: true }); res.json({ success: true });

View File

@@ -3,13 +3,15 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js'; import { getBacklogPlanStatus, loadBacklogPlan, getErrorMessage, logError } from '../common.js';
export function createStatusHandler() { export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const status = getBacklogPlanStatus(); const status = getBacklogPlanStatus();
res.json({ success: true, ...status }); const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : '';
const savedPlan = projectPath ? await loadBacklogPlan(projectPath) : null;
res.json({ success: true, ...status, savedPlan });
} catch (error) { } catch (error) {
logError(error, 'Get backlog plan status failed'); logError(error, 'Get backlog plan status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -3,7 +3,13 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js'; import {
getAbortController,
setRunningState,
setRunningDetails,
getErrorMessage,
logError,
} from '../common.js';
export function createStopHandler() { export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -12,6 +18,7 @@ export function createStopHandler() {
if (abortController) { if (abortController) {
abortController.abort(); abortController.abort();
setRunningState(false, null); setRunningState(false, null);
setRunningDetails(null);
} }
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {

View File

@@ -19,7 +19,10 @@ import { simpleQuery } from '../../../providers/simple-query-service.js';
import * as secureFs from '../../../lib/secure-fs.js'; import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path'; import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile'); const logger = createLogger('DescribeFile');
@@ -130,11 +133,12 @@ export function createDescribeFileHandler(
// Get the filename for context // Get the filename for context
const fileName = path.basename(resolvedPath); const fileName = path.basename(resolvedPath);
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[DescribeFile]');
// Build prompt with file content passed as structured data // Build prompt with file content passed as structured data
// The file content is included directly, not via tool invocation // The file content is included directly, not via tool invocation
const prompt = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project"). const prompt = `${prompts.contextDescription.describeFilePrompt}
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
File: ${fileName}${truncated ? ' (truncated)' : ''} File: ${fileName}${truncated ? ' (truncated)' : ''}

View File

@@ -19,7 +19,10 @@ import { simpleQuery } from '../../../providers/simple-query-service.js';
import * as secureFs from '../../../lib/secure-fs.js'; import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path'; import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage'); const logger = createLogger('DescribeImage');
@@ -278,12 +281,11 @@ export function createDescribeImageHandler(
logger.info(`[${requestId}] Using model: ${model}`); logger.info(`[${requestId}] Using model: ${model}`);
// Build the instruction text // Get customized prompts from settings
const instructionText = const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
`Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` +
`Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` + // Build the instruction text from centralized prompts
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` + const instructionText = prompts.contextDescription.describeImagePrompt;
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
// Build prompt based on provider capability // Build prompt based on provider capability
// Some providers (like Cursor) may not support image content blocks // Some providers (like Cursor) may not support image content blocks

View File

@@ -0,0 +1,19 @@
/**
* Common utilities for event history routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for event history operations */
export const logger = createLogger('EventHistory');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,68 @@
/**
* Event History routes - HTTP API for event history management
*
* Provides endpoints for:
* - Listing events with filtering
* - Getting individual event details
* - Deleting events
* - Clearing all events
* - Replaying events to test hooks
*
* Mounted at /api/event-history in the main server.
*/
import { Router } from 'express';
import type { EventHistoryService } from '../../services/event-history-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js';
import { createDeleteHandler } from './routes/delete.js';
import { createClearHandler } from './routes/clear.js';
import { createReplayHandler } from './routes/replay.js';
/**
* Create event history router with all endpoints
*
* Endpoints:
* - POST /list - List events with optional filtering
* - POST /get - Get a single event by ID
* - POST /delete - Delete an event by ID
* - POST /clear - Clear all events for a project
* - POST /replay - Replay an event to trigger hooks
*
* @param eventHistoryService - Instance of EventHistoryService
* @param settingsService - Instance of SettingsService (for replay)
* @returns Express Router configured with all event history endpoints
*/
export function createEventHistoryRoutes(
eventHistoryService: EventHistoryService,
settingsService: SettingsService
): Router {
const router = Router();
// List events with filtering
router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService));
// Get single event
router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService));
// Delete event
router.post(
'/delete',
validatePathParams('projectPath'),
createDeleteHandler(eventHistoryService)
);
// Clear all events
router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService));
// Replay event
router.post(
'/replay',
validatePathParams('projectPath'),
createReplayHandler(eventHistoryService, settingsService)
);
return router;
}

View File

@@ -0,0 +1,33 @@
/**
* POST /api/event-history/clear - Clear all events for a project
*
* Request body: { projectPath: string }
* Response: { success: true, cleared: number }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createClearHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const cleared = await eventHistoryService.clearEvents(projectPath);
res.json({
success: true,
cleared,
});
} catch (error) {
logError(error, 'Clear events failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,43 @@
/**
* POST /api/event-history/delete - Delete an event by ID
*
* Request body: { projectPath: string, eventId: string }
* Response: { success: true } or { success: false, error: string }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId } = req.body as {
projectPath: string;
eventId: string;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
const deleted = await eventHistoryService.deleteEvent(projectPath, eventId);
if (!deleted) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
res.json({ success: true });
} catch (error) {
logError(error, 'Delete event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,46 @@
/**
* POST /api/event-history/get - Get a single event by ID
*
* Request body: { projectPath: string, eventId: string }
* Response: { success: true, event: StoredEvent } or { success: false, error: string }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createGetHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId } = req.body as {
projectPath: string;
eventId: string;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
const event = await eventHistoryService.getEvent(projectPath, eventId);
if (!event) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
res.json({
success: true,
event,
});
} catch (error) {
logError(error, 'Get event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,53 @@
/**
* POST /api/event-history/list - List events for a project
*
* Request body: {
* projectPath: string,
* filter?: {
* trigger?: EventHookTrigger,
* featureId?: string,
* since?: string,
* until?: string,
* limit?: number,
* offset?: number
* }
* }
* Response: { success: true, events: StoredEventSummary[], total: number }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import type { EventHistoryFilter } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createListHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, filter } = req.body as {
projectPath: string;
filter?: EventHistoryFilter;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const events = await eventHistoryService.getEvents(projectPath, filter);
const total = await eventHistoryService.getEventCount(projectPath, {
...filter,
limit: undefined,
offset: undefined,
});
res.json({
success: true,
events,
total,
});
} catch (error) {
logError(error, 'List events failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,234 @@
/**
* POST /api/event-history/replay - Replay an event to trigger hooks
*
* Request body: {
* projectPath: string,
* eventId: string,
* hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks)
* }
* Response: { success: true, result: EventReplayResult }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError, logger } from '../common.js';
const execAsync = promisify(exec);
/** Default timeout for shell commands (30 seconds) */
const DEFAULT_SHELL_TIMEOUT = 30000;
/** Default timeout for HTTP requests (10 seconds) */
const DEFAULT_HTTP_TIMEOUT = 10000;
interface HookContext {
featureId?: string;
featureName?: string;
projectPath?: string;
projectName?: string;
error?: string;
errorType?: string;
timestamp: string;
eventType: string;
}
/**
* Substitute {{variable}} placeholders in a string
*/
function substituteVariables(template: string, context: HookContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
const value = context[variable as keyof HookContext];
if (value === undefined || value === null) {
return '';
}
return String(value);
});
}
/**
* Execute a single hook and return the result
*/
async function executeHook(hook: EventHook, context: HookContext): Promise<EventReplayHookResult> {
const hookName = hook.name || hook.id;
const startTime = Date.now();
try {
if (hook.action.type === 'shell') {
const command = substituteVariables(hook.action.command, context);
const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT;
logger.info(`Replaying shell hook "${hookName}": ${command}`);
await execAsync(command, {
timeout,
maxBuffer: 1024 * 1024,
});
return {
hookId: hook.id,
hookName: hook.name,
success: true,
durationMs: Date.now() - startTime,
};
} else if (hook.action.type === 'http') {
const url = substituteVariables(hook.action.url, context);
const method = hook.action.method || 'POST';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (hook.action.headers) {
for (const [key, value] of Object.entries(hook.action.headers)) {
headers[key] = substituteVariables(value, context);
}
}
let body: string | undefined;
if (hook.action.body) {
body = substituteVariables(hook.action.body, context);
} else if (method !== 'GET') {
body = JSON.stringify({
eventType: context.eventType,
timestamp: context.timestamp,
featureId: context.featureId,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
});
}
logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
const response = await fetch(url, {
method,
headers,
body: method !== 'GET' ? body : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: `HTTP ${response.status}: ${response.statusText}`,
durationMs: Date.now() - startTime,
};
}
return {
hookId: hook.id,
hookName: hook.name,
success: true,
durationMs: Date.now() - startTime,
};
}
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: 'Unknown hook action type',
durationMs: Date.now() - startTime,
};
} catch (error) {
const errorMessage =
error instanceof Error
? error.name === 'AbortError'
? 'Request timed out'
: error.message
: String(error);
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: errorMessage,
durationMs: Date.now() - startTime,
};
}
}
export function createReplayHandler(
eventHistoryService: EventHistoryService,
settingsService: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId, hookIds } = req.body as {
projectPath: string;
eventId: string;
hookIds?: string[];
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
// Get the event
const event = await eventHistoryService.getEvent(projectPath, eventId);
if (!event) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
// Get hooks from settings
const settings = await settingsService.getGlobalSettings();
let hooks = settings.eventHooks || [];
// Filter to matching trigger and enabled hooks
hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger);
// If specific hook IDs requested, filter to those
if (hookIds && hookIds.length > 0) {
hooks = hooks.filter((h) => hookIds.includes(h.id));
}
// Build context for variable substitution
const context: HookContext = {
featureId: event.featureId,
featureName: event.featureName,
projectPath: event.projectPath,
projectName: event.projectName,
error: event.error,
errorType: event.errorType,
timestamp: event.timestamp,
eventType: event.trigger,
};
// Execute all hooks in parallel
const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context)));
const result: EventReplayResult = {
eventId,
hooksTriggered: hooks.length,
hookResults,
};
logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`);
res.json({
success: true,
result,
});
} catch (error) {
logError(error, 'Replay event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -4,6 +4,8 @@
import { Router } from 'express'; import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js'; import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js'; import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js'; import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js'; import { createGetHandler } from './routes/get.js';
@@ -15,12 +17,20 @@ import { createDeleteHandler } from './routes/delete.js';
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js'; import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
import { createGenerateTitleHandler } from './routes/generate-title.js'; import { createGenerateTitleHandler } from './routes/generate-title.js';
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { export function createFeaturesRoutes(
featureLoader: FeatureLoader,
settingsService?: SettingsService,
events?: EventEmitter
): Router {
const router = Router(); const router = Router();
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader)); router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader)); router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader)); router.post(
'/create',
validatePathParams('projectPath'),
createCreateHandler(featureLoader, events)
);
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader)); router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
router.post( router.post(
'/bulk-update', '/bulk-update',
@@ -35,7 +45,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
router.post('/agent-output', createAgentOutputHandler(featureLoader)); router.post('/agent-output', createAgentOutputHandler(featureLoader));
router.post('/raw-output', createRawOutputHandler(featureLoader)); router.post('/raw-output', createRawOutputHandler(featureLoader));
router.post('/generate-title', createGenerateTitleHandler()); router.post('/generate-title', createGenerateTitleHandler(settingsService));
return router; return router;
} }

View File

@@ -30,19 +30,27 @@ export function createBulkDeleteHandler(featureLoader: FeatureLoader) {
return; return;
} }
const results = await Promise.all( // Process in parallel batches of 20 for efficiency
featureIds.map(async (featureId) => { const BATCH_SIZE = 20;
const success = await featureLoader.delete(projectPath, featureId); const results: BulkDeleteResult[] = [];
if (success) {
return { featureId, success: true }; for (let i = 0; i < featureIds.length; i += BATCH_SIZE) {
} const batch = featureIds.slice(i, i + BATCH_SIZE);
return { const batchResults = await Promise.all(
featureId, batch.map(async (featureId) => {
success: false, const success = await featureLoader.delete(projectPath, featureId);
error: 'Deletion failed. Check server logs for details.', if (success) {
}; return { featureId, success: true };
}) }
); return {
featureId,
success: false,
error: 'Deletion failed. Check server logs for details.',
};
})
);
results.push(...batchResults);
}
const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0); const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0);
const failureCount = results.length - successCount; const failureCount = results.length - successCount;

View File

@@ -43,17 +43,36 @@ export function createBulkUpdateHandler(featureLoader: FeatureLoader) {
const results: BulkUpdateResult[] = []; const results: BulkUpdateResult[] = [];
const updatedFeatures: Feature[] = []; const updatedFeatures: Feature[] = [];
for (const featureId of featureIds) { // Process in parallel batches of 20 for efficiency
try { const BATCH_SIZE = 20;
const updated = await featureLoader.update(projectPath, featureId, updates); for (let i = 0; i < featureIds.length; i += BATCH_SIZE) {
results.push({ featureId, success: true }); const batch = featureIds.slice(i, i + BATCH_SIZE);
updatedFeatures.push(updated); const batchResults = await Promise.all(
} catch (error) { batch.map(async (featureId) => {
results.push({ try {
featureId, const updated = await featureLoader.update(projectPath, featureId, updates);
success: false, return { featureId, success: true as const, feature: updated };
error: getErrorMessage(error), } catch (error) {
}); return {
featureId,
success: false as const,
error: getErrorMessage(error),
};
}
})
);
for (const result of batchResults) {
if (result.success) {
results.push({ featureId: result.featureId, success: true });
updatedFeatures.push(result.feature);
} else {
results.push({
featureId: result.featureId,
success: false,
error: result.error,
});
}
} }
} }

View File

@@ -4,10 +4,11 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js'; import { FeatureLoader } from '../../../services/feature-loader.js';
import type { EventEmitter } from '../../../lib/events.js';
import type { Feature } from '@automaker/types'; import type { Feature } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
export function createCreateHandler(featureLoader: FeatureLoader) { export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, feature } = req.body as { const { projectPath, feature } = req.body as {
@@ -23,7 +24,30 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
return; return;
} }
// Check for duplicate title if title is provided
if (feature.title && feature.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${feature.title}" already exists`,
duplicateFeatureId: duplicate.id,
});
return;
}
}
const created = await featureLoader.create(projectPath, feature); const created = await featureLoader.create(projectPath, feature);
// Emit feature_created event for hooks
if (events) {
events.emit('feature:created', {
featureId: created.id,
featureName: created.name,
projectPath,
});
}
res.json({ success: true, feature: created }); res.json({ success: true, feature: created });
} catch (error) { } catch (error) {
logError(error, 'Create feature failed'); logError(error, 'Create feature failed');

View File

@@ -9,6 +9,8 @@ import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js'; import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateTitle'); const logger = createLogger('GenerateTitle');
@@ -26,16 +28,9 @@ interface GenerateTitleErrorResponse {
error: string; error: string;
} }
const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description. export function createGenerateTitleHandler(
settingsService?: SettingsService
Rules: ): (req: Request, res: Response) => Promise<void> {
- Output ONLY the title, nothing else
- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation")
- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.)
- No quotes, periods, or extra formatting
- Capture the essence of the feature in a scannable way`;
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { description } = req.body as GenerateTitleRequestBody; const { description } = req.body as GenerateTitleRequestBody;
@@ -61,11 +56,15 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`); logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`);
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
const systemPrompt = prompts.titleGeneration.systemPrompt;
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
// Use simpleQuery - provider abstraction handles all the streaming/extraction // Use simpleQuery - provider abstraction handles all the streaming/extraction
const result = await simpleQuery({ const result = await simpleQuery({
prompt: `${SYSTEM_PROMPT}\n\n${userPrompt}`, prompt: `${systemPrompt}\n\n${userPrompt}`,
model: CLAUDE_MODEL_MAP.haiku, model: CLAUDE_MODEL_MAP.haiku,
cwd: process.cwd(), cwd: process.cwd(),
maxTurns: 1, maxTurns: 1,

View File

@@ -4,8 +4,14 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js'; import { FeatureLoader } from '../../../services/feature-loader.js';
import type { Feature } from '@automaker/types'; import type { Feature, FeatureStatus } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('features/update');
// Statuses that should trigger syncing to app_spec.txt
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
export function createUpdateHandler(featureLoader: FeatureLoader) { export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
return; return;
} }
// Check for duplicate title if title is being updated
if (updates.title && updates.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(
projectPath,
updates.title,
featureId // Exclude the current feature from duplicate check
);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${updates.title}" already exists`,
duplicateFeatureId: duplicate.id,
});
return;
}
}
// Get the current feature to detect status changes
const currentFeature = await featureLoader.get(projectPath, featureId);
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
const newStatus = updates.status as FeatureStatus | undefined;
const updated = await featureLoader.update( const updated = await featureLoader.update(
projectPath, projectPath,
featureId, featureId,
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
enhancementMode, enhancementMode,
preEnhancementDescription preEnhancementDescription
); );
// Trigger sync to app_spec.txt when status changes to verified or completed
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
try {
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
if (synced) {
logger.info(
`Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}`
);
}
} catch (syncError) {
// Log the sync error but don't fail the update operation
logger.error(`Failed to sync feature to app_spec.txt:`, syncError);
}
}
res.json({ success: true, feature: updated }); res.json({ success: true, feature: updated });
} catch (error) { } catch (error) {
logError(error, 'Update feature failed'); logError(error, 'Update feature failed');

View File

@@ -30,11 +30,11 @@ import { writeValidation } from '../../../lib/validation-storage.js';
import { streamingQuery } from '../../../providers/simple-query-service.js'; import { streamingQuery } from '../../../providers/simple-query-service.js';
import { import {
issueValidationSchema, issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
buildValidationPrompt, buildValidationPrompt,
ValidationComment, ValidationComment,
ValidationLinkedPR, ValidationLinkedPR,
} from './validation-schema.js'; } from './validation-schema.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import { import {
trySetValidationRunning, trySetValidationRunning,
clearValidationStatus, clearValidationStatus,
@@ -117,13 +117,17 @@ async function runValidation(
let responseText = ''; let responseText = '';
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't) // Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model); const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions // Build the final prompt - for Cursor, include system prompt and JSON schema instructions
let finalPrompt = basePrompt; let finalPrompt = basePrompt;
if (!useStructuredOutput) { if (!useStructuredOutput) {
finalPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT} finalPrompt = `${issueValidationSystemPrompt}
CRITICAL INSTRUCTIONS: CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only. 1. DO NOT write any files. Return the JSON in your response only.
@@ -167,7 +171,7 @@ ${basePrompt}`;
prompt: finalPrompt, prompt: finalPrompt,
model: model as string, model: model as string,
cwd: projectPath, cwd: projectPath,
systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined, systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined,
abortController, abortController,
thinkingLevel: effectiveThinkingLevel, thinkingLevel: effectiveThinkingLevel,
reasoningEffort: effectiveReasoningEffort, reasoningEffort: effectiveReasoningEffort,

View File

@@ -1,8 +1,11 @@
/** /**
* Issue Validation Schema and System Prompt * Issue Validation Schema and Prompt Building
* *
* Defines the JSON schema for Claude's structured output and * Defines the JSON schema for Claude's structured output and
* the system prompt that guides the validation process. * helper functions for building validation prompts.
*
* Note: The system prompt is now centralized in @automaker/prompts
* and accessed via getPromptCustomization() in validate-issue.ts
*/ */
/** /**
@@ -82,76 +85,6 @@ export const issueValidationSchema = {
additionalProperties: false, additionalProperties: false,
} as const; } as const;
/**
* System prompt that guides Claude in validating GitHub issues.
* Instructs the model to use read-only tools to analyze the codebase.
*/
export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase.
Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase.
## Validation Process
1. **Read the issue carefully** - Understand what is being reported or requested
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
4. **Check linked PRs** - If there are linked pull requests, use \`gh pr diff <PR_NUMBER>\` to review the changes
5. **Form your verdict** - Based on your analysis, determine if the issue is valid
## Verdicts
- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable.
- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior.
- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field.
## For Bug Reports, Check:
- Do the referenced files/components exist?
- Does the code match what the issue describes?
- Is the described behavior actually a bug or expected?
- Can you locate the code that would cause the reported issue?
## For Feature Requests, Check:
- Does the feature already exist?
- Is the implementation location clear?
- Is the request technically feasible given the codebase structure?
## Analyzing Linked Pull Requests
When an issue has linked PRs (especially open ones), you MUST analyze them:
1. **Run \`gh pr diff <PR_NUMBER>\`** to see what changes the PR makes
2. **Run \`gh pr view <PR_NUMBER>\`** to see PR description and status
3. **Evaluate if the PR fixes the issue** - Does the diff address the reported problem?
4. **Provide a recommendation**:
- \`wait_for_merge\`: The PR appears to fix the issue correctly. No additional work needed - just wait for it to be merged.
- \`pr_needs_work\`: The PR attempts to fix the issue but is incomplete or has problems.
- \`no_pr\`: No relevant PR exists for this issue.
5. **Include prAnalysis in your response** with:
- hasOpenPR: true/false
- prFixesIssue: true/false (based on diff analysis)
- prNumber: the PR number you analyzed
- prSummary: brief description of what the PR changes
- recommendation: one of the above values
## Response Guidelines
- **Always include relatedFiles** when you find relevant code
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
- **Include prAnalysis** when there are linked PRs - this is critical for avoiding duplicate work
- **Set estimatedComplexity** to help prioritize:
- trivial: Simple text changes, one-line fixes
- simple: Small changes to one file
- moderate: Changes to multiple files or moderate logic changes
- complex: Significant refactoring or new feature implementation
- very_complex: Major architectural changes or cross-cutting concerns
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
/** /**
* Comment data structure for validation prompt * Comment data structure for validation prompt
*/ */

View File

@@ -9,12 +9,14 @@ import type { Request, Response } from 'express';
export interface EnvironmentResponse { export interface EnvironmentResponse {
isContainerized: boolean; isContainerized: boolean;
skipSandboxWarning?: boolean;
} }
export function createEnvironmentHandler() { export function createEnvironmentHandler() {
return (_req: Request, res: Response): void => { return (_req: Request, res: Response): void => {
res.json({ res.json({
isContainerized: process.env.IS_CONTAINERIZED === 'true', isContainerized: process.env.IS_CONTAINERIZED === 'true',
skipSandboxWarning: process.env.AUTOMAKER_SKIP_SANDBOX_WARNING === 'true',
} satisfies EnvironmentResponse); } satisfies EnvironmentResponse);
}; };
} }

View File

@@ -0,0 +1,21 @@
/**
* Common utilities for notification routes
*
* Provides logger and error handling utilities shared across all notification endpoints.
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for notification-related operations */
export const logger = createLogger('Notifications');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,62 @@
/**
* Notifications routes - HTTP API for project-level notifications
*
* Provides endpoints for:
* - Listing notifications
* - Getting unread count
* - Marking notifications as read
* - Dismissing notifications
*
* All endpoints use handler factories that receive the NotificationService instance.
* Mounted at /api/notifications in the main server.
*/
import { Router } from 'express';
import type { NotificationService } from '../../services/notification-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createUnreadCountHandler } from './routes/unread-count.js';
import { createMarkReadHandler } from './routes/mark-read.js';
import { createDismissHandler } from './routes/dismiss.js';
/**
* Create notifications router with all endpoints
*
* Endpoints:
* - POST /list - List all notifications for a project
* - POST /unread-count - Get unread notification count
* - POST /mark-read - Mark notification(s) as read
* - POST /dismiss - Dismiss notification(s)
*
* @param notificationService - Instance of NotificationService
* @returns Express Router configured with all notification endpoints
*/
export function createNotificationsRoutes(notificationService: NotificationService): Router {
const router = Router();
// List notifications
router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService));
// Get unread count
router.post(
'/unread-count',
validatePathParams('projectPath'),
createUnreadCountHandler(notificationService)
);
// Mark as read (single or all)
router.post(
'/mark-read',
validatePathParams('projectPath'),
createMarkReadHandler(notificationService)
);
// Dismiss (single or all)
router.post(
'/dismiss',
validatePathParams('projectPath'),
createDismissHandler(notificationService)
);
return router;
}

View File

@@ -0,0 +1,53 @@
/**
* POST /api/notifications/dismiss - Dismiss notification(s)
*
* Request body: { projectPath: string, notificationId?: string }
* - If notificationId provided: dismisses that notification
* - If notificationId not provided: dismisses all notifications
*
* Response: { success: true, dismissed: boolean | count: number }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/dismiss
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createDismissHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, notificationId } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If notificationId provided, dismiss single notification
if (notificationId) {
const dismissed = await notificationService.dismissNotification(
projectPath,
notificationId
);
if (!dismissed) {
res.status(404).json({ success: false, error: 'Notification not found' });
return;
}
res.json({ success: true, dismissed: true });
return;
}
// Otherwise dismiss all
const count = await notificationService.dismissAll(projectPath);
res.json({ success: true, count });
} catch (error) {
logError(error, 'Dismiss failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* POST /api/notifications/list - List all notifications for a project
*
* Request body: { projectPath: string }
* Response: { success: true, notifications: Notification[] }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/list
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createListHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const notifications = await notificationService.getNotifications(projectPath);
res.json({
success: true,
notifications,
});
} catch (error) {
logError(error, 'List notifications failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,50 @@
/**
* POST /api/notifications/mark-read - Mark notification(s) as read
*
* Request body: { projectPath: string, notificationId?: string }
* - If notificationId provided: marks that notification as read
* - If notificationId not provided: marks all notifications as read
*
* Response: { success: true, count?: number, notification?: Notification }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/mark-read
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createMarkReadHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, notificationId } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If notificationId provided, mark single notification
if (notificationId) {
const notification = await notificationService.markAsRead(projectPath, notificationId);
if (!notification) {
res.status(404).json({ success: false, error: 'Notification not found' });
return;
}
res.json({ success: true, notification });
return;
}
// Otherwise mark all as read
const count = await notificationService.markAllAsRead(projectPath);
res.json({ success: true, count });
} catch (error) {
logError(error, 'Mark read failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* POST /api/notifications/unread-count - Get unread notification count
*
* Request body: { projectPath: string }
* Response: { success: true, count: number }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/unread-count
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createUnreadCountHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const count = await notificationService.getUnreadCount(projectPath);
res.json({
success: true,
count,
});
} catch (error) {
logError(error, 'Get unread count failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -4,12 +4,58 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js'; import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js';
import { getAllRunningGenerations } from '../../app-spec/common.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
export function createIndexHandler(autoModeService: AutoModeService) { export function createIndexHandler(autoModeService: AutoModeService) {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
try { try {
const runningAgents = await autoModeService.getRunningAgents(); const runningAgents = [...(await autoModeService.getRunningAgents())];
const backlogPlanStatus = getBacklogPlanStatus();
const backlogPlanDetails = getRunningDetails();
if (backlogPlanStatus.isRunning && backlogPlanDetails) {
runningAgents.push({
featureId: `backlog-plan:${backlogPlanDetails.projectPath}`,
projectPath: backlogPlanDetails.projectPath,
projectName: path.basename(backlogPlanDetails.projectPath),
isAutoMode: false,
title: 'Backlog plan',
description: backlogPlanDetails.prompt,
});
}
// Add spec/feature generation tasks
const specGenerations = getAllRunningGenerations();
for (const generation of specGenerations) {
let title: string;
let description: string;
switch (generation.type) {
case 'feature_generation':
title = 'Generating features from spec';
description = 'Creating features from the project specification';
break;
case 'sync':
title = 'Syncing spec with code';
description = 'Updating spec from codebase and completed features';
break;
default:
title = 'Regenerating spec';
description = 'Analyzing project and generating specification';
}
runningAgents.push({
featureId: `spec-generation:${generation.projectPath}`,
projectPath: generation.projectPath,
projectName: path.basename(generation.projectPath),
isAutoMode: false,
title,
description,
});
}
res.json({ res.json({
success: true, success: true,

View File

@@ -12,6 +12,18 @@ import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import type { GlobalSettings } from '../../../types/settings.js'; import type { GlobalSettings } from '../../../types/settings.js';
import { getErrorMessage, logError, logger } from '../common.js'; import { getErrorMessage, logError, logger } from '../common.js';
import { setLogLevel, LogLevel } from '@automaker/utils';
import { setRequestLoggingEnabled } from '../../../index.js';
/**
* Map server log level string to LogLevel enum
*/
const LOG_LEVEL_MAP: Record<string, LogLevel> = {
error: LogLevel.ERROR,
warn: LogLevel.WARN,
info: LogLevel.INFO,
debug: LogLevel.DEBUG,
};
/** /**
* Create handler factory for PUT /api/settings/global * Create handler factory for PUT /api/settings/global
@@ -46,6 +58,23 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
const settings = await settingsService.updateGlobalSettings(updates); const settings = await settingsService.updateGlobalSettings(updates);
// Apply server log level if it was updated
if ('serverLogLevel' in updates && updates.serverLogLevel) {
const level = LOG_LEVEL_MAP[updates.serverLogLevel];
if (level !== undefined) {
setLogLevel(level);
logger.info(`Server log level changed to: ${updates.serverLogLevel}`);
}
}
// Apply request logging setting if it was updated
if ('enableRequestLogging' in updates && typeof updates.enableRequestLogging === 'boolean') {
setRequestLoggingEnabled(updates.enableRequestLogging);
logger.info(
`HTTP request logging ${updates.enableRequestLogging ? 'enabled' : 'disabled'}`
);
}
res.json({ res.json({
success: true, success: true,
settings, settings,

View File

@@ -15,7 +15,7 @@ import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform'; import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js'; import * as secureFs from '../../lib/secure-fs.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions'); const logger = createLogger('Suggestions');
@@ -137,11 +137,15 @@ export async function generateSuggestions(
modelOverride?: string, modelOverride?: string,
thinkingLevelOverride?: ThinkingLevel thinkingLevelOverride?: ThinkingLevel
): Promise<void> { ): Promise<void> {
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[Suggestions]');
// Map suggestion types to their prompts
const typePrompts: Record<string, string> = { const typePrompts: Record<string, string> = {
features: 'Analyze this project and suggest new features that would add value.', features: prompts.suggestions.featuresPrompt,
refactoring: 'Analyze this project and identify refactoring opportunities.', refactoring: prompts.suggestions.refactoringPrompt,
security: 'Analyze this project for security vulnerabilities and suggest fixes.', security: prompts.suggestions.securityPrompt,
performance: 'Analyze this project for performance issues and suggest optimizations.', performance: prompts.suggestions.performancePrompt,
}; };
// Load existing context to avoid duplicates // Load existing context to avoid duplicates
@@ -151,15 +155,7 @@ export async function generateSuggestions(
${existingContext} ${existingContext}
${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''} ${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''}
Look at the codebase and provide 3-5 concrete suggestions. ${prompts.suggestions.baseTemplate}`;
For each suggestion, provide:
1. A category (e.g., "User Experience", "Security", "Performance")
2. A clear description of what to implement
3. Priority (1=high, 2=medium, 3=low)
4. Brief reasoning for why this would help
The response will be automatically formatted as structured JSON.`;
// Don't send initial message - let the agent output speak for itself // Don't send initial message - let the agent output speak for itself
// The first agent message will be captured as an info entry // The first agent message will be captured as an info entry

View File

@@ -34,6 +34,7 @@ export function createGetDevServerLogsHandler() {
result: { result: {
worktreePath: result.result.worktreePath, worktreePath: result.result.worktreePath,
port: result.result.port, port: result.result.port,
url: result.result.url,
logs: result.result.logs, logs: result.result.logs,
startedAt: result.result.startedAt, startedAt: result.result.startedAt,
}, },

View File

@@ -16,10 +16,27 @@ import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js'; import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import {
checkGitHubRemote,
type GitHubRemoteStatus,
} from '../../github/routes/check-github-remote.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const logger = createLogger('Worktree'); const logger = createLogger('Worktree');
/**
* Cache for GitHub remote status per project path.
* This prevents repeated "no git remotes found" warnings when polling
* projects that don't have a GitHub remote configured.
*/
interface GitHubRemoteCacheEntry {
status: GitHubRemoteStatus;
checkedAt: number;
}
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface WorktreeInfo { interface WorktreeInfo {
path: string; path: string;
branch: string; branch: string;
@@ -121,23 +138,63 @@ async function scanWorktreesDirectory(
return discovered; return discovered;
} }
/**
* Get cached GitHub remote status for a project, or check and cache it.
* Returns null if gh CLI is not available.
*/
async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteStatus | null> {
// Check if gh CLI is available first
const ghAvailable = await isGhCliAvailable();
if (!ghAvailable) {
return null;
}
const now = Date.now();
const cached = githubRemoteCache.get(projectPath);
// Return cached result if still valid
if (cached && now - cached.checkedAt < GITHUB_REMOTE_CACHE_TTL_MS) {
return cached.status;
}
// Check GitHub remote and cache the result
const status = await checkGitHubRemote(projectPath);
githubRemoteCache.set(projectPath, {
status,
checkedAt: Date.now(),
});
return status;
}
/** /**
* Fetch open PRs from GitHub and create a map of branch name to PR info. * Fetch open PRs from GitHub and create a map of branch name to PR info.
* This allows detecting PRs that were created outside the app. * This allows detecting PRs that were created outside the app.
*
* Uses cached GitHub remote status to avoid repeated warnings when the
* project doesn't have a GitHub remote configured.
*/ */
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> { async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
const prMap = new Map<string, WorktreePRInfo>(); const prMap = new Map<string, WorktreePRInfo>();
try { try {
// Check if gh CLI is available // Check GitHub remote status (uses cache to avoid repeated warnings)
const ghAvailable = await isGhCliAvailable(); const remoteStatus = await getGitHubRemoteStatus(projectPath);
if (!ghAvailable) {
// If gh CLI not available or no GitHub remote, return empty silently
if (!remoteStatus || !remoteStatus.hasGitHubRemote) {
return prMap; return prMap;
} }
// Use -R flag with owner/repo for more reliable PR fetching
const repoFlag =
remoteStatus.owner && remoteStatus.repo
? `-R ${remoteStatus.owner}/${remoteStatus.repo}`
: '';
// Fetch open PRs from GitHub // Fetch open PRs from GitHub
const { stdout } = await execAsync( const { stdout } = await execAsync(
'gh pr list --state open --json number,title,url,state,headRefName,createdAt --limit 1000', `gh pr list ${repoFlag} --state open --json number,title,url,state,headRefName,createdAt --limit 1000`,
{ cwd: projectPath, env: execEnv, timeout: 15000 } { cwd: projectPath, env: execEnv, timeout: 15000 }
); );
@@ -170,9 +227,10 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
export function createListHandler() { export function createListHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, includeDetails } = req.body as { const { projectPath, includeDetails, forceRefreshGitHub } = req.body as {
projectPath: string; projectPath: string;
includeDetails?: boolean; includeDetails?: boolean;
forceRefreshGitHub?: boolean;
}; };
if (!projectPath) { if (!projectPath) {
@@ -180,6 +238,12 @@ export function createListHandler() {
return; return;
} }
// Clear GitHub remote cache if force refresh requested
// This allows users to re-check for GitHub remote after adding one
if (forceRefreshGitHub) {
githubRemoteCache.delete(projectPath);
}
if (!(await isGitRepo(projectPath))) { if (!(await isGitRepo(projectPath))) {
res.json({ success: true, worktrees: [] }); res.json({ success: true, worktrees: [] });
return; return;

View File

@@ -29,6 +29,10 @@ import {
appendLearning, appendLearning,
recordMemoryUsage, recordMemoryUsage,
createLogger, createLogger,
atomicWriteJson,
readJsonWithRecovery,
logRecoveryWarning,
DEFAULT_BACKUP_COUNT,
} from '@automaker/utils'; } from '@automaker/utils';
const logger = createLogger('AutoMode'); const logger = createLogger('AutoMode');
@@ -60,6 +64,7 @@ import {
getMCPServersFromSettings, getMCPServersFromSettings,
getPromptCustomization, getPromptCustomization,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
import { getNotificationService } from './notification-service.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -386,6 +391,7 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_error', { this.emitAutoModeEvent('auto_mode_error', {
error: errorInfo.message, error: errorInfo.message,
errorType: errorInfo.type, errorType: errorInfo.type,
projectPath,
}); });
}); });
} }
@@ -579,6 +585,9 @@ export class AutoModeService {
'[AutoMode]' '[AutoMode]'
); );
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
// Build the prompt - use continuation prompt if provided (for recovery after plan approval) // Build the prompt - use continuation prompt if provided (for recovery after plan approval)
let prompt: string; let prompt: string;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
@@ -604,7 +613,7 @@ export class AutoModeService {
logger.info(`Using continuation prompt for feature ${featureId}`); logger.info(`Using continuation prompt for feature ${featureId}`);
} else { } else {
// Normal flow: build prompt with planning phase // Normal flow: build prompt with planning phase
const featurePrompt = this.buildFeaturePrompt(feature); const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
const planningPrefix = await this.getPlanningPromptPrefix(feature); const planningPrefix = await this.getPlanningPromptPrefix(feature);
prompt = planningPrefix + featurePrompt; prompt = planningPrefix + featurePrompt;
@@ -783,6 +792,9 @@ export class AutoModeService {
): Promise<void> { ): Promise<void> {
logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`); logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
// Load context files once with feature context for smart memory selection // Load context files once with feature context for smart memory selection
const contextResult = await loadContextFiles({ const contextResult = await loadContextFiles({
projectPath, projectPath,
@@ -827,7 +839,12 @@ export class AutoModeService {
}); });
// Build prompt for this pipeline step // Build prompt for this pipeline step
const prompt = this.buildPipelineStepPrompt(step, feature, previousContext); const prompt = this.buildPipelineStepPrompt(
step,
feature,
previousContext,
prompts.taskExecution
);
// Get model from feature // Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
@@ -882,14 +899,18 @@ export class AutoModeService {
private buildPipelineStepPrompt( private buildPipelineStepPrompt(
step: PipelineStep, step: PipelineStep,
feature: Feature, feature: Feature,
previousContext: string previousContext: string,
taskExecutionPrompts: {
implementationInstructions: string;
playwrightVerificationInstructions: string;
}
): string { ): string {
let prompt = `## Pipeline Step: ${step.name} let prompt = `## Pipeline Step: ${step.name}
This is an automated pipeline step following the initial feature implementation. This is an automated pipeline step following the initial feature implementation.
### Feature Context ### Feature Context
${this.buildFeaturePrompt(feature)} ${this.buildFeaturePrompt(feature, taskExecutionPrompts)}
`; `;
@@ -1279,6 +1300,9 @@ Complete the pipeline step instructions above. Review the previous work and appl
'[AutoMode]' '[AutoMode]'
); );
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextResult = await loadContextFiles({ const contextResult = await loadContextFiles({
projectPath, projectPath,
@@ -1296,7 +1320,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
// Build complete prompt with feature info, previous context, and follow-up instructions // Build complete prompt with feature info, previous context, and follow-up instructions
let fullPrompt = `## Follow-up on Feature Implementation let fullPrompt = `## Follow-up on Feature Implementation
${feature ? this.buildFeaturePrompt(feature) : `**Feature ID:** ${featureId}`} ${feature ? this.buildFeaturePrompt(feature, prompts.taskExecution) : `**Feature ID:** ${featureId}`}
`; `;
if (previousContext) { if (previousContext) {
@@ -1396,13 +1420,13 @@ Address the follow-up instructions above. Review the previous work and make the
allImagePaths.push(...allPaths); allImagePaths.push(...allPaths);
} }
// Save updated feature.json with new images // Save updated feature.json with new images (atomic write with backup)
if (copiedImagePaths.length > 0 && feature) { if (copiedImagePaths.length > 0 && feature) {
const featureDirForSave = getFeatureDir(projectPath, featureId); const featureDirForSave = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDirForSave, 'feature.json'); const featurePath = path.join(featureDirForSave, 'feature.json');
try { try {
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
} catch (error) { } catch (error) {
logger.error(`Failed to save feature.json:`, error); logger.error(`Failed to save feature.json:`, error);
} }
@@ -1529,6 +1553,7 @@ Address the follow-up instructions above. Review the previous work and make the
message: allPassed message: allPassed
? 'All verification checks passed' ? 'All verification checks passed'
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
projectPath,
}); });
return allPassed; return allPassed;
@@ -1602,6 +1627,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId, featureId,
passes: true, passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`, message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath,
}); });
return hash.trim(); return hash.trim();
@@ -1888,13 +1914,17 @@ Format your response as a structured markdown document.`;
content: editedPlan || feature.planSpec.content, content: editedPlan || feature.planSpec.content,
}); });
// Build continuation prompt and re-run the feature // Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
// Build continuation prompt using centralized template
const planContent = editedPlan || feature.planSpec.content || ''; const planContent = editedPlan || feature.planSpec.content || '';
let continuationPrompt = `The plan/specification has been approved. `; let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
if (feedback) { continuationPrompt = continuationPrompt.replace(
continuationPrompt += `\n\nUser feedback: ${feedback}\n\n`; /\{\{userFeedback\}\}/g,
} feedback || ''
continuationPrompt += `Now proceed with the implementation as specified in the plan:\n\n${planContent}\n\nImplement the feature now.`; );
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
logger.info(`Starting recovery execution for feature ${featureId}`); logger.info(`Starting recovery execution for feature ${featureId}`);
@@ -2066,8 +2096,20 @@ Format your response as a structured markdown document.`;
const featurePath = path.join(featureDir, 'feature.json'); const featurePath = path.join(featureDir, 'feature.json');
try { try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; // Use recovery-enabled read for corrupted file handling
const feature = JSON.parse(data); const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
logRecoveryWarning(result, `Feature ${featureId}`, logger);
const feature = result.data;
if (!feature) {
logger.warn(`Feature ${featureId} not found or could not be recovered`);
return;
}
feature.status = status; feature.status = status;
feature.updatedAt = new Date().toISOString(); feature.updatedAt = new Date().toISOString();
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
@@ -2078,9 +2120,41 @@ Format your response as a structured markdown document.`;
// Clear the timestamp when moving to other statuses // Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined; feature.justFinishedAt = undefined;
} }
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch { // Use atomic write with backup support
// Feature file may not exist await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
// Create notifications for important status changes
const notificationService = getNotificationService();
if (status === 'waiting_approval') {
await notificationService.createNotification({
type: 'feature_waiting_approval',
title: 'Feature Ready for Review',
message: `"${feature.name || featureId}" is ready for your review and approval.`,
featureId,
projectPath,
});
} else if (status === 'verified') {
await notificationService.createNotification({
type: 'feature_verified',
title: 'Feature Verified',
message: `"${feature.name || featureId}" has been verified and is complete.`,
featureId,
projectPath,
});
}
// Sync completed/verified features to app_spec.txt
if (status === 'verified' || status === 'completed') {
try {
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
} catch (syncError) {
// Log but don't fail the status update if sync fails
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
}
}
} catch (error) {
logger.error(`Failed to update feature status for ${featureId}:`, error);
} }
} }
@@ -2092,11 +2166,24 @@ Format your response as a structured markdown document.`;
featureId: string, featureId: string,
updates: Partial<PlanSpec> updates: Partial<PlanSpec>
): Promise<void> { ): Promise<void> {
const featurePath = path.join(projectPath, '.automaker', 'features', featureId, 'feature.json'); // Use getFeatureDir helper for consistent path resolution
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try { try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; // Use recovery-enabled read for corrupted file handling
const feature = JSON.parse(data); const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
logRecoveryWarning(result, `Feature ${featureId}`, logger);
const feature = result.data;
if (!feature) {
logger.warn(`Feature ${featureId} not found or could not be recovered`);
return;
}
// Initialize planSpec if it doesn't exist // Initialize planSpec if it doesn't exist
if (!feature.planSpec) { if (!feature.planSpec) {
@@ -2116,7 +2203,9 @@ Format your response as a structured markdown document.`;
} }
feature.updatedAt = new Date().toISOString(); feature.updatedAt = new Date().toISOString();
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
// Use atomic write with backup support
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
} catch (error) { } catch (error) {
logger.error(`Failed to update planSpec for ${featureId}:`, error); logger.error(`Failed to update planSpec for ${featureId}:`, error);
} }
@@ -2133,25 +2222,34 @@ Format your response as a structured markdown document.`;
const allFeatures: Feature[] = []; const allFeatures: Feature[] = [];
const pendingFeatures: Feature[] = []; const pendingFeatures: Feature[] = [];
// Load all features (for dependency checking) // Load all features (for dependency checking) with recovery support
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, 'feature.json'); const featurePath = path.join(featuresDir, entry.name, 'feature.json');
try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
const feature = JSON.parse(data);
allFeatures.push(feature);
// Track pending features separately // Use recovery-enabled read for corrupted file handling
if ( const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
feature.status === 'pending' || maxBackups: DEFAULT_BACKUP_COUNT,
feature.status === 'ready' || autoRestore: true,
feature.status === 'backlog' });
) {
pendingFeatures.push(feature); logRecoveryWarning(result, `Feature ${entry.name}`, logger);
}
} catch { const feature = result.data;
// Skip invalid features if (!feature) {
// Skip features that couldn't be loaded or recovered
continue;
}
allFeatures.push(feature);
// Track pending features separately
if (
feature.status === 'pending' ||
feature.status === 'ready' ||
feature.status === 'backlog'
) {
pendingFeatures.push(feature);
} }
} }
} }
@@ -2225,7 +2323,13 @@ Format your response as a structured markdown document.`;
return planningPrompt + '\n\n---\n\n## Feature Request\n\n'; return planningPrompt + '\n\n---\n\n## Feature Request\n\n';
} }
private buildFeaturePrompt(feature: Feature): string { private buildFeaturePrompt(
feature: Feature,
taskExecutionPrompts: {
implementationInstructions: string;
playwrightVerificationInstructions: string;
}
): string {
const title = this.extractTitleFromDescription(feature.description); const title = this.extractTitleFromDescription(feature.description);
let prompt = `## Feature Implementation Task let prompt = `## Feature Implementation Task
@@ -2267,80 +2371,10 @@ You can use the Read tool to view these images at any time during implementation
// Add verification instructions based on testing mode // Add verification instructions based on testing mode
if (feature.skipTests) { if (feature.skipTests) {
// Manual verification - just implement the feature // Manual verification - just implement the feature
prompt += ` prompt += `\n${taskExecutionPrompts.implementationInstructions}`;
## Instructions
Implement this feature by:
1. First, explore the codebase to understand the existing structure
2. Plan your implementation approach
3. Write the necessary code changes
4. Ensure the code follows existing patterns and conventions
When done, wrap your final summary in <summary> tags like this:
<summary>
## Summary: [Feature Title]
### Changes Implemented
- [List of changes made]
### Files Modified
- [List of files]
### Notes for Developer
- [Any important notes]
</summary>
This helps parse your summary correctly in the output logs.`;
} else { } else {
// Automated testing - implement and verify with Playwright // Automated testing - implement and verify with Playwright
prompt += ` prompt += `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
## Instructions
Implement this feature by:
1. First, explore the codebase to understand the existing structure
2. Plan your implementation approach
3. Write the necessary code changes
4. Ensure the code follows existing patterns and conventions
## Verification with Playwright (REQUIRED)
After implementing the feature, you MUST verify it works correctly using Playwright:
1. **Create a temporary Playwright test** to verify the feature works as expected
2. **Run the test** to confirm the feature is working
3. **Delete the test file** after verification - this is a temporary verification test, not a permanent test suite addition
Example verification workflow:
\`\`\`bash
# Create a simple verification test
npx playwright test my-verification-test.spec.ts
# After successful verification, delete the test
rm my-verification-test.spec.ts
\`\`\`
The test should verify the core functionality of the feature. If the test fails, fix the implementation and re-test.
When done, wrap your final summary in <summary> tags like this:
<summary>
## Summary: [Feature Title]
### Changes Implemented
- [List of changes made]
### Files Modified
- [List of files]
### Verification Status
- [Describe how the feature was verified with Playwright]
### Notes for Developer
- [Any important notes]
</summary>
This helps parse your summary correctly in the output logs.`;
} }
return prompt; return prompt;
@@ -2910,6 +2944,12 @@ After generating the revised spec, output:
`Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}` `Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}`
); );
// Get customized prompts for task execution
const taskPrompts = await getPromptCustomization(
this.settingsService,
'[AutoMode]'
);
// Execute each task with a separate agent // Execute each task with a separate agent
for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) { for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) {
const task = parsedTasks[taskIndex]; const task = parsedTasks[taskIndex];
@@ -2941,6 +2981,7 @@ After generating the revised spec, output:
parsedTasks, parsedTasks,
taskIndex, taskIndex,
approvedPlanContent, approvedPlanContent,
taskPrompts.taskExecution.taskPromptTemplate,
userFeedback userFeedback
); );
@@ -3023,15 +3064,21 @@ After generating the revised spec, output:
`No parsed tasks, using single-agent execution for feature ${featureId}` `No parsed tasks, using single-agent execution for feature ${featureId}`
); );
const continuationPrompt = `The plan/specification has been approved. Now implement it. // Get customized prompts for continuation
${userFeedback ? `\n## User Feedback\n${userFeedback}\n` : ''} const taskPrompts = await getPromptCustomization(
## Approved Plan this.settingsService,
'[AutoMode]'
${approvedPlanContent} );
let continuationPrompt =
## Instructions taskPrompts.taskExecution.continuationAfterApprovalTemplate;
continuationPrompt = continuationPrompt.replace(
Implement all the changes described in the plan above.`; /\{\{userFeedback\}\}/g,
userFeedback || ''
);
continuationPrompt = continuationPrompt.replace(
/\{\{approvedPlan\}\}/g,
approvedPlanContent
);
const continuationStream = provider.executeQuery({ const continuationStream = provider.executeQuery({
prompt: continuationPrompt, prompt: continuationPrompt,
@@ -3151,17 +3198,16 @@ Implement all the changes described in the plan above.`;
throw new Error(`Feature ${featureId} not found`); throw new Error(`Feature ${featureId} not found`);
} }
const prompt = `## Continuing Feature Implementation // Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
${this.buildFeaturePrompt(feature)} // Build the feature prompt
const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
## Previous Context // Use the resume feature template with variable substitution
The following is the output from a previous implementation attempt. Continue from where you left off: let prompt = prompts.taskExecution.resumeFeatureTemplate;
prompt = prompt.replace(/\{\{featurePrompt\}\}/g, featurePrompt);
${context} prompt = prompt.replace(/\{\{previousContext\}\}/g, context);
## Instructions
Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`;
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
continuationPrompt: prompt, continuationPrompt: prompt,
@@ -3282,68 +3328,42 @@ Review the previous work and continue the implementation. If the feature appears
allTasks: ParsedTask[], allTasks: ParsedTask[],
taskIndex: number, taskIndex: number,
planContent: string, planContent: string,
taskPromptTemplate: string,
userFeedback?: string userFeedback?: string
): string { ): string {
const completedTasks = allTasks.slice(0, taskIndex); const completedTasks = allTasks.slice(0, taskIndex);
const remainingTasks = allTasks.slice(taskIndex + 1); const remainingTasks = allTasks.slice(taskIndex + 1);
let prompt = `# Task Execution: ${task.id} // Build completed tasks string
const completedTasksStr =
completedTasks.length > 0
? `### Already Completed (${completedTasks.length} tasks)\n${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')}\n`
: '';
You are executing a specific task as part of a larger feature implementation. // Build remaining tasks string
const remainingTasksStr =
remainingTasks.length > 0
? `### Coming Up Next (${remainingTasks.length} tasks remaining)\n${remainingTasks
.slice(0, 3)
.map((t) => `- [ ] ${t.id}: ${t.description}`)
.join(
'\n'
)}${remainingTasks.length > 3 ? `\n... and ${remainingTasks.length - 3} more tasks` : ''}\n`
: '';
## Your Current Task // Build user feedback string
const userFeedbackStr = userFeedback ? `### User Feedback\n${userFeedback}\n` : '';
**Task ID:** ${task.id} // Use centralized template with variable substitution
**Description:** ${task.description} let prompt = taskPromptTemplate;
${task.filePath ? `**Primary File:** ${task.filePath}` : ''} prompt = prompt.replace(/\{\{taskId\}\}/g, task.id);
${task.phase ? `**Phase:** ${task.phase}` : ''} prompt = prompt.replace(/\{\{taskDescription\}\}/g, task.description);
prompt = prompt.replace(/\{\{taskFilePath\}\}/g, task.filePath || '');
## Context prompt = prompt.replace(/\{\{taskPhase\}\}/g, task.phase || '');
prompt = prompt.replace(/\{\{completedTasks\}\}/g, completedTasksStr);
`; prompt = prompt.replace(/\{\{remainingTasks\}\}/g, remainingTasksStr);
prompt = prompt.replace(/\{\{userFeedback\}\}/g, userFeedbackStr);
// Show what's already done prompt = prompt.replace(/\{\{planContent\}\}/g, planContent);
if (completedTasks.length > 0) {
prompt += `### Already Completed (${completedTasks.length} tasks)
${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')}
`;
}
// Show remaining tasks
if (remainingTasks.length > 0) {
prompt += `### Coming Up Next (${remainingTasks.length} tasks remaining)
${remainingTasks
.slice(0, 3)
.map((t) => `- [ ] ${t.id}: ${t.description}`)
.join('\n')}
${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ''}
`;
}
// Add user feedback if any
if (userFeedback) {
prompt += `### User Feedback
${userFeedback}
`;
}
// Add relevant excerpt from plan (just the task-related part to save context)
prompt += `### Reference: Full Plan
<details>
${planContent}
</details>
## Instructions
1. Focus ONLY on completing task ${task.id}: "${task.description}"
2. Do not work on other tasks
3. Use the existing codebase patterns
4. When done, summarize what you implemented
Begin implementing task ${task.id} now.`;
return prompt; return prompt;
} }
@@ -3461,31 +3481,39 @@ Begin implementing task ${task.id} now.`;
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, 'feature.json'); const featurePath = path.join(featuresDir, entry.name, 'feature.json');
try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
const feature = JSON.parse(data) as Feature;
// Check if feature was interrupted (in_progress or pipeline_*) // Use recovery-enabled read for corrupted file handling
if ( const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
feature.status === 'in_progress' || maxBackups: DEFAULT_BACKUP_COUNT,
(feature.status && feature.status.startsWith('pipeline_')) autoRestore: true,
) { });
// Verify it has existing context (agent-output.md)
const featureDir = getFeatureDir(projectPath, feature.id); logRecoveryWarning(result, `Feature ${entry.name}`, logger);
const contextPath = path.join(featureDir, 'agent-output.md');
try { const feature = result.data;
await secureFs.access(contextPath); if (!feature) {
interruptedFeatures.push(feature); // Skip features that couldn't be loaded or recovered
logger.info( continue;
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}` }
);
} catch { // Check if feature was interrupted (in_progress or pipeline_*)
// No context file, skip this feature - it will be restarted fresh if (
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`); feature.status === 'in_progress' ||
} (feature.status && feature.status.startsWith('pipeline_'))
) {
// Verify it has existing context (agent-output.md)
const featureDir = getFeatureDir(projectPath, feature.id);
const contextPath = path.join(featureDir, 'agent-output.md');
try {
await secureFs.access(contextPath);
interruptedFeatures.push(feature);
logger.info(
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}`
);
} catch {
// No context file, skip this feature - it will be restarted fresh
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`);
} }
} catch {
// Skip invalid features
} }
} }
} }
@@ -3553,32 +3581,13 @@ Begin implementing task ${task.id} now.`;
// Limit output to avoid token limits // Limit output to avoid token limits
const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput; const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput;
const userPrompt = `You are an Architecture Decision Record (ADR) extractor. Analyze this implementation and return ONLY JSON with learnings. No explanations. // Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
Feature: "${feature.title}" // Build user prompt using centralized template with variable substitution
let userPrompt = prompts.taskExecution.learningExtractionUserPromptTemplate;
Implementation log: userPrompt = userPrompt.replace(/\{\{featureTitle\}\}/g, feature.title || '');
${truncatedOutput} userPrompt = userPrompt.replace(/\{\{implementationLog\}\}/g, truncatedOutput);
Extract MEANINGFUL learnings - not obvious things. For each, capture:
- DECISIONS: Why this approach vs alternatives? What would break if changed?
- GOTCHAS: What was unexpected? What's the root cause? How to avoid?
- PATTERNS: Why this pattern? What problem does it solve? Trade-offs?
JSON format ONLY (no markdown, no text):
{"learnings": [{
"category": "architecture|api|ui|database|auth|testing|performance|security|gotchas",
"type": "decision|gotcha|pattern",
"content": "What was done/learned",
"context": "Problem being solved or situation faced",
"why": "Reasoning - why this approach",
"rejected": "Alternative considered and why rejected",
"tradeoffs": "What became easier/harder",
"breaking": "What breaks if this is changed/removed"
}]}
IMPORTANT: Only include NON-OBVIOUS learnings with real reasoning. Skip trivial patterns.
If nothing notable: {"learnings": []}`;
try { try {
// Get model from phase settings // Get model from phase settings
@@ -3612,8 +3621,7 @@ If nothing notable: {"learnings": []}`;
cwd: projectPath, cwd: projectPath,
maxTurns: 1, maxTurns: 1,
allowedTools: [], allowedTools: [],
systemPrompt: systemPrompt: prompts.taskExecution.learningExtractionSystemPrompt,
'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.',
}); });
const responseText = result.text; const responseText = result.text;

View File

@@ -22,6 +22,29 @@ export class ClaudeUsageService {
private timeout = 30000; // 30 second timeout private timeout = 30000; // 30 second timeout
private isWindows = os.platform() === 'win32'; private isWindows = os.platform() === 'win32';
private isLinux = os.platform() === 'linux'; private isLinux = os.platform() === 'linux';
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
// Detect Electron by checking for electron-specific env vars or process properties
// When in Electron, always use winpty to avoid ConPTY's AttachConsole errors
private isElectron =
!!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
/**
* Kill a PTY process with platform-specific handling.
* Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments.
* On Unix-like systems (macOS, Linux), we can specify the signal.
*
* @param ptyProcess - The PTY process to kill
* @param signal - The signal to send on Unix-like systems (default: 'SIGTERM')
*/
private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void {
if (this.isWindows) {
ptyProcess.kill();
} else {
ptyProcess.kill(signal);
}
}
/** /**
* Check if Claude CLI is available on the system * Check if Claude CLI is available on the system
@@ -181,37 +204,94 @@ export class ClaudeUsageService {
? ['/c', 'claude', '--add-dir', workingDirectory] ? ['/c', 'claude', '--add-dir', workingDirectory]
: ['-c', `claude --add-dir "${workingDirectory}"`]; : ['-c', `claude --add-dir "${workingDirectory}"`];
// Using 'any' for ptyProcess because node-pty types don't include 'killed' property
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ptyProcess: any = null; let ptyProcess: any = null;
// Build PTY spawn options
const ptyOptions: pty.IPtyForkOptions = {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
};
// On Windows, always use winpty instead of ConPTY
// ConPTY requires AttachConsole which fails in many contexts:
// - Electron apps without a console
// - VS Code integrated terminal
// - Spawned from other applications
// The error happens in a subprocess so we can't catch it - must proactively disable
if (this.isWindows) {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
logger.info(
'[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)'
);
}
try { try {
ptyProcess = pty.spawn(shell, args, { ptyProcess = pty.spawn(shell, args, ptyOptions);
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
});
} catch (spawnError) { } catch (spawnError) {
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
// Return a user-friendly error instead of crashing // Check for Windows ConPTY-specific errors
reject( if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
new Error( // ConPTY failed - try winpty fallback
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` if (!this.useConptyFallback) {
) logger.warn(
); '[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback'
return; );
this.useConptyFallback = true;
try {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
ptyProcess = pty.spawn(shell, args, ptyOptions);
logger.info(
'[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback'
);
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
logger.error(
'[executeClaudeUsageCommandPty] Winpty fallback also failed:',
fallbackMessage
);
reject(
new Error(
`Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}`
)
);
return;
}
} else {
logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage);
reject(
new Error(
`Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.`
)
);
return;
}
} else {
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
reject(
new Error(
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
)
);
return;
}
} }
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (!settled) { if (!settled) {
settled = true; settled = true;
if (ptyProcess && !ptyProcess.killed) { if (ptyProcess && !ptyProcess.killed) {
ptyProcess.kill(); this.killPtyProcess(ptyProcess);
} }
// Don't fail if we have data - return it instead // Don't fail if we have data - return it instead
if (output.includes('Current session')) { if (output.includes('Current session')) {
@@ -244,16 +324,23 @@ export class ClaudeUsageService {
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
// Check for specific authentication/permission errors // Check for specific authentication/permission errors
if ( // Must be very specific to avoid false positives from garbled terminal encoding
cleanOutput.includes('OAuth token does not meet scope requirement') || // Removed permission_error check as it was causing false positives with winpty encoding
cleanOutput.includes('permission_error') || const authChecks = {
cleanOutput.includes('token_expired') || oauth: cleanOutput.includes('OAuth token does not meet scope requirement'),
cleanOutput.includes('authentication_error') tokenExpired: cleanOutput.includes('token_expired'),
) { // Only match if it looks like a JSON API error response
authError:
cleanOutput.includes('"type":"authentication_error"') ||
cleanOutput.includes('"type": "authentication_error"'),
};
const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError;
if (hasAuthError) {
if (!settled) { if (!settled) {
settled = true; settled = true;
if (ptyProcess && !ptyProcess.killed) { if (ptyProcess && !ptyProcess.killed) {
ptyProcess.kill(); this.killPtyProcess(ptyProcess);
} }
reject( reject(
new Error( new Error(
@@ -265,11 +352,16 @@ export class ClaudeUsageService {
} }
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header) // Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
if ( // Also check for percentage patterns that appear in usage output
!hasSeenUsageData && const hasUsageIndicators =
(cleanOutput.includes('Current session') || cleanOutput.includes('Current session') ||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
) { // Additional patterns for winpty - look for percentage patterns
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
cleanOutput.includes('Resets in') ||
cleanOutput.includes('Current week');
if (!hasSeenUsageData && hasUsageIndicators) {
hasSeenUsageData = true; hasSeenUsageData = true;
// Wait for full output, then send escape to exit // Wait for full output, then send escape to exit
setTimeout(() => { setTimeout(() => {
@@ -277,9 +369,10 @@ export class ClaudeUsageService {
ptyProcess.write('\x1b'); // Send escape key ptyProcess.write('\x1b'); // Send escape key
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
// Windows doesn't support signals, so killPtyProcess handles platform differences
setTimeout(() => { setTimeout(() => {
if (!settled && ptyProcess && !ptyProcess.killed) { if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.kill('SIGTERM'); this.killPtyProcess(ptyProcess);
} }
}, 2000); }, 2000);
} }
@@ -307,10 +400,18 @@ export class ClaudeUsageService {
} }
// Detect REPL prompt and send /usage command // Detect REPL prompt and send /usage command
if ( // On Windows with winpty, Unicode prompt char gets garbled, so also check for ASCII indicators
!hasSentCommand && const isReplReady =
(cleanOutput.includes('') || cleanOutput.includes('? for shortcuts')) cleanOutput.includes('') ||
) { cleanOutput.includes('? for shortcuts') ||
// Fallback for winpty garbled encoding - detect CLI welcome screen elements
(cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) ||
(cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) ||
// Detect model indicator which appears when REPL is ready
(cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) ||
(cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API'));
if (!hasSentCommand && isReplReady) {
hasSentCommand = true; hasSentCommand = true;
// Wait for REPL to fully settle // Wait for REPL to fully settle
setTimeout(() => { setTimeout(() => {
@@ -347,11 +448,9 @@ export class ClaudeUsageService {
if (settled) return; if (settled) return;
settled = true; settled = true;
if ( // Check for auth errors - must be specific to avoid false positives
output.includes('token_expired') || // Removed permission_error check as it was causing false positives with winpty encoding
output.includes('authentication_error') || if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) {
output.includes('permission_error')
) {
reject(new Error("Authentication required - please run 'claude login'")); reject(new Error("Authentication required - please run 'claude login'"));
return; return;
} }

View File

@@ -379,10 +379,11 @@ class DevServerService {
// Create server info early so we can reference it in handlers // Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully // We'll add it to runningServers after verifying the process started successfully
const hostname = process.env.HOSTNAME || 'localhost';
const serverInfo: DevServerInfo = { const serverInfo: DevServerInfo = {
worktreePath, worktreePath,
port, port,
url: `http://localhost:${port}`, url: `http://${hostname}:${port}`,
process: devProcess, process: devProcess,
startedAt: new Date(), startedAt: new Date(),
scrollbackBuffer: '', scrollbackBuffer: '',
@@ -474,7 +475,7 @@ class DevServerService {
result: { result: {
worktreePath, worktreePath,
port, port,
url: `http://localhost:${port}`, url: `http://${hostname}:${port}`,
message: `Dev server started on port ${port}`, message: `Dev server started on port ${port}`,
}, },
}; };
@@ -594,6 +595,7 @@ class DevServerService {
result?: { result?: {
worktreePath: string; worktreePath: string;
port: number; port: number;
url: string;
logs: string; logs: string;
startedAt: string; startedAt: string;
}; };
@@ -613,6 +615,7 @@ class DevServerService {
result: { result: {
worktreePath: server.worktreePath, worktreePath: server.worktreePath,
port: server.port, port: server.port,
url: server.url,
logs: server.scrollbackBuffer, logs: server.scrollbackBuffer,
startedAt: server.startedAt.toISOString(), startedAt: server.startedAt.toISOString(),
}, },

View File

@@ -0,0 +1,338 @@
/**
* Event History Service - Stores and retrieves event records for debugging and replay
*
* Provides persistent storage for events in {projectPath}/.automaker/events/
* Each event is stored as a separate JSON file with an index for quick listing.
*
* Features:
* - Store events when they occur
* - List and filter historical events
* - Replay events to test hook configurations
* - Delete old events to manage disk space
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
getEventHistoryDir,
getEventHistoryIndexPath,
getEventPath,
ensureEventHistoryDir,
} from '@automaker/platform';
import type {
StoredEvent,
StoredEventIndex,
StoredEventSummary,
EventHistoryFilter,
EventHookTrigger,
} from '@automaker/types';
import { DEFAULT_EVENT_HISTORY_INDEX } from '@automaker/types';
import { randomUUID } from 'crypto';
const logger = createLogger('EventHistoryService');
/** Maximum events to keep in the index (oldest are pruned) */
const MAX_EVENTS_IN_INDEX = 1000;
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Input for storing a new event
*/
export interface StoreEventInput {
trigger: EventHookTrigger;
projectPath: string;
featureId?: string;
featureName?: string;
error?: string;
errorType?: string;
passes?: boolean;
metadata?: Record<string, unknown>;
}
/**
* EventHistoryService - Manages persistent storage of events
*/
export class EventHistoryService {
/**
* Store a new event to history
*
* @param input - Event data to store
* @returns Promise resolving to the stored event
*/
async storeEvent(input: StoreEventInput): Promise<StoredEvent> {
const { projectPath, trigger, featureId, featureName, error, errorType, passes, metadata } =
input;
// Ensure events directory exists
await ensureEventHistoryDir(projectPath);
const eventId = `evt-${Date.now()}-${randomUUID().slice(0, 8)}`;
const timestamp = new Date().toISOString();
const projectName = this.extractProjectName(projectPath);
const event: StoredEvent = {
id: eventId,
trigger,
timestamp,
projectPath,
projectName,
featureId,
featureName,
error,
errorType,
passes,
metadata,
};
// Write the full event to its own file
const eventPath = getEventPath(projectPath, eventId);
await atomicWriteJson(eventPath, event);
// Update the index
await this.addToIndex(projectPath, event);
logger.info(`Stored event ${eventId} (${trigger}) for project ${projectName}`);
return event;
}
/**
* Get all events for a project with optional filtering
*
* @param projectPath - Absolute path to project directory
* @param filter - Optional filter criteria
* @returns Promise resolving to array of event summaries
*/
async getEvents(projectPath: string, filter?: EventHistoryFilter): Promise<StoredEventSummary[]> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
let events = [...index.events];
// Apply filters
if (filter) {
if (filter.trigger) {
events = events.filter((e) => e.trigger === filter.trigger);
}
if (filter.featureId) {
events = events.filter((e) => e.featureId === filter.featureId);
}
if (filter.since) {
const sinceDate = new Date(filter.since).getTime();
events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceDate);
}
if (filter.until) {
const untilDate = new Date(filter.until).getTime();
events = events.filter((e) => new Date(e.timestamp).getTime() <= untilDate);
}
}
// Sort by timestamp (newest first)
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Apply pagination
if (filter?.offset) {
events = events.slice(filter.offset);
}
if (filter?.limit) {
events = events.slice(0, filter.limit);
}
return events;
}
/**
* Get a single event by ID
*
* @param projectPath - Absolute path to project directory
* @param eventId - Event identifier
* @returns Promise resolving to the full event or null if not found
*/
async getEvent(projectPath: string, eventId: string): Promise<StoredEvent | null> {
const eventPath = getEventPath(projectPath, eventId);
try {
const content = (await secureFs.readFile(eventPath, 'utf-8')) as string;
return JSON.parse(content) as StoredEvent;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Error reading event ${eventId}:`, error);
return null;
}
}
/**
* Delete an event by ID
*
* @param projectPath - Absolute path to project directory
* @param eventId - Event identifier
* @returns Promise resolving to true if deleted
*/
async deleteEvent(projectPath: string, eventId: string): Promise<boolean> {
// Remove from index
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const initialLength = index.events.length;
index.events = index.events.filter((e) => e.id !== eventId);
if (index.events.length === initialLength) {
return false; // Event not found in index
}
await atomicWriteJson(indexPath, index);
// Delete the event file
const eventPath = getEventPath(projectPath, eventId);
try {
await secureFs.unlink(eventPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`Error deleting event file ${eventId}:`, error);
}
}
logger.info(`Deleted event ${eventId}`);
return true;
}
/**
* Clear all events for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of events cleared
*/
async clearEvents(projectPath: string): Promise<number> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const count = index.events.length;
// Delete all event files
for (const event of index.events) {
const eventPath = getEventPath(projectPath, event.id);
try {
await secureFs.unlink(eventPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`Error deleting event file ${event.id}:`, error);
}
}
}
// Reset the index
await atomicWriteJson(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
logger.info(`Cleared ${count} events for project`);
return count;
}
/**
* Get event count for a project
*
* @param projectPath - Absolute path to project directory
* @param filter - Optional filter criteria
* @returns Promise resolving to event count
*/
async getEventCount(projectPath: string, filter?: EventHistoryFilter): Promise<number> {
const events = await this.getEvents(projectPath, {
...filter,
limit: undefined,
offset: undefined,
});
return events.length;
}
/**
* Add an event to the index (internal)
*/
private async addToIndex(projectPath: string, event: StoredEvent): Promise<void> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const summary: StoredEventSummary = {
id: event.id,
trigger: event.trigger,
timestamp: event.timestamp,
featureName: event.featureName,
featureId: event.featureId,
};
// Add to beginning (newest first)
index.events.unshift(summary);
// Prune old events if over limit
if (index.events.length > MAX_EVENTS_IN_INDEX) {
const removed = index.events.splice(MAX_EVENTS_IN_INDEX);
// Delete the pruned event files
for (const oldEvent of removed) {
const eventPath = getEventPath(projectPath, oldEvent.id);
try {
await secureFs.unlink(eventPath);
} catch {
// Ignore deletion errors for pruned events
}
}
logger.info(`Pruned ${removed.length} old events from history`);
}
await atomicWriteJson(indexPath, index);
}
/**
* Extract project name from path
*/
private extractProjectName(projectPath: string): string {
const parts = projectPath.split(/[/\\]/);
return parts[parts.length - 1] || projectPath;
}
}
// Singleton instance
let eventHistoryServiceInstance: EventHistoryService | null = null;
/**
* Get the singleton event history service instance
*/
export function getEventHistoryService(): EventHistoryService {
if (!eventHistoryServiceInstance) {
eventHistoryServiceInstance = new EventHistoryService();
}
return eventHistoryServiceInstance;
}

View File

@@ -0,0 +1,373 @@
/**
* Event Hook Service - Executes custom actions when system events occur
*
* Listens to the event emitter and triggers configured hooks:
* - Shell commands: Executed with configurable timeout
* - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution
*
* Also stores events to history for debugging and replay.
*
* Supported events:
* - feature_created: A new feature was created
* - feature_success: Feature completed successfully
* - feature_error: Feature failed with an error
* - auto_mode_complete: Auto mode finished all features (idle state)
* - auto_mode_error: Auto mode encountered a critical error
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
import type { EventHistoryService } from './event-history-service.js';
import type {
EventHook,
EventHookTrigger,
EventHookShellAction,
EventHookHttpAction,
} from '@automaker/types';
const execAsync = promisify(exec);
const logger = createLogger('EventHooks');
/** Default timeout for shell commands (30 seconds) */
const DEFAULT_SHELL_TIMEOUT = 30000;
/** Default timeout for HTTP requests (10 seconds) */
const DEFAULT_HTTP_TIMEOUT = 10000;
/**
* Context available for variable substitution in hooks
*/
interface HookContext {
featureId?: string;
featureName?: string;
projectPath?: string;
projectName?: string;
error?: string;
errorType?: string;
timestamp: string;
eventType: EventHookTrigger;
}
/**
* Auto-mode event payload structure
*/
interface AutoModeEventPayload {
type?: string;
featureId?: string;
passes?: boolean;
message?: string;
error?: string;
errorType?: string;
projectPath?: string;
}
/**
* Feature created event payload structure
*/
interface FeatureCreatedPayload {
featureId: string;
featureName?: string;
projectPath: string;
}
/**
* Event Hook Service
*
* Manages execution of user-configured event hooks in response to system events.
* Also stores events to history for debugging and replay.
*/
export class EventHookService {
private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null;
private unsubscribe: (() => void) | null = null;
/**
* Initialize the service with event emitter, settings service, and event history service
*/
initialize(
emitter: EventEmitter,
settingsService: SettingsService,
eventHistoryService?: EventHistoryService
): void {
this.emitter = emitter;
this.settingsService = settingsService;
this.eventHistoryService = eventHistoryService || null;
// Subscribe to events
this.unsubscribe = emitter.subscribe((type, payload) => {
if (type === 'auto-mode:event') {
this.handleAutoModeEvent(payload as AutoModeEventPayload);
} else if (type === 'feature:created') {
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
}
});
logger.info('Event hook service initialized');
}
/**
* Cleanup subscriptions
*/
destroy(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
this.emitter = null;
this.settingsService = null;
this.eventHistoryService = null;
}
/**
* Handle auto-mode events and trigger matching hooks
*/
private async handleAutoModeEvent(payload: AutoModeEventPayload): Promise<void> {
if (!payload.type) return;
// Map internal event types to hook triggers
let trigger: EventHookTrigger | null = null;
switch (payload.type) {
case 'auto_mode_feature_complete':
trigger = payload.passes ? 'feature_success' : 'feature_error';
break;
case 'auto_mode_error':
// Feature-level error (has featureId) vs auto-mode level error
trigger = payload.featureId ? 'feature_error' : 'auto_mode_error';
break;
case 'auto_mode_idle':
trigger = 'auto_mode_complete';
break;
default:
// Other event types don't trigger hooks
return;
}
if (!trigger) return;
// Build context for variable substitution
const context: HookContext = {
featureId: payload.featureId,
projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message,
errorType: payload.errorType,
timestamp: new Date().toISOString(),
eventType: trigger,
};
// Execute matching hooks (pass passes for feature completion events)
await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
}
/**
* Handle feature:created events and trigger matching hooks
*/
private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise<void> {
const context: HookContext = {
featureId: payload.featureId,
featureName: payload.featureName,
projectPath: payload.projectPath,
projectName: this.extractProjectName(payload.projectPath),
timestamp: new Date().toISOString(),
eventType: 'feature_created',
};
await this.executeHooksForTrigger('feature_created', context);
}
/**
* Execute all enabled hooks matching the given trigger and store event to history
*/
private async executeHooksForTrigger(
trigger: EventHookTrigger,
context: HookContext,
additionalData?: { passes?: boolean }
): Promise<void> {
// Store event to history (even if no hooks match)
if (this.eventHistoryService && context.projectPath) {
try {
await this.eventHistoryService.storeEvent({
trigger,
projectPath: context.projectPath,
featureId: context.featureId,
featureName: context.featureName,
error: context.error,
errorType: context.errorType,
passes: additionalData?.passes,
});
} catch (error) {
logger.error('Failed to store event to history:', error);
}
}
if (!this.settingsService) {
logger.warn('Settings service not available');
return;
}
try {
const settings = await this.settingsService.getGlobalSettings();
const hooks = settings.eventHooks || [];
// Filter to enabled hooks matching this trigger
const matchingHooks = hooks.filter((hook) => hook.enabled && hook.trigger === trigger);
if (matchingHooks.length === 0) {
return;
}
logger.info(`Executing ${matchingHooks.length} hook(s) for trigger: ${trigger}`);
// Execute hooks in parallel (don't wait for one to finish before starting next)
await Promise.allSettled(matchingHooks.map((hook) => this.executeHook(hook, context)));
} catch (error) {
logger.error('Error executing hooks:', error);
}
}
/**
* Execute a single hook
*/
private async executeHook(hook: EventHook, context: HookContext): Promise<void> {
const hookName = hook.name || hook.id;
try {
if (hook.action.type === 'shell') {
await this.executeShellHook(hook.action, context, hookName);
} else if (hook.action.type === 'http') {
await this.executeHttpHook(hook.action, context, hookName);
}
} catch (error) {
logger.error(`Hook "${hookName}" failed:`, error);
}
}
/**
* Execute a shell command hook
*/
private async executeShellHook(
action: EventHookShellAction,
context: HookContext,
hookName: string
): Promise<void> {
const command = this.substituteVariables(action.command, context);
const timeout = action.timeout || DEFAULT_SHELL_TIMEOUT;
logger.info(`Executing shell hook "${hookName}": ${command}`);
try {
const { stdout, stderr } = await execAsync(command, {
timeout,
maxBuffer: 1024 * 1024, // 1MB buffer
});
if (stdout) {
logger.debug(`Hook "${hookName}" stdout: ${stdout.trim()}`);
}
if (stderr) {
logger.warn(`Hook "${hookName}" stderr: ${stderr.trim()}`);
}
logger.info(`Shell hook "${hookName}" completed successfully`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
logger.error(`Shell hook "${hookName}" timed out after ${timeout}ms`);
}
throw error;
}
}
/**
* Execute an HTTP webhook hook
*/
private async executeHttpHook(
action: EventHookHttpAction,
context: HookContext,
hookName: string
): Promise<void> {
const url = this.substituteVariables(action.url, context);
const method = action.method || 'POST';
// Substitute variables in headers
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (action.headers) {
for (const [key, value] of Object.entries(action.headers)) {
headers[key] = this.substituteVariables(value, context);
}
}
// Substitute variables in body
let body: string | undefined;
if (action.body) {
body = this.substituteVariables(action.body, context);
} else if (method !== 'GET') {
// Default body with context information
body = JSON.stringify({
eventType: context.eventType,
timestamp: context.timestamp,
featureId: context.featureId,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
});
}
logger.info(`Executing HTTP hook "${hookName}": ${method} ${url}`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
const response = await fetch(url, {
method,
headers,
body: method !== 'GET' ? body : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(`HTTP hook "${hookName}" received status ${response.status}`);
} else {
logger.info(`HTTP hook "${hookName}" completed successfully (status: ${response.status})`);
}
} catch (error) {
if ((error as Error).name === 'AbortError') {
logger.error(`HTTP hook "${hookName}" timed out after ${DEFAULT_HTTP_TIMEOUT}ms`);
}
throw error;
}
}
/**
* Substitute {{variable}} placeholders in a string
*/
private substituteVariables(template: string, context: HookContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
const value = context[variable as keyof HookContext];
if (value === undefined || value === null) {
return '';
}
return String(value);
});
}
/**
* Extract project name from path
*/
private extractProjectName(projectPath: string): string {
const parts = projectPath.split(/[/\\]/);
return parts[parts.length - 1] || projectPath;
}
}
// Singleton instance
export const eventHookService = new EventHookService();

View File

@@ -5,14 +5,22 @@
import path from 'path'; import path from 'path';
import type { Feature, DescriptionHistoryEntry } from '@automaker/types'; import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
import { createLogger } from '@automaker/utils'; import {
createLogger,
atomicWriteJson,
readJsonWithRecovery,
logRecoveryWarning,
DEFAULT_BACKUP_COUNT,
} from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js'; import * as secureFs from '../lib/secure-fs.js';
import { import {
getFeaturesDir, getFeaturesDir,
getFeatureDir, getFeatureDir,
getFeatureImagesDir, getFeatureImagesDir,
getAppSpecPath,
ensureAutomakerDir, ensureAutomakerDir,
} from '@automaker/platform'; } from '@automaker/platform';
import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js';
const logger = createLogger('FeatureLoader'); const logger = createLogger('FeatureLoader');
@@ -192,31 +200,31 @@ export class FeatureLoader {
})) as any[]; })) as any[];
const featureDirs = entries.filter((entry) => entry.isDirectory()); const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load all features concurrently (secureFs has built-in concurrency limiting) // Load all features concurrently with automatic recovery from backups
const featurePromises = featureDirs.map(async (dir) => { const featurePromises = featureDirs.map(async (dir) => {
const featureId = dir.name; const featureId = dir.name;
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try { // Use recovery-enabled read to handle corrupted files
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, {
const feature = JSON.parse(content); maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
if (!feature.id) { logRecoveryWarning(result, `Feature ${featureId}`, logger);
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
return null;
}
return feature as Feature; const feature = result.data;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { if (!feature) {
return null;
} else if (error instanceof SyntaxError) {
logger.warn(`Failed to parse feature.json for ${featureId}: ${error.message}`);
} else {
logger.error(`Failed to load feature ${featureId}:`, (error as Error).message);
}
return null; return null;
} }
if (!feature.id) {
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
return null;
}
return feature;
}); });
const results = await Promise.all(featurePromises); const results = await Promise.all(featurePromises);
@@ -236,21 +244,85 @@ export class FeatureLoader {
} }
} }
/**
* Normalize a title for comparison (case-insensitive, trimmed)
*/
private normalizeTitle(title: string): string {
return title.toLowerCase().trim();
}
/**
* Find a feature by its title (case-insensitive match)
* @param projectPath - Path to the project
* @param title - Title to search for
* @returns The matching feature or null if not found
*/
async findByTitle(projectPath: string, title: string): Promise<Feature | null> {
if (!title || !title.trim()) {
return null;
}
const normalizedTitle = this.normalizeTitle(title);
const features = await this.getAll(projectPath);
for (const feature of features) {
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
return feature;
}
}
return null;
}
/**
* Check if a title already exists on another feature (for duplicate detection)
* @param projectPath - Path to the project
* @param title - Title to check
* @param excludeFeatureId - Optional feature ID to exclude from the check (for updates)
* @returns The duplicate feature if found, null otherwise
*/
async findDuplicateTitle(
projectPath: string,
title: string,
excludeFeatureId?: string
): Promise<Feature | null> {
if (!title || !title.trim()) {
return null;
}
const normalizedTitle = this.normalizeTitle(title);
const features = await this.getAll(projectPath);
for (const feature of features) {
// Skip the feature being updated (if provided)
if (excludeFeatureId && feature.id === excludeFeatureId) {
continue;
}
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
return feature;
}
}
return null;
}
/** /**
* Get a single feature by ID * Get a single feature by ID
* Uses automatic recovery from backups if the main file is corrupted
*/ */
async get(projectPath: string, featureId: string): Promise<Feature | null> { async get(projectPath: string, featureId: string): Promise<Feature | null> {
try { const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; // Use recovery-enabled read to handle corrupted files
return JSON.parse(content); const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, {
} catch (error) { maxBackups: DEFAULT_BACKUP_COUNT,
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { autoRestore: true,
return null; });
}
logger.error(`Failed to get feature ${featureId}:`, error); logRecoveryWarning(result, `Feature ${featureId}`, logger);
throw error;
} return result.data;
} }
/** /**
@@ -294,8 +366,8 @@ export class FeatureLoader {
descriptionHistory: initialHistory, descriptionHistory: initialHistory,
}; };
// Write feature.json // Write feature.json atomically with backup support
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), 'utf-8'); await atomicWriteJson(featureJsonPath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
logger.info(`Created feature ${featureId}`); logger.info(`Created feature ${featureId}`);
return feature; return feature;
@@ -379,9 +451,9 @@ export class FeatureLoader {
descriptionHistory: updatedHistory, descriptionHistory: updatedHistory,
}; };
// Write back to file // Write back to file atomically with backup support
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await secureFs.writeFile(featureJsonPath, JSON.stringify(updatedFeature, null, 2), 'utf-8'); await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT });
logger.info(`Updated feature ${featureId}`); logger.info(`Updated feature ${featureId}`);
return updatedFeature; return updatedFeature;
@@ -460,4 +532,64 @@ export class FeatureLoader {
} }
} }
} }
/**
* Sync a completed feature to the app_spec.txt implemented_features section
*
* When a feature is completed, this method adds it to the implemented_features
* section of the project's app_spec.txt file. This keeps the spec in sync
* with the actual state of the codebase.
*
* @param projectPath - Path to the project
* @param feature - The feature to sync (must have title or description)
* @param fileLocations - Optional array of file paths where the feature was implemented
* @returns True if the spec was updated, false if no spec exists or feature was skipped
*/
async syncFeatureToAppSpec(
projectPath: string,
feature: Feature,
fileLocations?: string[]
): Promise<boolean> {
try {
const appSpecPath = getAppSpecPath(projectPath);
// Read the current app_spec.txt
let specContent: string;
try {
specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`);
return false;
}
throw error;
}
// Build the implemented feature entry
const featureName = feature.title || `Feature: ${feature.id}`;
const implementedFeature: ImplementedFeature = {
name: featureName,
description: feature.description,
...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}),
};
// Add the feature to the implemented_features section
const updatedSpecContent = addImplementedFeature(specContent, implementedFeature);
// Check if the content actually changed (feature might already exist)
if (updatedSpecContent === specContent) {
logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`);
return false;
}
// Write the updated spec back to the file
await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8');
logger.info(`Synced feature "${featureName}" to app_spec.txt`);
return true;
} catch (error) {
logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error);
throw error;
}
}
} }

View File

@@ -41,6 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString } from '@automaker/model-resolver'; import { resolveModelString } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types';
import { getPromptCustomization } from '../lib/settings-helpers.js';
const logger = createLogger('IdeationService'); const logger = createLogger('IdeationService');
@@ -195,8 +196,12 @@ export class IdeationService {
// Gather existing features and ideas to prevent duplicate suggestions // Gather existing features and ideas to prevent duplicate suggestions
const existingWorkContext = await this.gatherExistingWorkContext(projectPath); const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
// Build system prompt for ideation // Build system prompt for ideation
const systemPrompt = this.buildIdeationSystemPrompt( const systemPrompt = this.buildIdeationSystemPrompt(
prompts.ideation.ideationSystemPrompt,
contextResult.formattedPrompt, contextResult.formattedPrompt,
activeSession.session.promptCategory, activeSession.session.promptCategory,
existingWorkContext existingWorkContext
@@ -645,8 +650,12 @@ export class IdeationService {
// Gather existing features and ideas to prevent duplicates // Gather existing features and ideas to prevent duplicates
const existingWorkContext = await this.gatherExistingWorkContext(projectPath); const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
// Build system prompt for structured suggestions // Build system prompt for structured suggestions
const systemPrompt = this.buildSuggestionsSystemPrompt( const systemPrompt = this.buildSuggestionsSystemPrompt(
prompts.ideation.suggestionsSystemPrompt,
contextPrompt, contextPrompt,
category, category,
count, count,
@@ -721,8 +730,14 @@ export class IdeationService {
/** /**
* Build system prompt for structured suggestion generation * Build system prompt for structured suggestion generation
* @param basePrompt - The base system prompt from settings
* @param contextFilesPrompt - Project context from loaded files
* @param category - The idea category to focus on
* @param count - Number of suggestions to generate
* @param existingWorkContext - Context about existing features/ideas
*/ */
private buildSuggestionsSystemPrompt( private buildSuggestionsSystemPrompt(
basePrompt: string,
contextFilesPrompt: string | undefined, contextFilesPrompt: string | undefined,
category: IdeaCategory, category: IdeaCategory,
count: number = 10, count: number = 10,
@@ -734,35 +749,18 @@ export class IdeationService {
const existingWorkSection = existingWorkContext ? `\n\n${existingWorkContext}` : ''; const existingWorkSection = existingWorkContext ? `\n\n${existingWorkContext}` : '';
return `You are an AI product strategist helping brainstorm feature ideas for a software project. // Replace placeholder {{count}} if present, otherwise append count instruction
let prompt = basePrompt;
if (prompt.includes('{{count}}')) {
prompt = prompt.replace(/\{\{count\}\}/g, String(count));
} else {
prompt += `\n\nGenerate exactly ${count} suggestions.`;
}
IMPORTANT: You do NOT have access to any tools. You CANNOT read files, search code, or run commands. return `${prompt}
You must generate suggestions based ONLY on the project context provided below.
Do NOT say "I'll analyze" or "Let me explore" - you cannot do those things.
Based on the project context and the user's prompt, generate exactly ${count} creative and actionable feature suggestions.
YOUR RESPONSE MUST BE ONLY A JSON ARRAY - nothing else. No explanation, no preamble, no markdown code fences.
Each suggestion must have this structure:
{
"title": "Short, actionable title (max 60 chars)",
"description": "Clear description of what to build or improve (2-3 sentences)",
"rationale": "Why this is valuable - the problem it solves or opportunity it creates",
"priority": "high" | "medium" | "low"
}
Focus area: ${this.getCategoryDescription(category)} Focus area: ${this.getCategoryDescription(category)}
Guidelines:
- Generate exactly ${count} suggestions
- Be specific and actionable - avoid vague ideas
- Mix different priority levels (some high, some medium, some low)
- Each suggestion should be independently implementable
- Think creatively - include both obvious improvements and innovative ideas
- Consider the project's domain and target users
- IMPORTANT: Do NOT suggest features or ideas that already exist in the "Existing Features" or "Existing Ideas" sections below
${contextSection}${existingWorkSection}`; ${contextSection}${existingWorkSection}`;
} }
@@ -1269,30 +1267,11 @@ ${contextSection}${existingWorkSection}`;
// ============================================================================ // ============================================================================
private buildIdeationSystemPrompt( private buildIdeationSystemPrompt(
basePrompt: string,
contextFilesPrompt: string | undefined, contextFilesPrompt: string | undefined,
category?: IdeaCategory, category?: IdeaCategory,
existingWorkContext?: string existingWorkContext?: string
): string { ): string {
const basePrompt = `You are an AI product strategist and UX expert helping brainstorm ideas for improving a software project.
Your role is to:
- Analyze the codebase structure and patterns
- Identify opportunities for improvement
- Suggest actionable ideas with clear rationale
- Consider user experience, technical feasibility, and business value
- Be specific and reference actual files/components when possible
When suggesting ideas:
1. Provide a clear, concise title
2. Explain the problem or opportunity
3. Describe the proposed solution
4. Highlight the expected benefit
5. Note any dependencies or considerations
IMPORTANT: Do NOT suggest features or ideas that already exist in the project. Check the "Existing Features" and "Existing Ideas" sections below to avoid duplicates.
Focus on practical, implementable suggestions that would genuinely improve the product.`;
const categoryContext = category const categoryContext = category
? `\n\nFocus area: ${this.getCategoryDescription(category)}` ? `\n\nFocus area: ${this.getCategoryDescription(category)}`
: ''; : '';

View File

@@ -0,0 +1,280 @@
/**
* Notification Service - Handles reading/writing notifications to JSON files
*
* Provides persistent storage for project-level notifications in
* {projectPath}/.automaker/notifications.json
*
* Notifications alert users when:
* - Features reach specific statuses (waiting_approval, verified)
* - Long-running operations complete (spec generation)
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform';
import type { Notification, NotificationsFile, NotificationType } from '@automaker/types';
import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types';
import type { EventEmitter } from '../lib/events.js';
import { randomUUID } from 'crypto';
const logger = createLogger('NotificationService');
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Input for creating a new notification
*/
export interface CreateNotificationInput {
type: NotificationType;
title: string;
message: string;
featureId?: string;
projectPath: string;
}
/**
* NotificationService - Manages persistent storage of notifications
*
* Handles reading and writing notifications to JSON files with atomic operations
* for reliability. Each project has its own notifications.json file.
*/
export class NotificationService {
private events: EventEmitter | null = null;
/**
* Set the event emitter for broadcasting notification events
*/
setEventEmitter(events: EventEmitter): void {
this.events = events;
}
/**
* Get all notifications for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to array of notifications
*/
async getNotifications(projectPath: string): Promise<Notification[]> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
// Filter out dismissed notifications and sort by date (newest first)
return file.notifications
.filter((n) => !n.dismissed)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
/**
* Get unread notification count for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to unread count
*/
async getUnreadCount(projectPath: string): Promise<number> {
const notifications = await this.getNotifications(projectPath);
return notifications.filter((n) => !n.read).length;
}
/**
* Create a new notification
*
* @param input - Notification creation input
* @returns Promise resolving to the created notification
*/
async createNotification(input: CreateNotificationInput): Promise<Notification> {
const { projectPath, type, title, message, featureId } = input;
// Ensure automaker directory exists
await ensureAutomakerDir(projectPath);
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification: Notification = {
id: randomUUID(),
type,
title,
message,
createdAt: new Date().toISOString(),
read: false,
dismissed: false,
featureId,
projectPath,
};
file.notifications.push(notification);
await atomicWriteJson(notificationsPath, file);
logger.info(`Created notification: ${title} for project ${projectPath}`);
// Emit event for real-time updates
if (this.events) {
this.events.emit('notification:created', notification);
}
return notification;
}
/**
* Mark a notification as read
*
* @param projectPath - Absolute path to project directory
* @param notificationId - ID of the notification to mark as read
* @returns Promise resolving to the updated notification or null if not found
*/
async markAsRead(projectPath: string, notificationId: string): Promise<Notification | null> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification = file.notifications.find((n) => n.id === notificationId);
if (!notification) {
return null;
}
notification.read = true;
await atomicWriteJson(notificationsPath, file);
logger.info(`Marked notification ${notificationId} as read`);
return notification;
}
/**
* Mark all notifications as read for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of notifications marked as read
*/
async markAllAsRead(projectPath: string): Promise<number> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
let count = 0;
for (const notification of file.notifications) {
if (!notification.read && !notification.dismissed) {
notification.read = true;
count++;
}
}
if (count > 0) {
await atomicWriteJson(notificationsPath, file);
logger.info(`Marked ${count} notifications as read`);
}
return count;
}
/**
* Dismiss a notification
*
* @param projectPath - Absolute path to project directory
* @param notificationId - ID of the notification to dismiss
* @returns Promise resolving to true if notification was dismissed
*/
async dismissNotification(projectPath: string, notificationId: string): Promise<boolean> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification = file.notifications.find((n) => n.id === notificationId);
if (!notification) {
return false;
}
notification.dismissed = true;
await atomicWriteJson(notificationsPath, file);
logger.info(`Dismissed notification ${notificationId}`);
return true;
}
/**
* Dismiss all notifications for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of notifications dismissed
*/
async dismissAll(projectPath: string): Promise<number> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
let count = 0;
for (const notification of file.notifications) {
if (!notification.dismissed) {
notification.dismissed = true;
count++;
}
}
if (count > 0) {
await atomicWriteJson(notificationsPath, file);
logger.info(`Dismissed ${count} notifications`);
}
return count;
}
}
// Singleton instance
let notificationServiceInstance: NotificationService | null = null;
/**
* Get the singleton notification service instance
*/
export function getNotificationService(): NotificationService {
if (!notificationServiceInstance) {
notificationServiceInstance = new NotificationService();
}
return notificationServiceInstance;
}

View File

@@ -7,7 +7,7 @@
* - Per-project settings ({projectPath}/.automaker/settings.json) * - Per-project settings ({projectPath}/.automaker/settings.json)
*/ */
import { createLogger } from '@automaker/utils'; import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js'; import * as secureFs from '../lib/secure-fs.js';
import { import {
@@ -42,28 +42,8 @@ import {
const logger = createLogger('SettingsService'); const logger = createLogger('SettingsService');
/** /**
* Atomic file write - write to temp file then rename * Wrapper for readJsonFile from utils that uses the local secureFs
*/ * to maintain compatibility with the server's secure file system
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/ */
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> { async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try { try {
@@ -90,6 +70,13 @@ async function fileExists(filePath: string): Promise<boolean> {
} }
} }
/**
* Write settings atomically with backup support
*/
async function writeSettingsJson(filePath: string, data: unknown): Promise<void> {
await atomicWriteJson(filePath, data, { backupCount: DEFAULT_BACKUP_COUNT });
}
/** /**
* SettingsService - Manages persistent storage of user settings and credentials * SettingsService - Manages persistent storage of user settings and credentials
* *
@@ -180,7 +167,7 @@ export class SettingsService {
if (needsSave) { if (needsSave) {
try { try {
await ensureDataDir(this.dataDir); await ensureDataDir(this.dataDir);
await atomicWriteJson(settingsPath, result); await writeSettingsJson(settingsPath, result);
logger.info('Settings migration complete'); logger.info('Settings migration complete');
} catch (error) { } catch (error) {
logger.error('Failed to save migrated settings:', error); logger.error('Failed to save migrated settings:', error);
@@ -340,7 +327,7 @@ export class SettingsService {
}; };
} }
await atomicWriteJson(settingsPath, updated); await writeSettingsJson(settingsPath, updated);
logger.info('Global settings updated'); logger.info('Global settings updated');
return updated; return updated;
@@ -414,7 +401,7 @@ export class SettingsService {
}; };
} }
await atomicWriteJson(credentialsPath, updated); await writeSettingsJson(credentialsPath, updated);
logger.info('Credentials updated'); logger.info('Credentials updated');
return updated; return updated;
@@ -525,7 +512,7 @@ export class SettingsService {
}; };
} }
await atomicWriteJson(settingsPath, updated); await writeSettingsJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`); logger.info(`Project settings updated for ${projectPath}`);
return updated; return updated;

View File

@@ -70,6 +70,29 @@ export class TerminalService extends EventEmitter {
private sessions: Map<string, TerminalSession> = new Map(); private sessions: Map<string, TerminalSession> = new Map();
private dataCallbacks: Set<DataCallback> = new Set(); private dataCallbacks: Set<DataCallback> = new Set();
private exitCallbacks: Set<ExitCallback> = new Set(); private exitCallbacks: Set<ExitCallback> = new Set();
private isWindows = os.platform() === 'win32';
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
// Detect Electron by checking for electron-specific env vars or process properties
private isElectron =
!!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
/**
* Kill a PTY process with platform-specific handling.
* Windows doesn't support Unix signals like SIGTERM/SIGKILL, so we call kill() without arguments.
* On Unix-like systems (macOS, Linux), we can specify the signal.
*
* @param ptyProcess - The PTY process to kill
* @param signal - The signal to send on Unix-like systems (default: 'SIGTERM')
*/
private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void {
if (this.isWindows) {
ptyProcess.kill();
} else {
ptyProcess.kill(signal);
}
}
/** /**
* Detect the best shell for the current platform * Detect the best shell for the current platform
@@ -322,13 +345,60 @@ export class TerminalService extends EventEmitter {
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`); logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
const ptyProcess = pty.spawn(shell, shellArgs, { // Build PTY spawn options
const ptyOptions: pty.IPtyForkOptions = {
name: 'xterm-256color', name: 'xterm-256color',
cols: options.cols || 80, cols: options.cols || 80,
rows: options.rows || 24, rows: options.rows || 24,
cwd, cwd,
env, env,
}); };
// On Windows, always use winpty instead of ConPTY
// ConPTY requires AttachConsole which fails in many contexts:
// - Electron apps without a console
// - VS Code integrated terminal
// - Spawned from other applications
// The error happens in a subprocess so we can't catch it - must proactively disable
if (this.isWindows) {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
logger.info(
`[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)`
);
}
let ptyProcess: pty.IPty;
try {
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
} catch (spawnError) {
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
// Check for Windows ConPTY-specific errors
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
// ConPTY failed - try winpty fallback
if (!this.useConptyFallback) {
logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`);
this.useConptyFallback = true;
try {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`);
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage);
return null;
}
} else {
logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage);
return null;
}
} else {
logger.error(`[createSession] PTY spawn failed:`, errorMessage);
return null;
}
}
const session: TerminalSession = { const session: TerminalSession = {
id, id,
@@ -392,7 +462,11 @@ export class TerminalService extends EventEmitter {
// Handle exit // Handle exit
ptyProcess.onExit(({ exitCode }) => { ptyProcess.onExit(({ exitCode }) => {
logger.info(`Session ${id} exited with code ${exitCode}`); const exitMessage =
exitCode === undefined || exitCode === null
? 'Session terminated'
: `Session exited with code ${exitCode}`;
logger.info(`${exitMessage} (${id})`);
this.sessions.delete(id); this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode)); this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit('exit', id, exitCode); this.emit('exit', id, exitCode);
@@ -477,8 +551,9 @@ export class TerminalService extends EventEmitter {
} }
// First try graceful SIGTERM to allow process cleanup // First try graceful SIGTERM to allow process cleanup
// On Windows, killPtyProcess calls kill() without signal since Windows doesn't support Unix signals
logger.info(`Session ${sessionId} sending SIGTERM`); logger.info(`Session ${sessionId} sending SIGTERM`);
session.pty.kill('SIGTERM'); this.killPtyProcess(session.pty, 'SIGTERM');
// Schedule SIGKILL fallback if process doesn't exit gracefully // Schedule SIGKILL fallback if process doesn't exit gracefully
// The onExit handler will remove session from map when it actually exits // The onExit handler will remove session from map when it actually exits
@@ -486,7 +561,7 @@ export class TerminalService extends EventEmitter {
if (this.sessions.has(sessionId)) { if (this.sessions.has(sessionId)) {
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`); logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
try { try {
session.pty.kill('SIGKILL'); this.killPtyProcess(session.pty, 'SIGKILL');
} catch { } catch {
// Process may have already exited // Process may have already exited
} }
@@ -588,7 +663,8 @@ export class TerminalService extends EventEmitter {
if (session.flushTimeout) { if (session.flushTimeout) {
clearTimeout(session.flushTimeout); clearTimeout(session.flushTimeout);
} }
session.pty.kill(); // Use platform-specific kill to ensure proper termination on Windows
this.killPtyProcess(session.pty);
} catch { } catch {
// Ignore errors during cleanup // Ignore errors during cleanup
} }

File diff suppressed because it is too large Load Diff

View File

@@ -286,6 +286,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({ const generator = provider.executeQuery({
prompt: 'Test', prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test', cwd: '/test',
}); });
@@ -312,6 +313,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({ const generator = provider.executeQuery({
prompt: 'Test', prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test', cwd: '/test',
}); });
@@ -339,6 +341,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({ const generator = provider.executeQuery({
prompt: 'Test', prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test', cwd: '/test',
}); });

View File

@@ -11,6 +11,11 @@ import {
getCodexConfigDir, getCodexConfigDir,
getCodexAuthIndicators, getCodexAuthIndicators,
} from '@automaker/platform'; } from '@automaker/platform';
import {
calculateReasoningTimeout,
REASONING_TIMEOUT_MULTIPLIERS,
DEFAULT_TIMEOUT_MS,
} from '@automaker/types';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV]; const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV];
@@ -289,5 +294,121 @@ describe('codex-provider.ts', () => {
expect(codexRunMock).not.toHaveBeenCalled(); expect(codexRunMock).not.toHaveBeenCalled();
expect(spawnJSONLProcess).toHaveBeenCalled(); expect(spawnJSONLProcess).toHaveBeenCalled();
}); });
it('passes extended timeout for high reasoning effort', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Complex reasoning task',
model: 'gpt-5.1-codex-max',
cwd: '/tmp',
reasoningEffort: 'high',
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// High reasoning effort should have 3x the default timeout (90000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
});
it('passes extended timeout for xhigh reasoning effort', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Very complex reasoning task',
model: 'gpt-5.1-codex-max',
cwd: '/tmp',
reasoningEffort: 'xhigh',
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// xhigh reasoning effort should have 4x the default timeout (120000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
});
it('uses default timeout when no reasoning effort is specified', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Simple task',
model: 'gpt-5.2',
cwd: '/tmp',
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// No reasoning effort should use the default timeout
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS);
});
});
describe('calculateReasoningTimeout', () => {
it('returns default timeout when no reasoning effort is specified', () => {
expect(calculateReasoningTimeout()).toBe(DEFAULT_TIMEOUT_MS);
expect(calculateReasoningTimeout(undefined)).toBe(DEFAULT_TIMEOUT_MS);
});
it('returns default timeout for none reasoning effort', () => {
expect(calculateReasoningTimeout('none')).toBe(DEFAULT_TIMEOUT_MS);
});
it('applies correct multiplier for minimal reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.minimal);
expect(calculateReasoningTimeout('minimal')).toBe(expected);
});
it('applies correct multiplier for low reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.low);
expect(calculateReasoningTimeout('low')).toBe(expected);
});
it('applies correct multiplier for medium reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.medium);
expect(calculateReasoningTimeout('medium')).toBe(expected);
});
it('applies correct multiplier for high reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
expect(calculateReasoningTimeout('high')).toBe(expected);
});
it('applies correct multiplier for xhigh reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
expect(calculateReasoningTimeout('xhigh')).toBe(expected);
});
it('uses custom base timeout when provided', () => {
const customBase = 60000;
expect(calculateReasoningTimeout('high', customBase)).toBe(
Math.round(customBase * REASONING_TIMEOUT_MULTIPLIERS.high)
);
});
it('falls back to 1.0 multiplier for invalid reasoning effort', () => {
// Test that invalid values fallback gracefully to default multiplier
// This tests the defensive ?? 1.0 in calculateReasoningTimeout
const invalidEffort = 'invalid_effort' as never;
expect(calculateReasoningTimeout(invalidEffort)).toBe(DEFAULT_TIMEOUT_MS);
});
it('produces expected absolute timeout values', () => {
// Verify the actual timeout values that will be used:
// none: 30000ms (30s)
// minimal: 36000ms (36s)
// low: 45000ms (45s)
// medium: 60000ms (1m)
// high: 90000ms (1m 30s)
// xhigh: 120000ms (2m)
expect(calculateReasoningTimeout('none')).toBe(30000);
expect(calculateReasoningTimeout('minimal')).toBe(36000);
expect(calculateReasoningTimeout('low')).toBe(45000);
expect(calculateReasoningTimeout('medium')).toBe(60000);
expect(calculateReasoningTimeout('high')).toBe(90000);
expect(calculateReasoningTimeout('xhigh')).toBe(120000);
});
}); });
}); });

View File

@@ -202,8 +202,17 @@ describe('auto-mode-service.ts - Planning Mode', () => {
}); });
describe('buildFeaturePrompt', () => { describe('buildFeaturePrompt', () => {
const buildFeaturePrompt = (svc: any, feature: any) => { const defaultTaskExecutionPrompts = {
return svc.buildFeaturePrompt(feature); implementationInstructions: 'Test implementation instructions',
playwrightVerificationInstructions: 'Test playwright instructions',
};
const buildFeaturePrompt = (
svc: any,
feature: any,
taskExecutionPrompts = defaultTaskExecutionPrompts
) => {
return svc.buildFeaturePrompt(feature, taskExecutionPrompts);
}; };
it('should include feature ID and description', () => { it('should include feature ID and description', () => {
@@ -242,14 +251,15 @@ describe('auto-mode-service.ts - Planning Mode', () => {
expect(result).toContain('/tmp/image2.jpg'); expect(result).toContain('/tmp/image2.jpg');
}); });
it('should include summary tags instruction', () => { it('should include implementation instructions', () => {
const feature = { const feature = {
id: 'feat-123', id: 'feat-123',
description: 'Test feature', description: 'Test feature',
}; };
const result = buildFeaturePrompt(service, feature); const result = buildFeaturePrompt(service, feature);
expect(result).toContain('<summary>'); // The prompt should include the implementation instructions passed to it
expect(result).toContain('</summary>'); expect(result).toContain('Test implementation instructions');
expect(result).toContain('Test playwright instructions');
}); });
}); });

View File

@@ -91,7 +91,7 @@ describe('claude-usage-service.ts', () => {
it("should use 'where' command on Windows", async () => { it("should use 'where' command on Windows", async () => {
vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(os.platform).mockReturnValue('win32');
const windowsService = new ClaudeUsageService(); // Create new service after platform mock const ptyService = new ClaudeUsageService(); // Create new service after platform mock
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') { if (event === 'close') {
@@ -100,7 +100,7 @@ describe('claude-usage-service.ts', () => {
return mockSpawnProcess; return mockSpawnProcess;
}); });
await windowsService.isAvailable(); await ptyService.isAvailable();
expect(spawn).toHaveBeenCalledWith('where', ['claude']); expect(spawn).toHaveBeenCalledWith('where', ['claude']);
}); });
@@ -403,120 +403,22 @@ Resets Jan 15, 3pm
}); });
}); });
describe('executeClaudeUsageCommandMac', () => { // Note: executeClaudeUsageCommandMac tests removed - the service now uses PTY for all platforms
beforeEach(() => { // The executeClaudeUsageCommandMac method exists but is dead code (never called)
vi.mocked(os.platform).mockReturnValue('darwin'); describe.skip('executeClaudeUsageCommandMac (deprecated - uses PTY now)', () => {
vi.spyOn(process, 'env', 'get').mockReturnValue({ HOME: '/Users/testuser' }); it('should be skipped - service now uses PTY for all platforms', () => {
}); expect(true).toBe(true);
it('should execute expect script and return output', async () => {
const mockOutput = `
Current session
65% left
Resets in 2h
`;
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
// Simulate stdout data
stdoutCallback!(Buffer.from(mockOutput));
// Simulate successful close
closeCallback!(0);
const result = await promise;
expect(result.sessionPercentage).toBe(35); // 100 - 65
expect(spawn).toHaveBeenCalledWith(
'expect',
expect.arrayContaining(['-c']),
expect.any(Object)
);
});
it('should handle authentication errors', async () => {
const mockOutput = 'token_expired';
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
stdoutCallback!(Buffer.from(mockOutput));
closeCallback!(1);
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout with no data', async () => {
vi.useFakeTimers();
mockSpawnProcess.stdout = {
on: vi.fn(),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn(() => mockSpawnProcess);
mockSpawnProcess.kill = vi.fn();
const promise = service.fetchUsageData();
// Advance time past timeout (30 seconds)
vi.advanceTimersByTime(31000);
await expect(promise).rejects.toThrow('Command timed out');
vi.useRealTimers();
}); });
}); });
describe('executeClaudeUsageCommandWindows', () => { describe('executeClaudeUsageCommandPty', () => {
// Note: The service now uses PTY for all platforms, using process.cwd() as the working directory
beforeEach(() => { beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser');
vi.spyOn(process, 'env', 'get').mockReturnValue({ USERPROFILE: 'C:\\Users\\testuser' });
}); });
it('should use node-pty on Windows and return output', async () => { it('should use node-pty and return output', async () => {
const windowsService = new ClaudeUsageService(); // Create new service for Windows platform const ptyService = new ClaudeUsageService();
const mockOutput = ` const mockOutput = `
Current session Current session
65% left 65% left
@@ -538,7 +440,7 @@ Resets in 2h
}; };
vi.mocked(pty.spawn).mockReturnValue(mockPty as any); vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData(); const promise = ptyService.fetchUsageData();
// Simulate data // Simulate data
dataCallback!(mockOutput); dataCallback!(mockOutput);
@@ -549,16 +451,19 @@ Resets in 2h
const result = await promise; const result = await promise;
expect(result.sessionPercentage).toBe(35); expect(result.sessionPercentage).toBe(35);
// Service uses process.cwd() for --add-dir
expect(pty.spawn).toHaveBeenCalledWith( expect(pty.spawn).toHaveBeenCalledWith(
'cmd.exe', 'cmd.exe',
['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'], ['/c', 'claude', '--add-dir', process.cwd()],
expect.any(Object) expect.objectContaining({
cwd: process.cwd(),
})
); );
}); });
it('should send escape key after seeing usage data', async () => { it('should send escape key after seeing usage data', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const windowsService = new ClaudeUsageService(); const ptyService = new ClaudeUsageService();
const mockOutput = 'Current session\n65% left'; const mockOutput = 'Current session\n65% left';
@@ -577,7 +482,7 @@ Resets in 2h
}; };
vi.mocked(pty.spawn).mockReturnValue(mockPty as any); vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData(); const promise = ptyService.fetchUsageData();
// Simulate seeing usage data // Simulate seeing usage data
dataCallback!(mockOutput); dataCallback!(mockOutput);
@@ -594,8 +499,8 @@ Resets in 2h
vi.useRealTimers(); vi.useRealTimers();
}); });
it('should handle authentication errors on Windows', async () => { it('should handle authentication errors', async () => {
const windowsService = new ClaudeUsageService(); const ptyService = new ClaudeUsageService();
let dataCallback: Function | undefined; let dataCallback: Function | undefined;
let exitCallback: Function | undefined; let exitCallback: Function | undefined;
@@ -611,18 +516,22 @@ Resets in 2h
}; };
vi.mocked(pty.spawn).mockReturnValue(mockPty as any); vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData(); const promise = ptyService.fetchUsageData();
dataCallback!('authentication_error'); // Send data containing the authentication error pattern the service looks for
dataCallback!('"type":"authentication_error"');
// Trigger the exit handler which checks for auth errors
exitCallback!({ exitCode: 1 });
await expect(promise).rejects.toThrow( await expect(promise).rejects.toThrow(
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
); );
}); });
it('should handle timeout with no data on Windows', async () => { it('should handle timeout with no data', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const windowsService = new ClaudeUsageService(); const ptyService = new ClaudeUsageService();
const mockPty = { const mockPty = {
onData: vi.fn(), onData: vi.fn(),
@@ -633,7 +542,7 @@ Resets in 2h
}; };
vi.mocked(pty.spawn).mockReturnValue(mockPty as any); vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData(); const promise = ptyService.fetchUsageData();
// Advance time past timeout (45 seconds) // Advance time past timeout (45 seconds)
vi.advanceTimersByTime(46000); vi.advanceTimersByTime(46000);
@@ -648,7 +557,7 @@ Resets in 2h
it('should return data on timeout if data was captured', async () => { it('should return data on timeout if data was captured', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const windowsService = new ClaudeUsageService(); const ptyService = new ClaudeUsageService();
let dataCallback: Function | undefined; let dataCallback: Function | undefined;
@@ -663,7 +572,7 @@ Resets in 2h
}; };
vi.mocked(pty.spawn).mockReturnValue(mockPty as any); vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData(); const promise = ptyService.fetchUsageData();
// Simulate receiving usage data // Simulate receiving usage data
dataCallback!('Current session\n65% left\nResets in 2h'); dataCallback!('Current session\n65% left\nResets in 2h');
@@ -681,7 +590,9 @@ Resets in 2h
it('should send SIGTERM after ESC if process does not exit', async () => { it('should send SIGTERM after ESC if process does not exit', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const windowsService = new ClaudeUsageService(); // Mock Unix platform to test SIGTERM behavior (Windows calls kill() without signal)
vi.mocked(os.platform).mockReturnValue('darwin');
const ptyService = new ClaudeUsageService();
let dataCallback: Function | undefined; let dataCallback: Function | undefined;
@@ -696,7 +607,7 @@ Resets in 2h
}; };
vi.mocked(pty.spawn).mockReturnValue(mockPty as any); vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
windowsService.fetchUsageData(); ptyService.fetchUsageData();
// Simulate seeing usage data // Simulate seeing usage data
dataCallback!('Current session\n65% left'); dataCallback!('Current session\n65% left');

View File

@@ -190,9 +190,10 @@ describe('feature-loader.ts', () => {
const result = await loader.getAll(testProjectPath); const result = await loader.getAll(testProjectPath);
expect(result).toEqual([]); expect(result).toEqual([]);
// With recovery-enabled reads, warnings come from AtomicWriter and FeatureLoader
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/WARN.*\[FeatureLoader\]/), expect.stringMatching(/WARN.*\[AtomicWriter\]/),
expect.stringContaining('Failed to parse feature.json') expect.stringContaining('unavailable')
); );
consoleSpy.mockRestore(); consoleSpy.mockRestore();
@@ -260,10 +261,13 @@ describe('feature-loader.ts', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('should throw on other errors', async () => { it('should return null on other errors (with recovery attempt)', async () => {
// With recovery-enabled reads, get() returns null instead of throwing
// because it attempts to recover from backups before giving up
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied')); vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
await expect(loader.get(testProjectPath, 'feature-123')).rejects.toThrow('Permission denied'); const result = await loader.get(testProjectPath, 'feature-123');
expect(result).toBeNull();
}); });
}); });
@@ -442,4 +446,471 @@ describe('feature-loader.ts', () => {
); );
}); });
}); });
describe('findByTitle', () => {
it('should find feature by exact title match (case-insensitive)', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'Login Feature',
category: 'auth',
description: 'Login implementation',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Logout Feature',
category: 'auth',
description: 'Logout implementation',
})
);
const result = await loader.findByTitle(testProjectPath, 'LOGIN FEATURE');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
expect(result?.title).toBe('Login Feature');
});
it('should return null when title is not found', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'Login Feature',
category: 'auth',
description: 'Login implementation',
})
);
const result = await loader.findByTitle(testProjectPath, 'Nonexistent Feature');
expect(result).toBeNull();
});
it('should return null for empty or whitespace title', async () => {
const result1 = await loader.findByTitle(testProjectPath, '');
const result2 = await loader.findByTitle(testProjectPath, ' ');
expect(result1).toBeNull();
expect(result2).toBeNull();
});
it('should skip features without titles', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
// no title
category: 'auth',
description: 'Login implementation',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Login Feature',
category: 'auth',
description: 'Another login',
})
);
const result = await loader.findByTitle(testProjectPath, 'Login Feature');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-2000-def');
});
});
describe('findDuplicateTitle', () => {
it('should find duplicate title', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature description',
})
);
const result = await loader.findDuplicateTitle(testProjectPath, 'my feature');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
});
it('should exclude specified feature ID from duplicate check', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature 1',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Other Feature',
category: 'ui',
description: 'Feature 2',
})
);
// Should not find duplicate when excluding the feature that has the title
const result = await loader.findDuplicateTitle(
testProjectPath,
'My Feature',
'feature-1000-abc'
);
expect(result).toBeNull();
});
it('should find duplicate when title exists on different feature', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature 1',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Other Feature',
category: 'ui',
description: 'Feature 2',
})
);
// Should find duplicate because feature-1000-abc has the title and we're excluding feature-2000-def
const result = await loader.findDuplicateTitle(
testProjectPath,
'My Feature',
'feature-2000-def'
);
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
});
it('should return null for empty or whitespace title', async () => {
const result1 = await loader.findDuplicateTitle(testProjectPath, '');
const result2 = await loader.findDuplicateTitle(testProjectPath, ' ');
expect(result1).toBeNull();
expect(result2).toBeNull();
});
it('should handle titles with leading/trailing whitespace', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature description',
})
);
const result = await loader.findDuplicateTitle(testProjectPath, ' My Feature ');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
});
});
describe('syncFeatureToAppSpec', () => {
const sampleAppSpec = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
<project_name>Test Project</project_name>
<core_capabilities>
<capability>Testing</capability>
</core_capabilities>
<implemented_features>
<feature>
<name>Existing Feature</name>
<description>Already implemented</description>
</feature>
</implemented_features>
</project_specification>`;
const appSpecWithoutFeatures = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
<project_name>Test Project</project_name>
<core_capabilities>
<capability>Testing</capability>
</core_capabilities>
</project_specification>`;
it('should add feature to app_spec.txt', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature description',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('app_spec.txt'),
expect.stringContaining('New Feature'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('A new feature description'),
'utf-8'
);
});
it('should add feature with file locations', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'Feature With Locations',
category: 'backend',
description: 'Feature with file locations',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature, [
'src/feature.ts',
'src/utils/helper.ts',
]);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('src/feature.ts'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('src/utils/helper.ts'),
'utf-8'
);
});
it('should return false when app_spec.txt does not exist', async () => {
const error: any = new Error('File not found');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature description',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(false);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('should return false when feature already exists (duplicate)', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
const feature = {
id: 'feature-5678-xyz',
title: 'Existing Feature', // Same name as existing feature
category: 'ui',
description: 'Different description',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(false);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('should use feature ID as fallback name when title is missing', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
category: 'ui',
description: 'Feature without title',
// No title property
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('Feature: feature-1234-abc'),
'utf-8'
);
});
it('should handle app_spec without implemented_features section', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(appSpecWithoutFeatures);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'First Feature',
category: 'ui',
description: 'First implemented feature',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('<implemented_features>'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('First Feature'),
'utf-8'
);
});
it('should throw on non-ENOENT file read errors', async () => {
const error = new Error('Permission denied');
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature description',
};
await expect(loader.syncFeatureToAppSpec(testProjectPath, feature)).rejects.toThrow(
'Permission denied'
);
});
it('should preserve existing features when adding a new one', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature',
};
await loader.syncFeatureToAppSpec(testProjectPath, feature);
// Verify both old and new features are in the output
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('Existing Feature'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('New Feature'),
'utf-8'
);
});
it('should escape special characters in feature name and description', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'Feature with <special> & "chars"',
category: 'ui',
description: 'Description with <tags> & "quotes"',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
// The XML should have escaped characters
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('&lt;special&gt;'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('&amp;'),
'utf-8'
);
});
it('should not add empty file_locations array', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'Feature Without Locations',
category: 'ui',
description: 'No file locations',
};
await loader.syncFeatureToAppSpec(testProjectPath, feature, []);
// File locations should not be included when array is empty
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const writtenContent = writeCall[1] as string;
// Count occurrences of file_locations - should only have the one from Existing Feature if any
// The new feature should not add file_locations
expect(writtenContent).toContain('Feature Without Locations');
});
});
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@automaker/ui", "name": "@automaker/ui",
"version": "0.11.0", "version": "0.12.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker", "homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": { "repository": {
@@ -48,6 +48,22 @@
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@fontsource/cascadia-code": "^5.2.3",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inconsolata": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/iosevka": "^5.2.5",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/lato": "^5.2.7",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@fontsource/poppins": "^5.2.7",
"@fontsource/raleway": "^5.2.8",
"@fontsource/roboto": "^5.2.9",
"@fontsource/source-code-pro": "^5.2.7",
"@fontsource/source-sans-3": "^5.2.9",
"@fontsource/work-sans": "^5.2.8",
"@lezer/highlight": "1.2.3", "@lezer/highlight": "1.2.3",
"@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collapsible": "1.1.12",
@@ -204,12 +220,34 @@
"arch": [ "arch": [
"x64" "x64"
] ]
},
{
"target": "rpm",
"arch": [
"x64"
]
} }
], ],
"category": "Development", "category": "Development",
"icon": "public/logo_larger.png", "icon": "public/logo_larger.png",
"maintainer": "webdevcody@gmail.com", "maintainer": "webdevcody@gmail.com",
"executableName": "automaker" "executableName": "automaker",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"synopsis": "AI-powered autonomous development studio"
},
"rpm": {
"depends": [
"gtk3",
"libnotify",
"nss",
"libXScrnSaver",
"libXtst",
"xdg-utils",
"at-spi2-core",
"libuuid"
],
"compression": "xz",
"vendor": "AutoMaker Team"
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,

View File

@@ -8,6 +8,7 @@ import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import { useProviderAuthInit } from './hooks/use-provider-auth-init'; import { useProviderAuthInit } from './hooks/use-provider-auth-init';
import './styles/global.css'; import './styles/global.css';
import './styles/theme-imports'; import './styles/theme-imports';
import './styles/font-imports';
const logger = createLogger('App'); const logger = createLogger('App');

View File

@@ -0,0 +1,67 @@
/* Zed Fonts - https://github.com/zed-industries/zed-fonts */
/* Zed Sans - UI Font */
@font-face {
font-family: 'Zed Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./zed-sans-extended.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./zed-sans-extendeditalic.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./zed-sans-extendedbold.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('./zed-sans-extendedbolditalic.ttf') format('truetype');
}
/* Zed Mono - Code Font */
@font-face {
font-family: 'Zed Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./zed-mono-extended.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./zed-mono-extendeditalic.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./zed-mono-extendedbold.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Mono';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('./zed-mono-extendedbolditalic.ttf') format('truetype');
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -448,7 +448,9 @@ export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) {
); );
const getIconComponent = (iconName: string) => { const getIconComponent = (iconName: string) => {
return (LucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[iconName]; return (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[
iconName
];
}; };
return ( return (

View File

@@ -0,0 +1,207 @@
/**
* Notification Bell - Bell icon with unread count and popover
*/
import { useCallback } from 'react';
import { Bell, Check, Trash2, ExternalLink } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { useNotificationsStore } from '@/store/notifications-store';
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { Notification } from '@automaker/types';
import { cn } from '@/lib/utils';
/**
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString();
}
interface NotificationBellProps {
projectPath: string | null;
}
export function NotificationBell({ projectPath }: NotificationBellProps) {
const navigate = useNavigate();
const {
notifications,
unreadCount,
isPopoverOpen,
setPopoverOpen,
markAsRead,
dismissNotification,
} = useNotificationsStore();
// Load notifications and subscribe to events
useLoadNotifications(projectPath);
useNotificationEvents(projectPath);
const handleMarkAsRead = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
markAsRead(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.markAsRead(projectPath, notificationId);
},
[projectPath, markAsRead]
);
const handleDismiss = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
dismissNotification(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.dismiss(projectPath, notificationId);
},
[projectPath, dismissNotification]
);
const handleNotificationClick = useCallback(
(notification: Notification) => {
// Mark as read
handleMarkAsRead(notification.id);
setPopoverOpen(false);
// Navigate to the relevant view based on notification type
if (notification.featureId) {
navigate({ to: '/board' });
}
},
[handleMarkAsRead, setPopoverOpen, navigate]
);
const handleViewAll = useCallback(() => {
setPopoverOpen(false);
navigate({ to: '/notifications' });
}, [setPopoverOpen, navigate]);
const getNotificationIcon = (type: string) => {
switch (type) {
case 'feature_waiting_approval':
return <Bell className="h-4 w-4 text-yellow-500" />;
case 'feature_verified':
return <Check className="h-4 w-4 text-green-500" />;
case 'spec_regeneration_complete':
return <Check className="h-4 w-4 text-blue-500" />;
default:
return <Bell className="h-4 w-4" />;
}
};
// Show recent 3 notifications in popover
const recentNotifications = notifications.slice(0, 3);
if (!projectPath) {
return null;
}
return (
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'relative flex items-center justify-center w-8 h-8 rounded-md',
'hover:bg-accent transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
)}
title="Notifications"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start" side="right">
<div className="flex items-center justify-between px-4 py-3 border-b">
<h4 className="font-medium text-sm">Notifications</h4>
{unreadCount > 0 && (
<span className="text-xs text-muted-foreground">{unreadCount} unread</span>
)}
</div>
{recentNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 px-4">
<Bell className="h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No notifications</p>
</div>
) : (
<div className="max-h-[300px] overflow-y-auto">
{recentNotifications.map((notification) => (
<div
key={notification.id}
className={cn(
'flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-accent/50 border-b last:border-b-0',
!notification.read && 'bg-primary/5'
)}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex-shrink-0 mt-0.5">{getNotificationIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate">{notification.title}</p>
{!notification.read && (
<span className="h-1.5 w-1.5 rounded-full bg-primary flex-shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
{notification.message}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
{formatRelativeTime(new Date(notification.createdAt))}
</p>
</div>
<div className="flex-shrink-0 flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDismiss(notification.id);
}}
title="Dismiss"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{notifications.length > 0 && (
<div className="border-t px-4 py-2">
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={handleViewAll}>
View all notifications
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, memo } from 'react'; import { useEffect, useRef, useState, memo, useCallback } from 'react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { type ThemeMode, useAppStore } from '@/store/app-store'; import { type ThemeMode, useAppStore } from '@/store/app-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -8,6 +9,9 @@ import type { Project } from '@/lib/electron';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants'; import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants';
import { useThemePreview } from '@/components/layout/sidebar/hooks'; import { useThemePreview } from '@/components/layout/sidebar/hooks';
// Constant for "use global theme" option
const USE_GLOBAL_THEME = '' as const;
// Constants for z-index values // Constants for z-index values
const Z_INDEX = { const Z_INDEX = {
CONTEXT_MENU: 100, CONTEXT_MENU: 100,
@@ -124,19 +128,26 @@ export function ProjectContextMenu({
} = useAppStore(); } = useAppStore();
const [showRemoveDialog, setShowRemoveDialog] = useState(false); const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const [removeConfirmed, setRemoveConfirmed] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null); const themeSubmenuRef = useRef<HTMLDivElement>(null);
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: globalThis.MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) { // Don't close if a confirmation dialog is open (dialog is in a portal)
if (showRemoveDialog) return;
if (menuRef.current && !menuRef.current.contains(event.target as globalThis.Node)) {
setPreviewTheme(null); setPreviewTheme(null);
onClose(); onClose();
} }
}; };
const handleEscape = (event: KeyboardEvent) => { const handleEscape = (event: globalThis.KeyboardEvent) => {
// Don't close if a confirmation dialog is open (let the dialog handle escape)
if (showRemoveDialog) return;
if (event.key === 'Escape') { if (event.key === 'Escape') {
setPreviewTheme(null); setPreviewTheme(null);
onClose(); onClose();
@@ -150,7 +161,7 @@ export function ProjectContextMenu({
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape); document.removeEventListener('keydown', handleEscape);
}; };
}, [onClose, setPreviewTheme]); }, [onClose, setPreviewTheme, showRemoveDialog]);
const handleEdit = () => { const handleEdit = () => {
onEdit(project); onEdit(project);
@@ -160,166 +171,187 @@ export function ProjectContextMenu({
setShowRemoveDialog(true); setShowRemoveDialog(true);
}; };
const handleThemeSelect = (value: ThemeMode | '') => { const handleThemeSelect = useCallback(
setPreviewTheme(null); (value: ThemeMode | typeof USE_GLOBAL_THEME) => {
if (value !== '') { setPreviewTheme(null);
setTheme(value); const isUsingGlobal = value === USE_GLOBAL_THEME;
} else { setTheme(isUsingGlobal ? globalTheme : value);
setTheme(globalTheme); setProjectTheme(project.id, isUsingGlobal ? null : value);
} setShowThemeSubmenu(false);
setProjectTheme(project.id, value === '' ? null : value); },
setShowThemeSubmenu(false); [globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme]
}; );
const handleConfirmRemove = () => { const handleConfirmRemove = useCallback(() => {
moveProjectToTrash(project.id); moveProjectToTrash(project.id);
onClose(); toast.success('Project removed', {
}; description: `${project.name} has been removed from your projects list`,
});
setRemoveConfirmed(true);
}, [moveProjectToTrash, project.id, project.name]);
const handleDialogClose = useCallback(
(isOpen: boolean) => {
setShowRemoveDialog(isOpen);
// Close the context menu when dialog closes (whether confirmed or cancelled)
// This prevents the context menu from reappearing after dialog interaction
if (!isOpen) {
// Reset confirmation state
setRemoveConfirmed(false);
// Always close the context menu when dialog closes
onClose();
}
},
[onClose]
);
return ( return (
<> <>
<div {/* Hide context menu when confirm dialog is open */}
ref={menuRef} {!showRemoveDialog && (
className={cn( <div
'fixed min-w-48 rounded-lg', ref={menuRef}
'bg-popover text-popover-foreground', className={cn(
'border border-border shadow-lg', 'fixed min-w-48 rounded-lg',
'animate-in fade-in zoom-in-95 duration-100' 'bg-popover text-popover-foreground',
)} 'border border-border shadow-lg',
style={{ 'animate-in fade-in zoom-in-95 duration-100'
top: position.y, )}
left: position.x, style={{
zIndex: Z_INDEX.CONTEXT_MENU, top: position.y,
}} left: position.x,
data-testid="project-context-menu" zIndex: Z_INDEX.CONTEXT_MENU,
> }}
<div className="p-1"> data-testid="project-context-menu"
<button >
onClick={handleEdit} <div className="p-1">
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent'
)}
data-testid="edit-project-button"
>
<Edit2 className="w-4 h-4" />
<span>Edit Name & Icon</span>
</button>
{/* Theme Submenu Trigger */}
<div
className="relative"
onMouseEnter={() => setShowThemeSubmenu(true)}
onMouseLeave={() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
>
<button <button
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)} onClick={handleEdit}
className={cn( className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md', 'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left', 'text-sm font-medium text-left',
'hover:bg-accent transition-colors', 'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent' 'focus:outline-none focus:bg-accent'
)} )}
data-testid="theme-project-button" data-testid="edit-project-button"
> >
<Palette className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
<span className="flex-1">Project Theme</span> <span>Edit Name & Icon</span>
{project.theme && (
<span className="text-[10px] text-muted-foreground capitalize">
{project.theme}
</span>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button> </button>
{/* Theme Submenu */} {/* Theme Submenu Trigger */}
{showThemeSubmenu && ( <div
<div className="relative"
ref={themeSubmenuRef} onMouseEnter={() => setShowThemeSubmenu(true)}
onMouseLeave={() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
>
<button
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
className={cn( className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg', 'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'bg-popover text-popover-foreground', 'text-sm font-medium text-left',
'border border-border shadow-lg', 'hover:bg-accent transition-colors',
'animate-in fade-in zoom-in-95 duration-100' 'focus:outline-none focus:bg-accent'
)} )}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }} data-testid="theme-project-button"
data-testid="project-theme-submenu"
> >
<div className="p-2"> <Palette className="w-4 h-4" />
{/* Use Global Option */} <span className="flex-1">Project Theme</span>
<button {project.theme && (
onPointerEnter={() => handlePreviewEnter(globalTheme)} <span className="text-[10px] text-muted-foreground capitalize">
onPointerLeave={handlePreviewLeave} {project.theme}
onClick={() => handleThemeSelect('')} </span>
className={cn( )}
'w-full flex items-center gap-2 px-3 py-2 rounded-md', <ChevronRight className="w-4 h-4 text-muted-foreground" />
'text-sm font-medium text-left', </button>
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent',
!project.theme && 'bg-accent'
)}
data-testid="project-theme-global"
>
<Monitor className="w-4 h-4" />
<span>Use Global</span>
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
</button>
<div className="h-px bg-border my-2" /> {/* Theme Submenu */}
{showThemeSubmenu && (
<div
ref={themeSubmenuRef}
className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
data-testid="project-theme-submenu"
>
<div className="p-2">
{/* Use Global Option */}
<button
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={handlePreviewLeave}
onClick={() => handleThemeSelect(USE_GLOBAL_THEME)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent',
!project.theme && 'bg-accent'
)}
data-testid="project-theme-global"
>
<Monitor className="w-4 h-4" />
<span>Use Global</span>
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
</button>
{/* Two Column Layout - Using reusable ThemeColumn component */} <div className="h-px bg-border my-2" />
<div className="flex gap-2">
<ThemeColumn {/* Two Column Layout - Using reusable ThemeColumn component */}
title="Dark" <div className="flex gap-2">
icon={Moon} <ThemeColumn
themes={PROJECT_DARK_THEMES as ThemeOption[]} title="Dark"
selectedTheme={project.theme as ThemeMode | null} icon={Moon}
onPreviewEnter={handlePreviewEnter} themes={PROJECT_DARK_THEMES as ThemeOption[]}
onPreviewLeave={handlePreviewLeave} selectedTheme={project.theme as ThemeMode | null}
onSelect={handleThemeSelect} onPreviewEnter={handlePreviewEnter}
/> onPreviewLeave={handlePreviewLeave}
<ThemeColumn onSelect={handleThemeSelect}
title="Light" />
icon={Sun} <ThemeColumn
themes={PROJECT_LIGHT_THEMES as ThemeOption[]} title="Light"
selectedTheme={project.theme as ThemeMode | null} icon={Sun}
onPreviewEnter={handlePreviewEnter} themes={PROJECT_LIGHT_THEMES as ThemeOption[]}
onPreviewLeave={handlePreviewLeave} selectedTheme={project.theme as ThemeMode | null}
onSelect={handleThemeSelect} onPreviewEnter={handlePreviewEnter}
/> onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
</div>
</div> </div>
</div> </div>
</div> )}
)} </div>
</div>
<button <button
onClick={handleRemove} onClick={handleRemove}
className={cn( className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md', 'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left', 'text-sm font-medium text-left',
'text-destructive hover:bg-destructive/10', 'text-destructive hover:bg-destructive/10',
'transition-colors', 'transition-colors',
'focus:outline-none focus:bg-destructive/10' 'focus:outline-none focus:bg-destructive/10'
)} )}
data-testid="remove-project-button" data-testid="remove-project-button"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
<span>Remove Project</span> <span>Remove Project</span>
</button> </button>
</div>
</div> </div>
</div> )}
<ConfirmDialog <ConfirmDialog
open={showRemoveDialog} open={showRemoveDialog}
onOpenChange={setShowRemoveDialog} onOpenChange={handleDialogClose}
onConfirm={handleConfirmRemove} onConfirm={handleConfirmRemove}
title="Remove Project" title="Remove Project"
description={`Are you sure you want to remove "${project.name}" from the project list? This won't delete any files on disk.`} description={`Are you sure you want to remove "${project.name}" from the project list? This won't delete any files on disk.`}

View File

@@ -29,7 +29,7 @@ export function ProjectSwitcherItem({
// Get the icon component from lucide-react // Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => { const getIconComponent = (): LucideIcon => {
if (project.icon && project.icon in LucideIcons) { if (project.icon && project.icon in LucideIcons) {
return (LucideIcons as Record<string, LucideIcon>)[project.icon]; return (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon];
} }
return Folder; return Folder;
}; };

View File

@@ -1,20 +1,23 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug, FolderOpen } from 'lucide-react'; import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store'; import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection'; import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu'; import { ProjectContextMenu } from './components/project-context-menu';
import { EditProjectDialog } from './components/edit-project-dialog'; import { EditProjectDialog } from './components/edit-project-dialog';
import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks'; import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
import type { FeatureCount } from '@/components/views/spec-view/types';
function getOSAbbreviation(os: string): string { function getOSAbbreviation(os: string): string {
switch (os) { switch (os) {
@@ -31,6 +34,9 @@ function getOSAbbreviation(os: string): string {
export function ProjectSwitcher() { export function ProjectSwitcher() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { hideWiki } = SIDEBAR_FEATURE_FLAGS;
const isWikiActive = location.pathname === '/wiki';
const { const {
projects, projects,
currentProject, currentProject,
@@ -52,7 +58,7 @@ export function ProjectSwitcher() {
const [projectOverview, setProjectOverview] = useState(''); const [projectOverview, setProjectOverview] = useState('');
const [generateFeatures, setGenerateFeatures] = useState(true); const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true); const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState(5); const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
// Derive isCreatingSpec from store state // Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null; const isCreatingSpec = specCreatingForProject !== null;
@@ -124,6 +130,10 @@ export function ProjectSwitcher() {
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
}, []); }, []);
const handleWikiClick = useCallback(() => {
navigate({ to: '/wiki' });
}, [navigate]);
/** /**
* Opens the system folder selection dialog and initializes the selected project. * Opens the system folder selection dialog and initializes the selected project.
*/ */
@@ -199,13 +209,18 @@ export function ProjectSwitcher() {
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
await api.generateAppSpec({ if (!api.specRegeneration) {
projectPath: setupProjectPath, toast.error('Spec regeneration not available');
setSpecCreatingForProject(null);
return;
}
await api.specRegeneration.create(
setupProjectPath,
projectOverview, projectOverview,
generateFeatures, generateFeatures,
analyzeProject, analyzeProject,
featureCount, featureCount
}); );
} catch (error) { } catch (error) {
console.error('Failed to generate spec:', error); console.error('Failed to generate spec:', error);
toast.error('Failed to generate spec', { toast.error('Failed to generate spec', {
@@ -319,6 +334,11 @@ export function ProjectSwitcher() {
v{appVersion} {versionSuffix} v{appVersion} {versionSuffix}
</span> </span>
</button> </button>
{/* Notification Bell */}
<div className="flex justify-center mt-2">
<NotificationBell projectPath={currentProject?.path ?? null} />
</div>
<div className="w-full h-px bg-border mt-3" /> <div className="w-full h-px bg-border mt-3" />
</div> </div>
@@ -405,8 +425,37 @@ export function ProjectSwitcher() {
)} )}
</div> </div>
{/* Bug Report Button at the very bottom */} {/* Wiki and Bug Report Buttons at the very bottom */}
<div className="p-2 border-t border-border/40"> <div className="p-2 border-t border-border/40 space-y-2">
{/* Wiki Button */}
{!hideWiki && (
<button
onClick={handleWikiClick}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
isWikiActive
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95',
]
)}
title="Wiki"
data-testid="wiki-button"
>
<BookOpen
className={cn('w-5 h-5', isWikiActive && 'text-brand-500 drop-shadow-sm')}
/>
</button>
)}
{/* Bug Report Button */}
<button <button
onClick={handleBugReportClick} onClick={handleBugReportClick}
className={cn( className={cn(

View File

@@ -5,6 +5,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar'); const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store'; import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
@@ -19,7 +20,10 @@ import {
SidebarHeader, SidebarHeader,
SidebarNavigation, SidebarNavigation,
SidebarFooter, SidebarFooter,
MobileSidebarToggle,
} from './sidebar/components'; } from './sidebar/components';
import { useIsCompact } from '@/hooks/use-media-query';
import { PanelLeftClose } from 'lucide-react';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants'; import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import { import {
@@ -43,9 +47,11 @@ export function Sidebar() {
trashedProjects, trashedProjects,
currentProject, currentProject,
sidebarOpen, sidebarOpen,
mobileSidebarHidden,
projectHistory, projectHistory,
upsertAndSetCurrentProject, upsertAndSetCurrentProject,
toggleSidebar, toggleSidebar,
toggleMobileSidebarHidden,
restoreTrashedProject, restoreTrashedProject,
deleteTrashedProject, deleteTrashedProject,
emptyTrash, emptyTrash,
@@ -56,13 +62,17 @@ export function Sidebar() {
setSpecCreatingForProject, setSpecCreatingForProject,
} = useAppStore(); } = useAppStore();
const isCompact = useIsCompact();
// Environment variable flags for hiding sidebar items // Environment variable flags for hiding sidebar items
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor } = const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts // Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
// Get unread notifications count
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
// State for delete project confirmation dialog // State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
@@ -239,6 +249,7 @@ export function Sidebar() {
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount, unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating: isCurrentProjectGeneratingSpec, isSpecGenerating: isCurrentProjectGeneratingSpec,
}); });
@@ -251,10 +262,16 @@ export function Sidebar() {
return location.pathname === routePath; return location.pathname === routePath;
}; };
// Check if sidebar should be completely hidden on mobile
const shouldHideSidebar = isCompact && mobileSidebarHidden;
return ( return (
<> <>
{/* Floating toggle to show sidebar on mobile when hidden */}
<MobileSidebarToggle />
{/* Mobile backdrop overlay */} {/* Mobile backdrop overlay */}
{sidebarOpen && ( {sidebarOpen && !shouldHideSidebar && (
<div <div
className="fixed inset-0 bg-black/50 z-20 lg:hidden" className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar} onClick={toggleSidebar}
@@ -270,8 +287,11 @@ export function Sidebar() {
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]', 'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition // Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]', 'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: completely hidden when mobileSidebarHidden is true
shouldHideSidebar && 'hidden',
// Mobile: overlay when open, collapsed when closed // Mobile: overlay when open, collapsed when closed
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16' !shouldHideSidebar &&
(sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16')
)} )}
data-testid="sidebar" data-testid="sidebar"
> >
@@ -281,8 +301,33 @@ export function Sidebar() {
shortcut={shortcuts.toggleSidebar} shortcut={shortcuts.toggleSidebar}
/> />
{/* Floating hide button on right edge - only visible on compact screens when sidebar is collapsed */}
{!sidebarOpen && isCompact && (
<button
onClick={toggleMobileSidebarHidden}
className={cn(
'absolute -right-6 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center w-6 h-10 rounded-r-lg',
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
'shadow-lg hover:shadow-xl hover:shadow-brand-500/10',
'transition-all duration-200',
'hover:w-8 active:scale-95'
)}
aria-label="Hide sidebar"
data-testid="sidebar-mobile-hide"
>
<PanelLeftClose className="w-3.5 h-3.5" />
</button>
)}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} /> <SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onClose={toggleSidebar}
onExpand={toggleSidebar}
/>
<SidebarNavigation <SidebarNavigation
currentProject={currentProject} currentProject={currentProject}
@@ -297,7 +342,6 @@ export function Sidebar() {
sidebarOpen={sidebarOpen} sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute} isActiveRoute={isActiveRoute}
navigate={navigate} navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents} hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount} runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }} shortcuts={{ settings: shortcuts.settings }}

View File

@@ -1,6 +1,7 @@
import { PanelLeft, PanelLeftClose } from 'lucide-react'; import { PanelLeft, PanelLeftClose } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store'; import { formatShortcut } from '@/store/app-store';
import { useIsCompact } from '@/hooks/use-media-query';
interface CollapseToggleButtonProps { interface CollapseToggleButtonProps {
sidebarOpen: boolean; sidebarOpen: boolean;
@@ -13,6 +14,13 @@ export function CollapseToggleButton({
toggleSidebar, toggleSidebar,
shortcut, shortcut,
}: CollapseToggleButtonProps) { }: CollapseToggleButtonProps) {
const isCompact = useIsCompact();
// Hide when in compact mode (mobile menu is shown in board header)
if (isCompact) {
return null;
}
return ( return (
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}

View File

@@ -8,3 +8,4 @@ export { ProjectActions } from './project-actions';
export { SidebarNavigation } from './sidebar-navigation'; export { SidebarNavigation } from './sidebar-navigation';
export { ProjectSelectorWithOptions } from './project-selector-with-options'; export { ProjectSelectorWithOptions } from './project-selector-with-options';
export { SidebarFooter } from './sidebar-footer'; export { SidebarFooter } from './sidebar-footer';
export { MobileSidebarToggle } from './mobile-sidebar-toggle';

View File

@@ -0,0 +1,42 @@
import { PanelLeft } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useIsCompact } from '@/hooks/use-media-query';
/**
* Floating toggle button for mobile that completely hides/shows the sidebar.
* Positioned at the left-center of the screen.
* Only visible on compact/mobile screens when the sidebar is hidden.
*/
export function MobileSidebarToggle() {
const isCompact = useIsCompact();
const { mobileSidebarHidden, toggleMobileSidebarHidden } = useAppStore();
// Only show on compact screens when sidebar is hidden
if (!isCompact || !mobileSidebarHidden) {
return null;
}
return (
<button
onClick={toggleMobileSidebarHidden}
className={cn(
'fixed left-0 top-1/2 -translate-y-1/2 z-50',
'flex items-center justify-center',
'w-8 h-12 rounded-r-lg',
// Glass morphism background
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
// Shadow and hover effects
'shadow-lg shadow-black/10 hover:shadow-xl hover:shadow-brand-500/10',
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
'hover:border-brand-500/30',
'transition-all duration-200 ease-out',
'hover:w-10 active:scale-95'
)}
aria-label="Show sidebar"
data-testid="mobile-sidebar-toggle"
>
<PanelLeft className="w-4 h-4 pointer-events-none" />
</button>
);
}

View File

@@ -1,13 +1,12 @@
import type { NavigateOptions } from '@tanstack/react-router'; import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store'; import { formatShortcut } from '@/store/app-store';
import { BookOpen, Activity, Settings } from 'lucide-react'; import { Activity, Settings } from 'lucide-react';
interface SidebarFooterProps { interface SidebarFooterProps {
sidebarOpen: boolean; sidebarOpen: boolean;
isActiveRoute: (id: string) => boolean; isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void; navigate: (opts: NavigateOptions) => void;
hideWiki: boolean;
hideRunningAgents: boolean; hideRunningAgents: boolean;
runningAgentsCount: number; runningAgentsCount: number;
shortcuts: { shortcuts: {
@@ -19,7 +18,6 @@ export function SidebarFooter({
sidebarOpen, sidebarOpen,
isActiveRoute, isActiveRoute,
navigate, navigate,
hideWiki,
hideRunningAgents, hideRunningAgents,
runningAgentsCount, runningAgentsCount,
shortcuts, shortcuts,
@@ -34,66 +32,6 @@ export function SidebarFooter({
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent' 'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
)} )}
> >
{/* Wiki Link */}
{!hideWiki && (
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: '/wiki' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('wiki')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? 'Wiki' : undefined}
data-testid="wiki-link"
>
<BookOpen
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('wiki')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
)}
>
Wiki
</span>
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
>
Wiki
</span>
)}
</button>
</div>
)}
{/* Running Agents Link */} {/* Running Agents Link */}
{!hideRunningAgents && ( {!hideRunningAgents && (
<div className="p-2 pb-0"> <div className="p-2 pb-0">
@@ -213,7 +151,7 @@ export function SidebarFooter({
sidebarOpen ? 'justify-start' : 'justify-center', sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]' 'hover:scale-[1.02] active:scale-[0.97]'
)} )}
title={!sidebarOpen ? 'Settings' : undefined} title={!sidebarOpen ? 'Global Settings' : undefined}
data-testid="settings-button" data-testid="settings-button"
> >
<Settings <Settings
@@ -230,7 +168,7 @@ export function SidebarFooter({
sidebarOpen ? 'block' : 'hidden' sidebarOpen ? 'block' : 'hidden'
)} )}
> >
Settings Global Settings
</span> </span>
{sidebarOpen && ( {sidebarOpen && (
<span <span
@@ -256,7 +194,7 @@ export function SidebarFooter({
'translate-x-1 group-hover:translate-x-0' 'translate-x-1 group-hover:translate-x-0'
)} )}
> >
Settings Global Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground"> <span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)} {formatShortcut(shortcuts.settings, true)}
</span> </span>

View File

@@ -1,19 +1,33 @@
import { Folder, LucideIcon } from 'lucide-react'; import { useState } from 'react';
import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { cn, isMac } from '@/lib/utils'; import { cn, isMac } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { isElectron, type Project } from '@/lib/electron'; import { isElectron, type Project } from '@/lib/electron';
import { useIsCompact } from '@/hooks/use-media-query';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/store/app-store';
interface SidebarHeaderProps { interface SidebarHeaderProps {
sidebarOpen: boolean; sidebarOpen: boolean;
currentProject: Project | null; currentProject: Project | null;
onClose?: () => void;
onExpand?: () => void;
} }
export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) { export function SidebarHeader({
sidebarOpen,
currentProject,
onClose,
onExpand,
}: SidebarHeaderProps) {
const isCompact = useIsCompact();
const [projectListOpen, setProjectListOpen] = useState(false);
const { projects, setCurrentProject } = useAppStore();
// Get the icon component from lucide-react // Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => { const getIconComponent = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) { if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as Record<string, LucideIcon>)[currentProject.icon]; return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
} }
return Folder; return Folder;
}; };
@@ -24,43 +38,141 @@ export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProp
return ( return (
<div <div
className={cn( className={cn(
'shrink-0 flex flex-col', 'shrink-0 flex flex-col relative',
// Add padding on macOS Electron for traffic light buttons // Add padding on macOS Electron for traffic light buttons
isMac && isElectron() && 'pt-[10px]' isMac && isElectron() && 'pt-[10px]'
)} )}
> >
{/* Project name and icon display */} {/* Mobile close button - only visible on mobile when sidebar is open */}
{currentProject && ( {sidebarOpen && onClose && (
<div <button
onClick={onClose}
className={cn( className={cn(
'flex items-center gap-3 px-4 pt-3 pb-1', 'lg:hidden absolute top-3 right-3 z-10',
!sidebarOpen && 'justify-center px-2' 'flex items-center justify-center w-8 h-8 rounded-lg',
'bg-muted/50 hover:bg-muted',
'text-muted-foreground hover:text-foreground',
'transition-colors duration-200'
)} )}
aria-label="Close navigation"
data-testid="sidebar-mobile-close"
> >
{/* Project Icon */} <X className="w-5 h-5" />
<div className="shrink-0"> </button>
{hasCustomIcon ? ( )}
<img {/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */}
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)} {!sidebarOpen && isCompact && onExpand && (
alt={currentProject.name} <button
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50" onClick={onExpand}
/> className={cn(
) : ( 'flex items-center justify-center w-10 h-10 mx-auto mt-2 rounded-lg',
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center"> 'bg-muted/50 hover:bg-muted',
<IconComponent className="w-5 h-5 text-brand-500" /> 'text-muted-foreground hover:text-foreground',
</div> 'transition-colors duration-200'
)}
</div>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
)} )}
</div> aria-label="Expand navigation"
data-testid="sidebar-mobile-expand"
>
<Menu className="w-5 h-5" />
</button>
)}
{/* Project name and icon display - entire element clickable on mobile */}
{currentProject && (
<Popover open={projectListOpen} onOpenChange={setProjectListOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'flex items-center gap-3 px-4 pt-3 pb-1 w-full text-left',
'rounded-lg transition-colors duration-150',
!sidebarOpen && 'justify-center px-2',
// Only enable click behavior on compact screens
isCompact && 'hover:bg-accent/50 cursor-pointer',
!isCompact && 'pointer-events-none'
)}
title={isCompact ? 'Switch project' : undefined}
>
{/* Project Icon */}
<div className="shrink-0">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(
currentProject.customIconPath!,
currentProject.path
)}
alt={currentProject.name}
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<IconComponent className="w-5 h-5 text-brand-500" />
</div>
)}
</div>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start" side="bottom" sideOffset={8}>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Switch Project</p>
{projects.map((project) => {
const ProjectIcon =
project.icon && project.icon in LucideIcons
? (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon]
: Folder;
const isActive = currentProject?.id === project.id;
return (
<button
key={project.id}
onClick={() => {
setCurrentProject(project);
setProjectListOpen(false);
}}
className={cn(
'w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left',
'transition-colors duration-150',
isActive
? 'bg-brand-500/10 text-brand-500'
: 'hover:bg-accent text-foreground'
)}
>
{project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
alt={project.name}
className="w-6 h-6 rounded object-cover ring-1 ring-border/50"
/>
) : (
<div
className={cn(
'w-6 h-6 rounded flex items-center justify-center',
isActive ? 'bg-brand-500/20' : 'bg-muted'
)}
>
<ProjectIcon
className={cn(
'w-4 h-4',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
</div>
)}
<span className="flex-1 text-sm truncate">{project.name}</span>
{isActive && <Check className="w-4 h-4 text-brand-500" />}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)} )}
</div> </div>
); );

View File

@@ -21,7 +21,12 @@ export function SidebarNavigation({
navigate, navigate,
}: SidebarNavigationProps) { }: SidebarNavigationProps) {
return ( return (
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-1' : 'mt-1')}> <nav
className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
sidebarOpen ? 'mt-1' : 'mt-1'
)}
>
{!currentProject && sidebarOpen ? ( {!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state) // Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4"> <div className="flex items-center justify-center h-full px-4">
@@ -41,7 +46,13 @@ export function SidebarNavigation({
</span> </span>
</div> </div>
)} )}
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>} {/* Separator for sections without label (visual separation) */}
{!section.label && sectionIdx > 0 && sidebarOpen && (
<div className="h-px bg-border/40 mx-3 mb-4"></div>
)}
{(section.label || sectionIdx > 0) && !sidebarOpen && (
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
)}
{/* Nav Items */} {/* Nav Items */}
<div className="space-y-1.5"> <div className="space-y-1.5">

View File

@@ -11,6 +11,8 @@ import {
Lightbulb, Lightbulb,
Brain, Brain,
Network, Network,
Bell,
Settings,
} from 'lucide-react'; } from 'lucide-react';
import type { NavSection, NavItem } from '../types'; import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -32,9 +34,11 @@ interface UseNavigationProps {
agent: string; agent: string;
terminal: string; terminal: string;
settings: string; settings: string;
projectSettings: string;
ideation: string; ideation: string;
githubIssues: string; githubIssues: string;
githubPrs: string; githubPrs: string;
notifications: string;
}; };
hideSpecEditor: boolean; hideSpecEditor: boolean;
hideContext: boolean; hideContext: boolean;
@@ -49,6 +53,8 @@ interface UseNavigationProps {
cycleNextProject: () => void; cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */ /** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number; unviewedValidationsCount?: number;
/** Count of unread notifications to show on Notifications nav item */
unreadNotificationsCount?: number;
/** Whether spec generation is currently running for the current project */ /** Whether spec generation is currently running for the current project */
isSpecGenerating?: boolean; isSpecGenerating?: boolean;
} }
@@ -67,6 +73,7 @@ export function useNavigation({
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount, unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating, isSpecGenerating,
}: UseNavigationProps) { }: UseNavigationProps) {
// Track if current project has a GitHub remote // Track if current project has a GitHub remote
@@ -199,6 +206,26 @@ export function useNavigation({
}); });
} }
// Add Notifications and Project Settings as a standalone section (no label for visual separation)
sections.push({
label: '',
items: [
{
id: 'notifications',
label: 'Notifications',
icon: Bell,
shortcut: shortcuts.notifications,
count: unreadNotificationsCount,
},
{
id: 'project-settings',
label: 'Project Settings',
icon: Settings,
shortcut: shortcuts.projectSettings,
},
],
});
return sections; return sections;
}, [ }, [
shortcuts, shortcuts,
@@ -207,6 +234,7 @@ export function useNavigation({
hideTerminal, hideTerminal,
hasGitHubRemote, hasGitHubRemote,
unviewedValidationsCount, unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating, isSpecGenerating,
]); ]);
@@ -257,11 +285,11 @@ export function useNavigation({
}); });
}); });
// Add settings shortcut // Add global settings shortcut
shortcutsList.push({ shortcutsList.push({
key: shortcuts.settings, key: shortcuts.settings,
action: () => navigate({ to: '/settings' }), action: () => navigate({ to: '/settings' }),
description: 'Navigate to Settings', description: 'Navigate to Global Settings',
}); });
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
@@ -6,26 +6,48 @@ const logger = createLogger('RunningAgents');
export function useRunningAgents() { export function useRunningAgents() {
const [runningAgentsCount, setRunningAgentsCount] = useState(0); const [runningAgentsCount, setRunningAgentsCount] = useState(0);
const fetchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Fetch running agents count function - used for initial load and event-driven updates // Fetch running agents count function - used for initial load and event-driven updates
const fetchRunningAgentsCount = useCallback(async () => { const fetchRunningAgentsCount = useCallback(async () => {
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
if (api.runningAgents) { if (api.runningAgents) {
logger.debug('Fetching running agents count');
const result = await api.runningAgents.getAll(); const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) { if (result.success && result.runningAgents) {
logger.debug('Running agents count fetched', {
count: result.runningAgents.length,
});
setRunningAgentsCount(result.runningAgents.length); setRunningAgentsCount(result.runningAgents.length);
} else {
logger.debug('Running agents count fetch returned empty/failed', {
success: result.success,
});
} }
} else {
logger.debug('Running agents API not available');
} }
} catch (error) { } catch (error) {
logger.error('Error fetching running agents count:', error); logger.error('Error fetching running agents count:', error);
} }
}, []); }, []);
// Debounced fetch to avoid excessive API calls from frequent events
const debouncedFetchRunningAgentsCount = useCallback(() => {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current);
}
fetchTimeoutRef.current = setTimeout(() => {
fetchRunningAgentsCount();
}, 300);
}, [fetchRunningAgentsCount]);
// Subscribe to auto-mode events to update running agents count in real-time // Subscribe to auto-mode events to update running agents count in real-time
useEffect(() => { useEffect(() => {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api.autoMode) { if (!api.autoMode) {
logger.debug('Auto mode API not available for running agents hook');
// If autoMode is not available, still fetch initial count // If autoMode is not available, still fetch initial count
fetchRunningAgentsCount(); fetchRunningAgentsCount();
return; return;
@@ -35,6 +57,9 @@ export function useRunningAgents() {
fetchRunningAgentsCount(); fetchRunningAgentsCount();
const unsubscribe = api.autoMode.onEvent((event) => { const unsubscribe = api.autoMode.onEvent((event) => {
logger.debug('Auto mode event for running agents hook', {
type: event.type,
});
// When a feature starts, completes, or errors, refresh the count // When a feature starts, completes, or errors, refresh the count
if ( if (
event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_feature_complete' ||
@@ -50,6 +75,57 @@ export function useRunningAgents() {
}; };
}, [fetchRunningAgentsCount]); }, [fetchRunningAgentsCount]);
// Subscribe to backlog plan events to update running agents count
useEffect(() => {
const api = getElectronAPI();
if (!api.backlogPlan) return;
fetchRunningAgentsCount();
const unsubscribe = api.backlogPlan.onEvent(() => {
fetchRunningAgentsCount();
});
return () => {
unsubscribe();
};
}, [fetchRunningAgentsCount]);
// Subscribe to spec regeneration events to update running agents count
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
fetchRunningAgentsCount();
const unsubscribe = api.specRegeneration.onEvent((event) => {
logger.debug('Spec regeneration event for running agents hook', {
type: event.type,
});
// When spec regeneration completes or errors, refresh immediately
if (event.type === 'spec_regeneration_complete' || event.type === 'spec_regeneration_error') {
fetchRunningAgentsCount();
}
// For progress events, use debounced fetch to avoid excessive calls
else if (event.type === 'spec_regeneration_progress') {
debouncedFetchRunningAgentsCount();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgentsCount, debouncedFetchRunningAgentsCount]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current);
}
};
}, []);
return { return {
runningAgentsCount, runningAgentsCount,
}; };

Some files were not shown because too many files have changed in this diff Show More