Compare commits

..

6 Commits

Author SHA1 Message Date
Stefan de Vogelaere
e3f3f5fbd1 test: fix server tests for provider model passthrough behavior
- Update model-resolver.test.ts to expect unknown models to pass through
  unchanged (supports ClaudeCompatibleProvider models like GLM-4.7)
- Remove warning expectations for unknown models (valid for providers)
- Add missing getCredentials and getGlobalSettings mocks to
  ideation-service.test.ts for settingsService
2026-01-20 20:23:29 +01:00
Stefan de Vogelaere
f007ca2c80 refactor: simplify getPhaseModelWithOverrides calls per code review
Address code review feedback on PR #629:
- Make settingsService parameter optional in getPhaseModelWithOverrides
- Function now handles undefined settingsService gracefully by returning defaults
- Remove redundant ternary checks in 4 call sites:
  - apps/server/src/routes/context/routes/describe-file.ts
  - apps/server/src/routes/context/routes/describe-image.ts
  - apps/server/src/routes/worktree/routes/generate-commit-message.ts
  - apps/server/src/services/auto-mode-service.ts
- Remove unused DEFAULT_PHASE_MODELS imports where applicable
2026-01-20 20:16:25 +01:00
Stefan de Vogelaere
8efd14c580 fix: update tests for new model resolver passthrough behavior
1. model-resolver tests:
   - Unknown models now pass through unchanged (provider model support)
   - Removed expectations for warnings on unknown models
   - Updated case sensitivity and edge case tests accordingly
   - Added tests for provider-like model names (GLM-4.7, MiniMax-M2.1)

2. atomic-writer tests:
   - Updated regex to match new temp file format with random suffix
   - Format changed from .tmp.{timestamp} to .tmp.{timestamp}.{hex}
2026-01-20 20:09:52 +01:00
Stefan de Vogelaere
86e3892c66 fix: atomic writer race condition and bulk replace reset to defaults
1. AtomicWriter Race Condition Fix (libs/utils/src/atomic-writer.ts):
   - Changed temp file naming from Date.now() to Date.now() + random hex
   - Uses crypto.randomBytes(4).toString('hex') for uniqueness
   - Prevents ENOENT errors when multiple concurrent writes happen
     within the same millisecond

2. Bulk Replace "Anthropic Direct" Reset (both dialogs):
   - When selecting "Anthropic Direct", now uses DEFAULT_PHASE_MODELS
   - Properly resets thinking levels and other settings to defaults
   - Added thinkingLevel to the change detection comparison
   - Affects both global and project-level bulk replace dialogs
2026-01-20 19:55:13 +01:00
Stefan de Vogelaere
8ffe69feb1 merge: integrate v0.13.0rc with React Query refactor
Resolved conflict in use-project-settings-loader.ts:
- Keep React Query approach from upstream
- Add phaseModelOverrides loading for provider model persistence
- Update both currentProject and projects array to keep in sync
2026-01-20 19:36:30 +01:00
Stefan de Vogelaere
2ceab3d65e feat: refactor Claude API Profiles to Claude Compatible Providers
- Rename ClaudeApiProfile to ClaudeCompatibleProvider with models[] array
- Each ProviderModel has mapsToClaudeModel field for Claude tier mapping
- Add providerType field for provider-specific icons (glm, minimax, openrouter)
- Add thinking level support for provider models in phase selectors
- Show all mapped Claude models per provider model (e.g., "Maps to Haiku, Sonnet, Opus")
- Add Bulk Replace feature to switch all phases to a provider at once
- Hide Bulk Replace button when no providers are enabled
- Fix project-level phaseModelOverrides not persisting after refresh
- Fix deleting last provider not persisting (remove empty array guard)
- Add getProviderByModelId() helper for all SDK routes
- Update all routes to pass provider config for provider models
- Update terminology from "profiles" to "providers" throughout UI
- Update documentation to reflect new provider system
2026-01-20 19:13:34 +01:00
437 changed files with 16863 additions and 46426 deletions

View File

@@ -4,9 +4,6 @@ on:
release:
types: [published]
permissions:
contents: write
jobs:
build:
strategy:
@@ -65,10 +62,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: macos-builds
path: |
apps/ui/release/*.dmg
apps/ui/release/*.zip
if-no-files-found: error
path: apps/ui/release/*.{dmg,zip}
retention-days: 30
- name: Upload Windows artifacts
@@ -77,7 +71,6 @@ jobs:
with:
name: windows-builds
path: apps/ui/release/*.exe
if-no-files-found: error
retention-days: 30
- name: Upload Linux artifacts
@@ -85,11 +78,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: linux-builds
path: |
apps/ui/release/*.AppImage
apps/ui/release/*.deb
apps/ui/release/*.rpm
if-no-files-found: error
path: apps/ui/release/*.{AppImage,deb,rpm}
retention-days: 30
upload:
@@ -119,13 +108,9 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
fail_on_unmatched_files: true
files: |
artifacts/macos-builds/*.dmg
artifacts/macos-builds/*.zip
artifacts/windows-builds/*.exe
artifacts/linux-builds/*.AppImage
artifacts/linux-builds/*.deb
artifacts/linux-builds/*.rpm
artifacts/macos-builds/*.{dmg,zip,blockmap}
artifacts/windows-builds/*.{exe,blockmap}
artifacts/linux-builds/*.{AppImage,deb,rpm,blockmap}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -95,6 +95,3 @@ data/.api-key
data/credentials.json
data/
.codex/
# GSD planning docs (local-only)
.planning/

View File

@@ -1,81 +0,0 @@
# AutoModeService Refactoring
## What This Is
A comprehensive refactoring of the `auto-mode-service.ts` file (5k+ lines) into smaller, focused services with clear boundaries. This is an architectural cleanup of accumulated technical debt from rapid development, breaking the "god object" anti-pattern into maintainable, debuggable modules.
## Core Value
All existing auto-mode functionality continues working — features execute, pipelines flow, merges complete — while the codebase becomes maintainable.
## Requirements
### Validated
<!-- Existing functionality that must be preserved -->
- ✓ Single feature execution with AI agent — existing
- ✓ Concurrent execution with configurable limits — existing
- ✓ Pipeline orchestration (backlog → in-progress → approval → verified) — existing
- ✓ Git worktree isolation per feature — existing
- ✓ Automatic merging of completed work — existing
- ✓ Custom pipeline support — existing
- ✓ Test runner integration — existing
- ✓ Event streaming to frontend — existing
### Active
<!-- Refactoring goals -->
- [ ] No service file exceeds ~500 lines
- [ ] Each service has single, clear responsibility
- [ ] Service boundaries make debugging obvious
- [ ] Changes to one service don't risk breaking unrelated features
- [ ] Test coverage for critical paths
### Out of Scope
- New auto-mode features — this is cleanup, not enhancement
- UI changes — backend refactor only
- Performance optimization — maintain current performance, don't optimize
- Other service refactoring — focus on auto-mode-service.ts only
## Context
**Current state:** `apps/server/src/services/auto-mode-service.ts` is ~5700 lines handling:
- Worktree management (create, cleanup, track)
- Agent/task execution coordination
- Concurrency control and queue management
- Pipeline state machine (column transitions)
- Merge handling and conflict resolution
- Event emission for real-time updates
**Technical environment:**
- Express 5 backend, TypeScript
- Event-driven architecture via EventEmitter
- WebSocket streaming to React frontend
- Git worktrees via @automaker/git-utils
- Minimal existing test coverage
**Codebase analysis:** See `.planning/codebase/` for full architecture, conventions, and existing patterns.
## Constraints
- **Breaking changes**: Acceptable — other parts of the app can be updated to match new service interfaces
- **Test coverage**: Currently minimal — must add tests during refactoring to catch regressions
- **Incremental approach**: Required — can't do big-bang rewrite with everything critical
- **Existing patterns**: Follow conventions in `.planning/codebase/CONVENTIONS.md`
## Key Decisions
| Decision | Rationale | Outcome |
| ------------------------- | --------------------------------------------------- | --------- |
| Accept breaking changes | Allows cleaner interfaces, worth the migration cost | — Pending |
| Add tests during refactor | No existing safety net, need to build one | — Pending |
| Incremental extraction | Everything is critical, can't break it all at once | — Pending |
---
_Last updated: 2026-01-27 after initialization_

View File

@@ -1,234 +0,0 @@
# Architecture
**Analysis Date:** 2026-01-27
## Pattern Overview
**Overall:** Monorepo with layered client-server architecture (Electron-first) and pluggable provider abstraction for AI models.
**Key Characteristics:**
- Event-driven communication via WebSocket between frontend and backend
- Multi-provider AI model abstraction layer (Claude, Cursor, Codex, Gemini, OpenCode, Copilot)
- Feature-centric workflow stored in `.automaker/` directories
- Isolated git worktree execution for each feature
- State management through Zustand stores with API persistence
## Layers
**Presentation Layer (UI):**
- Purpose: React 19 Electron/web frontend with TanStack Router file-based routing
- Location: `apps/ui/src/`
- Contains: Route components, view pages, custom React hooks, Zustand stores, API client
- Depends on: @automaker/types, @automaker/utils, HTTP API backend
- Used by: Electron main process (desktop), web browser (web mode)
**API Layer (Server):**
- Purpose: Express 5 backend exposing RESTful and WebSocket endpoints
- Location: `apps/server/src/`
- Contains: Route handlers, business logic services, middleware, provider adapters
- Depends on: @automaker/types, @automaker/utils, @automaker/platform, Claude Agent SDK
- Used by: UI frontend via HTTP/WebSocket
**Service Layer (Server):**
- Purpose: Business logic and domain operations
- Location: `apps/server/src/services/`
- Contains: AgentService, FeatureLoader, AutoModeService, SettingsService, DevServerService, etc.
- Depends on: Providers, secure filesystem, feature storage
- Used by: Route handlers
**Provider Abstraction (Server):**
- Purpose: Unified interface for different AI model providers
- Location: `apps/server/src/providers/`
- Contains: ProviderFactory, specific provider implementations (ClaudeProvider, CursorProvider, CodexProvider, GeminiProvider, OpencodeProvider, CopilotProvider)
- Depends on: @automaker/types, provider SDKs
- Used by: AgentService
**Shared Library Layer:**
- Purpose: Type definitions and utilities shared across apps
- Location: `libs/`
- Contains: @automaker/types, @automaker/utils, @automaker/platform, @automaker/prompts, @automaker/model-resolver, @automaker/dependency-resolver, @automaker/git-utils, @automaker/spec-parser
- Depends on: None (types has no external deps)
- Used by: All apps and services
## Data Flow
**Feature Execution Flow:**
1. User creates/updates feature via UI (`apps/ui/src/`)
2. UI sends HTTP request to backend (`POST /api/features`)
3. Server route handler invokes FeatureLoader to persist to `.automaker/features/{featureId}/`
4. When executing, AgentService loads feature, creates isolated git worktree via @automaker/git-utils
5. AgentService invokes ProviderFactory to get appropriate AI provider (Claude, Cursor, etc.)
6. Provider executes with context from CLAUDE.md files via @automaker/utils loadContextFiles()
7. Server emits events via EventEmitter throughout execution
8. Events stream to frontend via WebSocket
9. UI updates stores and renders real-time progress
10. Feature results persist back to `.automaker/features/` with generated agent-output.md
**State Management:**
**Frontend State (Zustand):**
- `app-store.ts`: Global app state (projects, features, settings, boards, themes)
- `setup-store.ts`: First-time setup wizard flow
- `ideation-store.ts`: Ideation feature state
- `test-runners-store.ts`: Test runner configurations
- Settings now persist via API (`/api/settings`) rather than localStorage (see use-settings-sync.ts)
**Backend State (Services):**
- SettingsService: Global and project-specific settings (in-memory with file persistence)
- AgentService: Active agent sessions and conversation history
- FeatureLoader: Feature data model operations
- DevServerService: Development server logs
- EventHistoryService: Persists event logs for replay
**Real-Time Updates (WebSocket):**
- Server EventEmitter emits TypedEvent (type + payload)
- WebSocket handler subscribes to events and broadcasts to all clients
- Frontend listens on multiple WebSocket subscriptions and updates stores
## Key Abstractions
**Feature:**
- Purpose: Represents a development task/story with rich metadata
- Location: @automaker/types`libs/types/src/feature.ts`
- Fields: id, title, description, status, images, tasks, priority, etc.
- Stored: `.automaker/features/{featureId}/feature.json`
**Provider:**
- Purpose: Abstracts different AI model implementations
- Location: `apps/server/src/providers/{provider}-provider.ts`
- Interface: Common execute() method with consistent message format
- Implementations: Claude, Cursor, Codex, Gemini, OpenCode, Copilot
- Factory: ProviderFactory picks correct provider based on model ID
**Event:**
- Purpose: Real-time updates streamed to frontend
- Location: @automaker/types`libs/types/src/event.ts`
- Format: { type: EventType, payload: unknown }
- Examples: agent-started, agent-step, agent-complete, feature-updated, etc.
**AgentSession:**
- Purpose: Represents a conversation between user and AI agent
- Location: @automaker/types`libs/types/src/session.ts`
- Contains: Messages (user + assistant), metadata, creation timestamp
- Stored: `{DATA_DIR}/agent-sessions/{sessionId}.json`
**Settings:**
- Purpose: Configuration for global and per-project behavior
- Location: @automaker/types`libs/types/src/settings.ts`
- Stored: Global in `{DATA_DIR}/settings.json`, per-project in `.automaker/settings.json`
- Service: SettingsService in `apps/server/src/services/settings-service.ts`
## Entry Points
**Server:**
- Location: `apps/server/src/index.ts`
- Triggers: `npm run dev:server` or Docker startup
- Responsibilities:
- Initialize Express app with middleware
- Create shared EventEmitter for WebSocket streaming
- Bootstrap services (SettingsService, AgentService, FeatureLoader, etc.)
- Mount API routes at `/api/*`
- Create WebSocket servers for agent streaming and terminal sessions
- Load and apply user settings (log level, request logging, etc.)
**UI (Web):**
- Location: `apps/ui/src/main.ts` (Vite entry), `apps/ui/src/app.tsx` (React component)
- Triggers: `npm run dev:web` or `npm run build`
- Responsibilities:
- Initialize Zustand stores from API settings
- Setup React Router with TanStack Router
- Render root layout with sidebar and main content area
- Handle authentication via verifySession()
**UI (Electron):**
- Location: `apps/ui/src/main.ts` (Vite entry), `apps/ui/electron/main-process.ts` (Electron main process)
- Triggers: `npm run dev:electron`
- Responsibilities:
- Launch local server via node-pty
- Create native Electron window
- Bridge IPC between renderer and main process
- Provide file system access via preload.ts APIs
## Error Handling
**Strategy:** Layered error classification and user-friendly messaging
**Patterns:**
**Backend Error Handling:**
- Errors classified via `classifyError()` from @automaker/utils
- Classification: ParseError, NetworkError, AuthenticationError, RateLimitError, etc.
- Response format: `{ success: false, error: { type, message, code }, details? }`
- Example: `apps/server/src/lib/error-handler.ts`
**Frontend Error Handling:**
- HTTP errors caught by api-fetch.ts with retry logic
- WebSocket disconnects trigger reconnection with exponential backoff
- Errors shown in toast notifications via `sonner` library
- Validation errors caught and displayed inline in forms
**Agent Execution Errors:**
- AgentService wraps provider calls in try-catch
- Aborts handled specially via `isAbortError()` check
- Rate limit errors trigger cooldown before retry
- Model-specific errors mapped to user guidance
## Cross-Cutting Concerns
**Logging:**
- Framework: @automaker/utils createLogger()
- Pattern: `const logger = createLogger('ModuleName')`
- Levels: ERROR, WARN, INFO, DEBUG (configurable via settings)
- Output: stdout (dev), files (production)
**Validation:**
- File path validation: @automaker/platform initAllowedPaths() enforces restrictions
- Model ID validation: @automaker/model-resolver resolveModelString()
- JSON schema validation: Manual checks in route handlers (no JSON schema lib)
- Authentication: Session token validation via validateWsConnectionToken()
**Authentication:**
- Frontend: Session token stored in httpOnly cookie
- Backend: authMiddleware checks token on protected routes
- WebSocket: validateWsConnectionToken() for upgrade requests
- Providers: API keys stored encrypted in `{DATA_DIR}/credentials.json`
**Internationalization:**
- Not detected - strings are English-only
**Performance:**
- Code splitting: File-based routing via TanStack Router
- Lazy loading: React.lazy() in route components
- Caching: React Query for HTTP requests (query-keys.ts defines cache strategy)
- Image optimization: Automatic base64 encoding for agent context
- State hydration: Settings loaded once at startup, synced via API
---
_Architecture analysis: 2026-01-27_

View File

@@ -1,245 +0,0 @@
# Codebase Concerns
**Analysis Date:** 2026-01-27
## Tech Debt
**Loose Type Safety in Error Handling:**
- Issue: Multiple uses of `as any` type assertions bypass TypeScript safety, particularly in error context handling and provider responses
- Files: `apps/server/src/providers/claude-provider.ts` (lines 318-322), `apps/server/src/lib/error-handler.ts`, `apps/server/src/routes/settings/routes/update-global.ts`
- Impact: Errors could have unchecked properties; refactoring becomes risky without compiler assistance
- Fix approach: Replace `as any` with proper type guards and discriminated unions; create helper functions for safe property access
**Missing Test Coverage for Critical Services:**
- Issue: Several core services explicitly excluded from test coverage thresholds due to integration complexity
- Files: `apps/server/vitest.config.ts` (line 22), explicitly excluded: `claude-usage-service.ts`, `mcp-test-service.ts`, `cli-provider.ts`, `cursor-provider.ts`
- Impact: Usage tracking, MCP integration, and CLI detection could break undetected; regression detection is limited
- Fix approach: Create integration test fixtures for CLI providers; mock MCP SDK for mcp-test-service tests; add usage tracking unit tests with mocked API calls
**Unused/Stub TODO Item Processing:**
- Issue: TodoWrite tool implementation exists but is partially integrated; tool name constants scattered across codex provider
- Files: `apps/server/src/providers/codex-tool-mapping.ts`, `apps/server/src/providers/codex-provider.ts`
- Impact: Todo list updates may not synchronize properly with all providers; unclear which providers support TodoWrite
- Fix approach: Consolidate tool name constants; add provider capability flags for todo support
**Electron Electron.ts Size and Complexity:**
- Issue: Single 3741-line file handles all Electron IPC, native bindings, and communication
- Files: `apps/ui/src/lib/electron.ts`
- Impact: Difficult to test; hard to isolate bugs; changes require full testing of all features; potential memory overhead from monolithic file
- Fix approach: Split by responsibility (IPC, window management, file operations, debug tools); create separate bridge layers
## Known Bugs
**API Key Management Incomplete for Gemini:**
- Symptoms: Gemini API key verification endpoint not implemented despite other providers having verification
- Files: `apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts` (line 122)
- Trigger: User tries to verify Gemini API key in settings
- Workaround: Key verification skipped for Gemini; settings page still accepts and stores key
**Orphaned Features Detection Vulnerable to False Negatives:**
- Symptoms: Features marked as orphaned when branch matching logic doesn't account for all scenarios
- Files: `apps/server/src/services/auto-mode-service.ts` (lines 5714-5773)
- Trigger: Features that were manually switched branches or rebased
- Workaround: Manual cleanup via feature deletion; branch comparison is basic name matching only
**Terminal Themes Incomplete:**
- Symptoms: Light theme themes (solarizedlight, github) map to same generic lightTheme; no dedicated implementations
- Files: `apps/ui/src/config/terminal-themes.ts` (lines 593-594)
- Trigger: User selects solarizedlight or github terminal theme
- Workaround: Uses generic light theme instead of specific scheme; visual appearance doesn't match expectation
## Security Considerations
**Process Environment Variable Exposure:**
- Risk: Child processes inherit all parent `process.env` including sensitive credentials (API keys, tokens)
- Files: `apps/server/src/providers/cursor-provider.ts` (line 993), `apps/server/src/providers/codex-provider.ts` (line 1099)
- Current mitigation: Dotenv provides isolation at app startup; selective env passing to some providers
- Recommendations: Use explicit allowlists for env vars passed to child processes (only pass REQUIRED_KEYS); audit all spawn calls for env handling; document which providers need which credentials
**Unvalidated Provider Tool Input:**
- Risk: Tool input from CLI providers (Cursor, Copilot, Codex) is partially validated through Record<string, unknown> patterns; execution context could be escaped
- Files: `apps/server/src/providers/codex-provider.ts` (lines 506-543), `apps/server/src/providers/tool-normalization.ts`
- Current mitigation: Status enums validated; tool names checked against allow-lists in some providers
- Recommendations: Implement comprehensive schema validation for all tool inputs before execution; use zod or similar for runtime validation; add security tests for injection patterns
**API Key Storage in Settings Files:**
- Risk: API keys stored in plaintext in `~/.automaker/settings.json` and `data/settings.json`; file permissions may not be restricted
- Files: `apps/server/src/services/settings-service.ts`, uses `atomicWriteJson` without file permission enforcement
- Current mitigation: Limited by file system permissions; Electron mode has single-user access
- Recommendations: Encrypt sensitive settings fields (apiKeys, tokens); use OS credential stores (Keychain/Credential Manager) for production; add file permission checks on startup
## Performance Bottlenecks
**Synchronous Feature Loading at Startup:**
- Problem: All features loaded synchronously at project load; blocks UI with 1000+ features
- Files: `apps/server/src/services/feature-loader.ts` (line 230 Promise.all, but synchronous enumeration)
- Cause: Feature directory walk and JSON parsing is not paginated or lazy-loaded
- Improvement path: Implement lazy loading with pagination (load first 50, fetch more on scroll); add caching layer with TTL; move to background indexing; add feature count limits with warnings
**Auto-Mode Concurrency at Max Can Exceed Rate Limits:**
- Problem: maxConcurrency = 10 can quickly exhaust Claude API rate limits if all features execute simultaneously
- Files: `apps/server/src/services/auto-mode-service.ts` (line 2931 Promise.all for concurrent agents)
- Cause: No adaptive backoff; no API usage tracking before queuing; hint mentions reducing concurrency but doesn't enforce it
- Improvement path: Integrate with claude-usage-service to check remaining quota before starting features; implement exponential backoff on 429 errors; add per-model rate limit tracking
**Terminal Session Memory Leak Risk:**
- Problem: Terminal sessions accumulate in memory; expired sessions not cleaned up reliably
- Files: `apps/server/src/routes/terminal/common.ts` (line 66 cleanup runs every 5 minutes, but only for tokens)
- Cause: Cleanup interval is arbitrary; session map not bounded; no session lifespan limit
- Improvement path: Implement LRU eviction with max session count; reduce cleanup interval to 1 minute; add memory usage monitoring; auto-close idle sessions after 30 minutes
**Large File Content Loading Without Limits:**
- Problem: File content loaded entirely into memory; `describe-file.ts` truncates at 50KB but loads all content first
- Files: `apps/server/src/routes/context/routes/describe-file.ts` (line 128)
- Cause: Synchronous file read; no streaming; no check before reading large files
- Improvement path: Check file size before reading; stream large files; add file size warnings; implement chunked processing for analysis
## Fragile Areas
**Provider Factory Model Resolution:**
- Files: `apps/server/src/providers/provider-factory.ts`, `apps/server/src/providers/simple-query-service.ts`
- Why fragile: Each provider interprets model strings differently; no central registry; model aliases resolved at multiple layers (model-resolver, provider-specific maps, CLI validation)
- Safe modification: Add integration tests for each model alias per provider; create model capability matrix; centralize model validation before dispatch
- Test coverage: No dedicated tests; relies on E2E; no isolated unit tests for model resolution
**WebSocket Session Authentication:**
- Files: `apps/server/src/lib/auth.ts` (line 40 setInterval), `apps/server/src/index.ts` (token validation per message)
- Why fragile: Session tokens generated and validated at multiple points; no single source of truth; expiration is not atomic
- Safe modification: Add tests for token expiration edge cases; ensure cleanup removes all references; log all auth failures
- Test coverage: Auth middleware tested, but not session lifecycle
**Auto-Mode Feature State Machine:**
- Files: `apps/server/src/services/auto-mode-service.ts` (lines 465-600)
- Why fragile: Multiple states (running, queued, completed, error) managed across different methods; no explicit state transition validation; error recovery is defensive (catches all, logs, continues)
- Safe modification: Create explicit state enum with valid transitions; add invariant checks; unit test state transitions with all error cases
- Test coverage: Gaps in error recovery paths; no tests for concurrent state changes
## Scaling Limits
**Feature Count Scalability:**
- Current capacity: ~1000 features tested; UI performance degrades with pagination required
- Limit: 10K+ features cause >5s load times; memory usage ~100MB for metadata alone
- Scaling path: Implement feature database instead of file-per-feature; add ElasticSearch indexing for search; paginate API responses (50 per page); add feature archiving
**Concurrent Auto-Mode Executions:**
- Current capacity: maxConcurrency = 10 features; limited by Claude API rate limits
- Limit: Rate limit hits at ~4-5 simultaneous features with extended context (100K+ tokens)
- Scaling path: Implement token usage budgeting before feature start; queue features with estimated token cost; add provider-specific rate limit handling
**Terminal Session Count:**
- Current capacity: ~100 active terminal sessions per server
- Limit: Memory grows unbounded; no session count limit enforced
- Scaling path: Add max session count with least-recently-used eviction; implement session federation for distributed setup
**Worktree Disk Usage:**
- Current capacity: 10K worktrees (~20GB with typical repos)
- Limit: `.worktrees` directory grows without cleanup; old worktrees accumulate
- Scaling path: Add worktree TTL (delete if not used for 30 days); implement cleanup job; add quota warnings at 50/80% disk
## Dependencies at Risk
**node-pty Beta Version:**
- Risk: `node-pty@1.1.0-beta41` used for terminal emulation; beta status indicates possible instability
- Impact: Terminal features could break on minor platform changes; no guarantees on bug fixes
- Migration plan: Monitor releases for stable version; pin to specific commit if needed; test extensively on target platforms (macOS, Linux, Windows)
**@anthropic-ai/claude-agent-sdk 0.1.x:**
- Risk: Pre-1.0 version; SDK API may change in future releases; limited version stability guarantees
- Impact: Breaking changes could require significant refactoring; feature additions in SDK may not align with Automaker roadmap
- Migration plan: Pin to specific 0.1.x version; review SDK changelogs before upgrades; maintain SDK compatibility tests; consider fallback implementation for critical paths
**@openai/codex-sdk 0.77.x:**
- Risk: Codex model deprecated by OpenAI; SDK may be archived or unsupported
- Impact: Codex provider could become non-functional; error messages may not be actionable
- Migration plan: Monitor OpenAI roadmap for migration path; implement fallback to Claude for Codex requests; add deprecation warning in UI
**Express 5.2.x RC Stage:**
- Risk: Express 5 is still in release candidate phase (as of Node 22); full stability not guaranteed
- Impact: Minor version updates could include breaking changes; middleware compatibility issues possible
- Migration plan: Maintain compatibility layer for Express 5 API; test with latest major before release; document any version-specific workarounds
## Missing Critical Features
**Persistent Session Storage:**
- Problem: Agent conversation sessions stored only in-memory; restart loses all chat history
- Blocks: Long-running analysis across server restarts; session recovery not possible
- Impact: Users must re-run entire analysis if server restarts; lost productivity
**Rate Limit Awareness:**
- Problem: No tracking of API usage relative to rate limits before executing features
- Blocks: Predictable concurrent feature execution; users frequently hit rate limits unexpectedly
- Impact: Feature execution fails with cryptic rate limit errors; poor user experience
**Feature Dependency Visualization:**
- Problem: Dependency-resolver package exists but no UI to visualize or manage dependencies
- Blocks: Users cannot plan feature order; complex dependencies not visible
- Impact: Features implemented in wrong order; blocking dependencies missed
## Test Coverage Gaps
**CLI Provider Integration:**
- What's not tested: Actual CLI execution paths; environment setup; error recovery from CLI crashes
- Files: `apps/server/src/providers/cli-provider.ts`, `apps/server/src/lib/cli-detection.ts`
- Risk: Changes to CLI handling could break silently; detection logic not validated on target platforms
- Priority: High - affects all CLI-based providers (Cursor, Copilot, Codex)
**Cursor Provider Platform-Specific Paths:**
- What's not tested: Windows/Linux Cursor installation detection; version directory parsing; APPDATA environment variable handling
- Files: `apps/server/src/providers/cursor-provider.ts` (lines 267-498)
- Risk: Platform-specific bugs not caught; Cursor detection fails on non-standard installations
- Priority: High - Cursor is primary provider; platform differences critical
**Event Hook System State Changes:**
- What's not tested: Concurrent hook execution; cleanup on server shutdown; webhook delivery retries
- Files: `apps/server/src/services/event-hook-service.ts` (line 248 Promise.allSettled)
- Risk: Hooks may not execute in expected order; memory not cleaned up; webhooks lost on failure
- Priority: Medium - affects automation workflows
**Error Classification for New Providers:**
- What's not tested: Each provider's unique error patterns mapped to ErrorType enum; new provider errors not classified
- Files: `apps/server/src/lib/error-handler.ts` (lines 58-80), each provider error mapping
- Risk: User sees generic "unknown error" instead of actionable message; categorization regresses with new providers
- Priority: Medium - impacts user experience
**Feature State Corruption Scenarios:**
- What's not tested: Concurrent feature updates; partial writes with power loss; JSON parsing recovery
- Files: `apps/server/src/services/feature-loader.ts`, `@automaker/utils` (atomicWriteJson)
- Risk: Feature data corrupted on concurrent access; recovery incomplete; no validation before use
- Priority: High - data loss risk
---
_Concerns audit: 2026-01-27_

View File

@@ -1,255 +0,0 @@
# Coding Conventions
**Analysis Date:** 2026-01-27
## Naming Patterns
**Files:**
- PascalCase for class/service files: `auto-mode-service.ts`, `feature-loader.ts`, `claude-provider.ts`
- kebab-case for route/handler directories: `auto-mode/`, `features/`, `event-history/`
- kebab-case for utility files: `secure-fs.ts`, `sdk-options.ts`, `settings-helpers.ts`
- kebab-case for React components: `card.tsx`, `ansi-output.tsx`, `count-up-timer.tsx`
- kebab-case for hooks: `use-board-background-settings.ts`, `use-responsive-kanban.ts`, `use-test-logs.ts`
- kebab-case for store files: `app-store.ts`, `auth-store.ts`, `setup-store.ts`
- Organized by functionality: `routes/features/routes/list.ts`, `routes/features/routes/get.ts`
**Functions:**
- camelCase for all function names: `createEventEmitter()`, `getAutomakerDir()`, `executeQuery()`
- Verb-first for action functions: `buildPrompt()`, `classifyError()`, `loadContextFiles()`, `atomicWriteJson()`
- Prefix with `use` for React hooks: `useBoardBackgroundSettings()`, `useAppStore()`, `useUpdateProjectSettings()`
- Private methods prefixed with underscore: `_deleteOrphanedImages()`, `_migrateImages()`
**Variables:**
- camelCase for constants and variables: `featureId`, `projectPath`, `modelId`, `tempDir`
- UPPER_SNAKE_CASE for global constants/enums: `DEFAULT_MAX_CONCURRENCY`, `DEFAULT_PHASE_MODELS`
- Meaningful naming over abbreviations: `featureDirectory` not `fd`, `featureImages` not `img`
- Prefixes for computed values: `is*` for booleans: `isClaudeModel`, `isContainerized`, `isAutoLoginEnabled`
**Types:**
- PascalCase for interfaces and types: `Feature`, `ExecuteOptions`, `EventEmitter`, `ProviderConfig`
- Type files suffixed with `.d.ts`: `paths.d.ts`, `types.d.ts`
- Organized by domain: `src/store/types/`, `src/lib/`
- Re-export pattern from main package indexes: `export type { Feature };`
## Code Style
**Formatting:**
- Tool: Prettier 3.7.4
- Print width: 100 characters
- Tab width: 2 spaces
- Single quotes for strings
- Semicolons required
- Trailing commas: es5 (trailing in arrays/objects, not in params)
- Arrow functions always include parentheses: `(x) => x * 2`
- Line endings: LF (Unix)
- Bracket spacing: `{ key: value }`
**Linting:**
- Tool: ESLint (flat config in `apps/ui/eslint.config.mjs`)
- TypeScript ESLint plugin for `.ts`/`.tsx` files
- Recommended configs: `@eslint/js`, `@typescript-eslint/recommended`
- Unused variables warning with exception for parameters starting with `_`
- Type assertions are allowed with description when using `@ts-ignore`
- `@typescript-eslint/no-explicit-any` is warn-level (allow with caution)
## Import Organization
**Order:**
1. Node.js standard library: `import fs from 'fs/promises'`, `import path from 'path'`
2. Third-party packages: `import { describe, it } from 'vitest'`, `import { Router } from 'express'`
3. Shared packages (monorepo): `import type { Feature } from '@automaker/types'`, `import { createLogger } from '@automaker/utils'`
4. Local relative imports: `import { FeatureLoader } from './feature-loader.js'`, `import * as secureFs from '../lib/secure-fs.js'`
5. Type imports: separated with `import type { ... } from`
**Path Aliases:**
- `@/` - resolves to `./src` in both UI (`apps/ui/`) and server (`apps/server/`)
- Shared packages prefixed with `@automaker/`:
- `@automaker/types` - core TypeScript definitions
- `@automaker/utils` - logging, errors, utilities
- `@automaker/prompts` - AI prompt templates
- `@automaker/platform` - path management, security, processes
- `@automaker/model-resolver` - model alias resolution
- `@automaker/dependency-resolver` - feature dependency ordering
- `@automaker/git-utils` - git operations
- Extensions: `.js` extension used in imports for ESM imports
**Import Rules:**
- Always import from shared packages, never from old paths
- No circular dependencies between layers
- Services import from providers and utilities
- Routes import from services
- Shared packages have strict dependency hierarchy (types → utils → platform → git-utils → server/ui)
## Error Handling
**Patterns:**
- Use `try-catch` blocks for async operations: wraps feature execution, file operations, git commands
- Throw `new Error(message)` with descriptive messages: `throw new Error('already running')`, `throw new Error('Feature ${featureId} not found')`
- Classify errors with `classifyError()` from `@automaker/utils` for categorization
- Log errors with context using `createLogger()`: includes error classification
- Return error info objects: `{ valid: false, errors: [...], warnings: [...] }`
- Validation returns structured result: `{ valid, errors, warnings }` from provider `validateConfig()`
**Error Types:**
- Authentication errors: distinguish from validation/runtime errors
- Path validation errors: caught by middleware in Express routes
- File system errors: logged and recovery attempted with backups
- SDK/API errors: classified and wrapped with context
- Abort/cancellation errors: handled without stack traces (graceful shutdown)
**Error Messages:**
- Descriptive and actionable: not vague error codes
- Include context when helpful: file paths, feature IDs, model names
- User-friendly messages via `getUserFriendlyErrorMessage()` for client display
## Logging
**Framework:**
- Built-in `createLogger()` from `@automaker/utils`
- Each module creates logger: `const logger = createLogger('ModuleName')`
- Logger functions: `info()`, `warn()`, `error()`, `debug()`
**Patterns:**
- Log operation start and completion for significant operations
- Log warnings for non-critical issues: file deletion failures, missing optional configs
- Log errors with full error object: `logger.error('operation failed', error)`
- Use module name as logger context: `createLogger('AutoMode')`, `createLogger('HttpClient')`
- Avoid logging sensitive data (API keys, passwords)
- No console.log in production code - use logger
**What to Log:**
- Feature execution start/completion
- Error classification and recovery attempts
- File operations (create, delete, migrate)
- API calls and responses (in debug mode)
- Async operation start/end
- Warnings for deprecated patterns
## Comments
**When to Comment:**
- Complex algorithms or business logic: explain the "why" not the "what"
- Integration points: explain how modules communicate
- Workarounds: explain the constraint that made the workaround necessary
- Non-obvious performance implications
- Edge cases and their handling
**JSDoc/TSDoc:**
- Used for public functions and classes
- Document parameters with `@param`
- Document return types with `@returns`
- Document exceptions with `@throws`
- Used for service classes: `/**\n * Module description\n * Manages: ...\n */`
- Not required for simple getters/setters
**Example JSDoc Pattern:**
```typescript
/**
* Delete images that were removed from a feature
*/
private async deleteOrphanedImages(
projectPath: string,
oldPaths: Array<string>,
newPaths: Array<string>
): Promise<void> {
// Implementation
}
```
## Function Design
**Size:**
- Keep functions under 100 lines when possible
- Large services split into multiple related methods
- Private helper methods extracted for complex logic
**Parameters:**
- Use destructuring for object parameters with multiple properties
- Document parameter types with TypeScript types
- Optional parameters marked with `?`
- Use `Record<string, unknown>` for flexible object parameters
**Return Values:**
- Explicit return types required for all public functions
- Return structured objects for multiple values
- Use `Promise<T>` for async functions
- Async generators use `AsyncGenerator<T>` for streaming responses
- Never implicitly return `undefined` (explicit return or throw)
## Module Design
**Exports:**
- Default export for class instantiation: `export default class FeatureLoader {}`
- Named exports for functions: `export function createEventEmitter() {}`
- Type exports separated: `export type { Feature };`
- Barrel files (index.ts) re-export from module
**Barrel Files:**
- Used in routes: `routes/features/index.ts` creates router and exports
- Used in stores: `store/index.ts` exports all store hooks
- Pattern: group related exports for easier importing
**Service Classes:**
- Instantiated once and dependency injected
- Public methods for API surface
- Private methods prefixed with `_`
- No static methods - prefer instances or functions
- Constructor takes dependencies: `constructor(config?: ProviderConfig)`
**Provider Pattern:**
- Abstract base class: `BaseProvider` with abstract methods
- Concrete implementations: `ClaudeProvider`, `CodexProvider`, `CursorProvider`
- Common interface: `executeQuery()`, `detectInstallation()`, `validateConfig()`
- Factory for instantiation: `ProviderFactory.create()`
## TypeScript Specific
**Strict Mode:** Always enabled globally
- `strict: true` in all tsconfigs
- No implicit `any` - declare types explicitly
- No optional chaining on base types without narrowing
**Type Definitions:**
- Interface for shapes: `interface Feature { ... }`
- Type for unions/aliases: `type ModelAlias = 'haiku' | 'sonnet' | 'opus'`
- Type guards for narrowing: `if (typeof x === 'string') { ... }`
- Generic types for reusable patterns: `EventCallback<T>`
**React Specific (UI):**
- Functional components only
- React 19 with hooks
- Type props interface: `interface CardProps extends React.ComponentProps<'div'> { ... }`
- Zustand stores for state management
- Custom hooks for shared logic
---
_Convention analysis: 2026-01-27_

View File

@@ -1,232 +0,0 @@
# External Integrations
**Analysis Date:** 2026-01-27
## APIs & External Services
**AI/LLM Providers:**
- Claude (Anthropic)
- SDK: `@anthropic-ai/claude-agent-sdk` (0.1.76)
- Auth: `ANTHROPIC_API_KEY` environment variable or stored credentials
- Features: Extended thinking, vision/images, tools, streaming
- Implementation: `apps/server/src/providers/claude-provider.ts`
- Models: Opus 4.5, Sonnet 4, Haiku 4.5, and legacy models
- Custom endpoints: `ANTHROPIC_BASE_URL` (optional)
- GitHub Copilot
- SDK: `@github/copilot-sdk` (0.1.16)
- Auth: GitHub OAuth (via `gh` CLI) or `GITHUB_TOKEN` environment variable
- Features: Tools, streaming, runtime model discovery
- Implementation: `apps/server/src/providers/copilot-provider.ts`
- CLI detection: Searches for Copilot CLI binary
- Models: Dynamic discovery via `copilot models list`
- OpenAI Codex/GPT-4
- SDK: `@openai/codex-sdk` (0.77.0)
- Auth: `OPENAI_API_KEY` environment variable or stored credentials
- Features: Extended thinking, tools, sandbox execution
- Implementation: `apps/server/src/providers/codex-provider.ts`
- Execution modes: CLI (with sandbox) or SDK (direct API)
- Models: Dynamic discovery via Codex CLI or SDK
- Google Gemini
- Implementation: `apps/server/src/providers/gemini-provider.ts`
- Features: Vision support, tools, streaming
- OpenCode (AWS/Azure/other)
- Implementation: `apps/server/src/providers/opencode-provider.ts`
- Supports: Amazon Bedrock, Azure models, local models
- Features: Flexible provider architecture
- Cursor Editor
- Implementation: `apps/server/src/providers/cursor-provider.ts`
- Features: Integration with Cursor IDE
**Model Context Protocol (MCP):**
- SDK: `@modelcontextprotocol/sdk` (1.25.2)
- Purpose: Connect AI agents to external tools and data sources
- Implementation: `apps/server/src/services/mcp-test-service.ts`, `apps/server/src/routes/mcp/`
- Configuration: Per-project in `.automaker/` directory
## Data Storage
**Databases:**
- None - This codebase does NOT use traditional databases (SQL/NoSQL)
- All data stored as files in local filesystem
**File Storage:**
- Local filesystem only
- Locations:
- `.automaker/` - Project-specific data (features, context, settings)
- `./data/` or `DATA_DIR` env var - Global data (settings, credentials, sessions)
- Secure file operations: `@automaker/platform` exports `secureFs` for restricted file access
**Caching:**
- In-memory caches for:
- Model lists (Copilot, Codex runtime discovery)
- Feature metadata
- Project specifications
- No distributed/persistent caching system
## Authentication & Identity
**Auth Provider:**
- Custom implementation (no third-party provider)
- Authentication methods:
1. Claude Max Plan (OAuth via Anthropic CLI)
2. API Key mode (ANTHROPIC_API_KEY)
3. Custom provider profiles with API keys
4. Token-based session authentication for WebSocket
**Implementation:**
- `apps/server/src/lib/auth.ts` - Auth middleware
- `apps/server/src/routes/auth/` - Auth routes
- Session tokens for WebSocket connections
- Credential storage in `./data/credentials.json` (encrypted/protected)
## Monitoring & Observability
**Error Tracking:**
- None - No automatic error reporting service integrated
- Custom error classification: `@automaker/utils` exports `classifyError()`
- User-friendly error messages: `getUserFriendlyErrorMessage()`
**Logs:**
- Console logging with configurable levels
- Logger: `@automaker/utils` exports `createLogger()`
- Log levels: ERROR, WARN, INFO, DEBUG
- Environment: `LOG_LEVEL` env var (optional)
- Storage: Logs output to console/stdout (no persistent logging to files)
**Usage Tracking:**
- Claude API usage: `apps/server/src/services/claude-usage-service.ts`
- Codex API usage: `apps/server/src/services/codex-usage-service.ts`
- Tracks: Tokens, costs, rates
## CI/CD & Deployment
**Hosting:**
- Local development: Node.js server + Vite dev server
- Desktop: Electron application (macOS, Windows, Linux)
- Web: Express server deployed to any Node.js host
**CI Pipeline:**
- GitHub Actions likely (`.github/workflows/` present in repo)
- Testing: Playwright E2E, Vitest unit tests
- Linting: ESLint
- Formatting: Prettier
**Build Process:**
- `npm run build:packages` - Build shared packages
- `npm run build` - Build web UI
- `npm run build:electron` - Build Electron apps (platform-specific)
- Electron Builder handles code signing and distribution
## Environment Configuration
**Required env vars:**
- `ANTHROPIC_API_KEY` - For Claude provider (or provide in settings)
- `OPENAI_API_KEY` - For Codex provider (optional)
- `GITHUB_TOKEN` - For GitHub operations (optional)
**Optional env vars:**
- `PORT` - Server port (default 3008)
- `HOST` - Server bind address (default 0.0.0.0)
- `HOSTNAME` - Public hostname (default localhost)
- `DATA_DIR` - Data storage directory (default ./data)
- `ANTHROPIC_BASE_URL` - Custom Claude endpoint
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to directory
- `AUTOMAKER_MOCK_AGENT` - Enable mock agent for testing
- `AUTOMAKER_AUTO_LOGIN` - Skip login prompt in dev
**Secrets location:**
- Runtime: Environment variables (`process.env`)
- Stored: `./data/credentials.json` (file-based)
- Retrieval: `apps/server/src/services/settings-service.ts`
## Webhooks & Callbacks
**Incoming:**
- WebSocket connections for real-time agent event streaming
- GitHub webhook routes (optional): `apps/server/src/routes/github/`
- Terminal WebSocket connections: `apps/server/src/routes/terminal/`
**Outgoing:**
- GitHub PRs: `apps/server/src/routes/worktree/routes/create-pr.ts`
- Git operations: `@automaker/git-utils` handles commits, pushes
- Terminal output streaming via WebSocket to clients
- Event hooks: `apps/server/src/services/event-hook-service.ts`
## Credential Management
**API Keys Storage:**
- File: `./data/credentials.json`
- Format: JSON with nested structure for different providers
```json
{
"apiKeys": {
"anthropic": "sk-...",
"openai": "sk-...",
"github": "ghp_..."
}
}
```
- Access: `SettingsService.getCredentials()` from `apps/server/src/services/settings-service.ts`
- Security: File permissions should restrict to current user only
**Profile/Provider Configuration:**
- File: `./data/settings.json` (global) or `.automaker/settings.json` (per-project)
- Stores: Alternative provider profiles, model mappings, sandbox settings
- Types: `ClaudeApiProfile`, `ClaudeCompatibleProvider` from `@automaker/types`
## Third-Party Service Integration Points
**Git/GitHub:**
- `@automaker/git-utils` - Git operations (worktrees, commits, diffs)
- Codex/Cursor providers can create GitHub PRs
- GitHub CLI (`gh`) detection for Copilot authentication
**Terminal Access:**
- `node-pty` (1.1.0-beta41) - Pseudo-terminal interface
- `TerminalService` manages terminal sessions
- WebSocket streaming to frontend
**AI Models - Multi-Provider Abstraction:**
- `BaseProvider` interface: `apps/server/src/providers/base-provider.ts`
- Factory pattern: `apps/server/src/providers/provider-factory.ts`
- Allows swapping providers without changing agent logic
- All providers implement: `executeQuery()`, `detectInstallation()`, `getAvailableModels()`
**Process Spawning:**
- `@automaker/platform` exports `spawnProcess()`, `spawnJSONLProcess()`
- Codex CLI execution: JSONL output parsing
- Copilot CLI execution: Subprocess management
- Cursor IDE interaction: Process spawning for tool execution
---
_Integration audit: 2026-01-27_

View File

@@ -1,230 +0,0 @@
# Technology Stack
**Analysis Date:** 2026-01-27
## Languages
**Primary:**
- TypeScript 5.9.3 - Used across all packages, apps, and configuration
- JavaScript (Node.js) - Runtime execution for scripts and tooling
**Secondary:**
- YAML 2.7.0 - Configuration files
- CSS/Tailwind CSS 4.1.18 - Frontend styling
## Runtime
**Environment:**
- Node.js 22.x (>=22.0.0 <23.0.0) - Required version, specified in `.nvmrc`
**Package Manager:**
- npm - Monorepo workspace management via npm workspaces
- Lockfile: `package-lock.json` (present)
## Frameworks
**Core - Frontend:**
- React 19.2.3 - UI framework with hooks and concurrent features
- Vite 7.3.0 - Build tool and dev server (`apps/ui/vite.config.ts`)
- Electron 39.2.7 - Desktop application runtime (`apps/ui/package.json`)
- TanStack Router 1.141.6 - File-based routing (React)
- Zustand 5.0.9 - State management (lightweight alternative to Redux)
- TanStack Query (React Query) 5.90.17 - Server state management
**Core - Backend:**
- Express 5.2.1 - HTTP server framework (`apps/server/package.json`)
- WebSocket (ws) 8.18.3 - Real-time bidirectional communication
- Claude Agent SDK (@anthropic-ai/claude-agent-sdk) 0.1.76 - AI provider integration
**Testing:**
- Playwright 1.57.0 - End-to-end testing (`apps/ui` E2E tests)
- Vitest 4.0.16 - Unit testing framework (runs on all packages and server)
- @vitest/ui 4.0.16 - Visual test runner UI
- @vitest/coverage-v8 4.0.16 - Code coverage reporting
**Build/Dev:**
- electron-builder 26.0.12 - Electron app packaging and distribution
- @vitejs/plugin-react 5.1.2 - Vite React support
- vite-plugin-electron 0.29.0 - Vite plugin for Electron main process
- vite-plugin-electron-renderer 0.14.6 - Vite plugin for Electron renderer
- ESLint 9.39.2 - Code linting (`apps/ui`)
- @typescript-eslint/eslint-plugin 8.50.0 - TypeScript ESLint rules
- Prettier 3.7.4 - Code formatting (root-level config)
- Tailwind CSS 4.1.18 - Utility-first CSS framework
- @tailwindcss/vite 4.1.18 - Tailwind Vite integration
**UI Components & Libraries:**
- Radix UI - Unstyled accessible component library (@radix-ui packages)
- react-dropdown-menu 2.1.16
- react-dialog 1.1.15
- react-select 2.2.6
- react-tooltip 1.2.8
- react-tabs 1.1.13
- react-collapsible 1.1.12
- react-checkbox 1.3.3
- react-radio-group 1.3.8
- react-popover 1.1.15
- react-slider 1.3.6
- react-switch 1.2.6
- react-scroll-area 1.2.10
- react-label 2.1.8
- Lucide React 0.562.0 - Icon library
- Geist 1.5.1 - Design system UI library
- Sonner 2.0.7 - Toast notifications
**Code Editor & Terminal:**
- @uiw/react-codemirror 4.25.4 - Code editor React component
- CodeMirror (@codemirror packages) 6.x - Editor toolkit
- xterm.js (@xterm/xterm) 5.5.0 - Terminal emulator
- @xterm/addon-fit 0.10.0 - Fit addon for terminal
- @xterm/addon-search 0.15.0 - Search addon for terminal
- @xterm/addon-web-links 0.11.0 - Web links addon
- @xterm/addon-webgl 0.18.0 - WebGL renderer for terminal
**Diagram/Graph Visualization:**
- @xyflow/react 12.10.0 - React flow diagram library
- dagre 0.8.5 - Graph layout algorithms
**Markdown/Content Rendering:**
- react-markdown 10.1.0 - Markdown parser and renderer
- remark-gfm 4.0.1 - GitHub Flavored Markdown support
- rehype-raw 7.0.0 - Raw HTML support in markdown
- rehype-sanitize 6.0.0 - HTML sanitization
**Data Validation & Parsing:**
- zod 3.24.1 or 4.0.0 - Schema validation and TypeScript type inference
**Utilities:**
- class-variance-authority 0.7.1 - CSS variant utilities
- clsx 2.1.1 - Conditional className utility
- cmdk 1.1.1 - Command menu/palette
- tailwind-merge 3.4.0 - Tailwind CSS conflict resolution
- usehooks-ts 3.1.1 - TypeScript React hooks
- @dnd-kit (drag-and-drop) 6.3.1 - Drag and drop library
**Font Libraries:**
- @fontsource - Web font packages (Cascadia Code, Fira Code, IBM Plex, Inconsolata, Inter, etc.)
**Development Utilities:**
- cross-spawn 7.0.6 - Cross-platform process spawning
- dotenv 17.2.3 - Environment variable loading
- tsx 4.21.0 - TypeScript execution for Node.js
- tree-kill 1.2.2 - Process tree killer utility
- node-pty 1.1.0-beta41 - PTY/terminal interface for Node.js
## Key Dependencies
**Critical - AI/Agent Integration:**
- @anthropic-ai/claude-agent-sdk 0.1.76 - Core Claude AI provider
- @github/copilot-sdk 0.1.16 - GitHub Copilot integration
- @openai/codex-sdk 0.77.0 - OpenAI Codex/GPT-4 integration
- @modelcontextprotocol/sdk 1.25.2 - Model Context Protocol servers
**Infrastructure - Internal Packages:**
- @automaker/types 1.0.0 - Shared TypeScript type definitions
- @automaker/utils 1.0.0 - Logging, error handling, utilities
- @automaker/platform 1.0.0 - Path management, security, process spawning
- @automaker/prompts 1.0.0 - AI prompt templates
- @automaker/model-resolver 1.0.0 - Claude model alias resolution
- @automaker/dependency-resolver 1.0.0 - Feature dependency ordering
- @automaker/git-utils 1.0.0 - Git operations & worktree management
- @automaker/spec-parser 1.0.0 - Project specification parsing
**Server Utilities:**
- express 5.2.1 - Web framework
- cors 2.8.5 - CORS middleware
- morgan 1.10.1 - HTTP request logger
- cookie-parser 1.4.7 - Cookie parsing middleware
- yaml 2.7.0 - YAML parsing and generation
**Type Definitions:**
- @types/express 5.0.6
- @types/node 22.19.3
- @types/react 19.2.7
- @types/react-dom 19.2.3
- @types/dagre 0.7.53
- @types/ws 8.18.1
- @types/cookie 0.6.0
- @types/cookie-parser 1.4.10
- @types/cors 2.8.19
- @types/morgan 1.9.10
**Optional Dependencies (Platform-specific):**
- lightningcss (various platforms) 1.29.2 - CSS parser (alternate to PostCSS)
- dmg-license 1.0.11 - DMG license dialog for macOS
## Configuration
**Environment:**
- `.env` and `.env.example` files in `apps/server/` and `apps/ui/`
- `dotenv` library loads variables from `.env` files
- Key env vars:
- `ANTHROPIC_API_KEY` - Claude API authentication
- `OPENAI_API_KEY` - OpenAI/Codex authentication
- `GITHUB_TOKEN` - GitHub API access
- `ANTHROPIC_BASE_URL` - Custom Claude endpoint (optional)
- `HOST` - Server bind address (default: 0.0.0.0)
- `HOSTNAME` - Hostname for URLs (default: localhost)
- `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations
- `AUTOMAKER_MOCK_AGENT` - Enable mock agent for testing
- `AUTOMAKER_AUTO_LOGIN` - Skip login in dev (disabled in production)
- `VITE_HOSTNAME` - Frontend API hostname
**Build:**
- `apps/ui/electron-builder.config.json` or `apps/ui/package.json` build config
- Electron builder targets:
- macOS: DMG and ZIP
- Windows: NSIS installer
- Linux: AppImage, DEB, RPM
- Vite config: `apps/ui/vite.config.ts`, `apps/server/tsconfig.json`
- TypeScript config: `tsconfig.json` files in each package
## Platform Requirements
**Development:**
- Node.js 22.x
- npm (included with Node.js)
- Git (for worktree operations)
- Python (optional, for some dev scripts)
**Production:**
- Electron desktop app: Windows, macOS, Linux
- Web browser: Modern Chromium-based browsers
- Server: Any platform supporting Node.js 22.x
**Deployment Target:**
- Local desktop (Electron)
- Local web server (Express + Vite)
- Remote server deployment (Docker, systemd, or other orchestration)
---
_Stack analysis: 2026-01-27_

View File

@@ -1,340 +0,0 @@
# Codebase Structure
**Analysis Date:** 2026-01-27
## Directory Layout
```
automaker/
├── apps/ # Application packages
│ ├── ui/ # React + Electron frontend (port 3007)
│ │ ├── src/
│ │ │ ├── main.ts # Electron/Vite entry point
│ │ │ ├── app.tsx # Root React component (splash, router)
│ │ │ ├── renderer.tsx # Electron renderer entry
│ │ │ ├── routes/ # TanStack Router file-based routes
│ │ │ ├── components/ # React components (views, dialogs, UI, layout)
│ │ │ ├── store/ # Zustand state management
│ │ │ ├── hooks/ # Custom React hooks
│ │ │ ├── lib/ # Utilities (API client, electron, queries, etc.)
│ │ │ ├── electron/ # Electron main & preload process files
│ │ │ ├── config/ # UI configuration (fonts, themes, routes)
│ │ │ └── styles/ # CSS and theme files
│ │ ├── public/ # Static assets
│ │ └── tests/ # E2E Playwright tests
│ │
│ └── server/ # Express backend (port 3008)
│ ├── src/
│ │ ├── index.ts # Express app initialization, route mounting
│ │ ├── routes/ # REST API endpoints (30+ route folders)
│ │ ├── services/ # Business logic services
│ │ ├── providers/ # AI model provider implementations
│ │ ├── lib/ # Utilities (events, auth, helpers, etc.)
│ │ ├── middleware/ # Express middleware
│ │ └── types/ # Server-specific type definitions
│ └── tests/ # Unit tests (Vitest)
├── libs/ # Shared npm packages (@automaker/*)
│ ├── types/ # @automaker/types (no dependencies)
│ │ └── src/
│ │ ├── index.ts # Main export with all type definitions
│ │ ├── feature.ts # Feature, FeatureStatus, etc.
│ │ ├── provider.ts # Provider interfaces, model definitions
│ │ ├── settings.ts # Global and project settings types
│ │ ├── event.ts # Event types for real-time updates
│ │ ├── session.ts # AgentSession, conversation types
│ │ ├── model*.ts # Model-specific types (cursor, codex, gemini, etc.)
│ │ └── ... 20+ more type files
│ │
│ ├── utils/ # @automaker/utils (logging, errors, images, context)
│ │ └── src/
│ │ ├── logger.ts # createLogger() with LogLevel enum
│ │ ├── errors.ts # classifyError(), error types
│ │ ├── image-utils.ts # Image processing, base64 encoding
│ │ ├── context-loader.ts # loadContextFiles() for AI prompts
│ │ └── ... more utilities
│ │
│ ├── platform/ # @automaker/platform (paths, security, OS)
│ │ └── src/
│ │ ├── index.ts # Path getters (getFeatureDir, getFeaturesDir, etc.)
│ │ ├── secure-fs.ts # Secure filesystem operations
│ │ └── config/ # Claude auth detection, allowed paths
│ │
│ ├── prompts/ # @automaker/prompts (AI prompt templates)
│ │ └── src/
│ │ ├── index.ts # Main prompts export
│ │ └── *-prompt.ts # Prompt templates for different features
│ │
│ ├── model-resolver/ # @automaker/model-resolver
│ │ └── src/
│ │ └── index.ts # resolveModelString() for model aliases
│ │
│ ├── dependency-resolver/ # @automaker/dependency-resolver
│ │ └── src/
│ │ └── index.ts # Resolve feature dependencies
│ │
│ ├── git-utils/ # @automaker/git-utils (git operations)
│ │ └── src/
│ │ ├── index.ts # getGitRepositoryDiffs(), worktree management
│ │ └── ... git helpers
│ │
│ ├── spec-parser/ # @automaker/spec-parser
│ │ └── src/
│ │ └── ... spec parsing utilities
│ │
│ └── tsconfig.base.json # Base TypeScript config for all packages
├── .automaker/ # Project data directory (created by app)
│ ├── features/ # Feature storage
│ │ └── {featureId}/
│ │ ├── feature.json # Feature metadata and content
│ │ ├── agent-output.md # Agent execution results
│ │ └── images/ # Feature images
│ ├── context/ # Context files (CLAUDE.md, etc.)
│ ├── settings.json # Per-project settings
│ ├── spec.md # Project specification
│ └── analysis.json # Project structure analysis
├── data/ # Global data directory (default, configurable)
│ ├── settings.json # Global settings, profiles
│ ├── credentials.json # Encrypted API keys
│ ├── sessions-metadata.json # Chat session metadata
│ └── agent-sessions/ # Conversation histories
├── .planning/ # Generated documentation by GSD orchestrator
│ └── codebase/ # Codebase analysis documents
│ ├── ARCHITECTURE.md # Architecture patterns and layers
│ ├── STRUCTURE.md # This file
│ ├── STACK.md # Technology stack
│ ├── INTEGRATIONS.md # External API integrations
│ ├── CONVENTIONS.md # Code style and naming
│ ├── TESTING.md # Testing patterns
│ └── CONCERNS.md # Technical debt and issues
├── .github/ # GitHub Actions workflows
├── scripts/ # Build and utility scripts
├── tests/ # Test data and utilities
├── docs/ # Documentation
├── package.json # Root workspace config
├── package-lock.json # Lock file
├── CLAUDE.md # Project instructions for Claude Code
├── DEVELOPMENT_WORKFLOW.md # Development guidelines
└── README.md # Project overview
```
## Directory Purposes
**apps/ui/:**
- Purpose: React frontend for desktop (Electron) and web modes
- Build system: Vite 7 with TypeScript
- Styling: Tailwind CSS 4
- State: Zustand 5 with API persistence
- Routing: TanStack Router with file-based structure
- Desktop: Electron 39 with preload IPC bridge
**apps/server/:**
- Purpose: Express backend API and service layer
- Build system: TypeScript → JavaScript
- Runtime: Node.js 18+
- WebSocket: ws library for real-time streaming
- Process management: node-pty for terminal isolation
**libs/types/:**
- Purpose: Central type definitions (no dependencies, fast import)
- Used by: All other packages and apps
- Pattern: Single namespace export from index.ts
- Build: Compiled to ESM only
**libs/utils/:**
- Purpose: Shared utilities for logging, errors, file operations, image processing
- Used by: Server, UI, other libraries
- Notable: `createLogger()`, `classifyError()`, `loadContextFiles()`, `readImageAsBase64()`
**libs/platform/:**
- Purpose: OS-agnostic path management and security enforcement
- Used by: Server services for file operations
- Notable: Path normalization, allowed directory enforcement, Claude auth detection
**libs/prompts/:**
- Purpose: AI prompt templates injected into agent context
- Used by: AgentService when executing features
- Pattern: Function exports that return prompt strings
## Key File Locations
**Entry Points:**
**Server:**
- `apps/server/src/index.ts`: Express server initialization, route mounting, WebSocket setup
**UI (Web):**
- `apps/ui/src/main.ts`: Vite entry point
- `apps/ui/src/app.tsx`: Root React component
**UI (Electron):**
- `apps/ui/src/main.ts`: Vite entry point
- `apps/ui/src/electron/main-process.ts`: Electron main process
- `apps/ui/src/preload.ts`: Electron preload script for IPC bridge
**Configuration:**
- `apps/server/src/index.ts`: PORT, HOST, HOSTNAME, DATA_DIR env vars
- `apps/ui/src/config/`: Theme options, fonts, model aliases
- `libs/types/src/settings.ts`: Settings schema
- `.env.local`: Local development overrides (git-ignored)
**Core Logic:**
**Server:**
- `apps/server/src/services/agent-service.ts`: AI agent execution engine (31KB)
- `apps/server/src/services/auto-mode-service.ts`: Feature batching and automation (216KB - largest)
- `apps/server/src/services/feature-loader.ts`: Feature persistence and loading
- `apps/server/src/services/settings-service.ts`: Settings management
- `apps/server/src/providers/provider-factory.ts`: AI provider selection
**UI:**
- `apps/ui/src/store/app-store.ts`: Global state (84KB - largest frontend file)
- `apps/ui/src/lib/http-api-client.ts`: API client with auth (92KB)
- `apps/ui/src/components/views/board-view.tsx`: Kanban board (70KB)
- `apps/ui/src/routes/__root.tsx`: Root layout with session init (32KB)
**Testing:**
**E2E Tests:**
- `apps/ui/tests/`: Playwright tests organized by feature area
- `settings/`, `features/`, `projects/`, `agent/`, `utils/`, `context/`
**Unit Tests:**
- `libs/*/tests/`: Package-specific Vitest tests
- `apps/server/src/tests/`: Server integration tests
**Test Config:**
- `vitest.config.ts`: Root Vitest configuration
- `apps/ui/playwright.config.ts`: Playwright configuration
## Naming Conventions
**Files:**
- **Components:** PascalCase.tsx (e.g., `board-view.tsx`, `session-manager.tsx`)
- **Services:** camelCase-service.ts (e.g., `agent-service.ts`, `settings-service.ts`)
- **Hooks:** use-kebab-case.ts (e.g., `use-auto-mode.ts`, `use-settings-sync.ts`)
- **Utilities:** camelCase.ts (e.g., `api-fetch.ts`, `log-parser.ts`)
- **Routes:** kebab-case with index.ts pattern (e.g., `routes/agent/index.ts`)
- **Tests:** _.test.ts or _.spec.ts (co-located with source)
**Directories:**
- **Feature domains:** kebab-case (e.g., `auto-mode/`, `event-history/`, `project-settings-view/`)
- **Type categories:** kebab-case plural (e.g., `types/`, `services/`, `providers/`, `routes/`)
- **Shared utilities:** kebab-case (e.g., `lib/`, `utils/`, `hooks/`)
**TypeScript:**
- **Types:** PascalCase (e.g., `Feature`, `AgentSession`, `ProviderMessage`)
- **Interfaces:** PascalCase (e.g., `EventEmitter`, `ProviderFactory`)
- **Enums:** PascalCase (e.g., `LogLevel`, `FeatureStatus`)
- **Functions:** camelCase (e.g., `createLogger()`, `classifyError()`)
- **Constants:** UPPER_SNAKE_CASE (e.g., `DEFAULT_TIMEOUT_MS`, `MAX_RETRIES`)
- **Variables:** camelCase (e.g., `featureId`, `settingsService`)
## Where to Add New Code
**New Feature (end-to-end):**
- API Route: `apps/server/src/routes/{feature-name}/index.ts`
- Service Logic: `apps/server/src/services/{feature-name}-service.ts`
- UI Route: `apps/ui/src/routes/{feature-name}.tsx` (simple) or `{feature-name}/` (complex with subdir)
- Store: `apps/ui/src/store/{feature-name}-store.ts` (if complex state)
- Tests: `apps/ui/tests/{feature-name}/` or `apps/server/src/tests/`
**New Component/Module:**
- View Components: `apps/ui/src/components/views/{component-name}/`
- Dialog Components: `apps/ui/src/components/dialogs/{dialog-name}.tsx`
- Shared Components: `apps/ui/src/components/shared/` or `components/ui/` (shadcn)
- Layout Components: `apps/ui/src/components/layout/`
**Utilities:**
- New Library: Create in `libs/{package-name}/` with package.json and tsconfig.json
- Server Utilities: `apps/server/src/lib/{utility-name}.ts`
- Shared Utilities: Extend `libs/utils/src/` or create new lib if self-contained
- UI Utilities: `apps/ui/src/lib/{utility-name}.ts`
**New Provider (AI Model):**
- Implementation: `apps/server/src/providers/{provider-name}-provider.ts`
- Types: Add to `libs/types/src/{provider-name}-models.ts`
- Model Resolver: Update `libs/model-resolver/src/index.ts` with model alias mapping
- Settings: Update `libs/types/src/settings.ts` for provider-specific config
## Special Directories
**apps/ui/electron/:**
- Purpose: Electron-specific code (main process, IPC handlers, native APIs)
- Generated: Yes (preload.ts)
- Committed: Yes
**apps/ui/public/**
- Purpose: Static assets (sounds, images, icons)
- Generated: No
- Committed: Yes
**apps/ui/dist/:**
- Purpose: Built web application
- Generated: Yes
- Committed: No (.gitignore)
**apps/ui/dist-electron/:**
- Purpose: Built Electron app bundle
- Generated: Yes
- Committed: No (.gitignore)
**.automaker/features/{featureId}/:**
- Purpose: Per-feature persistent storage
- Structure: feature.json, agent-output.md, images/
- Generated: Yes (at runtime)
- Committed: Yes (tracked in project git)
**data/:**
- Purpose: Global data directory (global settings, credentials, sessions)
- Generated: Yes (created at first run)
- Committed: No (.gitignore)
- Configurable: Via DATA_DIR env var
**node_modules/:**
- Purpose: Installed dependencies
- Generated: Yes
- Committed: No (.gitignore)
**dist/**, **build/:**
- Purpose: Build artifacts
- Generated: Yes
- Committed: No (.gitignore)
---
_Structure analysis: 2026-01-27_

View File

@@ -1,389 +0,0 @@
# Testing Patterns
**Analysis Date:** 2026-01-27
## Test Framework
**Runner:**
- Vitest 4.0.16 (for unit and integration tests)
- Playwright (for E2E tests)
- Config: `apps/server/vitest.config.ts`, `libs/*/vitest.config.ts`, `apps/ui/playwright.config.ts`
**Assertion Library:**
- Vitest built-in expect assertions
- API: `expect().toBe()`, `expect().toEqual()`, `expect().toHaveLength()`, `expect().toHaveProperty()`
**Run Commands:**
```bash
npm run test # E2E tests (Playwright, headless)
npm run test:headed # E2E tests with browser visible
npm run test:packages # All shared package unit tests (vitest)
npm run test:server # Server unit tests (vitest run)
npm run test:server:coverage # Server tests with coverage report
npm run test:all # All tests (packages + server)
npm run test:unit # Vitest run (all projects)
npm run test:unit:watch # Vitest watch mode
```
## Test File Organization
**Location:**
- Co-located with source: `src/module.ts` has `tests/unit/module.test.ts`
- Server tests: `apps/server/tests/` (separate directory)
- Library tests: `libs/*/tests/` (each package)
- E2E tests: `apps/ui/tests/` (Playwright)
**Naming:**
- Pattern: `{moduleName}.test.ts` for unit tests
- Pattern: `{moduleName}.spec.ts` for specification tests
- Glob pattern: `tests/**/*.test.ts`, `tests/**/*.spec.ts`
**Structure:**
```
apps/server/
├── tests/
│ ├── setup.ts # Global test setup
│ ├── unit/
│ │ ├── providers/ # Provider tests
│ │ │ ├── claude-provider.test.ts
│ │ │ ├── codex-provider.test.ts
│ │ │ └── base-provider.test.ts
│ │ └── services/
│ └── utils/
│ └── helpers.ts # Test utilities
└── src/
libs/platform/
├── tests/
│ ├── paths.test.ts
│ ├── security.test.ts
│ ├── subprocess.test.ts
│ └── node-finder.test.ts
└── src/
```
## Test Structure
**Suite Organization:**
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { FeatureLoader } from '@/services/feature-loader.js';
describe('feature-loader.ts', () => {
let featureLoader: FeatureLoader;
beforeEach(() => {
vi.clearAllMocks();
featureLoader = new FeatureLoader();
});
afterEach(async () => {
// Cleanup resources
});
describe('methodName', () => {
it('should do specific thing', () => {
expect(result).toBe(expected);
});
});
});
```
**Patterns:**
- Setup pattern: `beforeEach()` initializes test instance, clears mocks
- Teardown pattern: `afterEach()` cleans up temp directories, removes created files
- Assertion pattern: one logical assertion per test (or multiple closely related)
- Test isolation: each test runs with fresh setup
## Mocking
**Framework:**
- Vitest `vi` module: `vi.mock()`, `vi.mocked()`, `vi.clearAllMocks()`
- Mock patterns: module mocking, function spying, return value mocking
**Patterns:**
Module mocking:
```typescript
vi.mock('@anthropic-ai/claude-agent-sdk');
// In test:
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'Response 1' };
})()
);
```
Async generator mocking (for streaming APIs):
```typescript
const generator = provider.executeQuery({
prompt: 'Hello',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});
const results = await collectAsyncGenerator(generator);
```
Partial mocking with spies:
```typescript
const provider = new TestProvider();
const spy = vi.spyOn(provider, 'getName');
spy.mockReturnValue('mocked-name');
```
**What to Mock:**
- External APIs (Claude SDK, GitHub SDK, cloud services)
- File system operations (use temp directories instead when possible)
- Network calls
- Process execution
- Time-dependent operations
**What NOT to Mock:**
- Core business logic (test the actual implementation)
- Type definitions
- Internal module dependencies (test integration with real services)
- Standard library functions (fs, path, etc. - use fixtures instead)
## Fixtures and Factories
**Test Data:**
```typescript
// Test helper for collecting async generator results
async function collectAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<T[]> {
const results: T[] = [];
for await (const item of generator) {
results.push(item);
}
return results;
}
// Temporary directory fixture
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'));
projectPath = path.join(tempDir, 'test-project');
await fs.mkdir(projectPath, { recursive: true });
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
});
```
**Location:**
- Inline in test files for simple fixtures
- `tests/utils/helpers.ts` for shared test utilities
- Factory functions for complex test objects: `createTestProvider()`, `createMockFeature()`
## Coverage
**Requirements (Server):**
- Lines: 60%
- Functions: 75%
- Branches: 55%
- Statements: 60%
- Config: `apps/server/vitest.config.ts` with thresholds
**Excluded from Coverage:**
- Route handlers: tested via integration/E2E tests
- Type re-exports
- Middleware: tested via integration tests
- Prompt templates
- MCP integration: awaits MCP SDK integration tests
- Provider CLI integrations: awaits integration tests
**View Coverage:**
```bash
npm run test:server:coverage # Generate coverage report
# Opens HTML report in: apps/server/coverage/index.html
```
**Coverage Tools:**
- Provider: v8
- Reporters: text, json, html, lcov
- File inclusion: `src/**/*.ts`
- File exclusion: `src/**/*.d.ts`, specific service files in thresholds
## Test Types
**Unit Tests:**
- Scope: Individual functions and methods
- Approach: Test inputs → outputs with mocked dependencies
- Location: `apps/server/tests/unit/`
- Examples:
- Provider executeQuery() with mocked SDK
- Path construction functions with assertions
- Error classification with different error types
- Config validation with various inputs
**Integration Tests:**
- Scope: Multiple modules working together
- Approach: Test actual service calls with real file system or temp directories
- Pattern: Setup data → call method → verify results
- Example: Feature loader reading/writing feature.json files
- Example: Auto-mode service coordinating with multiple services
**E2E Tests:**
- Framework: Playwright
- Scope: Full user workflows from UI
- Location: `apps/ui/tests/`
- Config: `apps/ui/playwright.config.ts`
- Setup:
- Backend server with mock agent enabled
- Frontend Vite dev server
- Sequential execution (workers: 1) to avoid auth conflicts
- Screenshots/traces on failure
- Auth: Global setup authentication in `tests/global-setup.ts`
- Fixtures: `tests/e2e-fixtures/` for test project data
## Common Patterns
**Async Testing:**
```typescript
it('should execute async operation', async () => {
const result = await featureLoader.loadFeature(projectPath, featureId);
expect(result).toBeDefined();
expect(result.id).toBe(featureId);
});
// For streams/generators:
const generator = provider.executeQuery({ prompt, model, cwd });
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
```
**Error Testing:**
```typescript
it('should throw error when feature not found', async () => {
await expect(featureLoader.getFeature(projectPath, 'nonexistent')).rejects.toThrow('not found');
});
// Testing error classification:
const errorInfo = classifyError(new Error('ENOENT'));
expect(errorInfo.category).toBe('FileSystem');
```
**Fixture Setup:**
```typescript
it('should create feature with images', async () => {
// Setup: create temp feature directory
const featureDir = path.join(projectPath, '.automaker', 'features', featureId);
await fs.mkdir(featureDir, { recursive: true });
// Act: perform operation
const result = await featureLoader.updateFeature(projectPath, {
id: featureId,
imagePaths: ['/temp/image.png'],
});
// Assert: verify file operations
const migratedPath = path.join(featureDir, 'images', 'image.png');
expect(fs.existsSync(migratedPath)).toBe(true);
});
```
**Mock Reset Pattern:**
```typescript
// In vitest.config.ts:
mockReset: true, // Reset all mocks before each test
restoreMocks: true, // Restore original implementations
clearMocks: true, // Clear mock call history
// In test:
beforeEach(() => {
vi.clearAllMocks();
delete process.env.ANTHROPIC_API_KEY;
});
```
## Test Configuration
**Vitest Config Patterns:**
Server config (`apps/server/vitest.config.ts`):
- Environment: node
- Globals: true (describe/it without imports)
- Setup files: `./tests/setup.ts`
- Alias resolution: resolves `@automaker/*` to source files for mocking
Library config:
- Simpler setup: just environment and globals
- Coverage with high thresholds (90%+ lines)
**Global Setup:**
```typescript
// tests/setup.ts
import { vi, beforeEach } from 'vitest';
process.env.NODE_ENV = 'test';
process.env.DATA_DIR = '/tmp/test-data';
beforeEach(() => {
vi.clearAllMocks();
});
```
## Testing Best Practices
**Isolation:**
- Each test is independent (no state sharing)
- Cleanup temp files in afterEach
- Reset mocks and environment variables in beforeEach
**Clarity:**
- Descriptive test names: "should do X when Y condition"
- One logical assertion per test
- Clear arrange-act-assert structure
**Speed:**
- Mock external services
- Use in-memory temp directories
- Avoid real network calls
- Sequential E2E tests to prevent conflicts
**Maintainability:**
- Use beforeEach/afterEach for common setup
- Extract test helpers to `tests/utils/`
- Keep test data simple and local
- Mock consistently across tests
---
_Testing analysis: 2026-01-27_

View File

@@ -25,11 +25,9 @@ COPY libs/types/package*.json ./libs/types/
COPY libs/utils/package*.json ./libs/utils/
COPY libs/prompts/package*.json ./libs/prompts/
COPY libs/platform/package*.json ./libs/platform/
COPY libs/spec-parser/package*.json ./libs/spec-parser/
COPY libs/model-resolver/package*.json ./libs/model-resolver/
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
COPY libs/git-utils/package*.json ./libs/git-utils/
COPY libs/spec-parser/package*.json ./libs/spec-parser/
# Copy scripts (needed by npm workspace)
COPY scripts ./scripts

300
SECURITY_TODO.md Normal file
View File

@@ -0,0 +1,300 @@
# Security Audit Findings - v0.13.0rc Branch
**Date:** $(date)
**Audit Type:** Git diff security review against v0.13.0rc branch
**Status:** ⚠️ Security vulnerabilities found - requires fixes before release
## Executive Summary
No intentionally malicious code was detected in the changes. However, several **critical security vulnerabilities** were identified that could allow command injection attacks. These must be fixed before release.
---
## 🔴 Critical Security Issues
### 1. Command Injection in Merge Handler
**File:** `apps/server/src/routes/worktree/routes/merge.ts`
**Lines:** 43, 54, 65-66, 93
**Severity:** CRITICAL
**Issue:**
User-controlled inputs (`branchName`, `mergeTo`, `options?.message`) are directly interpolated into shell commands without validation, allowing command injection attacks.
**Vulnerable Code:**
```typescript
// Line 43 - branchName not validated
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
// Line 54 - mergeTo not validated
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
// Lines 65-66 - branchName and message not validated
const mergeCmd = options?.squash
? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
// Line 93 - message not sanitized
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
cwd: projectPath,
});
```
**Attack Vector:**
An attacker could inject shell commands via branch names or commit messages:
- Branch name: `main; rm -rf /`
- Commit message: `"; malicious_command; "`
**Fix Required:**
1. Validate `branchName` and `mergeTo` using `isValidBranchName()` before use
2. Sanitize commit messages or use `execGitCommand` with proper escaping
3. Replace `execAsync` template literals with `execGitCommand` array-based calls
**Note:** `isValidBranchName` is imported but only used AFTER deletion (line 119), not before execAsync calls.
---
### 2. Command Injection in Push Handler
**File:** `apps/server/src/routes/worktree/routes/push.ts`
**Lines:** 44, 49
**Severity:** CRITICAL
**Issue:**
User-controlled `remote` parameter and `branchName` are directly interpolated into shell commands without validation.
**Vulnerable Code:**
```typescript
// Line 38 - remote defaults to 'origin' but not validated
const targetRemote = remote || 'origin';
// Lines 44, 49 - targetRemote and branchName not validated
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
```
**Attack Vector:**
An attacker could inject commands via the remote name:
- Remote: `origin; malicious_command; #`
**Fix Required:**
1. Validate `targetRemote` parameter (alphanumeric + `-`, `_` only)
2. Validate `branchName` before use (even though it comes from git output)
3. Use `execGitCommand` with array arguments instead of template literals
---
### 3. Unsafe Environment Variable Export in Shell Script
**File:** `start-automaker.sh`
**Lines:** 5068, 5085
**Severity:** CRITICAL
**Issue:**
Unsafe parsing and export of `.env` file contents using `xargs` without proper handling of special characters.
**Vulnerable Code:**
```bash
export $(grep -v '^#' .env | xargs)
```
**Attack Vector:**
If `.env` file contains malicious content with spaces, special characters, or code, it could be executed:
- `.env` entry: `VAR="value; malicious_command"`
- Could lead to code execution during startup
**Fix Required:**
Replace with safer parsing method:
```bash
# Safer approach
set -a
source <(grep -v '^#' .env | sed 's/^/export /')
set +a
# Or even safer - validate each line
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
export "${BASH_REMATCH[1]}"="${BASH_REMATCH[2]}"
fi
done < .env
```
---
## 🟡 Moderate Security Concerns
### 4. Inconsistent Use of Secure Command Execution
**Issue:**
The codebase has `execGitCommand()` function available (which uses array arguments and is safer), but it's not consistently used. Some places still use `execAsync` with template literals.
**Files Affected:**
- `apps/server/src/routes/worktree/routes/merge.ts`
- `apps/server/src/routes/worktree/routes/push.ts`
**Recommendation:**
- Audit all `execAsync` calls with template literals
- Replace with `execGitCommand` where possible
- Document when `execAsync` is acceptable (only with fully validated inputs)
---
### 5. Missing Input Validation
**Issues:**
1. `targetRemote` in `push.ts` defaults to 'origin' but isn't validated
2. Commit messages in `merge.ts` aren't sanitized before use in shell commands
3. `worktreePath` validation relies on middleware but should be double-checked
**Recommendation:**
- Add validation functions for remote names
- Sanitize commit messages (remove shell metacharacters)
- Add defensive validation even when middleware exists
---
## ✅ Positive Security Findings
1. **No Hardcoded Credentials:** No API keys, passwords, or tokens found in the diff
2. **No Data Exfiltration:** No suspicious network requests or data transmission patterns
3. **No Backdoors:** No hidden functionality or unauthorized access patterns detected
4. **Safe Command Execution:** `execGitCommand` function properly uses array arguments in some places
5. **Environment Variable Handling:** `init-script-service.ts` properly sanitizes environment variables (lines 194-220)
---
## 📋 Action Items
### Immediate (Before Release)
- [ ] **Fix command injection in `merge.ts`**
- [ ] Validate `branchName` with `isValidBranchName()` before line 43
- [ ] Validate `mergeTo` with `isValidBranchName()` before line 54
- [ ] Sanitize commit messages or use `execGitCommand` for merge commands
- [ ] Replace `execAsync` template literals with `execGitCommand` array calls
- [ ] **Fix command injection in `push.ts`**
- [ ] Add validation function for remote names
- [ ] Validate `targetRemote` before use
- [ ] Validate `branchName` before use (defensive programming)
- [ ] Replace `execAsync` template literals with `execGitCommand`
- [ ] **Fix shell script security issue**
- [ ] Replace unsafe `export $(grep ... | xargs)` with safer parsing
- [ ] Add validation for `.env` file contents
- [ ] Test with edge cases (spaces, special chars, quotes)
### Short-term (Next Sprint)
- [ ] **Audit all `execAsync` calls**
- [ ] Create inventory of all `execAsync` calls with template literals
- [ ] Replace with `execGitCommand` where possible
- [ ] Document exceptions and why they're safe
- [ ] **Add input validation utilities**
- [ ] Create `isValidRemoteName()` function
- [ ] Create `sanitizeCommitMessage()` function
- [ ] Add validation for all user-controlled inputs
- [ ] **Security testing**
- [ ] Add unit tests for command injection prevention
- [ ] Add integration tests with malicious inputs
- [ ] Test shell script with malicious `.env` files
### Long-term (Security Hardening)
- [ ] **Code review process**
- [ ] Add security checklist for PR reviews
- [ ] Require security review for shell command execution changes
- [ ] Add automated security scanning
- [ ] **Documentation**
- [ ] Document secure coding practices for shell commands
- [ ] Create security guidelines for contributors
- [ ] Add security section to CONTRIBUTING.md
---
## 🔍 Testing Recommendations
### Command Injection Tests
```typescript
// Test cases for merge.ts
describe('merge handler security', () => {
it('should reject branch names with shell metacharacters', () => {
// Test: branchName = "main; rm -rf /"
// Expected: Validation error, command not executed
});
it('should sanitize commit messages', () => {
// Test: message = '"; malicious_command; "'
// Expected: Sanitized or rejected
});
});
// Test cases for push.ts
describe('push handler security', () => {
it('should reject remote names with shell metacharacters', () => {
// Test: remote = "origin; malicious_command; #"
// Expected: Validation error, command not executed
});
});
```
### Shell Script Tests
```bash
# Test with malicious .env content
echo 'VAR="value; echo PWNED"' > test.env
# Expected: Should not execute the command
# Test with spaces in values
echo 'VAR="value with spaces"' > test.env
# Expected: Should handle correctly
# Test with special characters
echo 'VAR="value\$with\$dollars"' > test.env
# Expected: Should handle correctly
```
---
## 📚 References
- [OWASP Command Injection](https://owasp.org/www-community/attacks/Command_Injection)
- [Node.js Child Process Security](https://nodejs.org/api/child_process.html#child_process_security_concerns)
- [Shell Script Security Best Practices](https://mywiki.wooledge.org/BashGuide/Practices)
---
## Notes
- All findings are based on code diff analysis
- No runtime testing was performed
- Assumes attacker has access to API endpoints (authenticated or unauthenticated)
- Fixes should be tested thoroughly before deployment
---
**Last Updated:** $(date)
**Next Review:** After fixes are implemented

25
TODO.md Normal file
View File

@@ -0,0 +1,25 @@
# Bugs
- Setting the default model does not seem like it works.
# Performance (completed)
- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering)
- [x] Render containment on heavy scroll regions (kanban columns, chat history)
- [x] Reduce blur/shadow effects when lists get large
- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect)
- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections)
# UX
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex.
- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live
- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card.
- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them.
- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time.
- Typing in the text area of the plan mode was super laggy.
- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something.
- modals are not scrollable if height of the screen is small enough
- and the Agent Runner add an archival button for the new sessions.
- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue.

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.13.0",
"version": "0.12.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
@@ -32,7 +32,6 @@
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.77.0",
"cookie-parser": "1.4.7",
@@ -41,8 +40,7 @@
"express": "5.2.1",
"morgan": "1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "8.18.3",
"yaml": "2.7.0"
"ws": "8.18.3"
},
"devDependencies": {
"@types/cookie": "0.6.0",

View File

@@ -16,7 +16,7 @@ import { createServer } from 'http';
import dotenv from 'dotenv';
import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform';
import { initAllowedPaths } from '@automaker/platform';
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
const logger = createLogger('Server');
@@ -43,6 +43,7 @@ import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js';
import { createWorktreeRoutes } from './routes/worktree/index.js';
import { createGitRoutes } from './routes/git/index.js';
import { createSetupRoutes } from './routes/setup/index.js';
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
import { createModelsRoutes } from './routes/models/index.js';
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
import { createWorkspaceRoutes } from './routes/workspace/index.js';
@@ -56,7 +57,7 @@ import {
import { createSettingsRoutes } from './routes/settings/index.js';
import { AgentService } from './services/agent-service.js';
import { FeatureLoader } from './services/feature-loader.js';
import { AutoModeServiceCompat } from './services/auto-mode/index.js';
import { AutoModeService } from './services/auto-mode-service.js';
import { getTerminalService } from './services/terminal-service.js';
import { SettingsService } from './services/settings-service.js';
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
@@ -82,8 +83,6 @@ 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';
import { getTestRunnerService } from './services/test-runner-service.js';
import { createProjectsRoutes } from './routes/projects/index.js';
// Load environment variables
dotenv.config();
@@ -117,44 +116,15 @@ export function isRequestLoggingEnabled(): boolean {
// Width for log box content (excluding borders)
const BOX_CONTENT_WIDTH = 67;
// Check for Claude authentication (async - runs in background)
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
(async () => {
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
if (hasAnthropicKey) {
logger.info('✓ ANTHROPIC_API_KEY detected');
return;
}
// Check for Claude Code CLI authentication
try {
const indicators = await getClaudeAuthIndicators();
const hasCliAuth =
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
(indicators.hasCredentialsFile &&
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
if (hasCliAuth) {
logger.info('✓ Claude Code CLI authentication detected');
return;
}
} catch (error) {
// Ignore errors checking CLI auth - will fall through to warning
logger.warn('Error checking for Claude Code CLI authentication:', error);
}
// No authentication found - show warning
if (!hasAnthropicKey) {
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd(
BOX_CONTENT_WIDTH
);
const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd(
const w2 = 'Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.padEnd(
BOX_CONTENT_WIDTH
);
@@ -167,13 +137,14 @@ const BOX_CONTENT_WIDTH = 67;
║ ║
${w2}
${w3}
║ ║
${w4}
${w5}
${w6}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
})();
} else {
logger.info('✓ ANTHROPIC_API_KEY detected');
}
// Initialize security
initAllowedPaths();
@@ -258,9 +229,7 @@ const events: EventEmitter = createEventEmitter();
const settingsService = new SettingsService(DATA_DIR);
const agentService = new AgentService(DATA_DIR, events, settingsService);
const featureLoader = new FeatureLoader();
// Auto-mode services: compatibility layer provides old interface while using new architecture
const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader);
const autoModeService = new AutoModeService(events, settingsService);
const claudeUsageService = new ClaudeUsageService();
const codexAppServerService = new CodexAppServerService();
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
@@ -279,10 +248,6 @@ notificationService.setEventEmitter(events);
// Initialize Event History Service
const eventHistoryService = getEventHistoryService();
// Initialize Test Runner Service with event emitter for real-time test output streaming
const testRunnerService = getTestRunnerService();
testRunnerService.setEventEmitter(events);
// Initialize Event Hook Service for custom event triggers (with history storage)
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
@@ -356,14 +321,12 @@ app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
app.use(
'/api/features',
createFeaturesRoutes(featureLoader, settingsService, events, autoModeService)
);
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
@@ -381,10 +344,6 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
app.use(
'/api/projects',
createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
);
// Create HTTP server
const server = createServer(app);
@@ -802,36 +761,21 @@ process.on('uncaughtException', (error: Error) => {
process.exit(1);
});
// Graceful shutdown timeout (30 seconds)
const SHUTDOWN_TIMEOUT_MS = 30000;
// Graceful shutdown helper
const gracefulShutdown = async (signal: string) => {
logger.info(`${signal} received, shutting down...`);
// Set up a force-exit timeout to prevent hanging
const forceExitTimeout = setTimeout(() => {
logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
// Mark all running features as interrupted before shutdown
// This ensures they can be resumed when the server restarts
// Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects
await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`);
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down...');
terminalService.cleanup();
server.close(() => {
clearTimeout(forceExitTimeout);
logger.info('Server closed');
process.exit(0);
});
};
process.on('SIGTERM', () => {
gracefulShutdown('SIGTERM');
});
process.on('SIGINT', () => {
gracefulShutdown('SIGINT');
logger.info('SIGINT received, shutting down...');
terminalService.cleanup();
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});

View File

@@ -546,10 +546,10 @@ export async function getPhaseModelWithOverrides(
}
}
// If no phase model found, use per-phase default
// If no phase model found, use a default
if (!phaseModel) {
phaseModel = DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' };
logger.debug(`${logPrefix} No ${phase} configured, using default: ${phaseModel.model}`);
phaseModel = { model: 'sonnet' };
logger.debug(`${logPrefix} No ${phase} configured, using default: sonnet`);
}
// Resolve provider if providerId is set

View File

@@ -98,14 +98,9 @@ const TEXT_ENCODING = 'utf-8';
* 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().
*
* For feature generation (which can generate 50+ features), we use a much longer
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
*
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000;
@@ -832,14 +827,7 @@ export class CodexProvider extends BaseProvider {
// 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.
//
// For feature generation with 'xhigh', use the extended 5-minute base timeout
// since generating 50+ features takes significantly longer than normal operations.
const baseTimeout =
options.reasoningEffort === 'xhigh'
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
: CODEX_CLI_TIMEOUT_MS;
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
const stream = spawnJSONLProcess({
command: commandPath,

View File

@@ -1,942 +0,0 @@
/**
* Copilot Provider - Executes queries using the GitHub Copilot SDK
*
* Uses the official @github/copilot-sdk for:
* - Session management and streaming responses
* - GitHub OAuth authentication (via gh CLI)
* - Tool call handling and permission management
* - Runtime model discovery
*
* Based on https://github.com/github/copilot-sdk
*/
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from './types.js';
// Note: validateBareModelId is not used because Copilot's bare model IDs
// legitimately contain prefixes like claude-, gemini-, gpt-
import {
COPILOT_MODEL_MAP,
type CopilotAuthStatus,
type CopilotRuntimeModel,
} from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
import {
normalizeTodos,
normalizeFilePathInput,
normalizeCommandInput,
normalizePatternInput,
} from './tool-normalization.js';
// Create logger for this module
const logger = createLogger('CopilotProvider');
// Default bare model (without copilot- prefix) for SDK calls
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
// =============================================================================
// SDK Event Types (from @github/copilot-sdk)
// =============================================================================
/**
* SDK session event data types
*/
interface SdkEvent {
type: string;
data?: unknown;
}
interface SdkMessageEvent extends SdkEvent {
type: 'assistant.message';
data: {
content: string;
};
}
// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise
// The final assistant.message event contains the complete content
interface SdkToolExecutionStartEvent extends SdkEvent {
type: 'tool.execution_start';
data: {
toolName: string;
toolCallId: string;
input?: Record<string, unknown>;
};
}
interface SdkToolExecutionEndEvent extends SdkEvent {
type: 'tool.execution_end';
data: {
toolName: string;
toolCallId: string;
result?: string;
error?: string;
};
}
interface SdkSessionIdleEvent extends SdkEvent {
type: 'session.idle';
}
interface SdkSessionErrorEvent extends SdkEvent {
type: 'session.error';
data: {
message: string;
code?: string;
};
}
// =============================================================================
// Error Codes
// =============================================================================
export enum CopilotErrorCode {
NOT_INSTALLED = 'COPILOT_NOT_INSTALLED',
NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED',
RATE_LIMITED = 'COPILOT_RATE_LIMITED',
MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE',
NETWORK_ERROR = 'COPILOT_NETWORK_ERROR',
PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED',
TIMEOUT = 'COPILOT_TIMEOUT',
CLI_ERROR = 'COPILOT_CLI_ERROR',
SDK_ERROR = 'COPILOT_SDK_ERROR',
UNKNOWN = 'COPILOT_UNKNOWN_ERROR',
}
export interface CopilotError extends Error {
code: CopilotErrorCode;
recoverable: boolean;
suggestion?: string;
}
// =============================================================================
// Tool Name Normalization
// =============================================================================
/**
* Copilot SDK tool name to standard tool name mapping
*
* Maps Copilot CLI tool names to our standard tool names for consistent UI display.
* Tool names are case-insensitive (normalized to lowercase before lookup).
*/
const COPILOT_TOOL_NAME_MAP: Record<string, string> = {
// File operations
read_file: 'Read',
read: 'Read',
view: 'Read', // Copilot uses 'view' for reading files
read_many_files: 'Read',
write_file: 'Write',
write: 'Write',
create_file: 'Write',
edit_file: 'Edit',
edit: 'Edit',
replace: 'Edit',
patch: 'Edit',
// Shell operations
run_shell: 'Bash',
run_shell_command: 'Bash',
shell: 'Bash',
bash: 'Bash',
execute: 'Bash',
terminal: 'Bash',
// Search operations
search: 'Grep',
grep: 'Grep',
search_file_content: 'Grep',
find_files: 'Glob',
glob: 'Glob',
list_dir: 'Ls',
list_directory: 'Ls',
ls: 'Ls',
// Web operations
web_fetch: 'WebFetch',
fetch: 'WebFetch',
web_search: 'WebSearch',
search_web: 'WebSearch',
google_web_search: 'WebSearch',
// Todo operations
todo_write: 'TodoWrite',
write_todos: 'TodoWrite',
update_todos: 'TodoWrite',
// Planning/intent operations (Copilot-specific)
report_intent: 'ReportIntent', // Keep as-is, it's a planning tool
think: 'Think',
plan: 'Plan',
};
/**
* Normalize Copilot tool names to standard tool names
*/
function normalizeCopilotToolName(copilotToolName: string): string {
const lowerName = copilotToolName.toLowerCase();
return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName;
}
/**
* Normalize Copilot tool input parameters to standard format
*
* Maps Copilot's parameter names to our standard parameter names.
* Uses shared utilities from tool-normalization.ts for common normalizations.
*/
function normalizeCopilotToolInput(
toolName: string,
input: Record<string, unknown>
): Record<string, unknown> {
const normalizedName = normalizeCopilotToolName(toolName);
// Normalize todo_write / write_todos: ensure proper format
if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) {
return { todos: normalizeTodos(input.todos) };
}
// Normalize file path parameters for Read/Write/Edit tools
if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') {
return normalizeFilePathInput(input);
}
// Normalize shell command parameters for Bash tool
if (normalizedName === 'Bash') {
return normalizeCommandInput(input);
}
// Normalize search parameters for Grep tool
if (normalizedName === 'Grep') {
return normalizePatternInput(input);
}
return input;
}
/**
* CopilotProvider - Integrates GitHub Copilot SDK as an AI provider
*
* Features:
* - GitHub OAuth authentication
* - SDK-based session management
* - Runtime model discovery
* - Tool call normalization
* - Per-execution working directory support
*/
export class CopilotProvider extends CliProvider {
private runtimeModels: CopilotRuntimeModel[] | null = null;
constructor(config: ProviderConfig = {}) {
super(config);
// Trigger CLI detection on construction
this.ensureCliDetected();
}
// ==========================================================================
// CliProvider Abstract Method Implementations
// ==========================================================================
getName(): string {
return 'copilot';
}
getCliName(): string {
return 'copilot';
}
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'npx', // Copilot CLI can be run via npx
npxPackage: '@github/copilot', // Official GitHub Copilot CLI package
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/copilot'),
'/usr/local/bin/copilot',
path.join(os.homedir(), '.npm-global/bin/copilot'),
],
darwin: [
path.join(os.homedir(), '.local/bin/copilot'),
'/usr/local/bin/copilot',
'/opt/homebrew/bin/copilot',
path.join(os.homedir(), '.npm-global/bin/copilot'),
],
win32: [
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'),
path.join(os.homedir(), '.npm-global', 'copilot.cmd'),
],
},
};
}
/**
* Extract prompt text from ExecuteOptions
*
* Note: CopilotProvider does not yet support vision/image inputs.
* If non-text content is provided, an error is thrown.
*/
private extractPromptText(options: ExecuteOptions): string {
if (typeof options.prompt === 'string') {
return options.prompt;
} else if (Array.isArray(options.prompt)) {
// Check for non-text content (images, etc.) which we don't support yet
const hasNonText = options.prompt.some((p) => p.type !== 'text');
if (hasNonText) {
throw new Error(
'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' +
'Please use text-only prompts or switch to a provider that supports vision.'
);
}
return options.prompt
.filter((p) => p.type === 'text' && p.text)
.map((p) => p.text)
.join('\n');
} else {
throw new Error('Invalid prompt format');
}
}
/**
* Not used with SDK approach - kept for interface compatibility
*/
buildCliArgs(_options: ExecuteOptions): string[] {
return [];
}
/**
* Convert SDK event to AutoMaker ProviderMessage format
*/
normalizeEvent(event: unknown): ProviderMessage | null {
const sdkEvent = event as SdkEvent;
switch (sdkEvent.type) {
case 'assistant.message': {
const messageEvent = sdkEvent as SdkMessageEvent;
return {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: messageEvent.data.content }],
},
};
}
case 'assistant.message_delta': {
// Skip delta events - they create too much noise
// The final assistant.message event has the complete content
return null;
}
case 'tool.execution_start': {
const toolEvent = sdkEvent as SdkToolExecutionStartEvent;
const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName);
const normalizedInput = toolEvent.data.input
? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input)
: {};
return {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: normalizedName,
tool_use_id: toolEvent.data.toolCallId,
input: normalizedInput,
},
],
},
};
}
case 'tool.execution_end': {
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
const isError = !!toolResultEvent.data.error;
const content = isError
? `[ERROR] ${toolResultEvent.data.error}`
: toolResultEvent.data.result || '';
return {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: toolResultEvent.data.toolCallId,
content,
},
],
},
};
}
case 'session.idle': {
logger.debug('Copilot session idle');
return {
type: 'result',
subtype: 'success',
};
}
case 'session.error': {
const errorEvent = sdkEvent as SdkSessionErrorEvent;
return {
type: 'error',
error: errorEvent.data.message || 'Unknown error',
};
}
default:
logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`);
return null;
}
}
// ==========================================================================
// CliProvider Overrides
// ==========================================================================
/**
* Override error mapping for Copilot-specific error codes
*/
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
const lower = stderr.toLowerCase();
if (
lower.includes('not authenticated') ||
lower.includes('please log in') ||
lower.includes('unauthorized') ||
lower.includes('login required') ||
lower.includes('authentication required') ||
lower.includes('github login')
) {
return {
code: CopilotErrorCode.NOT_AUTHENTICATED,
message: 'GitHub Copilot is not authenticated',
recoverable: true,
suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub',
};
}
if (
lower.includes('rate limit') ||
lower.includes('too many requests') ||
lower.includes('429') ||
lower.includes('quota exceeded')
) {
return {
code: CopilotErrorCode.RATE_LIMITED,
message: 'Copilot API rate limit exceeded',
recoverable: true,
suggestion: 'Wait a few minutes and try again',
};
}
if (
lower.includes('model not available') ||
lower.includes('invalid model') ||
lower.includes('unknown model') ||
lower.includes('model not found') ||
(lower.includes('not found') && lower.includes('404'))
) {
return {
code: CopilotErrorCode.MODEL_UNAVAILABLE,
message: 'Requested model is not available',
recoverable: true,
suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`,
};
}
if (
lower.includes('network') ||
lower.includes('connection') ||
lower.includes('econnrefused') ||
lower.includes('timeout')
) {
return {
code: CopilotErrorCode.NETWORK_ERROR,
message: 'Network connection error',
recoverable: true,
suggestion: 'Check your internet connection and try again',
};
}
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
return {
code: CopilotErrorCode.PROCESS_CRASHED,
message: 'Copilot CLI process was terminated',
recoverable: true,
suggestion: 'The process may have run out of memory. Try a simpler task.',
};
}
return {
code: CopilotErrorCode.UNKNOWN,
message: stderr || `Copilot CLI exited with code ${exitCode}`,
recoverable: false,
};
}
/**
* Override install instructions for Copilot-specific guidance
*/
protected getInstallInstructions(): string {
return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)';
}
/**
* Execute a prompt using Copilot SDK with real-time streaming
*
* Creates a new CopilotClient for each execution with the correct working directory.
* Streams tool execution events in real-time for UI display.
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Note: We don't use validateBareModelId here because Copilot's model IDs
// legitimately contain prefixes like claude-, gemini-, gpt- which are the
// actual model names from the Copilot CLI. We only need to ensure the
// copilot- prefix has been stripped by the ProviderFactory.
if (options.model?.startsWith('copilot-')) {
throw new Error(
`[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` +
`The ProviderFactory should strip this prefix before passing to the provider.`
);
}
if (!this.cliPath) {
throw this.createError(
CopilotErrorCode.NOT_INSTALLED,
'Copilot CLI is not installed',
true,
this.getInstallInstructions()
);
}
const promptText = this.extractPromptText(options);
const bareModel = options.model || DEFAULT_BARE_MODEL;
const workingDirectory = options.cwd || process.cwd();
logger.debug(
`CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"`
);
logger.debug(`Prompt length: ${promptText.length} characters`);
// Create a client for this execution with the correct working directory
const client = new CopilotClient({
logLevel: 'warning',
autoRestart: false,
cwd: workingDirectory,
});
// Use an async queue to bridge callback-based SDK events to async generator
const eventQueue: SdkEvent[] = [];
let resolveWaiting: (() => void) | null = null;
let sessionComplete = false;
let sessionError: Error | null = null;
const pushEvent = (event: SdkEvent) => {
eventQueue.push(event);
if (resolveWaiting) {
resolveWaiting();
resolveWaiting = null;
}
};
const waitForEvent = (): Promise<void> => {
if (eventQueue.length > 0 || sessionComplete) {
return Promise.resolve();
}
return new Promise((resolve) => {
resolveWaiting = resolve;
});
};
try {
await client.start();
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
// Create session with streaming enabled for real-time events
const session = await client.createSession({
model: bareModel,
streaming: true,
// AUTONOMOUS MODE: Auto-approve all permission requests.
// AutoMaker is designed for fully autonomous AI agent operation.
// Security boundary is provided by Docker containerization (see CLAUDE.md).
// User is warned about this at app startup.
onPermissionRequest: async (
request: PermissionRequest
): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => {
logger.debug(`Permission request: ${request.kind}`);
return { kind: 'approved' };
},
});
const sessionId = session.sessionId;
logger.debug(`Session created: ${sessionId}`);
// Set up event handler to push events to queue
session.on((event: SdkEvent) => {
logger.debug(`SDK event: ${event.type}`);
if (event.type === 'session.idle') {
sessionComplete = true;
pushEvent(event);
} else if (event.type === 'session.error') {
const errorEvent = event as SdkSessionErrorEvent;
sessionError = new Error(errorEvent.data.message);
sessionComplete = true;
pushEvent(event);
} else {
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
pushEvent(event);
}
});
// Send the prompt (non-blocking)
await session.send({ prompt: promptText });
// Process events as they arrive
while (!sessionComplete || eventQueue.length > 0) {
await waitForEvent();
// Check for errors first (before processing events to avoid race condition)
if (sessionError) {
await session.destroy();
await client.stop();
throw sessionError;
}
// Process all queued events
while (eventQueue.length > 0) {
const event = eventQueue.shift()!;
const normalized = this.normalizeEvent(event);
if (normalized) {
// Add session_id if not present
if (!normalized.session_id) {
normalized.session_id = sessionId;
}
yield normalized;
}
}
}
// Cleanup
await session.destroy();
await client.stop();
logger.debug('CopilotClient stopped successfully');
} catch (error) {
// Ensure client is stopped on error
try {
await client.stop();
} catch (cleanupError) {
// Log but don't throw cleanup errors - the original error is more important
logger.debug(`Failed to stop client during cleanup: ${cleanupError}`);
}
if (isAbortError(error)) {
logger.debug('Query aborted');
return;
}
// Map errors to CopilotError
if (error instanceof Error) {
logger.error(`Copilot SDK error: ${error.message}`);
const errorInfo = this.mapError(error.message, null);
throw this.createError(
errorInfo.code as CopilotErrorCode,
errorInfo.message,
errorInfo.recoverable,
errorInfo.suggestion
);
}
throw error;
}
}
// ==========================================================================
// Copilot-Specific Methods
// ==========================================================================
/**
* Create a CopilotError with details
*/
private createError(
code: CopilotErrorCode,
message: string,
recoverable: boolean = false,
suggestion?: string
): CopilotError {
const error = new Error(message) as CopilotError;
error.code = code;
error.recoverable = recoverable;
error.suggestion = suggestion;
error.name = 'CopilotError';
return error;
}
/**
* Get Copilot CLI version
*/
async getVersion(): Promise<string | null> {
this.ensureCliDetected();
if (!this.cliPath) return null;
try {
const result = execSync(`"${this.cliPath}" --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
}).trim();
return result;
} catch {
return null;
}
}
/**
* Check authentication status
*
* Uses GitHub CLI (gh) to check Copilot authentication status.
* The Copilot CLI relies on gh auth for authentication.
*/
async checkAuth(): Promise<CopilotAuthStatus> {
this.ensureCliDetected();
if (!this.cliPath) {
logger.debug('checkAuth: CLI not found');
return { authenticated: false, method: 'none' };
}
logger.debug('checkAuth: Starting credential check');
// Try to check GitHub CLI authentication status first
// The Copilot CLI uses gh auth for authentication
try {
const ghStatus = execSync('gh auth status --hostname github.com', {
encoding: 'utf8',
timeout: 10000,
stdio: 'pipe',
});
logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`);
// Parse gh auth status output
const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/);
if (loggedInMatch) {
return {
authenticated: true,
method: 'oauth',
login: loggedInMatch[1],
host: 'github.com',
};
}
// Check for token auth
if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) {
return {
authenticated: true,
method: 'oauth',
host: 'github.com',
};
}
} catch (ghError) {
logger.debug(`checkAuth: gh auth status failed: ${ghError}`);
}
// Try Copilot-specific auth check if gh is not available
try {
const result = execSync(`"${this.cliPath}" auth status`, {
encoding: 'utf8',
timeout: 10000,
stdio: 'pipe',
});
logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`);
if (result.includes('authenticated') || result.includes('logged in')) {
return {
authenticated: true,
method: 'cli',
};
}
} catch (copilotError) {
logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`);
}
// Check for GITHUB_TOKEN environment variable
if (process.env.GITHUB_TOKEN) {
logger.debug('checkAuth: Found GITHUB_TOKEN environment variable');
return {
authenticated: true,
method: 'oauth',
statusMessage: 'Using GITHUB_TOKEN environment variable',
};
}
// Check for gh config file
const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
try {
await fs.access(ghConfigPath);
const content = await fs.readFile(ghConfigPath, 'utf8');
if (content.includes('github.com') && content.includes('oauth_token')) {
logger.debug('checkAuth: Found gh config with oauth_token');
return {
authenticated: true,
method: 'oauth',
host: 'github.com',
};
}
} catch {
logger.debug('checkAuth: No gh config found');
}
// No credentials found
logger.debug('checkAuth: No valid credentials found');
return {
authenticated: false,
method: 'none',
error:
'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.',
};
}
/**
* Fetch available models from the CLI at runtime
*/
async fetchRuntimeModels(): Promise<CopilotRuntimeModel[]> {
this.ensureCliDetected();
if (!this.cliPath) {
return [];
}
try {
// Try to list models using the CLI
const result = execSync(`"${this.cliPath}" models list --format json`, {
encoding: 'utf8',
timeout: 15000,
stdio: 'pipe',
});
const models = JSON.parse(result) as CopilotRuntimeModel[];
this.runtimeModels = models;
logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`);
return models;
} catch (error) {
// Clear cache on failure to avoid returning stale data
this.runtimeModels = null;
logger.debug(`Failed to fetch runtime models: ${error}`);
return [];
}
}
/**
* Detect installation status (required by BaseProvider)
*/
async detectInstallation(): Promise<InstallationStatus> {
const installed = await this.isInstalled();
const version = installed ? await this.getVersion() : undefined;
const auth = await this.checkAuth();
return {
installed,
version: version || undefined,
path: this.cliPath || undefined,
method: 'cli',
authenticated: auth.authenticated,
};
}
/**
* Get the detected CLI path (public accessor for status endpoints)
*/
getCliPath(): string | null {
this.ensureCliDetected();
return this.cliPath;
}
/**
* Get available Copilot models
*
* Returns both static model definitions and runtime-discovered models
*/
getAvailableModels(): ModelDefinition[] {
// Start with static model definitions - explicitly typed to allow runtime models
const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map(
([id, config]) => ({
id, // Full model ID with copilot- prefix
name: config.label,
modelString: id.replace('copilot-', ''), // Bare model for CLI
provider: 'copilot',
description: config.description,
supportsTools: config.supportsTools,
supportsVision: config.supportsVision,
contextWindow: config.contextWindow,
})
);
// Add runtime models if available (discovered via CLI)
if (this.runtimeModels) {
for (const runtimeModel of this.runtimeModels) {
// Skip if already in static list
const staticId = `copilot-${runtimeModel.id}`;
if (staticModels.some((m) => m.id === staticId)) {
continue;
}
staticModels.push({
id: staticId,
name: runtimeModel.name || runtimeModel.id,
modelString: runtimeModel.id,
provider: 'copilot',
description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`,
supportsTools: true,
supportsVision: runtimeModel.capabilities?.supportsVision ?? false,
contextWindow: runtimeModel.capabilities?.maxInputTokens,
});
}
}
return staticModels;
}
/**
* Check if a feature is supported
*
* Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet.
* This may change in future versions of the Copilot SDK.
*/
supportsFeature(feature: string): boolean {
const supported = ['tools', 'text', 'streaming'];
return supported.includes(feature);
}
/**
* Check if runtime models have been cached
*/
hasCachedModels(): boolean {
return this.runtimeModels !== null && this.runtimeModels.length > 0;
}
/**
* Clear the runtime model cache
*/
clearModelCache(): void {
this.runtimeModels = null;
logger.debug('Cleared Copilot model cache');
}
/**
* Refresh models from CLI and return all available models
*/
async refreshModels(): Promise<ModelDefinition[]> {
logger.debug('Refreshing Copilot models from CLI');
await this.fetchRuntimeModels();
return this.getAvailableModels();
}
}

View File

@@ -337,11 +337,10 @@ export class CursorProvider extends CliProvider {
'--stream-partial-output' // Real-time streaming
);
// In read-only mode, use --mode ask for Q&A style (no tools)
// Otherwise, add --force to allow file edits
if (options.readOnly) {
cliArgs.push('--mode', 'ask');
} else {
// Only add --force if NOT in read-only mode
// Without --force, Cursor CLI suggests changes but doesn't apply them
// With --force, Cursor CLI can actually edit files
if (!options.readOnly) {
cliArgs.push('--force');
}
@@ -673,13 +672,10 @@ export class CursorProvider extends CliProvider {
);
}
// Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages)
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
// Extract prompt text to pass via stdin (avoids shell escaping issues)
const promptText = this.extractPromptText(effectiveOptions);
const promptText = this.extractPromptText(options);
const cliArgs = this.buildCliArgs(effectiveOptions);
const cliArgs = this.buildCliArgs(options);
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
// Pass prompt via stdin to avoid shell interpretation of special characters

View File

@@ -1,810 +0,0 @@
/**
* Gemini Provider - Executes queries using the Gemini CLI
*
* Extends CliProvider with Gemini-specific:
* - Event normalization for Gemini's JSONL streaming format
* - Google account and API key authentication support
* - Thinking level configuration
*
* Based on https://github.com/google-gemini/gemini-cli
*/
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
ContentBlock,
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { spawnJSONLProcess } from '@automaker/platform';
import { normalizeTodos } from './tool-normalization.js';
// Create logger for this module
const logger = createLogger('GeminiProvider');
// =============================================================================
// Gemini Stream Event Types
// =============================================================================
/**
* Base event structure from Gemini CLI --output-format stream-json
*
* Actual CLI output format:
* {"type":"init","timestamp":"...","session_id":"...","model":"..."}
* {"type":"message","timestamp":"...","role":"user","content":"..."}
* {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
* {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}}
* {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."}
* {"type":"result","timestamp":"...","status":"success","stats":{...}}
*/
interface GeminiStreamEvent {
type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error';
timestamp?: string;
session_id?: string;
}
interface GeminiInitEvent extends GeminiStreamEvent {
type: 'init';
session_id: string;
model: string;
}
interface GeminiMessageEvent extends GeminiStreamEvent {
type: 'message';
role: 'user' | 'assistant';
content: string;
delta?: boolean;
session_id?: string;
}
interface GeminiToolUseEvent extends GeminiStreamEvent {
type: 'tool_use';
tool_id: string;
tool_name: string;
parameters: Record<string, unknown>;
session_id?: string;
}
interface GeminiToolResultEvent extends GeminiStreamEvent {
type: 'tool_result';
tool_id: string;
status: 'success' | 'error';
output: string;
session_id?: string;
}
interface GeminiResultEvent extends GeminiStreamEvent {
type: 'result';
status: 'success' | 'error';
stats?: {
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
cached?: number;
input?: number;
duration_ms?: number;
tool_calls?: number;
};
error?: string;
session_id?: string;
}
// =============================================================================
// Error Codes
// =============================================================================
export enum GeminiErrorCode {
NOT_INSTALLED = 'GEMINI_NOT_INSTALLED',
NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED',
RATE_LIMITED = 'GEMINI_RATE_LIMITED',
MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE',
NETWORK_ERROR = 'GEMINI_NETWORK_ERROR',
PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED',
TIMEOUT = 'GEMINI_TIMEOUT',
UNKNOWN = 'GEMINI_UNKNOWN_ERROR',
}
export interface GeminiError extends Error {
code: GeminiErrorCode;
recoverable: boolean;
suggestion?: string;
}
// =============================================================================
// Tool Name Normalization
// =============================================================================
/**
* Gemini CLI tool name to standard tool name mapping
* This allows the UI to properly categorize and display Gemini tool calls
*/
const GEMINI_TOOL_NAME_MAP: Record<string, string> = {
write_todos: 'TodoWrite',
read_file: 'Read',
read_many_files: 'Read',
replace: 'Edit',
write_file: 'Write',
run_shell_command: 'Bash',
search_file_content: 'Grep',
glob: 'Glob',
list_directory: 'Ls',
web_fetch: 'WebFetch',
google_web_search: 'WebSearch',
};
/**
* Normalize Gemini tool names to standard tool names
*/
function normalizeGeminiToolName(geminiToolName: string): string {
return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName;
}
/**
* Normalize Gemini tool input parameters to standard format
*
* Uses shared normalizeTodos utility for consistent todo normalization.
*
* Gemini `write_todos` format:
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
*
* Claude `TodoWrite` format:
* {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]}
*/
function normalizeGeminiToolInput(
toolName: string,
input: Record<string, unknown>
): Record<string, unknown> {
// Normalize write_todos using shared utility
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
return { todos: normalizeTodos(input.todos) };
}
return input;
}
/**
* GeminiProvider - Integrates Gemini CLI as an AI provider
*
* Features:
* - Google account OAuth login support
* - API key authentication (GEMINI_API_KEY)
* - Vertex AI support
* - Thinking level configuration
* - Streaming JSON output
*/
export class GeminiProvider extends CliProvider {
constructor(config: ProviderConfig = {}) {
super(config);
// Trigger CLI detection on construction
this.ensureCliDetected();
}
// ==========================================================================
// CliProvider Abstract Method Implementations
// ==========================================================================
getName(): string {
return 'gemini';
}
getCliName(): string {
return 'gemini';
}
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'npx', // Gemini CLI can be run via npx
npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/gemini'),
'/usr/local/bin/gemini',
path.join(os.homedir(), '.npm-global/bin/gemini'),
],
darwin: [
path.join(os.homedir(), '.local/bin/gemini'),
'/usr/local/bin/gemini',
'/opt/homebrew/bin/gemini',
path.join(os.homedir(), '.npm-global/bin/gemini'),
],
win32: [
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
path.join(os.homedir(), '.npm-global', 'gemini.cmd'),
],
},
};
}
/**
* Extract prompt text from ExecuteOptions
*/
private extractPromptText(options: ExecuteOptions): string {
if (typeof options.prompt === 'string') {
return options.prompt;
} else if (Array.isArray(options.prompt)) {
return options.prompt
.filter((p) => p.type === 'text' && p.text)
.map((p) => p.text)
.join('\n');
} else {
throw new Error('Invalid prompt format');
}
}
buildCliArgs(options: ExecuteOptions): string[] {
// Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash')
// We need to add 'gemini-' back since it's part of the actual CLI model name
const bareModel = options.model || '2.5-flash';
const cliArgs: string[] = [];
// Streaming JSON output format for real-time updates
cliArgs.push('--output-format', 'stream-json');
// Model selection - Gemini CLI expects full model names like "gemini-2.5-flash"
// Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI
// the 'gemini-' is part of the actual model name Google expects
if (bareModel && bareModel !== 'auto') {
// Add gemini- prefix if not already present (handles edge cases)
const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`;
cliArgs.push('--model', cliModel);
}
// Disable sandbox mode for faster execution (sandbox adds overhead)
cliArgs.push('--sandbox', 'false');
// YOLO mode for automatic approval (required for non-interactive use)
// Use explicit approval-mode for clearer semantics
cliArgs.push('--approval-mode', 'yolo');
// Explicitly include the working directory in allowed workspace directories
// This ensures Gemini CLI allows file operations in the project directory,
// even if it has a different workspace cached from a previous session
if (options.cwd) {
cliArgs.push('--include-directories', options.cwd);
}
// Note: Gemini CLI doesn't have a --thinking-level flag.
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
// The model handles thinking internally based on the task complexity.
// The prompt will be passed as the last positional argument
// We'll append it in executeQuery after extracting the text
return cliArgs;
}
/**
* Convert Gemini event to AutoMaker ProviderMessage format
*/
normalizeEvent(event: unknown): ProviderMessage | null {
const geminiEvent = event as GeminiStreamEvent;
switch (geminiEvent.type) {
case 'init': {
// Init event - capture session but don't yield a message
const initEvent = geminiEvent as GeminiInitEvent;
logger.debug(
`Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}`
);
return null;
}
case 'message': {
const messageEvent = geminiEvent as GeminiMessageEvent;
// Skip user messages - already handled by caller
if (messageEvent.role === 'user') {
return null;
}
// Handle assistant messages
if (messageEvent.role === 'assistant') {
return {
type: 'assistant',
session_id: messageEvent.session_id,
message: {
role: 'assistant',
content: [{ type: 'text', text: messageEvent.content }],
},
};
}
return null;
}
case 'tool_use': {
const toolEvent = geminiEvent as GeminiToolUseEvent;
const normalizedName = normalizeGeminiToolName(toolEvent.tool_name);
const normalizedInput = normalizeGeminiToolInput(
toolEvent.tool_name,
toolEvent.parameters as Record<string, unknown>
);
return {
type: 'assistant',
session_id: toolEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: normalizedName,
tool_use_id: toolEvent.tool_id,
input: normalizedInput,
},
],
},
};
}
case 'tool_result': {
const toolResultEvent = geminiEvent as GeminiToolResultEvent;
// If tool result is an error, prefix with error indicator
const content =
toolResultEvent.status === 'error'
? `[ERROR] ${toolResultEvent.output}`
: toolResultEvent.output;
return {
type: 'assistant',
session_id: toolResultEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: toolResultEvent.tool_id,
content,
},
],
},
};
}
case 'result': {
const resultEvent = geminiEvent as GeminiResultEvent;
if (resultEvent.status === 'error') {
return {
type: 'error',
session_id: resultEvent.session_id,
error: resultEvent.error || 'Unknown error',
};
}
// Success result - include stats for logging
logger.debug(
`Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}`
);
return {
type: 'result',
subtype: 'success',
session_id: resultEvent.session_id,
};
}
case 'error': {
const errorEvent = geminiEvent as GeminiResultEvent;
return {
type: 'error',
session_id: errorEvent.session_id,
error: errorEvent.error || 'Unknown error',
};
}
default:
logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`);
return null;
}
}
// ==========================================================================
// CliProvider Overrides
// ==========================================================================
/**
* Override error mapping for Gemini-specific error codes
*/
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
const lower = stderr.toLowerCase();
if (
lower.includes('not authenticated') ||
lower.includes('please log in') ||
lower.includes('unauthorized') ||
lower.includes('login required') ||
lower.includes('error authenticating') ||
lower.includes('loadcodeassist') ||
(lower.includes('econnrefused') && lower.includes('8888'))
) {
return {
code: GeminiErrorCode.NOT_AUTHENTICATED,
message: 'Gemini CLI is not authenticated',
recoverable: true,
suggestion:
'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable',
};
}
if (
lower.includes('rate limit') ||
lower.includes('too many requests') ||
lower.includes('429') ||
lower.includes('quota exceeded')
) {
return {
code: GeminiErrorCode.RATE_LIMITED,
message: 'Gemini API rate limit exceeded',
recoverable: true,
suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day',
};
}
if (
lower.includes('model not available') ||
lower.includes('invalid model') ||
lower.includes('unknown model') ||
lower.includes('modelnotfounderror') ||
lower.includes('model not found') ||
(lower.includes('not found') && lower.includes('404'))
) {
return {
code: GeminiErrorCode.MODEL_UNAVAILABLE,
message: 'Requested model is not available',
recoverable: true,
suggestion: 'Try using "gemini-2.5-flash" or select a different model',
};
}
if (
lower.includes('network') ||
lower.includes('connection') ||
lower.includes('econnrefused') ||
lower.includes('timeout')
) {
return {
code: GeminiErrorCode.NETWORK_ERROR,
message: 'Network connection error',
recoverable: true,
suggestion: 'Check your internet connection and try again',
};
}
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
return {
code: GeminiErrorCode.PROCESS_CRASHED,
message: 'Gemini CLI process was terminated',
recoverable: true,
suggestion: 'The process may have run out of memory. Try a simpler task.',
};
}
return {
code: GeminiErrorCode.UNKNOWN,
message: stderr || `Gemini CLI exited with code ${exitCode}`,
recoverable: false,
};
}
/**
* Override install instructions for Gemini-specific guidance
*/
protected getInstallInstructions(): string {
return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)';
}
/**
* Execute a prompt using Gemini CLI with streaming
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
validateBareModelId(options.model, 'GeminiProvider');
if (!this.cliPath) {
throw this.createError(
GeminiErrorCode.NOT_INSTALLED,
'Gemini CLI is not installed',
true,
this.getInstallInstructions()
);
}
// Extract prompt text to pass as positional argument
const promptText = this.extractPromptText(options);
// Build CLI args and append the prompt as the last positional argument
const cliArgs = this.buildCliArgs(options);
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
let sessionId: string | undefined;
logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`);
try {
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
const event = rawEvent as GeminiStreamEvent;
// Capture session ID from init event
if (event.type === 'init') {
const initEvent = event as GeminiInitEvent;
sessionId = initEvent.session_id;
logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`);
}
// Normalize and yield the event
const normalized = this.normalizeEvent(event);
if (normalized) {
if (!normalized.session_id && sessionId) {
normalized.session_id = sessionId;
}
yield normalized;
}
}
} catch (error) {
if (isAbortError(error)) {
logger.debug('Query aborted');
return;
}
// Map CLI errors to GeminiError
if (error instanceof Error && 'stderr' in error) {
const errorInfo = this.mapError(
(error as { stderr?: string }).stderr || error.message,
(error as { exitCode?: number | null }).exitCode ?? null
);
throw this.createError(
errorInfo.code as GeminiErrorCode,
errorInfo.message,
errorInfo.recoverable,
errorInfo.suggestion
);
}
throw error;
}
}
// ==========================================================================
// Gemini-Specific Methods
// ==========================================================================
/**
* Create a GeminiError with details
*/
private createError(
code: GeminiErrorCode,
message: string,
recoverable: boolean = false,
suggestion?: string
): GeminiError {
const error = new Error(message) as GeminiError;
error.code = code;
error.recoverable = recoverable;
error.suggestion = suggestion;
error.name = 'GeminiError';
return error;
}
/**
* Get Gemini CLI version
*/
async getVersion(): Promise<string | null> {
this.ensureCliDetected();
if (!this.cliPath) return null;
try {
const result = execSync(`"${this.cliPath}" --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
}).trim();
return result;
} catch {
return null;
}
}
/**
* Check authentication status
*
* Uses a fast credential check approach:
* 1. Check for GEMINI_API_KEY environment variable
* 2. Check for Google Cloud credentials
* 3. Check for Gemini settings file with stored credentials
* 4. Quick CLI auth test with --help (fast, doesn't make API calls)
*/
async checkAuth(): Promise<GeminiAuthStatus> {
this.ensureCliDetected();
if (!this.cliPath) {
logger.debug('checkAuth: CLI not found');
return { authenticated: false, method: 'none' };
}
logger.debug('checkAuth: Starting credential check');
// Determine the likely auth method based on environment
const hasApiKey = !!process.env.GEMINI_API_KEY;
const hasEnvApiKey = hasApiKey;
const hasVertexAi = !!(
process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT
);
logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`);
// Check for Gemini credentials file (~/.gemini/settings.json)
const geminiConfigDir = path.join(os.homedir(), '.gemini');
const settingsPath = path.join(geminiConfigDir, 'settings.json');
let hasCredentialsFile = false;
let authType: string | null = null;
try {
await fs.access(settingsPath);
logger.debug(`checkAuth: Found settings file at ${settingsPath}`);
try {
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
// Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key")
const selectedType = settings?.security?.auth?.selectedType;
if (selectedType) {
hasCredentialsFile = true;
authType = selectedType;
logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`);
} else {
logger.debug(`checkAuth: Settings file found but no auth type configured`);
}
} catch (e) {
logger.debug(`checkAuth: Failed to parse settings file: ${e}`);
}
} catch {
logger.debug('checkAuth: No settings file found');
}
// If we have an API key, we're authenticated
if (hasApiKey) {
logger.debug('checkAuth: Using API key authentication');
return {
authenticated: true,
method: 'api_key',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// If we have Vertex AI credentials, we're authenticated
if (hasVertexAi) {
logger.debug('checkAuth: Using Vertex AI authentication');
return {
authenticated: true,
method: 'vertex_ai',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// Check if settings file indicates configured authentication
if (hasCredentialsFile && authType) {
// OAuth types: "oauth-personal", "oauth-adc"
// API key type: "api-key"
// Code assist: "code-assist" (requires IDE integration)
if (authType.startsWith('oauth')) {
logger.debug(`checkAuth: OAuth authentication configured (${authType})`);
return {
authenticated: true,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
if (authType === 'api-key') {
logger.debug('checkAuth: API key authentication configured in settings');
return {
authenticated: true,
method: 'api_key',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
if (authType === 'code-assist' || authType === 'codeassist') {
logger.debug('checkAuth: Code Assist auth configured but requires local server');
return {
authenticated: false,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
error:
'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.',
};
}
// Unknown auth type but something is configured
logger.debug(`checkAuth: Unknown auth type configured: ${authType}`);
return {
authenticated: true,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// No credentials found
logger.debug('checkAuth: No valid credentials found');
return {
authenticated: false,
method: 'none',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
error:
'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.',
};
}
/**
* Detect installation status (required by BaseProvider)
*/
async detectInstallation(): Promise<InstallationStatus> {
const installed = await this.isInstalled();
const version = installed ? await this.getVersion() : undefined;
const auth = await this.checkAuth();
return {
installed,
version: version || undefined,
path: this.cliPath || undefined,
method: 'cli',
hasApiKey: !!process.env.GEMINI_API_KEY,
authenticated: auth.authenticated,
};
}
/**
* Get the detected CLI path (public accessor for status endpoints)
*/
getCliPath(): string | null {
this.ensureCliDetected();
return this.cliPath;
}
/**
* Get available Gemini models
*/
getAvailableModels(): ModelDefinition[] {
return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash')
name: config.label,
modelString: id, // Same as id - CLI uses the full model name
provider: 'gemini',
description: config.description,
supportsTools: true,
supportsVision: config.supportsVision,
contextWindow: config.contextWindow,
}));
}
/**
* Check if a feature is supported
*/
supportsFeature(feature: string): boolean {
const supported = ['tools', 'text', 'streaming', 'vision', 'thinking'];
return supported.includes(feature);
}
}

View File

@@ -16,16 +16,6 @@ export type {
ProviderMessage,
InstallationStatus,
ModelDefinition,
AgentDefinition,
ReasoningEffort,
SystemPromptPreset,
ConversationMessage,
ContentBlock,
ValidationResult,
McpServerConfig,
McpStdioServerConfig,
McpSSEServerConfig,
McpHttpServerConfig,
} from './types.js';
// Claude provider
@@ -38,12 +28,6 @@ export { CursorConfigManager } from './cursor-config-manager.js';
// OpenCode provider
export { OpencodeProvider } from './opencode-provider.js';
// Gemini provider
export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js';
// Copilot provider (GitHub Copilot SDK)
export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js';
// Provider factory
export { ProviderFactory } from './provider-factory.js';

View File

@@ -1042,7 +1042,7 @@ export class OpencodeProvider extends CliProvider {
'lm studio': 'lmstudio',
lmstudio: 'lmstudio',
opencode: 'opencode',
'z.ai coding plan': 'zai-coding-plan',
'z.ai coding plan': 'z-ai',
'z.ai': 'z-ai',
};

View File

@@ -7,14 +7,7 @@
import { BaseProvider } from './base-provider.js';
import type { InstallationStatus, ModelDefinition } from './types.js';
import {
isCursorModel,
isCodexModel,
isOpencodeModel,
isGeminiModel,
isCopilotModel,
type ModelProvider,
} from '@automaker/types';
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
import * as fs from 'fs';
import * as path from 'path';
@@ -23,8 +16,6 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
codex: '.codex-disconnected',
cursor: '.cursor-disconnected',
opencode: '.opencode-disconnected',
gemini: '.gemini-disconnected',
copilot: '.copilot-disconnected',
};
/**
@@ -248,8 +239,8 @@ export class ProviderFactory {
model.modelString === modelId ||
model.id.endsWith(`-${modelId}`) ||
model.modelString.endsWith(`-${modelId}`) ||
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
) {
return model.supportsVision ?? true;
}
@@ -276,8 +267,6 @@ import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';
import { OpencodeProvider } from './opencode-provider.js';
import { GeminiProvider } from './gemini-provider.js';
import { CopilotProvider } from './copilot-provider.js';
// Register Claude provider
registerProvider('claude', {
@@ -312,19 +301,3 @@ registerProvider('opencode', {
canHandleModel: (model: string) => isOpencodeModel(model),
priority: 3, // Between codex (5) and claude (0)
});
// Register Gemini provider
registerProvider('gemini', {
factory: () => new GeminiProvider(),
aliases: ['google'],
canHandleModel: (model: string) => isGeminiModel(model),
priority: 4, // Between opencode (3) and codex (5)
});
// Register Copilot provider (GitHub Copilot SDK)
registerProvider('copilot', {
factory: () => new CopilotProvider(),
aliases: ['github-copilot', 'github'],
canHandleModel: (model: string) => isCopilotModel(model),
priority: 6, // High priority - check before Codex since both can handle GPT models
});

View File

@@ -1,112 +0,0 @@
/**
* Shared tool normalization utilities for AI providers
*
* These utilities help normalize tool inputs from various AI providers
* to the standard format expected by the application.
*/
/**
* Valid todo status values in the standard format
*/
type TodoStatus = 'pending' | 'in_progress' | 'completed';
/**
* Set of valid status values for validation
*/
const VALID_STATUSES = new Set<TodoStatus>(['pending', 'in_progress', 'completed']);
/**
* Todo item from various AI providers (Gemini, Copilot, etc.)
*/
interface ProviderTodo {
description?: string;
content?: string;
status?: string;
}
/**
* Standard todo format used by the application
*/
interface NormalizedTodo {
content: string;
status: TodoStatus;
activeForm: string;
}
/**
* Normalize a provider status value to a valid TodoStatus
*/
function normalizeStatus(status: string | undefined): TodoStatus {
if (!status) return 'pending';
if (status === 'cancelled' || status === 'canceled') return 'completed';
if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus;
return 'pending';
}
/**
* Normalize todos array from provider format to standard format
*
* Handles different formats from providers:
* - Gemini: { description, status } with 'cancelled' as possible status
* - Copilot: { content/description, status } with 'cancelled' as possible status
*
* Output format (Claude/Standard):
* - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed'
*/
export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] {
if (!todos) return [];
return todos.map((todo) => ({
content: todo.content || todo.description || '',
status: normalizeStatus(todo.status),
// Use content/description as activeForm since providers may not have it
activeForm: todo.content || todo.description || '',
}));
}
/**
* Normalize file path parameters from various provider formats
*
* Different providers use different parameter names for file paths:
* - path, file, filename, filePath -> file_path
*/
export function normalizeFilePathInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
if (!normalized.file_path) {
if (input.path) normalized.file_path = input.path;
else if (input.file) normalized.file_path = input.file;
else if (input.filename) normalized.file_path = input.filename;
else if (input.filePath) normalized.file_path = input.filePath;
}
return normalized;
}
/**
* Normalize shell command parameters from various provider formats
*
* Different providers use different parameter names for commands:
* - cmd, script -> command
*/
export function normalizeCommandInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
if (!normalized.command) {
if (input.cmd) normalized.command = input.cmd;
else if (input.script) normalized.command = input.script;
}
return normalized;
}
/**
* Normalize search pattern parameters from various provider formats
*
* Different providers use different parameter names for search patterns:
* - query, search, regex -> pattern
*/
export function normalizePatternInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
if (!normalized.pattern) {
if (input.query) normalized.pattern = input.query;
else if (input.search) normalized.pattern = input.search;
else if (input.regex) normalized.pattern = input.regex;
}
return normalized;
}

View File

@@ -19,7 +19,4 @@ export type {
InstallationStatus,
ValidationResult,
ModelDefinition,
AgentDefinition,
ReasoningEffort,
SystemPromptPreset,
} from '@automaker/types';

View File

@@ -8,11 +8,10 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
@@ -26,64 +25,6 @@ const logger = createLogger('SpecRegeneration');
const DEFAULT_MAX_FEATURES = 50;
/**
* Timeout for Codex models when generating features (5 minutes).
* Codex models are slower and need more time to generate 50+ features.
*/
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
/**
* Type for extracted features JSON response
*/
interface FeaturesExtractionResult {
features: Array<{
id: string;
category?: string;
title: string;
description: string;
priority?: number;
complexity?: 'simple' | 'moderate' | 'complex';
dependencies?: string[];
}>;
}
/**
* JSON schema for features output format (Claude/Codex structured output)
*/
const featuresOutputSchema = {
type: 'object',
properties: {
features: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
category: { type: 'string', description: 'Feature category' },
title: { type: 'string', description: 'Short, descriptive title' },
description: { type: 'string', description: 'Detailed feature description' },
priority: {
type: 'number',
description: 'Priority level: 1 (highest) to 5 (lowest)',
},
complexity: {
type: 'string',
enum: ['simple', 'moderate', 'complex'],
description: 'Implementation complexity',
},
dependencies: {
type: 'array',
items: { type: 'string' },
description: 'IDs of features this depends on',
},
},
required: ['id', 'title', 'description'],
},
},
},
required: ['features'],
} as const;
export async function generateFeaturesFromSpec(
projectPath: string,
events: EventEmitter,
@@ -195,80 +136,23 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
provider: undefined,
credentials: undefined,
};
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Codex models need extended timeout for generating many features.
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
// The Codex provider has a special 5-minute base timeout for feature generation.
const isCodex = isCodexModel(model);
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
if (isCodex) {
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
}
if (effectiveReasoningEffort) {
logger.info('Reasoning effort:', effectiveReasoningEffort);
}
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
3. The JSON must have this exact structure:
{
"features": [
{
"id": "unique-feature-id",
"category": "Category Name",
"title": "Short Feature Title",
"description": "Detailed description of the feature",
"priority": 1,
"complexity": "simple|moderate|complex",
"dependencies": ["other-feature-id"]
}
]
}
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
5. Priority ranges from 1 (highest) to 5 (lowest)
6. Complexity must be one of: "simple", "moderate", "complex"
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
}
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
prompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: featuresOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
@@ -279,51 +163,15 @@ Your entire response should be valid JSON starting with { and ending with }. No
},
});
// Get response content - prefer structured output if available
let contentForParsing: string;
const responseText = result.text;
if (result.structured_output) {
// Use structured output from Claude/Codex models
logger.info('✅ Received structured output from model');
contentForParsing = JSON.stringify(result.structured_output);
logger.debug('Structured output:', contentForParsing);
} else {
// Use text response (for non-Claude/Codex models or fallback)
// Pre-extract JSON to handle conversational text that may surround the JSON response
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
const rawText = result.text;
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${rawText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(rawText);
logger.info('========== END RESPONSE TEXT ==========');
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(responseText);
logger.info('========== END RESPONSE TEXT ==========');
// Pre-extract JSON from response - handles conversational text around the JSON
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
logger,
});
if (extracted) {
contentForParsing = JSON.stringify(extracted);
logger.info('✅ Pre-extracted JSON from text response');
} else {
// If pre-extraction fails, we know the next step will also fail.
// Throw an error here to avoid redundant parsing and make the failure point clearer.
logger.error(
'❌ Could not extract features JSON from model response. Full response text was:\n' +
rawText
);
const errorMessage =
'Failed to parse features from model response: No valid JSON with a "features" array found.';
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: errorMessage,
projectPath: projectPath,
});
throw new Error(errorMessage);
}
}
await parseAndCreateFeatures(projectPath, contentForParsing, events);
await parseAndCreateFeatures(projectPath, responseText, events);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -9,7 +9,7 @@ import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../lib/json-extractor.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
@@ -120,13 +120,10 @@ ${prompts.appSpec.structuredSpecInstructions}`;
let responseText = '';
let structuredOutput: SpecOutput | null = null;
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
// Build the final prompt - for Cursor, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}

View File

@@ -10,10 +10,9 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { extractJson } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
@@ -35,28 +34,6 @@ import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecSync');
/**
* Type for extracted tech stack JSON response
*/
interface TechStackExtractionResult {
technologies: string[];
}
/**
* JSON schema for tech stack analysis output (Claude/Codex structured output)
*/
const techStackOutputSchema = {
type: 'object',
properties: {
technologies: {
type: 'array',
items: { type: 'string' },
description: 'List of technologies detected in the project',
},
},
required: ['technologies'],
} as const;
/**
* Result of a sync operation
*/
@@ -199,14 +176,8 @@ export async function syncSpec(
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Use AI to analyze tech stack
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
Current known technologies: ${currentTechStack.join(', ')}
@@ -222,16 +193,6 @@ Return ONLY this JSON format, no other text:
"technologies": ["Technology 1", "Technology 2", ...]
}`;
// Add explicit JSON instructions for non-Claude/Codex models
if (!useStructuredOutput) {
techAnalysisPrompt = `${techAnalysisPrompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. Your entire response should be valid JSON starting with { and ending with }.
3. No explanations, no markdown, no text before or after the JSON.`;
}
try {
const techResult = await streamingQuery({
prompt: techAnalysisPrompt,
@@ -245,67 +206,44 @@ CRITICAL INSTRUCTIONS:
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: techStackOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},
});
// Parse tech stack from response - prefer structured output if available
let parsedTechnologies: string[] | null = null;
// 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[];
if (techResult.structured_output) {
// Use structured output from Claude/Codex models
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
if (Array.isArray(structured.technologies)) {
parsedTechnologies = structured.technologies;
logger.info('✅ Received structured output for tech analysis');
}
} else {
// Fall back to text parsing for non-Claude/Codex models
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
logger,
requiredKey: 'technologies',
requireArray: true,
});
if (extracted && Array.isArray(extracted.technologies)) {
parsedTechnologies = extracted.technologies;
logger.info('✅ Extracted tech stack from text response');
} else {
logger.warn('⚠️ Failed to extract tech stack JSON from response');
}
}
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
if (parsedTechnologies) {
const newTechStack = parsedTechnologies;
// 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 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);
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}`
);
// 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) {

View File

@@ -1,12 +1,11 @@
/**
* Auto Mode routes - HTTP API for autonomous feature implementation
*
* Uses AutoModeServiceCompat which provides the old interface while
* delegating to GlobalAutoModeService and per-project facades.
* Uses the AutoModeService for real feature execution with Claude Agent SDK
*/
import { Router } from 'express';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createStopFeatureHandler } from './routes/stop-feature.js';
import { createStatusHandler } from './routes/status.js';
@@ -22,12 +21,7 @@ import { createCommitFeatureHandler } from './routes/commit-feature.js';
import { createApprovePlanHandler } from './routes/approve-plan.js';
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
/**
* Create auto-mode routes.
*
* @param autoModeService - AutoModeServiceCompat instance
*/
export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Router {
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
// Auto loop control routes

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceCompat) {
export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) {
export function createApprovePlanHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
@@ -48,11 +48,11 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat)
// Resolve the pending approval (with recovery support)
const result = await autoModeService.resolvePlanApproval(
projectPath || '',
featureId,
approved,
editedPlan,
feedback
feedback,
projectPath
);
if (!result.success) {

View File

@@ -3,10 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createCommitFeatureHandler(autoModeService: AutoModeServiceCompat) {
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, worktreePath } = req.body as {

View File

@@ -3,10 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createContextExistsHandler(autoModeService: AutoModeServiceCompat) {
export function createContextExistsHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId } = req.body as {

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCompat) {
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as {
@@ -30,12 +30,16 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCom
// Start follow-up in background
// followUpFeature derives workDir from feature.branchName
// Default to false to match run-feature/resume-feature behavior.
// Worktrees should only be used when explicitly enabled by the user.
autoModeService
// Default to false to match run-feature/resume-feature behavior.
// Worktrees should only be used when explicitly enabled by the user.
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false)
.catch((error) => {
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when follow-up completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createResumeFeatureHandler(autoModeService: AutoModeServiceCompat) {
export function createResumeFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {

View File

@@ -7,7 +7,7 @@
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
const logger = createLogger('ResumeInterrupted');
@@ -15,7 +15,7 @@ interface ResumeInterruptedRequest {
projectPath: string;
}
export function createResumeInterruptedHandler(autoModeService: AutoModeServiceCompat) {
export function createResumeInterruptedHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
const { projectPath } = req.body as ResumeInterruptedRequest;
@@ -28,7 +28,6 @@ export function createResumeInterruptedHandler(autoModeService: AutoModeServiceC
try {
await autoModeService.resumeInterruptedFeatures(projectPath);
res.json({
success: true,
message: 'Resume check completed',

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) {
export function createRunFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {
@@ -50,6 +50,10 @@ export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat)
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
.catch((error) => {
logger.error(`Feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when execution completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStartHandler(autoModeService: AutoModeServiceCompat) {
export function createStartHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, maxConcurrency } = req.body as {

View File

@@ -6,13 +6,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create status handler.
*/
export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName } = req.body as {
@@ -24,7 +21,6 @@ export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
if (projectPath) {
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const projectStatus = autoModeService.getStatusForProject(
projectPath,
normalizedBranchName
@@ -42,7 +38,7 @@ export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
return;
}
// Global status for backward compatibility
// Fall back to global status for backward compatibility
const status = autoModeService.getStatus();
const activeProjects = autoModeService.getActiveAutoLoopProjects();
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();

View File

@@ -3,10 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createStopFeatureHandler(autoModeService: AutoModeServiceCompat) {
export function createStopFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { featureId } = req.body as { featureId: string };

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStopHandler(autoModeService: AutoModeServiceCompat) {
export function createStopHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName } = req.body as {

View File

@@ -3,10 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createVerifyFeatureHandler(autoModeService: AutoModeServiceCompat) {
export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId } = req.body as {

View File

@@ -128,10 +128,7 @@ export async function generateBacklogPlan(
let credentials: import('@automaker/types').Credentials | undefined;
if (effectiveModel) {
// Use explicit override - resolve model alias and get credentials
const resolved = resolvePhaseModel({ model: effectiveModel });
effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel;
// Use explicit override - just get credentials
credentials = await settingsService?.getCredentials();
} else if (settingsService) {
// Use settings-based model with provider info

View File

@@ -5,7 +5,6 @@
import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
@@ -17,22 +16,15 @@ import { createBulkDeleteHandler } from './routes/bulk-delete.js';
import { createDeleteHandler } from './routes/delete.js';
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
import { createGenerateTitleHandler } from './routes/generate-title.js';
import { createExportHandler } from './routes/export.js';
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
settingsService?: SettingsService,
events?: EventEmitter,
autoModeService?: AutoModeServiceCompat
events?: EventEmitter
): Router {
const router = Router();
router.post(
'/list',
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post(
'/create',
@@ -54,13 +46,6 @@ export function createFeaturesRoutes(
router.post('/agent-output', createAgentOutputHandler(featureLoader));
router.post('/raw-output', createRawOutputHandler(featureLoader));
router.post('/generate-title', createGenerateTitleHandler(settingsService));
router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader));
router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader));
router.post(
'/check-conflicts',
validatePathParams('projectPath'),
createConflictCheckHandler(featureLoader)
);
return router;
}

View File

@@ -43,7 +43,7 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
if (events) {
events.emit('feature:created', {
featureId: created.id,
featureName: created.title || 'Untitled Feature',
featureName: created.name,
projectPath,
});
}

View File

@@ -1,96 +0,0 @@
/**
* POST /export endpoint - Export features to JSON or YAML format
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import {
getFeatureExportService,
type ExportFormat,
type BulkExportOptions,
} from '../../../services/feature-export-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ExportRequest {
projectPath: string;
/** Feature IDs to export. If empty/undefined, exports all features */
featureIds?: string[];
/** Export format: 'json' or 'yaml' */
format?: ExportFormat;
/** Whether to include description history */
includeHistory?: boolean;
/** Whether to include plan spec */
includePlanSpec?: boolean;
/** Filter by category */
category?: string;
/** Filter by status */
status?: string;
/** Pretty print output */
prettyPrint?: boolean;
/** Optional metadata to include */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
}
export function createExportHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
featureIds,
format = 'json',
includeHistory = true,
includePlanSpec = true,
category,
status,
prettyPrint = true,
metadata,
} = req.body as ExportRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// Validate format
if (format !== 'json' && format !== 'yaml') {
res.status(400).json({
success: false,
error: 'format must be "json" or "yaml"',
});
return;
}
const options: BulkExportOptions = {
format,
includeHistory,
includePlanSpec,
category,
status,
featureIds,
prettyPrint,
metadata,
};
const exportData = await exportService.exportFeatures(projectPath, options);
// Return the export data as a string in the response
res.json({
success: true,
data: exportData,
format,
contentType: format === 'json' ? 'application/json' : 'application/x-yaml',
filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`,
});
} catch (error) {
logError(error, 'Export features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,210 +0,0 @@
/**
* POST /import endpoint - Import features from JSON or YAML format
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types';
import { getFeatureExportService } from '../../../services/feature-export-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ImportRequest {
projectPath: string;
/** Raw JSON or YAML string containing feature data */
data: string;
/** Whether to overwrite existing features with same ID */
overwrite?: boolean;
/** Whether to preserve branch info from imported features */
preserveBranchInfo?: boolean;
/** Optional category to assign to all imported features */
targetCategory?: string;
}
interface ConflictCheckRequest {
projectPath: string;
/** Raw JSON or YAML string containing feature data */
data: string;
}
interface ConflictInfo {
featureId: string;
title?: string;
existingTitle?: string;
hasConflict: boolean;
}
export function createImportHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
data,
overwrite = false,
preserveBranchInfo = false,
targetCategory,
} = req.body as ImportRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!data) {
res.status(400).json({ success: false, error: 'data is required' });
return;
}
// Detect format and parse the data
const format = exportService.detectFormat(data);
if (!format) {
res.status(400).json({
success: false,
error: 'Invalid data format. Expected valid JSON or YAML.',
});
return;
}
const parsed = exportService.parseImportData(data);
if (!parsed) {
res.status(400).json({
success: false,
error: 'Failed to parse import data. Ensure it is valid JSON or YAML.',
});
return;
}
// Determine if this is a single feature or bulk import
const isBulkImport =
'features' in parsed && Array.isArray((parsed as { features: unknown }).features);
let results: FeatureImportResult[];
if (isBulkImport) {
// Bulk import
results = await exportService.importFeatures(projectPath, data, {
overwrite,
preserveBranchInfo,
targetCategory,
});
} else {
// Single feature import - we know it's not a bulk export at this point
// It must be either a Feature or FeatureExport
const singleData = parsed as Feature | FeatureExport;
const result = await exportService.importFeature(projectPath, {
data: singleData,
overwrite,
preserveBranchInfo,
targetCategory,
});
results = [result];
}
const successCount = results.filter((r) => r.success).length;
const failureCount = results.filter((r) => !r.success).length;
const allSuccessful = failureCount === 0;
res.json({
success: allSuccessful,
importedCount: successCount,
failedCount: failureCount,
results,
});
} catch (error) {
logError(error, 'Import features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Create handler for checking conflicts before import
*/
export function createConflictCheckHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, data } = req.body as ConflictCheckRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!data) {
res.status(400).json({ success: false, error: 'data is required' });
return;
}
// Parse the import data
const format = exportService.detectFormat(data);
if (!format) {
res.status(400).json({
success: false,
error: 'Invalid data format. Expected valid JSON or YAML.',
});
return;
}
const parsed = exportService.parseImportData(data);
if (!parsed) {
res.status(400).json({
success: false,
error: 'Failed to parse import data.',
});
return;
}
// Extract features from the data using type guards
let featuresToCheck: Array<{ id: string; title?: string }> = [];
if (exportService.isBulkExport(parsed)) {
// Bulk export format
featuresToCheck = parsed.features.map((f) => ({
id: f.feature.id,
title: f.feature.title,
}));
} else if (exportService.isFeatureExport(parsed)) {
// Single FeatureExport format
featuresToCheck = [
{
id: parsed.feature.id,
title: parsed.feature.title,
},
];
} else if (exportService.isRawFeature(parsed)) {
// Raw Feature format
featuresToCheck = [{ id: parsed.id, title: parsed.title }];
}
// Check each feature for conflicts in parallel
const conflicts: ConflictInfo[] = await Promise.all(
featuresToCheck.map(async (feature) => {
const existing = await featureLoader.get(projectPath, feature.id);
return {
featureId: feature.id,
title: feature.title,
existingTitle: existing?.title,
hasConflict: !!existing,
};
})
);
const hasConflicts = conflicts.some((c) => c.hasConflict);
res.json({
success: true,
hasConflicts,
conflicts,
totalFeatures: featuresToCheck.length,
conflictCount: conflicts.filter((c) => c.hasConflict).length,
});
} catch (error) {
logError(error, 'Conflict check failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,22 +1,12 @@
/**
* POST /list endpoint - List all features for a project
*
* Also performs orphan detection when a project is loaded to identify
* features whose branches no longer exist. This runs on every project load/switch.
*/
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('FeaturesListRoute');
export function createListHandler(
featureLoader: FeatureLoader,
autoModeService?: AutoModeServiceCompat
) {
export function createListHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
@@ -27,26 +17,6 @@ export function createListHandler(
}
const features = await featureLoader.getAll(projectPath);
// Run orphan detection in background when project is loaded
// This detects features whose branches no longer exist (e.g., after merge/delete)
// We don't await this to keep the list response fast
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) {
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
);
for (const { feature, missingBranch } of orphanedFeatures) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
);
}
}
});
}
res.json({ success: true, features });
} catch (error) {
logError(error, 'List features failed');

View File

@@ -31,9 +31,7 @@ export function createSaveBoardBackgroundHandler() {
await secureFs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
// Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Use a fixed filename for the board background (overwrite previous)

View File

@@ -31,9 +31,7 @@ export function createSaveImageHandler() {
await secureFs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
// Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Generate unique filename with timestamp

View File

@@ -23,7 +23,6 @@ import {
isCodexModel,
isCursorModel,
isOpencodeModel,
supportsStructuredOutput,
} from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../../lib/json-extractor.js';
@@ -125,9 +124,8 @@ async function runValidation(
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
// Determine if we should use structured output based on model type
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
const useStructuredOutput = supportsStructuredOutput(model);
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
let finalPrompt = basePrompt;

View File

@@ -4,21 +4,15 @@
import type { Request, Response } from 'express';
import type { IdeationService } from '../../../services/ideation-service.js';
import type { IdeationContextSources } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('ideation:suggestions-generate');
/**
* Creates an Express route handler for generating AI-powered ideation suggestions.
* Accepts a prompt, category, and optional context sources configuration,
* then returns structured suggestions that can be added to the board.
*/
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, promptId, category, count, contextSources } = req.body;
const { projectPath, promptId, category, count } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
@@ -44,8 +38,7 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
projectPath,
promptId,
category,
suggestionCount,
contextSources as IdeationContextSources | undefined
suggestionCount
);
res.json({

View File

@@ -1,12 +0,0 @@
/**
* Common utilities for projects routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Projects');
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);

View File

@@ -1,27 +0,0 @@
/**
* Projects routes - HTTP API for multi-project overview and management
*/
import { Router } from 'express';
import type { FeatureLoader } from '../../services/feature-loader.js';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { NotificationService } from '../../services/notification-service.js';
import { createOverviewHandler } from './routes/overview.js';
export function createProjectsRoutes(
featureLoader: FeatureLoader,
autoModeService: AutoModeServiceCompat,
settingsService: SettingsService,
notificationService: NotificationService
): Router {
const router = Router();
// GET /overview - Get aggregate status for all projects
router.get(
'/overview',
createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService)
);
return router;
}

View File

@@ -1,324 +0,0 @@
/**
* GET /overview endpoint - Get aggregate status for all projects
*
* Returns a complete overview of all projects including:
* - Individual project status (features, auto-mode state)
* - Aggregate metrics across all projects
* - Recent activity feed (placeholder for future implementation)
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type {
AutoModeServiceCompat,
RunningAgentInfo,
ProjectAutoModeStatus,
} from '../../../services/auto-mode/index.js';
import type { SettingsService } from '../../../services/settings-service.js';
import type { NotificationService } from '../../../services/notification-service.js';
import type {
ProjectStatus,
AggregateStatus,
MultiProjectOverview,
FeatureStatusCounts,
AggregateFeatureCounts,
AggregateProjectCounts,
ProjectHealthStatus,
Feature,
ProjectRef,
} from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
/**
* Compute feature status counts from a list of features
*/
function computeFeatureCounts(features: Feature[]): FeatureStatusCounts {
const counts: FeatureStatusCounts = {
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
};
for (const feature of features) {
switch (feature.status) {
case 'pending':
case 'ready':
counts.pending++;
break;
case 'running':
case 'generating_spec':
case 'in_progress':
counts.running++;
break;
case 'waiting_approval':
// waiting_approval means agent finished, needs human review - count as pending
counts.pending++;
break;
case 'completed':
counts.completed++;
break;
case 'failed':
counts.failed++;
break;
case 'verified':
counts.verified++;
break;
default:
// Unknown status, treat as pending
counts.pending++;
}
}
return counts;
}
/**
* Determine the overall health status of a project based on its feature statuses
*/
function computeHealthStatus(
featureCounts: FeatureStatusCounts,
isAutoModeRunning: boolean
): ProjectHealthStatus {
const totalFeatures =
featureCounts.pending +
featureCounts.running +
featureCounts.completed +
featureCounts.failed +
featureCounts.verified;
// If there are failed features, the project has errors
if (featureCounts.failed > 0) {
return 'error';
}
// If there are running features or auto mode is running with pending work
if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) {
return 'active';
}
// Pending work but no active execution
if (featureCounts.pending > 0) {
return 'waiting';
}
// If all features are completed or verified
if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) {
return 'completed';
}
// Default to idle
return 'idle';
}
/**
* Get the most recent activity timestamp from features
*/
function getLastActivityAt(features: Feature[]): string | undefined {
if (features.length === 0) {
return undefined;
}
let latestTimestamp: number = 0;
for (const feature of features) {
// Check startedAt timestamp (the main timestamp available on Feature)
if (feature.startedAt) {
const timestamp = new Date(feature.startedAt).getTime();
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
// Also check planSpec timestamps if available
if (feature.planSpec?.generatedAt) {
const timestamp = new Date(feature.planSpec.generatedAt).getTime();
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
if (feature.planSpec?.approvedAt) {
const timestamp = new Date(feature.planSpec.approvedAt).getTime();
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
}
return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined;
}
export function createOverviewHandler(
featureLoader: FeatureLoader,
autoModeService: AutoModeServiceCompat,
settingsService: SettingsService,
notificationService: NotificationService
) {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Get all projects from settings
const settings = await settingsService.getGlobalSettings();
const projectRefs: ProjectRef[] = settings.projects || [];
// Get all running agents once to count live running features per project
const allRunningAgents: RunningAgentInfo[] = await autoModeService.getRunningAgents();
// Collect project statuses in parallel
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
try {
// Load features for this project
const features = await featureLoader.getAll(projectRef.path);
const featureCounts = computeFeatureCounts(features);
const totalFeatures = features.length;
// Get auto-mode status for this project (main worktree, branchName = null)
const autoModeStatus: ProjectAutoModeStatus = autoModeService.getStatusForProject(
projectRef.path,
null
);
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
// Count live running features for this project (across all branches)
// This ensures we only count features that are actually running in memory
const liveRunningCount = allRunningAgents.filter(
(agent) => agent.projectPath === projectRef.path
).length;
featureCounts.running = liveRunningCount;
// Get notification count for this project
let unreadNotificationCount = 0;
try {
const notifications = await notificationService.getNotifications(projectRef.path);
unreadNotificationCount = notifications.filter((n) => !n.read).length;
} catch {
// Ignore notification errors - project may not have any notifications yet
}
// Compute health status
const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning);
// Get last activity timestamp
const lastActivityAt = getLastActivityAt(features);
return {
projectId: projectRef.id,
projectName: projectRef.name,
projectPath: projectRef.path,
healthStatus,
featureCounts,
totalFeatures,
lastActivityAt,
isAutoModeRunning,
activeBranch: autoModeStatus.branchName ?? undefined,
unreadNotificationCount,
};
} catch (error) {
logError(error, `Failed to load project status: ${projectRef.name}`);
// Return a minimal status for projects that fail to load
return {
projectId: projectRef.id,
projectName: projectRef.name,
projectPath: projectRef.path,
healthStatus: 'error' as ProjectHealthStatus,
featureCounts: {
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
},
totalFeatures: 0,
isAutoModeRunning: false,
unreadNotificationCount: 0,
};
}
});
const projectStatuses = await Promise.all(projectStatusPromises);
// Compute aggregate metrics
const aggregateFeatureCounts: AggregateFeatureCounts = {
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
};
const aggregateProjectCounts: AggregateProjectCounts = {
total: projectStatuses.length,
active: 0,
idle: 0,
waiting: 0,
withErrors: 0,
allCompleted: 0,
};
let totalUnreadNotifications = 0;
let projectsWithAutoModeRunning = 0;
for (const status of projectStatuses) {
// Aggregate feature counts
aggregateFeatureCounts.total += status.totalFeatures;
aggregateFeatureCounts.pending += status.featureCounts.pending;
aggregateFeatureCounts.running += status.featureCounts.running;
aggregateFeatureCounts.completed += status.featureCounts.completed;
aggregateFeatureCounts.failed += status.featureCounts.failed;
aggregateFeatureCounts.verified += status.featureCounts.verified;
// Aggregate project counts by health status
switch (status.healthStatus) {
case 'active':
aggregateProjectCounts.active++;
break;
case 'idle':
aggregateProjectCounts.idle++;
break;
case 'waiting':
aggregateProjectCounts.waiting++;
break;
case 'error':
aggregateProjectCounts.withErrors++;
break;
case 'completed':
aggregateProjectCounts.allCompleted++;
break;
}
// Aggregate notifications
totalUnreadNotifications += status.unreadNotificationCount;
// Count projects with auto-mode running
if (status.isAutoModeRunning) {
projectsWithAutoModeRunning++;
}
}
const aggregateStatus: AggregateStatus = {
projectCounts: aggregateProjectCounts,
featureCounts: aggregateFeatureCounts,
totalUnreadNotifications,
projectsWithAutoModeRunning,
computedAt: new Date().toISOString(),
};
// Build the response (recentActivity is empty for now - can be populated later)
const overview: MultiProjectOverview = {
projects: projectStatuses,
aggregate: aggregateStatus,
recentActivity: [], // Placeholder for future activity feed implementation
generatedAt: new Date().toISOString(),
};
res.json({
success: true,
...overview,
});
} catch (error) {
logError(error, 'Get project overview failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -3,10 +3,10 @@
*/
import { Router } from 'express';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import { createIndexHandler } from './routes/index.js';
export function createRunningAgentsRoutes(autoModeService: AutoModeServiceCompat): Router {
export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router {
const router = Router();
router.get('/', createIndexHandler(autoModeService));

View File

@@ -3,17 +3,16 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.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';
export function createIndexHandler(autoModeService: AutoModeServiceCompat) {
export function createIndexHandler(autoModeService: AutoModeService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const runningAgents = [...(await autoModeService.getRunningAgents())];
const backlogPlanStatus = getBacklogPlanStatus();
const backlogPlanDetails = getRunningDetails();

View File

@@ -52,8 +52,3 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);
/**
* Marker file used to indicate a provider has been explicitly disconnected by user
*/
export const COPILOT_DISCONNECTED_MARKER_FILE = '.copilot-disconnected';

View File

@@ -24,17 +24,6 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import { createGeminiStatusHandler } from './routes/gemini-status.js';
import { createAuthGeminiHandler } from './routes/auth-gemini.js';
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
import { createCopilotStatusHandler } from './routes/copilot-status.js';
import { createAuthCopilotHandler } from './routes/auth-copilot.js';
import { createDeauthCopilotHandler } from './routes/deauth-copilot.js';
import {
createGetCopilotModelsHandler,
createRefreshCopilotModelsHandler,
createClearCopilotCacheHandler,
} from './routes/copilot-models.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
@@ -83,21 +72,6 @@ export function createSetupRoutes(): Router {
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// Gemini CLI routes
router.get('/gemini-status', createGeminiStatusHandler());
router.post('/auth-gemini', createAuthGeminiHandler());
router.post('/deauth-gemini', createDeauthGeminiHandler());
// Copilot CLI routes
router.get('/copilot-status', createCopilotStatusHandler());
router.post('/auth-copilot', createAuthCopilotHandler());
router.post('/deauth-copilot', createDeauthCopilotHandler());
// Copilot Dynamic Model Discovery routes
router.get('/copilot/models', createGetCopilotModelsHandler());
router.post('/copilot/models/refresh', createRefreshCopilotModelsHandler());
router.post('/copilot/cache/clear', createClearCopilotCacheHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());

View File

@@ -1,30 +0,0 @@
/**
* POST /auth-copilot endpoint - Connect Copilot CLI to the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { connectCopilot } from '../../../services/copilot-connection-service.js';
/**
* Creates handler for POST /api/setup/auth-copilot
* Removes the disconnection marker to allow Copilot CLI to be used
*/
export function createAuthCopilotHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
await connectCopilot();
res.json({
success: true,
message: 'Copilot CLI connected to app',
});
} catch (error) {
logError(error, 'Auth Copilot failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,42 +0,0 @@
/**
* POST /auth-gemini endpoint - Connect Gemini CLI to the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
/**
* Creates handler for POST /api/setup/auth-gemini
* Removes the disconnection marker to allow Gemini CLI to be used
*/
export function createAuthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
// Remove the disconnection marker if it exists
try {
await fs.unlink(markerPath);
} catch {
// File doesn't exist, nothing to remove
}
res.json({
success: true,
message: 'Gemini CLI connected to app',
});
} catch (error) {
logError(error, 'Auth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,139 +0,0 @@
/**
* Copilot Dynamic Models API Routes
*
* Provides endpoints for:
* - GET /api/setup/copilot/models - Get available models (cached or refreshed)
* - POST /api/setup/copilot/models/refresh - Force refresh models from CLI
*/
import type { Request, Response } from 'express';
import { CopilotProvider } from '../../../providers/copilot-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CopilotModelsRoute');
// Singleton provider instance for caching
let providerInstance: CopilotProvider | null = null;
function getProvider(): CopilotProvider {
if (!providerInstance) {
providerInstance = new CopilotProvider();
}
return providerInstance;
}
/**
* Response type for models endpoint
*/
interface ModelsResponse {
success: boolean;
models?: ModelDefinition[];
count?: number;
cached?: boolean;
error?: string;
}
/**
* Creates handler for GET /api/setup/copilot/models
*
* Returns currently available models (from cache if available).
* Query params:
* - refresh=true: Force refresh from CLI before returning
*
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
*/
export function createGetCopilotModelsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const forceRefresh = req.query.refresh === 'true';
let models: ModelDefinition[];
let cached = true;
if (forceRefresh) {
models = await provider.refreshModels();
cached = false;
} else {
// Check if we have cached models
if (!provider.hasCachedModels()) {
models = await provider.refreshModels();
cached = false;
} else {
models = provider.getAvailableModels();
}
}
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached,
};
res.json(response);
} catch (error) {
logError(error, 'Get Copilot models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/copilot/models/refresh
*
* Forces a refresh of models from the Copilot CLI.
*/
export function createRefreshCopilotModelsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const models = await provider.refreshModels();
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached: false,
};
res.json(response);
} catch (error) {
logError(error, 'Refresh Copilot models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/copilot/cache/clear
*
* Clears the model cache, forcing a fresh fetch on next access.
*/
export function createClearCopilotCacheHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
provider.clearModelCache();
res.json({
success: true,
message: 'Copilot model cache cleared',
});
} catch (error) {
logError(error, 'Clear Copilot cache failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,78 +0,0 @@
/**
* GET /copilot-status endpoint - Get Copilot CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { CopilotProvider } from '../../../providers/copilot-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
async function isCopilotDisconnectedFromApp(): Promise<boolean> {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
await fs.access(markerPath);
return true;
} catch {
return false;
}
}
/**
* Creates handler for GET /api/setup/copilot-status
* Returns Copilot CLI installation and authentication status
*/
export function createCopilotStatusHandler() {
const installCommand = 'npm install -g @github/copilot';
const loginCommand = 'gh auth login';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (await isCopilotDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
},
installCommand,
loginCommand,
});
return;
}
const provider = new CopilotProvider();
const status = await provider.detectInstallation();
const auth = await provider.checkAuth();
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: auth.authenticated,
method: auth.method,
login: auth.login,
host: auth.host,
error: auth.error,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Copilot status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,30 +0,0 @@
/**
* POST /deauth-copilot endpoint - Disconnect Copilot CLI from the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { disconnectCopilot } from '../../../services/copilot-connection-service.js';
/**
* Creates handler for POST /api/setup/deauth-copilot
* Creates a marker file to disconnect Copilot CLI from the app
*/
export function createDeauthCopilotHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
await disconnectCopilot();
res.json({
success: true,
message: 'Copilot CLI disconnected from app',
});
} catch (error) {
logError(error, 'Deauth Copilot failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,42 +0,0 @@
/**
* POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
/**
* Creates handler for POST /api/setup/deauth-gemini
* Creates a marker file to disconnect Gemini CLI from the app
*/
export function createDeauthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');
// Ensure .automaker directory exists
await fs.mkdir(automakerDir, { recursive: true });
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
// Create the disconnection marker
await fs.writeFile(markerPath, 'Gemini CLI disconnected from app');
res.json({
success: true,
message: 'Gemini CLI disconnected from app',
});
} catch (error) {
logError(error, 'Deauth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,79 +0,0 @@
/**
* GET /gemini-status endpoint - Get Gemini CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { GeminiProvider } from '../../../providers/gemini-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
async function isGeminiDisconnectedFromApp(): Promise<boolean> {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
await fs.access(markerPath);
return true;
} catch {
return false;
}
}
/**
* Creates handler for GET /api/setup/gemini-status
* Returns Gemini CLI installation and authentication status
*/
export function createGeminiStatusHandler() {
const installCommand = 'npm install -g @google/gemini-cli';
const loginCommand = 'gemini';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (await isGeminiDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
hasApiKey: false,
},
installCommand,
loginCommand,
});
return;
}
const provider = new GeminiProvider();
const status = await provider.detectInstallation();
const auth = await provider.checkAuth();
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: auth.authenticated,
method: auth.method,
hasApiKey: auth.hasApiKey || false,
hasEnvApiKey: auth.hasEnvApiKey || false,
error: auth.error,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Gemini status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,34 @@
/**
* Common utilities and state for suggestions routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Suggestions');
// Shared state for tracking generation status - private
let isRunning = false;
let currentAbortController: AbortController | null = null;
/**
* Get the current running state
*/
export function getSuggestionsStatus(): {
isRunning: boolean;
currentAbortController: AbortController | null;
} {
return { isRunning, currentAbortController };
}
/**
* Set the running state and abort controller
*/
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
isRunning = running;
currentAbortController = controller;
}
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);

View File

@@ -0,0 +1,320 @@
/**
* Business logic for generating suggestions
*
* Model is configurable via phaseModels.suggestionsModel in settings
* (AI Suggestions in the UI). Supports both Claude and Cursor models.
*/
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import type { SettingsService } from '../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions');
/**
* Extract implemented features from app_spec.txt XML content
*
* Note: This uses regex-based parsing which is sufficient for our controlled
* XML structure. If more complex XML parsing is needed in the future, consider
* using a library like 'fast-xml-parser' or 'xml2js'.
*/
function extractImplementedFeatures(specContent: string): string[] {
const features: string[] = [];
// Match <implemented_features>...</implemented_features> section
const implementedMatch = specContent.match(
/<implemented_features>([\s\S]*?)<\/implemented_features>/
);
if (implementedMatch) {
const implementedSection = implementedMatch[1];
// Extract feature names from <name>...</name> tags using matchAll
const nameRegex = /<name>(.*?)<\/name>/g;
const matches = implementedSection.matchAll(nameRegex);
for (const match of matches) {
features.push(match[1].trim());
}
}
return features;
}
/**
* Load existing context (app spec and backlog features) to avoid duplicates
*/
async function loadExistingContext(projectPath: string): Promise<string> {
let context = '';
// 1. Read app_spec.txt for implemented features
try {
const appSpecPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
if (specContent && specContent.trim().length > 0) {
const implementedFeatures = extractImplementedFeatures(specContent);
if (implementedFeatures.length > 0) {
context += '\n\n=== ALREADY IMPLEMENTED FEATURES ===\n';
context += 'These features are already implemented in the codebase:\n';
context += implementedFeatures.map((feature) => `- ${feature}`).join('\n') + '\n';
}
}
} catch (error) {
// app_spec.txt doesn't exist or can't be read - that's okay
logger.debug('No app_spec.txt found or error reading it:', error);
}
// 2. Load existing features from backlog
try {
const featureLoader = new FeatureLoader();
const features = await featureLoader.getAll(projectPath);
if (features.length > 0) {
context += '\n\n=== EXISTING FEATURES IN BACKLOG ===\n';
context += 'These features are already planned or in progress:\n';
context +=
features
.map((feature) => {
const status = feature.status || 'pending';
const title = feature.title || feature.description?.substring(0, 50) || 'Untitled';
return `- ${title} (${status})`;
})
.join('\n') + '\n';
}
} catch (error) {
// Features directory doesn't exist or can't be read - that's okay
logger.debug('No features found or error loading them:', error);
}
return context;
}
/**
* JSON Schema for suggestions output
*/
const suggestionsSchema = {
type: 'object',
properties: {
suggestions: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
category: { type: 'string' },
description: { type: 'string' },
priority: {
type: 'number',
minimum: 1,
maximum: 3,
},
reasoning: { type: 'string' },
},
required: ['category', 'description', 'priority', 'reasoning'],
},
},
},
required: ['suggestions'],
additionalProperties: false,
};
export async function generateSuggestions(
projectPath: string,
suggestionType: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
modelOverride?: string,
thinkingLevelOverride?: ThinkingLevel
): Promise<void> {
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[Suggestions]');
// Map suggestion types to their prompts
const typePrompts: Record<string, string> = {
features: prompts.suggestions.featuresPrompt,
refactoring: prompts.suggestions.refactoringPrompt,
security: prompts.suggestions.securityPrompt,
performance: prompts.suggestions.performancePrompt,
};
// Load existing context to avoid duplicates
const existingContext = await loadExistingContext(projectPath);
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
${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' : ''}
${prompts.suggestions.baseTemplate}`;
// Don't send initial message - let the agent output speak for itself
// The first agent message will be captured as an info entry
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[Suggestions]'
);
// Get model from phase settings with provider info (AI Suggestions = suggestionsModel)
// Use override if provided, otherwise fall back to settings
let model: string;
let thinkingLevel: ThinkingLevel | undefined;
let provider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
let credentials: import('@automaker/types').Credentials | undefined;
if (modelOverride) {
// Use explicit override - resolve the model string
const resolved = resolvePhaseModel({
model: modelOverride,
thinkingLevel: thinkingLevelOverride,
});
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
// For overrides, just get credentials without a specific provider
credentials = await settingsService?.getCredentials();
} else if (settingsService) {
// Use settings-based model with provider info
const phaseResult = await getPhaseModelWithOverrides(
'suggestionsModel',
settingsService,
projectPath,
'[Suggestions]'
);
const resolved = resolvePhaseModel(phaseResult.phaseModel);
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
provider = phaseResult.provider;
credentials = phaseResult.credentials;
} else {
// Fallback to defaults
const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.suggestionsModel);
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
}
logger.info(
'[Suggestions] Using model:',
model,
provider ? `via provider: ${provider.name}` : 'direct API'
);
let responseText = '';
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
// Build the final prompt - for Cursor, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. After analyzing the project, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
3. The JSON must match this exact schema:
${JSON.stringify(suggestionsSchema, null, 2)}
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
}
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
readOnly: true, // Suggestions only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: suggestionsSchema,
}
: undefined,
onText: (text) => {
responseText += text;
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: text,
});
},
onToolUse: (tool, input) => {
events.emit('suggestions:event', {
type: 'suggestions_tool',
tool,
input,
});
},
});
// Use structured output if available, otherwise fall back to parsing text
try {
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
if (result.structured_output) {
structuredOutput = result.structured_output as {
suggestions: Array<Record<string, unknown>>;
};
logger.debug('Received structured output:', structuredOutput);
} else if (responseText) {
// Fallback: try to parse from text using shared extraction utility
logger.warn('No structured output received, attempting to parse from text');
structuredOutput = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
responseText,
'suggestions',
{ logger }
);
}
if (structuredOutput && structuredOutput.suggestions) {
// Use structured output directly
events.emit('suggestions:event', {
type: 'suggestions_complete',
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
...s,
id: s.id || `suggestion-${Date.now()}-${i}`,
})),
});
} else {
throw new Error('No valid JSON found in response');
}
} catch (error) {
// Log the parsing error for debugging
logger.error('Failed to parse suggestions JSON from AI response:', error);
// Return generic suggestions if parsing fails
events.emit('suggestions:event', {
type: 'suggestions_complete',
suggestions: [
{
id: `suggestion-${Date.now()}-0`,
category: 'Analysis',
description: 'Review the AI analysis output for insights',
priority: 1,
reasoning: 'The AI provided analysis but suggestions need manual review',
},
],
});
}
}

View File

@@ -0,0 +1,28 @@
/**
* Suggestions routes - HTTP API for AI-powered feature suggestions
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGenerateHandler } from './routes/generate.js';
import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createSuggestionsRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router();
router.post(
'/generate',
validatePathParams('projectPath'),
createGenerateHandler(events, settingsService)
);
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());
return router;
}

View File

@@ -0,0 +1,75 @@
/**
* POST /generate endpoint - Generate suggestions
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils';
import type { ThinkingLevel } from '@automaker/types';
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
import { generateSuggestions } from '../generate-suggestions.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('Suggestions');
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
suggestionType = 'features',
model,
thinkingLevel,
} = req.body as {
projectPath: string;
suggestionType?: string;
model?: string;
thinkingLevel?: ThinkingLevel;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
const { isRunning } = getSuggestionsStatus();
if (isRunning) {
res.json({
success: false,
error: 'Suggestions generation is already running',
});
return;
}
setRunningState(true);
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background
generateSuggestions(
projectPath,
suggestionType,
events,
abortController,
settingsService,
model,
thinkingLevel
)
.catch((error) => {
logError(error, 'Generate suggestions failed (background)');
events.emit('suggestions:event', {
type: 'suggestions_error',
error: getErrorMessage(error),
});
})
.finally(() => {
setRunningState(false, null);
});
res.json({ success: true });
} catch (error) {
logError(error, 'Generate suggestions failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,18 @@
/**
* GET /status endpoint - Get status
*/
import type { Request, Response } from 'express';
import { getSuggestionsStatus, getErrorMessage, logError } from '../common.js';
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const { isRunning } = getSuggestionsStatus();
res.json({ success: true, isRunning });
} catch (error) {
logError(error, 'Get status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,22 @@
/**
* POST /stop endpoint - Stop suggestions generation
*/
import type { Request, Response } from 'express';
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const { currentAbortController } = getSuggestionsStatus();
if (currentAbortController) {
currentAbortController.abort();
}
setRunningState(false, null);
res.json({ success: true });
} catch (error) {
logError(error, 'Stop suggestions failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -42,9 +42,6 @@ import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
import { createStartTestsHandler } from './routes/start-tests.js';
import { createStopTestsHandler } from './routes/stop-tests.js';
import { createGetTestLogsHandler } from './routes/test-logs.js';
import {
createGetInitScriptHandler,
createPutInitScriptHandler,
@@ -53,7 +50,6 @@ import {
} from './routes/init-script.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js';
import { createListRemotesHandler } from './routes/list-remotes.js';
import { createAddRemoteHandler } from './routes/add-remote.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -134,7 +130,7 @@ export function createWorktreeRoutes(
router.post(
'/start-dev',
validatePathParams('projectPath', 'worktreePath'),
createStartDevHandler(settingsService)
createStartDevHandler()
);
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
@@ -144,15 +140,6 @@ export function createWorktreeRoutes(
createGetDevServerLogsHandler()
);
// Test runner routes
router.post(
'/start-tests',
validatePathParams('worktreePath', 'projectPath?'),
createStartTestsHandler(settingsService)
);
router.post('/stop-tests', createStopTestsHandler());
router.get('/test-logs', validatePathParams('worktreePath?'), createGetTestLogsHandler());
// Init script routes
router.get('/init-script', createGetInitScriptHandler());
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
@@ -179,13 +166,5 @@ export function createWorktreeRoutes(
createListRemotesHandler()
);
// Add remote route
router.post(
'/add-remote',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createAddRemoteHandler()
);
return router;
}

View File

@@ -1,166 +0,0 @@
/**
* POST /add-remote endpoint - Add a new remote to a git repository
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
const execFileAsync = promisify(execFile);
/** Maximum allowed length for remote names */
const MAX_REMOTE_NAME_LENGTH = 250;
/** Maximum allowed length for remote URLs */
const MAX_REMOTE_URL_LENGTH = 2048;
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30000;
/**
* Validate remote name - must be alphanumeric with dashes/underscores
* Git remote names have similar restrictions to branch names
*/
function isValidRemoteName(name: string): boolean {
// Remote names should be alphanumeric, may contain dashes, underscores, periods
// Cannot start with a dash or period, cannot be empty
if (!name || name.length === 0 || name.length > MAX_REMOTE_NAME_LENGTH) {
return false;
}
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
}
/**
* Validate remote URL - basic validation for git remote URLs
* Supports HTTPS, SSH, and git:// protocols
*/
function isValidRemoteUrl(url: string): boolean {
if (!url || url.length === 0 || url.length > MAX_REMOTE_URL_LENGTH) {
return false;
}
// Support common git URL formats:
// - https://github.com/user/repo.git
// - git@github.com:user/repo.git
// - git://github.com/user/repo.git
// - ssh://git@github.com/user/repo.git
const httpsPattern = /^https?:\/\/.+/;
const sshPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:.+/;
const gitProtocolPattern = /^git:\/\/.+/;
const sshProtocolPattern = /^ssh:\/\/.+/;
return (
httpsPattern.test(url) ||
sshPattern.test(url) ||
gitProtocolPattern.test(url) ||
sshProtocolPattern.test(url)
);
}
export function createAddRemoteHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remoteName, remoteUrl } = req.body as {
worktreePath: string;
remoteName: string;
remoteUrl: string;
};
// Validate required fields
const requiredFields = { worktreePath, remoteName, remoteUrl };
for (const [key, value] of Object.entries(requiredFields)) {
if (!value) {
res.status(400).json({ success: false, error: `${key} required` });
return;
}
}
// Validate remote name
if (!isValidRemoteName(remoteName)) {
res.status(400).json({
success: false,
error:
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
});
return;
}
// Validate remote URL
if (!isValidRemoteUrl(remoteUrl)) {
res.status(400).json({
success: false,
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
});
return;
}
// Check if remote already exists
try {
const { stdout: existingRemotes } = await execFileAsync('git', ['remote'], {
cwd: worktreePath,
});
const remoteNames = existingRemotes
.trim()
.split('\n')
.filter((r) => r.trim());
if (remoteNames.includes(remoteName)) {
res.status(400).json({
success: false,
error: `Remote '${remoteName}' already exists`,
code: 'REMOTE_EXISTS',
});
return;
}
} catch (error) {
// If git remote fails, continue with adding the remote. Log for debugging.
logWorktreeError(
error,
'Checking for existing remotes failed, proceeding to add.',
worktreePath
);
}
// Add the remote using execFile with array arguments to prevent command injection
await execFileAsync('git', ['remote', 'add', remoteName, remoteUrl], {
cwd: worktreePath,
});
// Optionally fetch from the new remote to get its branches
let fetchSucceeded = false;
try {
await execFileAsync('git', ['fetch', remoteName, '--quiet'], {
cwd: worktreePath,
timeout: FETCH_TIMEOUT_MS,
});
fetchSucceeded = true;
} catch (fetchError) {
// Fetch failed (maybe offline or invalid URL), but remote was added successfully
logWorktreeError(
fetchError,
`Fetch from new remote '${remoteName}' failed (remote added successfully)`,
worktreePath
);
fetchSucceeded = false;
}
res.json({
success: true,
result: {
remoteName,
remoteUrl,
fetched: fetchSucceeded,
message: fetchSucceeded
? `Successfully added remote '${remoteName}' and fetched its branches`
: `Successfully added remote '${remoteName}' (fetch failed - you may need to fetch manually)`,
},
});
} catch (error) {
const worktreePath = req.body?.worktreePath;
logWorktreeError(error, 'Add remote failed', worktreePath);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -168,7 +168,7 @@ export function createGenerateCommitMessageHandler(
worktreePath,
'[GenerateCommitMessage]'
);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
const { model } = resolvePhaseModel(phaseModelEntry);
logger.info(
`Using model for commit message: ${model}`,
@@ -199,7 +199,6 @@ export function createGenerateCommitMessageHandler(
maxTurns: 1,
allowedTools: [],
readOnly: true,
thinkingLevel, // Pass thinking level for extended thinking support
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});

View File

@@ -110,18 +110,6 @@ export function createListBranchesHandler() {
}
}
// Check if any remotes are configured for this repository
let hasAnyRemotes = false;
try {
const { stdout: remotesOutput } = await execAsync('git remote', {
cwd: worktreePath,
});
hasAnyRemotes = remotesOutput.trim().length > 0;
} catch {
// If git remote fails, assume no remotes
hasAnyRemotes = false;
}
// Get ahead/behind count for current branch and check if remote branch exists
let aheadCount = 0;
let behindCount = 0;
@@ -166,7 +154,6 @@ export function createListBranchesHandler() {
aheadCount,
behindCount,
hasRemoteBranch,
hasAnyRemotes,
},
});
} catch (error) {

View File

@@ -39,15 +39,8 @@ interface GitHubRemoteCacheEntry {
checkedAt: number;
}
interface GitHubPRCacheEntry {
prs: Map<string, WorktreePRInfo>;
fetchedAt: number;
}
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
const githubPRCache = new Map<string, GitHubPRCacheEntry>();
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll
interface WorktreeInfo {
path: string;
@@ -187,21 +180,9 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
* This also 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. Results are cached
* briefly to avoid hammering GitHub on frequent worktree polls.
* project doesn't have a GitHub remote configured.
*/
async function fetchGitHubPRs(
projectPath: string,
forceRefresh = false
): Promise<Map<string, WorktreePRInfo>> {
const now = Date.now();
const cached = githubPRCache.get(projectPath);
// Return cached result if valid and not forcing refresh
if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) {
return cached.prs;
}
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
const prMap = new Map<string, WorktreePRInfo>();
try {
@@ -244,22 +225,8 @@ async function fetchGitHubPRs(
createdAt: pr.createdAt,
});
}
// Only update cache on successful fetch
githubPRCache.set(projectPath, {
prs: prMap,
fetchedAt: Date.now(),
});
} catch (error) {
// On fetch failure, return stale cached data if available to avoid
// repeated API calls during GitHub API flakiness or temporary outages
if (cached) {
logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`);
// Extend cache TTL to avoid repeated retries during outages
githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() });
return cached.prs;
}
// No cache available, log warning and return empty map
// Silently fail - PR detection is optional
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
}
@@ -397,7 +364,7 @@ export function createListHandler() {
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
? await fetchGitHubPRs(projectPath)
: new Map<string, WorktreePRInfo>();
for (const worktree of worktrees) {

View File

@@ -1,22 +1,16 @@
/**
* POST /start-dev endpoint - Start a dev server for a worktree
*
* Spins up a development server in the worktree directory on a unique port,
* allowing preview of the worktree's changes without affecting the main dev server.
*
* If a custom devCommand is configured in project settings, it will be used.
* Otherwise, auto-detection based on package manager (npm/yarn/pnpm/bun run dev) is used.
* Spins up a development server (npm run dev) in the worktree directory
* on a unique port, allowing preview of the worktree's changes without
* affecting the main dev server.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import { getDevServerService } from '../../../services/dev-server-service.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('start-dev');
export function createStartDevHandler(settingsService?: SettingsService) {
export function createStartDevHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath } = req.body as {
@@ -40,25 +34,8 @@ export function createStartDevHandler(settingsService?: SettingsService) {
return;
}
// Get custom dev command from project settings (if configured)
let customCommand: string | undefined;
if (settingsService) {
const projectSettings = await settingsService.getProjectSettings(projectPath);
const devCommand = projectSettings?.devCommand?.trim();
if (devCommand) {
customCommand = devCommand;
logger.debug(`Using custom dev command from project settings: ${customCommand}`);
} else {
logger.debug('No custom dev command configured, using auto-detection');
}
}
const devServerService = getDevServerService();
const result = await devServerService.startDevServer(
projectPath,
worktreePath,
customCommand
);
const result = await devServerService.startDevServer(projectPath, worktreePath);
if (result.success && result.result) {
res.json({

View File

@@ -1,92 +0,0 @@
/**
* POST /start-tests endpoint - Start tests for a worktree
*
* Runs the test command configured in project settings.
* If no testCommand is configured, returns an error.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import { getTestRunnerService } from '../../../services/test-runner-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createStartTestsHandler(settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body;
// Validate request body
if (!body || typeof body !== 'object') {
res.status(400).json({
success: false,
error: 'Request body must be an object',
});
return;
}
const worktreePath = typeof body.worktreePath === 'string' ? body.worktreePath : undefined;
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined;
const testFile = typeof body.testFile === 'string' ? body.testFile : undefined;
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required and must be a string',
});
return;
}
// Get project settings to find the test command
// Use projectPath if provided, otherwise use worktreePath
const settingsPath = projectPath || worktreePath;
if (!settingsService) {
res.status(500).json({
success: false,
error: 'Settings service not available',
});
return;
}
const projectSettings = await settingsService.getProjectSettings(settingsPath);
const testCommand = projectSettings?.testCommand;
if (!testCommand) {
res.status(400).json({
success: false,
error:
'No test command configured. Please configure a test command in Project Settings > Testing Configuration.',
});
return;
}
const testRunnerService = getTestRunnerService();
const result = await testRunnerService.startTests(worktreePath, {
command: testCommand,
testFile,
});
if (result.success && result.result) {
res.json({
success: true,
result: {
sessionId: result.result.sessionId,
worktreePath: result.result.worktreePath,
command: result.result.command,
status: result.result.status,
testFile: result.result.testFile,
message: result.result.message,
},
});
} else {
res.status(400).json({
success: false,
error: result.error || 'Failed to start tests',
});
}
} catch (error) {
logError(error, 'Start tests failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,58 +0,0 @@
/**
* POST /stop-tests endpoint - Stop a running test session
*
* Stops the test runner process for a specific session,
* cancelling any ongoing tests and freeing up resources.
*/
import type { Request, Response } from 'express';
import { getTestRunnerService } from '../../../services/test-runner-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createStopTestsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body;
// Validate request body
if (!body || typeof body !== 'object') {
res.status(400).json({
success: false,
error: 'Request body must be an object',
});
return;
}
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined;
if (!sessionId) {
res.status(400).json({
success: false,
error: 'sessionId is required and must be a string',
});
return;
}
const testRunnerService = getTestRunnerService();
const result = await testRunnerService.stopTests(sessionId);
if (result.success && result.result) {
res.json({
success: true,
result: {
sessionId: result.result.sessionId,
message: result.result.message,
},
});
} else {
res.status(400).json({
success: false,
error: result.error || 'Failed to stop tests',
});
}
} catch (error) {
logError(error, 'Stop tests failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,160 +0,0 @@
/**
* GET /test-logs endpoint - Get buffered logs for a test runner session
*
* Returns the scrollback buffer containing historical log output for a test run.
* Used by clients to populate the log panel on initial connection
* before subscribing to real-time updates via WebSocket.
*
* Query parameters:
* - worktreePath: Path to the worktree (optional if sessionId provided)
* - sessionId: Specific test session ID (optional, uses active session if not provided)
*/
import type { Request, Response } from 'express';
import { getTestRunnerService } from '../../../services/test-runner-service.js';
import { getErrorMessage, logError } from '../common.js';
interface SessionInfo {
sessionId: string;
worktreePath?: string;
command?: string;
testFile?: string;
exitCode?: number | null;
}
interface OutputResult {
sessionId: string;
status: string;
output: string;
startedAt: string;
finishedAt?: string | null;
}
function buildLogsResponse(session: SessionInfo, output: OutputResult) {
return {
success: true,
result: {
sessionId: session.sessionId,
worktreePath: session.worktreePath,
command: session.command,
status: output.status,
testFile: session.testFile,
logs: output.output,
startedAt: output.startedAt,
finishedAt: output.finishedAt,
exitCode: session.exitCode ?? null,
},
};
}
export function createGetTestLogsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, sessionId } = req.query as {
worktreePath?: string;
sessionId?: string;
};
const testRunnerService = getTestRunnerService();
// If sessionId is provided, get logs for that specific session
if (sessionId) {
const result = testRunnerService.getSessionOutput(sessionId);
if (result.success && result.result) {
const session = testRunnerService.getSession(sessionId);
res.json(
buildLogsResponse(
{
sessionId: result.result.sessionId,
worktreePath: session?.worktreePath,
command: session?.command,
testFile: session?.testFile,
exitCode: session?.exitCode,
},
result.result
)
);
} else {
res.status(404).json({
success: false,
error: result.error || 'Failed to get test logs',
});
}
return;
}
// If worktreePath is provided, get logs for the active session
if (worktreePath) {
const activeSession = testRunnerService.getActiveSession(worktreePath);
if (activeSession) {
const result = testRunnerService.getSessionOutput(activeSession.id);
if (result.success && result.result) {
res.json(
buildLogsResponse(
{
sessionId: activeSession.id,
worktreePath: activeSession.worktreePath,
command: activeSession.command,
testFile: activeSession.testFile,
exitCode: activeSession.exitCode,
},
result.result
)
);
} else {
res.status(404).json({
success: false,
error: result.error || 'Failed to get test logs',
});
}
} else {
// No active session - check for most recent session for this worktree
const sessions = testRunnerService.listSessions(worktreePath);
if (sessions.result.sessions.length > 0) {
// Get the most recent session (list is not sorted, so find it)
const mostRecent = sessions.result.sessions.reduce((latest, current) => {
const latestTime = new Date(latest.startedAt).getTime();
const currentTime = new Date(current.startedAt).getTime();
return currentTime > latestTime ? current : latest;
});
const result = testRunnerService.getSessionOutput(mostRecent.sessionId);
if (result.success && result.result) {
res.json(
buildLogsResponse(
{
sessionId: mostRecent.sessionId,
worktreePath: mostRecent.worktreePath,
command: mostRecent.command,
testFile: mostRecent.testFile,
exitCode: mostRecent.exitCode,
},
result.result
)
);
return;
}
}
res.status(404).json({
success: false,
error: 'No test sessions found for this worktree',
});
}
return;
}
// Neither sessionId nor worktreePath provided
res.status(400).json({
success: false,
error: 'Either worktreePath or sessionId query parameter is required',
});
} catch (error) {
logError(error, 'Get test logs failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,83 +0,0 @@
/**
* AgentExecutor Types - Type definitions for agent execution
*/
import type {
PlanningMode,
ThinkingLevel,
ParsedTask,
ClaudeCompatibleProvider,
Credentials,
} from '@automaker/types';
import type { BaseProvider } from '../providers/base-provider.js';
export interface AgentExecutionOptions {
workDir: string;
featureId: string;
prompt: string;
projectPath: string;
abortController: AbortController;
imagePaths?: string[];
model?: string;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
previousContent?: string;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
thinkingLevel?: ThinkingLevel;
branchName?: string | null;
credentials?: Credentials;
claudeCompatibleProvider?: ClaudeCompatibleProvider;
mcpServers?: Record<string, unknown>;
sdkOptions?: {
maxTurns?: number;
allowedTools?: string[];
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string };
settingSources?: Array<'user' | 'project' | 'local'>;
};
provider: BaseProvider;
effectiveBareModel: string;
specAlreadyDetected?: boolean;
existingApprovedPlanContent?: string;
persistedTasks?: ParsedTask[];
}
export interface AgentExecutionResult {
responseText: string;
specDetected: boolean;
tasksCompleted: number;
aborted: boolean;
}
export type WaitForApprovalFn = (
featureId: string,
projectPath: string
) => Promise<{ approved: boolean; feedback?: string; editedPlan?: string }>;
export type SaveFeatureSummaryFn = (
projectPath: string,
featureId: string,
summary: string
) => Promise<void>;
export type UpdateFeatureSummaryFn = (
projectPath: string,
featureId: string,
summary: string
) => Promise<void>;
export type BuildTaskPromptFn = (
task: ParsedTask,
allTasks: ParsedTask[],
taskIndex: number,
planContent: string,
taskPromptTemplate: string,
userFeedback?: string
) => string;
export interface AgentExecutorCallbacks {
waitForApproval: WaitForApprovalFn;
saveFeatureSummary: SaveFeatureSummaryFn;
updateFeatureSummary: UpdateFeatureSummaryFn;
buildTaskPrompt: BuildTaskPromptFn;
}

View File

@@ -1,686 +0,0 @@
/**
* AgentExecutor - Core agent execution engine with streaming support
*/
import path from 'path';
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
import { buildPromptWithImages, createLogger } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import * as secureFs from '../lib/secure-fs.js';
import { TypedEventBus } from './typed-event-bus.js';
import { FeatureStateManager } from './feature-state-manager.js';
import { PlanApprovalService } from './plan-approval-service.js';
import type { SettingsService } from './settings-service.js';
import {
parseTasksFromSpec,
detectTaskStartMarker,
detectTaskCompleteMarker,
detectPhaseCompleteMarker,
detectSpecFallback,
extractSummary,
} from './spec-parser.js';
import { getPromptCustomization } from '../lib/settings-helpers.js';
import type {
AgentExecutionOptions,
AgentExecutionResult,
AgentExecutorCallbacks,
} from './agent-executor-types.js';
// Re-export types for backward compatibility
export type {
AgentExecutionOptions,
AgentExecutionResult,
WaitForApprovalFn,
SaveFeatureSummaryFn,
UpdateFeatureSummaryFn,
BuildTaskPromptFn,
} from './agent-executor-types.js';
const logger = createLogger('AgentExecutor');
export class AgentExecutor {
private static readonly WRITE_DEBOUNCE_MS = 500;
private static readonly STREAM_HEARTBEAT_MS = 15_000;
constructor(
private eventBus: TypedEventBus,
private featureStateManager: FeatureStateManager,
private planApprovalService: PlanApprovalService,
private settingsService: SettingsService | null = null
) {}
async execute(
options: AgentExecutionOptions,
callbacks: AgentExecutorCallbacks
): Promise<AgentExecutionResult> {
const {
workDir,
featureId,
projectPath,
abortController,
branchName = null,
provider,
effectiveBareModel,
previousContent,
planningMode = 'skip',
requirePlanApproval = false,
specAlreadyDetected = false,
existingApprovedPlanContent,
persistedTasks,
credentials,
claudeCompatibleProvider,
mcpServers,
sdkOptions,
} = options;
const { content: promptContent } = await buildPromptWithImages(
options.prompt,
options.imagePaths,
workDir,
false
);
const executeOptions: ExecuteOptions = {
prompt: promptContent,
model: effectiveBareModel,
maxTurns: sdkOptions?.maxTurns,
cwd: workDir,
allowedTools: sdkOptions?.allowedTools as string[] | undefined,
abortController,
systemPrompt: sdkOptions?.systemPrompt,
settingSources: sdkOptions?.settingSources,
mcpServers:
mcpServers && Object.keys(mcpServers).length > 0
? (mcpServers as Record<string, { command: string }>)
: undefined,
thinkingLevel: options.thinkingLevel,
credentials,
claudeCompatibleProvider,
};
const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, 'agent-output.md');
const rawOutputPath = path.join(featureDirForOutput, 'raw-output.jsonl');
const enableRawOutput =
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1';
let responseText = previousContent
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
: '';
let specDetected = specAlreadyDetected,
tasksCompleted = 0,
aborted = false;
let writeTimeout: ReturnType<typeof setTimeout> | null = null,
rawOutputLines: string[] = [],
rawWriteTimeout: ReturnType<typeof setTimeout> | null = null;
const writeToFile = async (): Promise<void> => {
try {
await secureFs.mkdir(path.dirname(outputPath), { recursive: true });
await secureFs.writeFile(outputPath, responseText);
} catch (error) {
logger.error(`Failed to write agent output for ${featureId}:`, error);
}
};
const scheduleWrite = (): void => {
if (writeTimeout) clearTimeout(writeTimeout);
writeTimeout = setTimeout(() => writeToFile(), AgentExecutor.WRITE_DEBOUNCE_MS);
};
const appendRawEvent = (event: unknown): void => {
if (!enableRawOutput) return;
try {
rawOutputLines.push(
JSON.stringify({ timestamp: new Date().toISOString(), event }, null, 4)
);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
rawWriteTimeout = setTimeout(async () => {
try {
await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true });
await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n');
rawOutputLines = [];
} catch {
/* ignore */
}
}, AgentExecutor.WRITE_DEBOUNCE_MS);
} catch {
/* ignore */
}
};
const streamStartTime = Date.now();
let receivedAnyStreamMessage = false;
const streamHeartbeat = setInterval(() => {
if (!receivedAnyStreamMessage)
logger.info(
`Waiting for first model response for feature ${featureId} (${Math.round((Date.now() - streamStartTime) / 1000)}s elapsed)...`
);
}, AgentExecutor.STREAM_HEARTBEAT_MS);
const planningModeRequiresApproval =
planningMode === 'spec' ||
planningMode === 'full' ||
(planningMode === 'lite' && requirePlanApproval);
const requiresApproval = planningModeRequiresApproval && requirePlanApproval;
if (existingApprovedPlanContent && persistedTasks && persistedTasks.length > 0) {
const result = await this.executeTasksLoop(
options,
persistedTasks,
existingApprovedPlanContent,
responseText,
scheduleWrite,
callbacks
);
clearInterval(streamHeartbeat);
if (writeTimeout) clearTimeout(writeTimeout);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
await writeToFile();
return {
responseText: result.responseText,
specDetected: true,
tasksCompleted: result.tasksCompleted,
aborted: result.aborted,
};
}
logger.info(`Starting stream for feature ${featureId}...`);
const stream = provider.executeQuery(executeOptions);
try {
streamLoop: for await (const msg of stream) {
receivedAnyStreamMessage = true;
appendRawEvent(msg);
if (abortController.signal.aborted) {
aborted = true;
throw new Error('Feature execution aborted');
}
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
const newText = block.text || '';
if (!newText) continue;
if (responseText.length > 0 && newText.length > 0) {
const endsWithSentence = /[.!?:]\s*$/.test(responseText),
endsWithNewline = /\n\s*$/.test(responseText);
if (
!endsWithNewline &&
(endsWithSentence || /^[\n#\-*>]/.test(newText)) &&
!/[a-zA-Z0-9]/.test(responseText.slice(-1))
)
responseText += '\n\n';
}
responseText += newText;
if (
block.text &&
(block.text.includes('Invalid API key') ||
block.text.includes('authentication_failed') ||
block.text.includes('Fix external API key'))
)
throw new Error(
"Authentication failed: Invalid or expired API key. Please check your ANTHROPIC_API_KEY, or run 'claude login' to re-authenticate."
);
scheduleWrite();
const hasExplicitMarker = responseText.includes('[SPEC_GENERATED]'),
hasFallbackSpec = !hasExplicitMarker && detectSpecFallback(responseText);
if (
planningModeRequiresApproval &&
!specDetected &&
(hasExplicitMarker || hasFallbackSpec)
) {
specDetected = true;
const planContent = hasExplicitMarker
? responseText.substring(0, responseText.indexOf('[SPEC_GENERATED]')).trim()
: responseText.trim();
if (!hasExplicitMarker)
logger.info(`Using fallback spec detection for feature ${featureId}`);
const result = await this.handleSpecGenerated(
options,
planContent,
responseText,
requiresApproval,
scheduleWrite,
callbacks
);
responseText = result.responseText;
tasksCompleted = result.tasksCompleted;
break streamLoop;
}
if (!specDetected)
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: block.text,
});
} else if (block.type === 'tool_use') {
this.eventBus.emitAutoModeEvent('auto_mode_tool', {
featureId,
branchName,
tool: block.name,
input: block.input,
});
if (responseText.length > 0 && !responseText.endsWith('\n')) responseText += '\n';
responseText += `\n Tool: ${block.name}\n`;
if (block.input) responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`;
scheduleWrite();
}
}
} else if (msg.type === 'error') {
throw new Error(msg.error || 'Unknown error');
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
}
await writeToFile();
if (enableRawOutput && rawOutputLines.length > 0) {
try {
await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true });
await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n');
} catch {
/* ignore */
}
}
} finally {
clearInterval(streamHeartbeat);
if (writeTimeout) clearTimeout(writeTimeout);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
}
return { responseText, specDetected, tasksCompleted, aborted };
}
private async executeTasksLoop(
options: AgentExecutionOptions,
tasks: ParsedTask[],
planContent: string,
initialResponseText: string,
scheduleWrite: () => void,
callbacks: AgentExecutorCallbacks,
userFeedback?: string
): Promise<{ responseText: string; tasksCompleted: number; aborted: boolean }> {
const {
featureId,
projectPath,
abortController,
branchName = null,
provider,
sdkOptions,
} = options;
logger.info(`Starting task execution for feature ${featureId} with ${tasks.length} tasks`);
const taskPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
let responseText = initialResponseText,
tasksCompleted = 0;
for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
const task = tasks[taskIndex];
if (task.status === 'completed') {
tasksCompleted++;
continue;
}
if (abortController.signal.aborted) return { responseText, tasksCompleted, aborted: true };
await this.featureStateManager.updateTaskStatus(
projectPath,
featureId,
task.id,
'in_progress'
);
this.eventBus.emitAutoModeEvent('auto_mode_task_started', {
featureId,
projectPath,
branchName,
taskId: task.id,
taskDescription: task.description,
taskIndex,
tasksTotal: tasks.length,
});
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
currentTaskId: task.id,
});
const taskPrompt = callbacks.buildTaskPrompt(
task,
tasks,
taskIndex,
planContent,
taskPrompts.taskExecution.taskPromptTemplate,
userFeedback
);
const taskStream = provider.executeQuery(
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns || 100, 50))
);
let taskOutput = '',
taskStartDetected = false,
taskCompleteDetected = false;
for await (const msg of taskStream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const b of msg.message.content) {
if (b.type === 'text') {
const text = b.text || '';
taskOutput += text;
responseText += text;
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: text,
});
scheduleWrite();
if (!taskStartDetected) {
const sid = detectTaskStartMarker(taskOutput);
if (sid) {
taskStartDetected = true;
await this.featureStateManager.updateTaskStatus(
projectPath,
featureId,
sid,
'in_progress'
);
}
}
if (!taskCompleteDetected) {
const cid = detectTaskCompleteMarker(taskOutput);
if (cid) {
taskCompleteDetected = true;
await this.featureStateManager.updateTaskStatus(
projectPath,
featureId,
cid,
'completed'
);
}
}
const pn = detectPhaseCompleteMarker(text);
if (pn !== null)
this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', {
featureId,
projectPath,
branchName,
phaseNumber: pn,
});
} else if (b.type === 'tool_use')
this.eventBus.emitAutoModeEvent('auto_mode_tool', {
featureId,
branchName,
tool: b.name,
input: b.input,
});
}
} else if (msg.type === 'error')
throw new Error(msg.error || `Error during task ${task.id}`);
else if (msg.type === 'result' && msg.subtype === 'success') {
taskOutput += msg.result || '';
responseText += msg.result || '';
}
}
if (!taskCompleteDetected)
await this.featureStateManager.updateTaskStatus(
projectPath,
featureId,
task.id,
'completed'
);
tasksCompleted = taskIndex + 1;
this.eventBus.emitAutoModeEvent('auto_mode_task_complete', {
featureId,
projectPath,
branchName,
taskId: task.id,
tasksCompleted,
tasksTotal: tasks.length,
});
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
tasksCompleted,
});
if (task.phase) {
const next = tasks[taskIndex + 1];
if (!next || next.phase !== task.phase) {
const m = task.phase.match(/Phase\s*(\d+)/i);
if (m)
this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', {
featureId,
projectPath,
branchName,
phaseNumber: parseInt(m[1], 10),
});
}
}
}
const summary = extractSummary(responseText);
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return { responseText, tasksCompleted, aborted: false };
}
private async handleSpecGenerated(
options: AgentExecutionOptions,
planContent: string,
initialResponseText: string,
requiresApproval: boolean,
scheduleWrite: () => void,
callbacks: AgentExecutorCallbacks
): Promise<{ responseText: string; tasksCompleted: number }> {
const {
workDir,
featureId,
projectPath,
abortController,
branchName = null,
planningMode = 'skip',
provider,
effectiveBareModel,
credentials,
claudeCompatibleProvider,
mcpServers,
sdkOptions,
} = options;
let responseText = initialResponseText,
parsedTasks = parseTasksFromSpec(planContent);
logger.info(`Parsed ${parsedTasks.length} tasks from spec for feature ${featureId}`);
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
status: 'generated',
content: planContent,
version: 1,
generatedAt: new Date().toISOString(),
reviewedByUser: false,
tasks: parsedTasks,
tasksTotal: parsedTasks.length,
tasksCompleted: 0,
});
const planSummary = extractSummary(planContent);
if (planSummary) await callbacks.updateFeatureSummary(projectPath, featureId, planSummary);
let approvedPlanContent = planContent,
userFeedback: string | undefined,
currentPlanContent = planContent,
planVersion = 1;
if (requiresApproval) {
let planApproved = false;
while (!planApproved) {
logger.info(
`Spec v${planVersion} generated for feature ${featureId}, waiting for approval`
);
this.eventBus.emitAutoModeEvent('plan_approval_required', {
featureId,
projectPath,
branchName,
planContent: currentPlanContent,
planningMode,
planVersion,
});
const approvalResult = await callbacks.waitForApproval(featureId, projectPath);
if (approvalResult.approved) {
planApproved = true;
userFeedback = approvalResult.feedback;
approvedPlanContent = approvalResult.editedPlan || currentPlanContent;
if (approvalResult.editedPlan)
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
content: approvalResult.editedPlan,
});
this.eventBus.emitAutoModeEvent('plan_approved', {
featureId,
projectPath,
branchName,
hasEdits: !!approvalResult.editedPlan,
planVersion,
});
} else {
const hasFeedback = approvalResult.feedback?.trim().length,
hasEdits = approvalResult.editedPlan?.trim().length;
if (!hasFeedback && !hasEdits) throw new Error('Plan cancelled by user');
planVersion++;
this.eventBus.emitAutoModeEvent('plan_revision_requested', {
featureId,
projectPath,
branchName,
feedback: approvalResult.feedback,
hasEdits: !!hasEdits,
planVersion,
});
const revPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const taskEx =
planningMode === 'full'
? '```tasks\n## Phase 1: Foundation\n- [ ] T001: [Description] | File: [path/to/file]\n```'
: '```tasks\n- [ ] T001: [Description] | File: [path/to/file]\n```';
let revPrompt = revPrompts.taskExecution.planRevisionTemplate
.replace(/\{\{planVersion\}\}/g, String(planVersion - 1))
.replace(
/\{\{previousPlan\}\}/g,
hasEdits ? approvalResult.editedPlan || currentPlanContent : currentPlanContent
)
.replace(
/\{\{userFeedback\}\}/g,
approvalResult.feedback || 'Please revise the plan based on the edits above.'
)
.replace(/\{\{planningMode\}\}/g, planningMode)
.replace(/\{\{taskFormatExample\}\}/g, taskEx);
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
status: 'generating',
version: planVersion,
});
let revText = '';
for await (const msg of provider.executeQuery(
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns || 100)
)) {
if (msg.type === 'assistant' && msg.message?.content)
for (const b of msg.message.content)
if (b.type === 'text') {
revText += b.text || '';
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
featureId,
content: b.text,
});
}
if (msg.type === 'error') throw new Error(msg.error || 'Error during plan revision');
if (msg.type === 'result' && msg.subtype === 'success') revText += msg.result || '';
}
const mi = revText.indexOf('[SPEC_GENERATED]');
currentPlanContent = mi > 0 ? revText.substring(0, mi).trim() : revText.trim();
const revisedTasks = parseTasksFromSpec(currentPlanContent);
if (revisedTasks.length === 0 && (planningMode === 'spec' || planningMode === 'full'))
this.eventBus.emitAutoModeEvent('plan_revision_warning', {
featureId,
projectPath,
branchName,
planningMode,
warning: 'Revised plan missing tasks block',
});
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
status: 'generated',
content: currentPlanContent,
version: planVersion,
tasks: revisedTasks,
tasksTotal: revisedTasks.length,
tasksCompleted: 0,
});
parsedTasks = revisedTasks;
responseText += revText;
}
}
} else {
this.eventBus.emitAutoModeEvent('plan_auto_approved', {
featureId,
projectPath,
branchName,
planContent,
planningMode,
});
}
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
status: 'approved',
approvedAt: new Date().toISOString(),
reviewedByUser: requiresApproval,
});
let tasksCompleted = 0;
if (parsedTasks.length > 0) {
const r = await this.executeTasksLoop(
options,
parsedTasks,
approvedPlanContent,
responseText,
scheduleWrite,
callbacks,
userFeedback
);
responseText = r.responseText;
tasksCompleted = r.tasksCompleted;
} else {
const r = await this.executeSingleAgentContinuation(
options,
approvedPlanContent,
userFeedback,
responseText
);
responseText = r.responseText;
}
const summary = extractSummary(responseText);
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return { responseText, tasksCompleted };
}
private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns?: number) {
return {
prompt,
model: o.effectiveBareModel,
maxTurns,
cwd: o.workDir,
allowedTools: o.sdkOptions?.allowedTools as string[] | undefined,
abortController: o.abortController,
mcpServers:
o.mcpServers && Object.keys(o.mcpServers).length > 0
? (o.mcpServers as Record<string, { command: string }>)
: undefined,
credentials: o.credentials,
claudeCompatibleProvider: o.claudeCompatibleProvider,
};
}
private async executeSingleAgentContinuation(
options: AgentExecutionOptions,
planContent: string,
userFeedback: string | undefined,
initialResponseText: string
): Promise<{ responseText: string }> {
const { featureId, branchName = null, provider } = options;
logger.info(`No parsed tasks, using single-agent execution for feature ${featureId}`);
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const contPrompt = prompts.taskExecution.continuationAfterApprovalTemplate
.replace(/\{\{userFeedback\}\}/g, userFeedback || '')
.replace(/\{\{approvedPlan\}\}/g, planContent);
let responseText = initialResponseText;
for await (const msg of provider.executeQuery(
this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns)
)) {
if (msg.type === 'assistant' && msg.message?.content)
for (const b of msg.message.content) {
if (b.type === 'text') {
responseText += b.text || '';
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: b.text,
});
} else if (b.type === 'tool_use')
this.eventBus.emitAutoModeEvent('auto_mode_tool', {
featureId,
branchName,
tool: b.name,
input: b.input,
});
}
else if (msg.type === 'error')
throw new Error(msg.error || 'Unknown error during implementation');
else if (msg.type === 'result' && msg.subtype === 'success') responseText += msg.result || '';
}
return { responseText };
}
}

View File

@@ -1,366 +0,0 @@
/**
* AutoLoopCoordinator - Manages the auto-mode loop lifecycle and failure tracking
*/
import type { Feature } from '@automaker/types';
import { createLogger, classifyError } from '@automaker/utils';
import type { TypedEventBus } from './typed-event-bus.js';
import type { ConcurrencyManager } from './concurrency-manager.js';
import type { SettingsService } from './settings-service.js';
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
const logger = createLogger('AutoLoopCoordinator');
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
const FAILURE_WINDOW_MS = 60000;
export interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
projectPath: string;
branchName: string | null;
}
export interface ProjectAutoLoopState {
abortController: AbortController;
config: AutoModeConfig;
isRunning: boolean;
consecutiveFailures: { timestamp: number; error: string }[];
pausedDueToFailures: boolean;
hasEmittedIdleEvent: boolean;
branchName: string | null;
}
export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
return `${projectPath}::${(branchName === 'main' ? null : branchName) ?? '__main__'}`;
}
export type ExecuteFeatureFn = (
projectPath: string,
featureId: string,
useWorktrees: boolean,
isAutoMode: boolean
) => Promise<void>;
export type LoadPendingFeaturesFn = (
projectPath: string,
branchName: string | null
) => Promise<Feature[]>;
export type SaveExecutionStateFn = (
projectPath: string,
branchName: string | null,
maxConcurrency: number
) => Promise<void>;
export type ClearExecutionStateFn = (
projectPath: string,
branchName: string | null
) => Promise<void>;
export type ResetStuckFeaturesFn = (projectPath: string) => Promise<void>;
export type IsFeatureFinishedFn = (feature: Feature) => boolean;
export class AutoLoopCoordinator {
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
constructor(
private eventBus: TypedEventBus,
private concurrencyManager: ConcurrencyManager,
private settingsService: SettingsService | null,
private executeFeatureFn: ExecuteFeatureFn,
private loadPendingFeaturesFn: LoadPendingFeaturesFn,
private saveExecutionStateFn: SaveExecutionStateFn,
private clearExecutionStateFn: ClearExecutionStateFn,
private resetStuckFeaturesFn: ResetStuckFeaturesFn,
private isFeatureFinishedFn: IsFeatureFinishedFn,
private isFeatureRunningFn: (featureId: string) => boolean
) {}
/**
* Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees)
* @param projectPath - The project to start auto mode for
* @param branchName - The branch name for worktree scoping, null for main worktree
* @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY)
*/
async startAutoLoopForProject(
projectPath: string,
branchName: string | null = null,
maxConcurrency?: number
): Promise<number> {
const resolvedMaxConcurrency = await this.resolveMaxConcurrency(
projectPath,
branchName,
maxConcurrency
);
// Use worktree-scoped key
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
// Check if this project/worktree already has an active autoloop
const existingState = this.autoLoopsByProject.get(worktreeKey);
if (existingState?.isRunning) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
throw new Error(
`Auto mode is already running for ${worktreeDesc} in project: ${projectPath}`
);
}
// Create new project/worktree autoloop state
const abortController = new AbortController();
const config: AutoModeConfig = {
maxConcurrency: resolvedMaxConcurrency,
useWorktrees: true,
projectPath,
branchName,
};
const projectState: ProjectAutoLoopState = {
abortController,
config,
isRunning: true,
consecutiveFailures: [],
pausedDueToFailures: false,
hasEmittedIdleEvent: false,
branchName,
};
this.autoLoopsByProject.set(worktreeKey, projectState);
try {
await this.resetStuckFeaturesFn(projectPath);
} catch {
/* ignore */
}
this.eventBus.emitAutoModeEvent('auto_mode_started', {
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
projectPath,
branchName,
maxConcurrency: resolvedMaxConcurrency,
});
await this.saveExecutionStateFn(projectPath, branchName, resolvedMaxConcurrency);
this.runAutoLoopForProject(worktreeKey).catch((error) => {
const errorInfo = classifyError(error);
this.eventBus.emitAutoModeEvent('auto_mode_error', {
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
branchName,
});
});
return resolvedMaxConcurrency;
}
private async runAutoLoopForProject(worktreeKey: string): Promise<void> {
const projectState = this.autoLoopsByProject.get(worktreeKey);
if (!projectState) return;
const { projectPath, branchName } = projectState.config;
let iterationCount = 0;
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
iterationCount++;
try {
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
if (runningCount >= projectState.config.maxConcurrency) {
await this.sleep(5000, projectState.abortController.signal);
continue;
}
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
if (pendingFeatures.length === 0) {
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath,
branchName,
});
projectState.hasEmittedIdleEvent = true;
}
await this.sleep(10000, projectState.abortController.signal);
continue;
}
const nextFeature = pendingFeatures.find(
(f) => !this.isFeatureRunningFn(f.id) && !this.isFeatureFinishedFn(f)
);
if (nextFeature) {
projectState.hasEmittedIdleEvent = false;
this.executeFeatureFn(
projectPath,
nextFeature.id,
projectState.config.useWorktrees,
true
).catch(() => {});
}
await this.sleep(2000, projectState.abortController.signal);
} catch {
if (projectState.abortController.signal.aborted) break;
await this.sleep(5000, projectState.abortController.signal);
}
}
projectState.isRunning = false;
}
async stopAutoLoopForProject(
projectPath: string,
branchName: string | null = null
): Promise<number> {
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
const projectState = this.autoLoopsByProject.get(worktreeKey);
if (!projectState) return 0;
const wasRunning = projectState.isRunning;
projectState.isRunning = false;
projectState.abortController.abort();
await this.clearExecutionStateFn(projectPath, branchName);
if (wasRunning)
this.eventBus.emitAutoModeEvent('auto_mode_stopped', {
message: 'Auto mode stopped',
projectPath,
branchName,
});
this.autoLoopsByProject.delete(worktreeKey);
return await this.getRunningCountForWorktree(projectPath, branchName);
}
isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean {
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
const projectState = this.autoLoopsByProject.get(worktreeKey);
return projectState?.isRunning ?? false;
}
/**
* Get auto loop config for a specific project/worktree
* @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree
*/
getAutoLoopConfigForProject(
projectPath: string,
branchName: string | null = null
): AutoModeConfig | null {
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
const projectState = this.autoLoopsByProject.get(worktreeKey);
return projectState?.config ?? null;
}
/**
* Get all active auto loop worktrees with their project paths and branch names
*/
getActiveWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = [];
for (const [, state] of this.autoLoopsByProject) {
if (state.isRunning) {
activeWorktrees.push({
projectPath: state.config.projectPath,
branchName: state.branchName,
});
}
}
return activeWorktrees;
}
getActiveProjects(): string[] {
const activeProjects = new Set<string>();
for (const [, state] of this.autoLoopsByProject) {
if (state.isRunning) activeProjects.add(state.config.projectPath);
}
return Array.from(activeProjects);
}
async getRunningCountForWorktree(
projectPath: string,
branchName: string | null
): Promise<number> {
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName);
}
trackFailureAndCheckPauseForProject(
projectPath: string,
errorInfo: { type: string; message: string }
): boolean {
const projectState = this.autoLoopsByProject.get(getWorktreeAutoLoopKey(projectPath, null));
if (!projectState) return false;
const now = Date.now();
projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
(f) => now - f.timestamp < FAILURE_WINDOW_MS
);
return (
projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD ||
errorInfo.type === 'quota_exhausted' ||
errorInfo.type === 'rate_limit'
);
}
signalShouldPauseForProject(
projectPath: string,
errorInfo: { type: string; message: string }
): void {
const projectState = this.autoLoopsByProject.get(getWorktreeAutoLoopKey(projectPath, null));
if (!projectState || projectState.pausedDueToFailures) return;
projectState.pausedDueToFailures = true;
const failureCount = projectState.consecutiveFailures.length;
this.eventBus.emitAutoModeEvent('auto_mode_paused_failures', {
message:
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
? `Auto Mode paused: ${failureCount} consecutive failures detected.`
: 'Auto Mode paused: Usage limit or API error detected.',
errorType: errorInfo.type,
originalError: errorInfo.message,
failureCount,
projectPath,
});
this.stopAutoLoopForProject(projectPath);
}
resetFailureTrackingForProject(projectPath: string): void {
const projectState = this.autoLoopsByProject.get(getWorktreeAutoLoopKey(projectPath, null));
if (projectState) {
projectState.consecutiveFailures = [];
projectState.pausedDueToFailures = false;
}
}
recordSuccessForProject(projectPath: string): void {
const projectState = this.autoLoopsByProject.get(getWorktreeAutoLoopKey(projectPath, null));
if (projectState) projectState.consecutiveFailures = [];
}
async resolveMaxConcurrency(
projectPath: string,
branchName: string | null,
provided?: number
): Promise<number> {
if (typeof provided === 'number' && Number.isFinite(provided)) return provided;
if (!this.settingsService) return DEFAULT_MAX_CONCURRENCY;
try {
const settings = await this.settingsService.getGlobalSettings();
const globalMax =
typeof settings.maxConcurrency === 'number'
? settings.maxConcurrency
: DEFAULT_MAX_CONCURRENCY;
const projectId = settings.projects?.find((p) => p.path === projectPath)?.id;
const autoModeByWorktree = settings.autoModeByWorktree;
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
const normalizedBranch =
branchName === null || branchName === 'main' ? '__main__' : branchName;
const worktreeId = `${projectId}::${normalizedBranch}`;
if (
worktreeId in autoModeByWorktree &&
typeof autoModeByWorktree[worktreeId]?.maxConcurrency === 'number'
) {
return autoModeByWorktree[worktreeId].maxConcurrency;
}
}
return globalMax;
} catch {
return DEFAULT_MAX_CONCURRENCY;
}
}
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error('Aborted'));
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timeout);
reject(new Error('Aborted'));
});
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +0,0 @@
/**
* Compatibility Shim - Provides AutoModeService-like interface using the new architecture
*
* This allows existing routes to work without major changes during the transition.
* Routes receive this shim which delegates to GlobalAutoModeService and facades.
*
* This is a TEMPORARY shim - routes should be updated to use the new interface directly.
*/
import type { Feature } from '@automaker/types';
import type { EventEmitter } from '../../lib/events.js';
import { GlobalAutoModeService } from './global-service.js';
import { AutoModeServiceFacade } from './facade.js';
import type { SettingsService } from '../settings-service.js';
import type { FeatureLoader } from '../feature-loader.js';
import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js';
/**
* AutoModeServiceCompat wraps GlobalAutoModeService and facades to provide
* the old AutoModeService interface that routes expect.
*/
export class AutoModeServiceCompat {
private readonly globalService: GlobalAutoModeService;
private readonly facadeOptions: FacadeOptions;
constructor(
events: EventEmitter,
settingsService: SettingsService | null,
featureLoader: FeatureLoader
) {
this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader);
const sharedServices = this.globalService.getSharedServices();
this.facadeOptions = {
events,
settingsService,
featureLoader,
sharedServices,
};
}
/**
* Get the global service for direct access
*/
getGlobalService(): GlobalAutoModeService {
return this.globalService;
}
/**
* Create a facade for a specific project
*/
createFacade(projectPath: string): AutoModeServiceFacade {
return AutoModeServiceFacade.create(projectPath, this.facadeOptions);
}
// ===========================================================================
// GLOBAL OPERATIONS (delegated to GlobalAutoModeService)
// ===========================================================================
getStatus(): AutoModeStatus {
return this.globalService.getStatus();
}
getActiveAutoLoopProjects(): string[] {
return this.globalService.getActiveAutoLoopProjects();
}
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
return this.globalService.getActiveAutoLoopWorktrees();
}
async getRunningAgents(): Promise<RunningAgentInfo[]> {
return this.globalService.getRunningAgents();
}
async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> {
return this.globalService.markAllRunningFeaturesInterrupted(reason);
}
// ===========================================================================
// PER-PROJECT OPERATIONS (delegated to facades)
// ===========================================================================
getStatusForProject(
projectPath: string,
branchName: string | null = null
): {
isAutoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
maxConcurrency: number;
branchName: string | null;
} {
const facade = this.createFacade(projectPath);
return facade.getStatusForProject(branchName);
}
isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean {
const facade = this.createFacade(projectPath);
return facade.isAutoLoopRunning(branchName);
}
async startAutoLoopForProject(
projectPath: string,
branchName: string | null = null,
maxConcurrency?: number
): Promise<number> {
const facade = this.createFacade(projectPath);
return facade.startAutoLoop(branchName, maxConcurrency);
}
async stopAutoLoopForProject(
projectPath: string,
branchName: string | null = null
): Promise<number> {
const facade = this.createFacade(projectPath);
return facade.stopAutoLoop(branchName);
}
async executeFeature(
projectPath: string,
featureId: string,
useWorktrees = false,
isAutoMode = false,
providedWorktreePath?: string,
options?: { continuationPrompt?: string; _calledInternally?: boolean }
): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.executeFeature(
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
options
);
}
async stopFeature(featureId: string): Promise<boolean> {
// Stop feature is tricky - we need to find which project the feature is running in
// The concurrency manager tracks this
const runningAgents = await this.getRunningAgents();
const agent = runningAgents.find((a) => a.featureId === featureId);
if (agent) {
const facade = this.createFacade(agent.projectPath);
return facade.stopFeature(featureId);
}
return false;
}
async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.resumeFeature(featureId, useWorktrees);
}
async followUpFeature(
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[],
useWorktrees = true
): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.followUpFeature(featureId, prompt, imagePaths, useWorktrees);
}
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
const facade = this.createFacade(projectPath);
return facade.verifyFeature(featureId);
}
async commitFeature(
projectPath: string,
featureId: string,
providedWorktreePath?: string
): Promise<string | null> {
const facade = this.createFacade(projectPath);
return facade.commitFeature(featureId, providedWorktreePath);
}
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
const facade = this.createFacade(projectPath);
return facade.contextExists(featureId);
}
async analyzeProject(projectPath: string): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.analyzeProject();
}
async resolvePlanApproval(
projectPath: string,
featureId: string,
approved: boolean,
editedPlan?: string,
feedback?: string
): Promise<{ success: boolean; error?: string }> {
const facade = this.createFacade(projectPath);
return facade.resolvePlanApproval(featureId, approved, editedPlan, feedback);
}
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.resumeInterruptedFeatures();
}
async checkWorktreeCapacity(
projectPath: string,
featureId: string
): Promise<{
hasCapacity: boolean;
currentAgents: number;
maxAgents: number;
branchName: string | null;
}> {
const facade = this.createFacade(projectPath);
return facade.checkWorktreeCapacity(featureId);
}
async detectOrphanedFeatures(
projectPath: string
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
const facade = this.createFacade(projectPath);
return facade.detectOrphanedFeatures();
}
}

View File

@@ -1,924 +0,0 @@
/**
* AutoModeServiceFacade - Clean interface for auto-mode functionality
*
* This facade provides a thin delegation layer over the extracted services,
* exposing all 23 public methods that routes currently call on AutoModeService.
*
* Key design decisions:
* - Per-project factory pattern (projectPath is implicit in method calls)
* - Clean method names (e.g., startAutoLoop instead of startAutoLoopForProject)
* - Thin delegation to underlying services - no new business logic
* - Maintains backward compatibility during transition period
*/
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import type { Feature } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
import { getPromptCustomization } from '../../lib/settings-helpers.js';
import { TypedEventBus } from '../typed-event-bus.js';
import { ConcurrencyManager } from '../concurrency-manager.js';
import { WorktreeResolver } from '../worktree-resolver.js';
import { FeatureStateManager } from '../feature-state-manager.js';
import { PlanApprovalService } from '../plan-approval-service.js';
import { AutoLoopCoordinator, type AutoModeConfig } from '../auto-loop-coordinator.js';
import { ExecutionService } from '../execution-service.js';
import { RecoveryService } from '../recovery-service.js';
import { PipelineOrchestrator } from '../pipeline-orchestrator.js';
import { AgentExecutor } from '../agent-executor.js';
import { TestRunnerService } from '../test-runner-service.js';
import { FeatureLoader } from '../feature-loader.js';
import type { SettingsService } from '../settings-service.js';
import type { EventEmitter } from '../../lib/events.js';
import type {
FacadeOptions,
AutoModeStatus,
ProjectAutoModeStatus,
WorktreeCapacityInfo,
RunningAgentInfo,
OrphanedFeatureInfo,
} from './types.js';
const execAsync = promisify(exec);
const logger = createLogger('AutoModeServiceFacade');
/**
* Generate a unique key for worktree-scoped auto loop state
* (mirrors the function in AutoModeService for status lookups)
*/
function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
const normalizedBranch = branchName === 'main' ? null : branchName;
return `${projectPath}::${normalizedBranch ?? '__main__'}`;
}
/**
* AutoModeServiceFacade provides a clean interface for auto-mode functionality.
*
* Created via factory pattern with a specific projectPath, allowing methods
* to use clean names without requiring projectPath as a parameter.
*/
export class AutoModeServiceFacade {
private constructor(
private readonly projectPath: string,
private readonly events: EventEmitter,
private readonly eventBus: TypedEventBus,
private readonly concurrencyManager: ConcurrencyManager,
private readonly worktreeResolver: WorktreeResolver,
private readonly featureStateManager: FeatureStateManager,
private readonly featureLoader: FeatureLoader,
private readonly planApprovalService: PlanApprovalService,
private readonly autoLoopCoordinator: AutoLoopCoordinator,
private readonly executionService: ExecutionService,
private readonly recoveryService: RecoveryService,
private readonly pipelineOrchestrator: PipelineOrchestrator,
private readonly settingsService: SettingsService | null
) {}
/**
* Create a new AutoModeServiceFacade instance for a specific project.
*
* @param projectPath - The project path this facade operates on
* @param options - Configuration options including events, settingsService, featureLoader
*/
static create(projectPath: string, options: FacadeOptions): AutoModeServiceFacade {
const {
events,
settingsService = null,
featureLoader = new FeatureLoader(),
sharedServices,
} = options;
// Use shared services if provided, otherwise create new ones
// Shared services allow multiple facades to share state (e.g., running features, auto loops)
const eventBus = sharedServices?.eventBus ?? new TypedEventBus(events);
const worktreeResolver = sharedServices?.worktreeResolver ?? new WorktreeResolver();
const concurrencyManager =
sharedServices?.concurrencyManager ??
new ConcurrencyManager((p) => worktreeResolver.getCurrentBranch(p));
const featureStateManager = new FeatureStateManager(events, featureLoader);
const planApprovalService = new PlanApprovalService(
eventBus,
featureStateManager,
settingsService
);
const agentExecutor = new AgentExecutor(
eventBus,
featureStateManager,
planApprovalService,
settingsService
);
const testRunnerService = new TestRunnerService();
// Helper for building feature prompts (used by pipeline orchestrator)
const buildFeaturePrompt = (
feature: Feature,
prompts: { implementationInstructions: string; playwrightVerificationInstructions: string }
): string => {
const title =
feature.title || feature.description?.split('\n')[0]?.substring(0, 60) || 'Untitled';
let prompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${title}\n**Description:** ${feature.description}\n`;
if (feature.spec) {
prompt += `\n**Specification:**\n${feature.spec}\n`;
}
if (!feature.skipTests) {
prompt += `\n${prompts.implementationInstructions}\n\n${prompts.playwrightVerificationInstructions}`;
} else {
prompt += `\n${prompts.implementationInstructions}`;
}
return prompt;
};
// Create placeholder callbacks - will be bound to facade methods after creation
// These use closures to capture the facade instance once created
let facadeInstance: AutoModeServiceFacade | null = null;
// PipelineOrchestrator - runAgentFn is a stub; routes use AutoModeService directly
const pipelineOrchestrator = new PipelineOrchestrator(
eventBus,
featureStateManager,
agentExecutor,
testRunnerService,
worktreeResolver,
concurrencyManager,
settingsService,
// Callbacks
(pPath, featureId, status) =>
featureStateManager.updateFeatureStatus(pPath, featureId, status),
loadContextFiles,
buildFeaturePrompt,
(pPath, featureId, useWorktrees, _isAutoMode, _model, opts) =>
facadeInstance!.executeFeature(featureId, useWorktrees, false, undefined, opts),
// runAgentFn stub - facade does not implement runAgent directly
async () => {
throw new Error('runAgentFn not implemented in facade');
}
);
// AutoLoopCoordinator - use shared if provided, otherwise create new
// Note: When using shared autoLoopCoordinator, callbacks are already set up by the global service
const autoLoopCoordinator =
sharedServices?.autoLoopCoordinator ??
new AutoLoopCoordinator(
eventBus,
concurrencyManager,
settingsService,
// Callbacks
(pPath, featureId, useWorktrees, isAutoMode) =>
facadeInstance!.executeFeature(featureId, useWorktrees, isAutoMode),
(pPath, branchName) =>
featureLoader
.getAll(pPath)
.then((features) =>
features.filter(
(f) =>
(f.status === 'backlog' || f.status === 'ready') &&
(branchName === null
? !f.branchName || f.branchName === 'main'
: f.branchName === branchName)
)
),
(pPath, branchName, maxConcurrency) =>
facadeInstance!.saveExecutionStateForProject(branchName, maxConcurrency),
(pPath, branchName) => facadeInstance!.clearExecutionState(branchName),
(pPath) => featureStateManager.resetStuckFeatures(pPath),
(feature) =>
feature.status === 'completed' ||
feature.status === 'verified' ||
feature.status === 'waiting_approval',
(featureId) => concurrencyManager.isRunning(featureId)
);
// ExecutionService - runAgentFn is a stub
const executionService = new ExecutionService(
eventBus,
concurrencyManager,
worktreeResolver,
settingsService,
// Callbacks - runAgentFn stub
async () => {
throw new Error('runAgentFn not implemented in facade');
},
(context) => pipelineOrchestrator.executePipeline(context),
(pPath, featureId, status) =>
featureStateManager.updateFeatureStatus(pPath, featureId, status),
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
async (_feature) => {
// getPlanningPromptPrefixFn - planning prompts handled by AutoModeService
return '';
},
(pPath, featureId, summary) =>
featureStateManager.saveFeatureSummary(pPath, featureId, summary),
async () => {
/* recordLearnings - stub */
},
(pPath, featureId) => facadeInstance!.contextExists(featureId),
(pPath, featureId, useWorktrees, _calledInternally) =>
facadeInstance!.resumeFeature(featureId, useWorktrees, _calledInternally),
(errorInfo) =>
autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, errorInfo),
(errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, errorInfo),
() => {
/* recordSuccess - no-op */
},
(_pPath) => facadeInstance!.saveExecutionState(),
loadContextFiles
);
// RecoveryService
const recoveryService = new RecoveryService(
eventBus,
concurrencyManager,
settingsService,
// Callbacks
(pPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, opts) =>
facadeInstance!.executeFeature(
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
opts
),
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
(pPath, featureId, status) =>
pipelineOrchestrator.detectPipelineStatus(pPath, featureId, status),
(pPath, feature, useWorktrees, pipelineInfo) =>
pipelineOrchestrator.resumePipeline(pPath, feature, useWorktrees, pipelineInfo),
(featureId) => concurrencyManager.isRunning(featureId),
(opts) => concurrencyManager.acquire(opts),
(featureId) => concurrencyManager.release(featureId)
);
// Create the facade instance
facadeInstance = new AutoModeServiceFacade(
projectPath,
events,
eventBus,
concurrencyManager,
worktreeResolver,
featureStateManager,
featureLoader,
planApprovalService,
autoLoopCoordinator,
executionService,
recoveryService,
pipelineOrchestrator,
settingsService
);
return facadeInstance;
}
// ===========================================================================
// AUTO LOOP CONTROL (4 methods)
// ===========================================================================
/**
* Start the auto mode loop for this project/worktree
* @param branchName - The branch name for worktree scoping, null for main worktree
* @param maxConcurrency - Maximum concurrent features
*/
async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise<number> {
return this.autoLoopCoordinator.startAutoLoopForProject(
this.projectPath,
branchName,
maxConcurrency
);
}
/**
* Stop the auto mode loop for this project/worktree
* @param branchName - The branch name, or null for main worktree
*/
async stopAutoLoop(branchName: string | null = null): Promise<number> {
return this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName);
}
/**
* Check if auto mode is running for this project/worktree
* @param branchName - The branch name, or null for main worktree
*/
isAutoLoopRunning(branchName: string | null = null): boolean {
return this.autoLoopCoordinator.isAutoLoopRunningForProject(this.projectPath, branchName);
}
/**
* Get auto loop config for this project/worktree
* @param branchName - The branch name, or null for main worktree
*/
getAutoLoopConfig(branchName: string | null = null): AutoModeConfig | null {
return this.autoLoopCoordinator.getAutoLoopConfigForProject(this.projectPath, branchName);
}
// ===========================================================================
// FEATURE EXECUTION (6 methods)
// ===========================================================================
/**
* Execute a single feature
* @param featureId - The feature ID to execute
* @param useWorktrees - Whether to use worktrees for isolation
* @param isAutoMode - Whether this is running in auto mode
* @param providedWorktreePath - Optional pre-resolved worktree path
* @param options - Additional execution options
*/
async executeFeature(
featureId: string,
useWorktrees = false,
isAutoMode = false,
providedWorktreePath?: string,
options?: {
continuationPrompt?: string;
_calledInternally?: boolean;
}
): Promise<void> {
return this.executionService.executeFeature(
this.projectPath,
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
options
);
}
/**
* Stop a specific feature
* @param featureId - ID of the feature to stop
*/
async stopFeature(featureId: string): Promise<boolean> {
// Cancel any pending plan approval for this feature
this.cancelPlanApproval(featureId);
return this.executionService.stopFeature(featureId);
}
/**
* Resume a feature (continues from saved context or starts fresh)
* @param featureId - ID of the feature to resume
* @param useWorktrees - Whether to use git worktrees
* @param _calledInternally - Internal flag for nested calls
*/
async resumeFeature(
featureId: string,
useWorktrees = false,
_calledInternally = false
): Promise<void> {
return this.recoveryService.resumeFeature(
this.projectPath,
featureId,
useWorktrees,
_calledInternally
);
}
/**
* Follow up on a feature with additional instructions
* @param featureId - The feature ID
* @param prompt - Follow-up prompt
* @param imagePaths - Optional image paths
* @param useWorktrees - Whether to use worktrees
*/
async followUpFeature(
featureId: string,
prompt: string,
imagePaths?: string[],
useWorktrees = true
): Promise<void> {
// This method contains substantial logic - delegates most work to AgentExecutor
validateWorkingDirectory(this.projectPath);
const runningEntry = this.concurrencyManager.acquire({
featureId,
projectPath: this.projectPath,
isAutoMode: false,
});
const abortController = runningEntry.abortController;
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
let workDir = path.resolve(this.projectPath);
let worktreePath: string | null = null;
const branchName = feature?.branchName || `feature/${featureId}`;
if (useWorktrees && branchName) {
worktreePath = await this.worktreeResolver.findWorktreeForBranch(
this.projectPath,
branchName
);
if (worktreePath) {
workDir = worktreePath;
}
}
// Load previous context
const featureDir = getFeatureDir(this.projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
let previousContext = '';
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No previous context
}
const prompts = await getPromptCustomization(this.settingsService, '[Facade]');
// Build follow-up prompt inline (no template in TaskExecutionPrompts)
let fullPrompt = `## Follow-up on Feature Implementation
${feature ? `**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled'}\n**Description:** ${feature.description}` : `**Feature ID:** ${featureId}`}
`;
if (previousContext) {
fullPrompt += `
## Previous Agent Work
The following is the output from the previous implementation attempt:
${previousContext}
`;
}
fullPrompt += `
## Follow-up Instructions
${prompt}
## Task
Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`;
try {
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath: this.projectPath,
branchName: feature?.branchName ?? null,
feature: {
id: featureId,
title: feature?.title || 'Follow-up',
description: feature?.description || 'Following up on feature',
},
});
// NOTE: Facade does not have runAgent - this method requires AutoModeService
// For now, throw to indicate routes should use AutoModeService.followUpFeature
throw new Error(
'followUpFeature not fully implemented in facade - use AutoModeService.followUpFeature instead'
);
} catch (error) {
const errorInfo = classifyError(error);
if (!errorInfo.isAbort) {
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath: this.projectPath,
});
}
throw error;
} finally {
this.concurrencyManager.release(featureId);
}
}
/**
* Verify a feature's implementation
* @param featureId - The feature ID to verify
*/
async verifyFeature(featureId: string): Promise<boolean> {
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(this.projectPath, '.worktrees', sanitizedFeatureId);
let workDir = this.projectPath;
try {
await secureFs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree
}
const verificationChecks = [
{ cmd: 'npm run lint', name: 'Lint' },
{ cmd: 'npm run typecheck', name: 'Type check' },
{ cmd: 'npm test', name: 'Tests' },
{ cmd: 'npm run build', name: 'Build' },
];
let allPassed = true;
const results: Array<{ check: string; passed: boolean; output?: string }> = [];
for (const check of verificationChecks) {
try {
const { stdout, stderr } = await execAsync(check.cmd, { cwd: workDir, timeout: 120000 });
results.push({ check: check.name, passed: true, output: stdout || stderr });
} catch (error) {
allPassed = false;
results.push({ check: check.name, passed: false, output: (error as Error).message });
break;
}
}
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: allPassed,
message: allPassed
? 'All verification checks passed'
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
projectPath: this.projectPath,
});
return allPassed;
}
/**
* Commit feature changes
* @param featureId - The feature ID to commit
* @param providedWorktreePath - Optional worktree path
*/
async commitFeature(featureId: string, providedWorktreePath?: string): Promise<string | null> {
let workDir = this.projectPath;
if (providedWorktreePath) {
try {
await secureFs.access(providedWorktreePath);
workDir = providedWorktreePath;
} catch {
// Use project path
}
} else {
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const legacyWorktreePath = path.join(this.projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
} catch {
// Use project path
}
}
try {
const { stdout: status } = await execAsync('git status --porcelain', { cwd: workDir });
if (!status.trim()) {
return null;
}
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
const title =
feature?.description?.split('\n')[0]?.substring(0, 60) || `Feature ${featureId}`;
const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`;
await execAsync('git add -A', { cwd: workDir });
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { cwd: workDir });
const { stdout: hash } = await execAsync('git rev-parse HEAD', { cwd: workDir });
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath: this.projectPath,
});
return hash.trim();
} catch (error) {
logger.error(`Commit failed for ${featureId}:`, error);
return null;
}
}
// ===========================================================================
// STATUS AND QUERIES (7 methods)
// ===========================================================================
/**
* Get current status (global across all projects)
*/
getStatus(): AutoModeStatus {
const allRunning = this.concurrencyManager.getAllRunning();
return {
isRunning: allRunning.length > 0,
runningFeatures: allRunning.map((rf) => rf.featureId),
runningCount: allRunning.length,
};
}
/**
* Get status for this project/worktree
* @param branchName - The branch name, or null for main worktree
*/
getStatusForProject(branchName: string | null = null): ProjectAutoModeStatus {
const isAutoLoopRunning = this.autoLoopCoordinator.isAutoLoopRunningForProject(
this.projectPath,
branchName
);
const config = this.autoLoopCoordinator.getAutoLoopConfigForProject(
this.projectPath,
branchName
);
const runningFeatures = this.concurrencyManager
.getAllRunning()
.filter((f) => f.projectPath === this.projectPath && f.branchName === branchName)
.map((f) => f.featureId);
return {
isAutoLoopRunning,
runningFeatures,
runningCount: runningFeatures.length,
maxConcurrency: config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
branchName,
};
}
/**
* Get all active auto loop projects (unique project paths)
*/
getActiveAutoLoopProjects(): string[] {
// This needs access to internal state - for now return empty
// Routes should migrate to getActiveAutoLoopWorktrees
return [];
}
/**
* Get all active auto loop worktrees
*/
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
// This needs access to internal state - for now return empty
// Will be properly implemented when routes migrate
return [];
}
/**
* Get detailed info about all running agents
*/
async getRunningAgents(): Promise<RunningAgentInfo[]> {
const agents = await Promise.all(
this.concurrencyManager.getAllRunning().map(async (rf) => {
let title: string | undefined;
let description: string | undefined;
let branchName: string | undefined;
try {
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
if (feature) {
title = feature.title;
description = feature.description;
branchName = feature.branchName;
}
} catch {
// Silently ignore
}
return {
featureId: rf.featureId,
projectPath: rf.projectPath,
projectName: path.basename(rf.projectPath),
isAutoMode: rf.isAutoMode,
model: rf.model,
provider: rf.provider,
title,
description,
branchName,
};
})
);
return agents;
}
/**
* Check if there's capacity to start a feature on a worktree
* @param featureId - The feature ID to check capacity for
*/
async checkWorktreeCapacity(featureId: string): Promise<WorktreeCapacityInfo> {
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
const rawBranchName = feature?.branchName ?? null;
const branchName = rawBranchName === 'main' ? null : rawBranchName;
const maxAgents = await this.autoLoopCoordinator.resolveMaxConcurrency(
this.projectPath,
branchName
);
const currentAgents = await this.concurrencyManager.getRunningCountForWorktree(
this.projectPath,
branchName
);
return {
hasCapacity: currentAgents < maxAgents,
currentAgents,
maxAgents,
branchName,
};
}
/**
* Check if context exists for a feature
* @param featureId - The feature ID
*/
async contextExists(featureId: string): Promise<boolean> {
return this.recoveryService.contextExists(this.projectPath, featureId);
}
// ===========================================================================
// PLAN APPROVAL (4 methods)
// ===========================================================================
/**
* Resolve a pending plan approval
* @param featureId - The feature ID
* @param approved - Whether the plan was approved
* @param editedPlan - Optional edited plan content
* @param feedback - Optional feedback
*/
async resolvePlanApproval(
featureId: string,
approved: boolean,
editedPlan?: string,
feedback?: string
): Promise<{ success: boolean; error?: string }> {
const result = await this.planApprovalService.resolveApproval(featureId, approved, {
editedPlan,
feedback,
projectPath: this.projectPath,
});
// Handle recovery case
if (result.success && result.needsRecovery) {
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
if (feature) {
const prompts = await getPromptCustomization(this.settingsService, '[Facade]');
const planContent = editedPlan || feature.planSpec?.content || '';
let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, feedback || '');
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
// Start execution async
this.executeFeature(featureId, true, false, undefined, { continuationPrompt }).catch(
(error) => {
logger.error(`Recovery execution failed for feature ${featureId}:`, error);
}
);
}
}
return { success: result.success, error: result.error };
}
/**
* Wait for plan approval
* @param featureId - The feature ID
*/
waitForPlanApproval(
featureId: string
): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> {
return this.planApprovalService.waitForApproval(featureId, this.projectPath);
}
/**
* Check if a feature has a pending plan approval
* @param featureId - The feature ID
*/
hasPendingApproval(featureId: string): boolean {
return this.planApprovalService.hasPendingApproval(featureId);
}
/**
* Cancel a pending plan approval
* @param featureId - The feature ID
*/
cancelPlanApproval(featureId: string): void {
this.planApprovalService.cancelApproval(featureId);
}
// ===========================================================================
// ANALYSIS AND RECOVERY (3 methods)
// ===========================================================================
/**
* Analyze project to gather context
*
* NOTE: This method requires complex provider integration that is only available
* in AutoModeService. The facade exposes the method signature for API compatibility,
* but routes should use AutoModeService.analyzeProject() until migration is complete.
*/
async analyzeProject(): Promise<void> {
// analyzeProject requires provider.execute which is complex to wire up
// For now, throw to indicate routes should use AutoModeService
throw new Error(
'analyzeProject not fully implemented in facade - use AutoModeService.analyzeProject instead'
);
}
/**
* Resume interrupted features after server restart
*/
async resumeInterruptedFeatures(): Promise<void> {
return this.recoveryService.resumeInterruptedFeatures(this.projectPath);
}
/**
* Detect orphaned features (features with missing branches)
*/
async detectOrphanedFeatures(): Promise<OrphanedFeatureInfo[]> {
const orphanedFeatures: OrphanedFeatureInfo[] = [];
try {
const allFeatures = await this.featureLoader.getAll(this.projectPath);
const featuresWithBranches = allFeatures.filter(
(f) => f.branchName && f.branchName.trim() !== ''
);
if (featuresWithBranches.length === 0) {
return orphanedFeatures;
}
// Get existing branches
const { stdout } = await execAsync(
'git for-each-ref --format="%(refname:short)" refs/heads/',
{ cwd: this.projectPath }
);
const existingBranches = new Set(
stdout
.trim()
.split('\n')
.map((b) => b.trim())
.filter(Boolean)
);
const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath);
for (const feature of featuresWithBranches) {
const branchName = feature.branchName!;
if (primaryBranch && branchName === primaryBranch) {
continue;
}
if (!existingBranches.has(branchName)) {
orphanedFeatures.push({ feature, missingBranch: branchName });
}
}
return orphanedFeatures;
} catch (error) {
logger.error('[detectOrphanedFeatures] Error:', error);
return orphanedFeatures;
}
}
// ===========================================================================
// LIFECYCLE (1 method)
// ===========================================================================
/**
* Mark all running features as interrupted
* @param reason - Optional reason for the interruption
*/
async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> {
const allRunning = this.concurrencyManager.getAllRunning();
for (const rf of allRunning) {
await this.featureStateManager.markFeatureInterrupted(rf.projectPath, rf.featureId, reason);
}
if (allRunning.length > 0) {
logger.info(
`Marked ${allRunning.length} running feature(s) as interrupted: ${reason || 'no reason provided'}`
);
}
}
// ===========================================================================
// INTERNAL HELPERS
// ===========================================================================
/**
* Save execution state for recovery
*/
private async saveExecutionState(): Promise<void> {
return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY);
}
/**
* Save execution state for a specific worktree
*/
private async saveExecutionStateForProject(
branchName: string | null,
maxConcurrency: number
): Promise<void> {
return this.recoveryService.saveExecutionStateForProject(
this.projectPath,
branchName,
maxConcurrency
);
}
/**
* Clear execution state
*/
private async clearExecutionState(branchName: string | null = null): Promise<void> {
return this.recoveryService.clearExecutionState(this.projectPath, branchName);
}
}

View File

@@ -1,200 +0,0 @@
/**
* GlobalAutoModeService - Global operations for auto-mode that span across all projects
*
* This service manages global state and operations that are not project-specific:
* - Overall status (all running features across all projects)
* - Active auto loop projects and worktrees
* - Graceful shutdown (mark all features as interrupted)
*
* Per-project operations should use AutoModeServiceFacade instead.
*/
import path from 'path';
import type { Feature } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../lib/events.js';
import { TypedEventBus } from '../typed-event-bus.js';
import { ConcurrencyManager } from '../concurrency-manager.js';
import { WorktreeResolver } from '../worktree-resolver.js';
import { AutoLoopCoordinator } from '../auto-loop-coordinator.js';
import { FeatureStateManager } from '../feature-state-manager.js';
import { FeatureLoader } from '../feature-loader.js';
import type { SettingsService } from '../settings-service.js';
import type { SharedServices, AutoModeStatus, RunningAgentInfo } from './types.js';
const logger = createLogger('GlobalAutoModeService');
/**
* GlobalAutoModeService provides global operations for auto-mode.
*
* Created once at server startup, shared across all facades.
*/
export class GlobalAutoModeService {
private readonly eventBus: TypedEventBus;
private readonly concurrencyManager: ConcurrencyManager;
private readonly autoLoopCoordinator: AutoLoopCoordinator;
private readonly worktreeResolver: WorktreeResolver;
private readonly featureStateManager: FeatureStateManager;
private readonly featureLoader: FeatureLoader;
constructor(
events: EventEmitter,
settingsService: SettingsService | null,
featureLoader: FeatureLoader = new FeatureLoader()
) {
this.featureLoader = featureLoader;
this.eventBus = new TypedEventBus(events);
this.worktreeResolver = new WorktreeResolver();
this.concurrencyManager = new ConcurrencyManager((p) =>
this.worktreeResolver.getCurrentBranch(p)
);
this.featureStateManager = new FeatureStateManager(events, featureLoader);
// Create AutoLoopCoordinator with callbacks
// These callbacks use placeholders since GlobalAutoModeService doesn't execute features
// Feature execution is done via facades
this.autoLoopCoordinator = new AutoLoopCoordinator(
this.eventBus,
this.concurrencyManager,
settingsService,
// executeFeatureFn - not used by global service, routes handle execution
async () => {
throw new Error('executeFeatureFn not available in GlobalAutoModeService');
},
// getBacklogFeaturesFn
(pPath, branchName) =>
featureLoader
.getAll(pPath)
.then((features) =>
features.filter(
(f) =>
(f.status === 'backlog' || f.status === 'ready') &&
(branchName === null
? !f.branchName || f.branchName === 'main'
: f.branchName === branchName)
)
),
// saveExecutionStateFn - placeholder
async () => {},
// clearExecutionStateFn - placeholder
async () => {},
// resetStuckFeaturesFn
(pPath) => this.featureStateManager.resetStuckFeatures(pPath),
// isFeatureDoneFn
(feature) =>
feature.status === 'completed' ||
feature.status === 'verified' ||
feature.status === 'waiting_approval',
// isFeatureRunningFn
(featureId) => this.concurrencyManager.isRunning(featureId)
);
}
/**
* Get the shared services for use by facades.
* This allows facades to share state with the global service.
*/
getSharedServices(): SharedServices {
return {
eventBus: this.eventBus,
concurrencyManager: this.concurrencyManager,
autoLoopCoordinator: this.autoLoopCoordinator,
worktreeResolver: this.worktreeResolver,
};
}
// ===========================================================================
// GLOBAL STATUS (3 methods)
// ===========================================================================
/**
* Get global status (all projects combined)
*/
getStatus(): AutoModeStatus {
const allRunning = this.concurrencyManager.getAllRunning();
return {
isRunning: allRunning.length > 0,
runningFeatures: allRunning.map((rf) => rf.featureId),
runningCount: allRunning.length,
};
}
/**
* Get all active auto loop projects (unique project paths)
*/
getActiveAutoLoopProjects(): string[] {
return this.autoLoopCoordinator.getActiveProjects();
}
/**
* Get all active auto loop worktrees
*/
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
return this.autoLoopCoordinator.getActiveWorktrees();
}
// ===========================================================================
// RUNNING AGENTS (1 method)
// ===========================================================================
/**
* Get detailed info about all running agents
*/
async getRunningAgents(): Promise<RunningAgentInfo[]> {
const agents = await Promise.all(
this.concurrencyManager.getAllRunning().map(async (rf) => {
let title: string | undefined;
let description: string | undefined;
let branchName: string | undefined;
try {
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
if (feature) {
title = feature.title;
description = feature.description;
branchName = feature.branchName;
}
} catch {
// Silently ignore
}
return {
featureId: rf.featureId,
projectPath: rf.projectPath,
projectName: path.basename(rf.projectPath),
isAutoMode: rf.isAutoMode,
model: rf.model,
provider: rf.provider,
title,
description,
branchName,
};
})
);
return agents;
}
// ===========================================================================
// LIFECYCLE (1 method)
// ===========================================================================
/**
* Mark all running features as interrupted.
* Called during graceful shutdown.
*
* @param reason - Optional reason for the interruption
*/
async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> {
const allRunning = this.concurrencyManager.getAllRunning();
for (const rf of allRunning) {
await this.featureStateManager.markFeatureInterrupted(rf.projectPath, rf.featureId, reason);
}
if (allRunning.length > 0) {
logger.info(
`Marked ${allRunning.length} running feature(s) as interrupted: ${reason || 'no reason provided'}`
);
}
}
}

View File

@@ -1,76 +0,0 @@
/**
* Auto Mode Service Module
*
* Entry point for auto-mode functionality. Exports:
* - GlobalAutoModeService: Global operations that span all projects
* - AutoModeServiceFacade: Per-project facade for auto-mode operations
* - createAutoModeFacade: Convenience factory function
* - Types for route consumption
*/
// Main exports
export { GlobalAutoModeService } from './global-service.js';
export { AutoModeServiceFacade } from './facade.js';
export { AutoModeServiceCompat } from './compat.js';
// Convenience factory function
import { AutoModeServiceFacade } from './facade.js';
import type { FacadeOptions } from './types.js';
/**
* Create an AutoModeServiceFacade instance for a specific project.
*
* This is a convenience wrapper around AutoModeServiceFacade.create().
*
* @param projectPath - The project path this facade operates on
* @param options - Configuration options including events, settingsService, featureLoader
* @returns A new AutoModeServiceFacade instance
*
* @example
* ```typescript
* import { createAutoModeFacade } from './services/auto-mode';
*
* const facade = createAutoModeFacade('/path/to/project', {
* events: eventEmitter,
* settingsService,
* });
*
* // Start auto mode
* await facade.startAutoLoop(null, 3);
*
* // Check status
* const status = facade.getStatusForProject();
* ```
*/
export function createAutoModeFacade(
projectPath: string,
options: FacadeOptions
): AutoModeServiceFacade {
return AutoModeServiceFacade.create(projectPath, options);
}
// Type exports from types.ts
export type {
FacadeOptions,
SharedServices,
AutoModeStatus,
ProjectAutoModeStatus,
WorktreeCapacityInfo,
RunningAgentInfo,
OrphanedFeatureInfo,
GlobalAutoModeOperations,
} from './types.js';
// Re-export types from extracted services for route convenience
export type {
AutoModeConfig,
ProjectAutoLoopState,
RunningFeature,
AcquireParams,
WorktreeInfo,
PipelineContext,
PipelineStatusInfo,
PlanApprovalResult,
ResolveApprovalResult,
ExecutionState,
} from './types.js';

View File

@@ -1,128 +0,0 @@
/**
* Facade Types - Type definitions for AutoModeServiceFacade
*
* Contains:
* - FacadeOptions interface for factory configuration
* - Re-exports of types from extracted services that routes might need
* - Additional types for facade method signatures
*/
import type { EventEmitter } from '../../lib/events.js';
import type { Feature, ModelProvider } from '@automaker/types';
import type { SettingsService } from '../settings-service.js';
import type { FeatureLoader } from '../feature-loader.js';
import type { ConcurrencyManager } from '../concurrency-manager.js';
import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js';
import type { WorktreeResolver } from '../worktree-resolver.js';
import type { TypedEventBus } from '../typed-event-bus.js';
// Re-export types from extracted services for route consumption
export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js';
export type { RunningFeature, AcquireParams } from '../concurrency-manager.js';
export type { WorktreeInfo } from '../worktree-resolver.js';
export type { PipelineContext, PipelineStatusInfo } from '../pipeline-orchestrator.js';
export type { PlanApprovalResult, ResolveApprovalResult } from '../plan-approval-service.js';
export type { ExecutionState } from '../recovery-service.js';
/**
* Shared services that can be passed to facades to enable state sharing
*/
export interface SharedServices {
/** TypedEventBus for typed event emission */
eventBus: TypedEventBus;
/** ConcurrencyManager for tracking running features across all projects */
concurrencyManager: ConcurrencyManager;
/** AutoLoopCoordinator for managing auto loop state across all projects */
autoLoopCoordinator: AutoLoopCoordinator;
/** WorktreeResolver for git worktree operations */
worktreeResolver: WorktreeResolver;
}
/**
* Options for creating an AutoModeServiceFacade instance
*/
export interface FacadeOptions {
/** EventEmitter for broadcasting events to clients */
events: EventEmitter;
/** SettingsService for reading project/global settings (optional) */
settingsService?: SettingsService | null;
/** FeatureLoader for loading feature data (optional, defaults to new FeatureLoader()) */
featureLoader?: FeatureLoader;
/** Shared services for state sharing across facades (optional) */
sharedServices?: SharedServices;
}
/**
* Status returned by getStatus()
*/
export interface AutoModeStatus {
isRunning: boolean;
runningFeatures: string[];
runningCount: number;
}
/**
* Status returned by getStatusForProject()
*/
export interface ProjectAutoModeStatus {
isAutoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
maxConcurrency: number;
branchName: string | null;
}
/**
* Capacity info returned by checkWorktreeCapacity()
*/
export interface WorktreeCapacityInfo {
hasCapacity: boolean;
currentAgents: number;
maxAgents: number;
branchName: string | null;
}
/**
* Running agent info returned by getRunningAgents()
*/
export interface RunningAgentInfo {
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
model?: string;
provider?: ModelProvider;
title?: string;
description?: string;
branchName?: string;
}
/**
* Orphaned feature info returned by detectOrphanedFeatures()
*/
export interface OrphanedFeatureInfo {
feature: Feature;
missingBranch: string;
}
/**
* Interface describing global auto-mode operations (not project-specific).
* Used by routes that need global state access.
*/
export interface GlobalAutoModeOperations {
/** Get global status (all projects combined) */
getStatus(): AutoModeStatus;
/** Get all active auto loop projects (unique project paths) */
getActiveAutoLoopProjects(): string[];
/** Get all active auto loop worktrees */
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }>;
/** Get detailed info about all running agents */
getRunningAgents(): Promise<RunningAgentInfo[]>;
/** Mark all running features as interrupted (for graceful shutdown) */
markAllRunningFeaturesInterrupted(reason?: string): Promise<void>;
}

View File

@@ -1,226 +0,0 @@
/**
* ConcurrencyManager - Manages running feature slots with lease-based reference counting
*
* Extracted from AutoModeService to provide a standalone service for tracking
* running feature execution with proper lease counting to support nested calls
* (e.g., resumeFeature -> executeFeature).
*
* Key behaviors:
* - acquire() with existing entry + allowReuse: increment leaseCount, return existing
* - acquire() with existing entry + no allowReuse: throw Error('already running')
* - release() decrements leaseCount, only deletes at 0
* - release() with force:true bypasses leaseCount check
*/
import type { ModelProvider } from '@automaker/types';
/**
* Function type for getting the current branch of a project.
* Injected to allow for testing and decoupling from git operations.
*/
export type GetCurrentBranchFn = (projectPath: string) => Promise<string | null>;
/**
* Represents a running feature execution with all tracking metadata
*/
export interface RunningFeature {
featureId: string;
projectPath: string;
worktreePath: string | null;
branchName: string | null;
abortController: AbortController;
isAutoMode: boolean;
startTime: number;
leaseCount: number;
model?: string;
provider?: ModelProvider;
}
/**
* Parameters for acquiring a running feature slot
*/
export interface AcquireParams {
featureId: string;
projectPath: string;
isAutoMode: boolean;
allowReuse?: boolean;
abortController?: AbortController;
}
/**
* ConcurrencyManager manages the running features Map with lease-based reference counting.
*
* This supports nested execution patterns where a feature may be acquired multiple times
* (e.g., during resume operations) and should only be released when all references are done.
*/
export class ConcurrencyManager {
private runningFeatures = new Map<string, RunningFeature>();
private getCurrentBranch: GetCurrentBranchFn;
/**
* @param getCurrentBranch - Function to get the current branch for a project.
* If not provided, defaults to returning 'main'.
*/
constructor(getCurrentBranch?: GetCurrentBranchFn) {
this.getCurrentBranch = getCurrentBranch ?? (() => Promise.resolve('main'));
}
/**
* Acquire a slot in the runningFeatures map for a feature.
* Implements reference counting via leaseCount to support nested calls
* (e.g., resumeFeature -> executeFeature).
*
* @param params.featureId - ID of the feature to track
* @param params.projectPath - Path to the project
* @param params.isAutoMode - Whether this is an auto-mode execution
* @param params.allowReuse - If true, allows incrementing leaseCount for already-running features
* @param params.abortController - Optional abort controller to use
* @returns The RunningFeature entry (existing or newly created)
* @throws Error if feature is already running and allowReuse is false
*/
acquire(params: AcquireParams): RunningFeature {
const existing = this.runningFeatures.get(params.featureId);
if (existing) {
if (!params.allowReuse) {
throw new Error('already running');
}
existing.leaseCount += 1;
return existing;
}
const abortController = params.abortController ?? new AbortController();
const entry: RunningFeature = {
featureId: params.featureId,
projectPath: params.projectPath,
worktreePath: null,
branchName: null,
abortController,
isAutoMode: params.isAutoMode,
startTime: Date.now(),
leaseCount: 1,
};
this.runningFeatures.set(params.featureId, entry);
return entry;
}
/**
* Release a slot in the runningFeatures map for a feature.
* Decrements leaseCount and only removes the entry when it reaches zero,
* unless force option is used.
*
* @param featureId - ID of the feature to release
* @param options.force - If true, immediately removes the entry regardless of leaseCount
*/
release(featureId: string, options?: { force?: boolean }): void {
const entry = this.runningFeatures.get(featureId);
if (!entry) {
return;
}
if (options?.force) {
this.runningFeatures.delete(featureId);
return;
}
entry.leaseCount -= 1;
if (entry.leaseCount <= 0) {
this.runningFeatures.delete(featureId);
}
}
/**
* Check if a feature is currently running
*
* @param featureId - ID of the feature to check
* @returns true if the feature is in the runningFeatures map
*/
isRunning(featureId: string): boolean {
return this.runningFeatures.has(featureId);
}
/**
* Get the RunningFeature entry for a feature
*
* @param featureId - ID of the feature
* @returns The RunningFeature entry or undefined if not running
*/
getRunningFeature(featureId: string): RunningFeature | undefined {
return this.runningFeatures.get(featureId);
}
/**
* Get count of running features for a specific project
*
* @param projectPath - The project path to count features for
* @returns Number of running features for the project
*/
getRunningCount(projectPath: string): number {
let count = 0;
for (const [, feature] of this.runningFeatures) {
if (feature.projectPath === projectPath) {
count++;
}
}
return count;
}
/**
* Get count of running features for a specific worktree
*
* @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree
* (features without branchName or matching primary branch)
* @returns Number of running features for the worktree
*/
async getRunningCountForWorktree(
projectPath: string,
branchName: string | null
): Promise<number> {
// Get the actual primary branch name for the project
const primaryBranch = await this.getCurrentBranch(projectPath);
let count = 0;
for (const [, feature] of this.runningFeatures) {
// Filter by project path AND branchName to get accurate worktree-specific count
const featureBranch = feature.branchName ?? null;
if (branchName === null) {
// Main worktree: match features with branchName === null OR branchName matching primary branch
const isPrimaryBranch =
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
if (feature.projectPath === projectPath && isPrimaryBranch) {
count++;
}
} else {
// Feature worktree: exact match
if (feature.projectPath === projectPath && featureBranch === branchName) {
count++;
}
}
}
return count;
}
/**
* Get all currently running features
*
* @returns Array of all RunningFeature entries
*/
getAllRunning(): RunningFeature[] {
return Array.from(this.runningFeatures.values());
}
/**
* Update properties of a running feature
*
* @param featureId - ID of the feature to update
* @param updates - Partial RunningFeature properties to update
*/
updateRunningFeature(featureId: string, updates: Partial<RunningFeature>): void {
const entry = this.runningFeatures.get(featureId);
if (!entry) {
return;
}
Object.assign(entry, updates);
}
}

View File

@@ -1,80 +0,0 @@
/**
* Copilot Connection Service
*
* Handles the connection and disconnection of Copilot CLI to the app.
* Uses a marker file to track the disconnected state.
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { createLogger } from '@automaker/utils';
import { COPILOT_DISCONNECTED_MARKER_FILE } from '../routes/setup/common.js';
const logger = createLogger('CopilotConnectionService');
/**
* Get the path to the disconnected marker file
*/
function getMarkerPath(projectRoot?: string): string {
const root = projectRoot || process.cwd();
const automakerDir = path.join(root, '.automaker');
return path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
}
/**
* Connect Copilot CLI to the app by removing the disconnected marker
*
* @param projectRoot - Optional project root directory (defaults to cwd)
* @returns Promise that resolves when the connection is established
*/
export async function connectCopilot(projectRoot?: string): Promise<void> {
const markerPath = getMarkerPath(projectRoot);
try {
await fs.unlink(markerPath);
logger.info('Copilot CLI connected to app (marker removed)');
} catch (error) {
// File doesn't exist - that's fine, Copilot is already connected
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Failed to remove disconnected marker:', error);
throw error;
}
logger.debug('Copilot already connected (no marker file found)');
}
}
/**
* Disconnect Copilot CLI from the app by creating the disconnected marker
*
* @param projectRoot - Optional project root directory (defaults to cwd)
* @returns Promise that resolves when the disconnection is complete
*/
export async function disconnectCopilot(projectRoot?: string): Promise<void> {
const root = projectRoot || process.cwd();
const automakerDir = path.join(root, '.automaker');
const markerPath = path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
// Ensure .automaker directory exists
await fs.mkdir(automakerDir, { recursive: true });
// Create the disconnection marker
await fs.writeFile(markerPath, 'Copilot CLI disconnected from app');
logger.info('Copilot CLI disconnected from app (marker created)');
}
/**
* Check if Copilot CLI is connected (not disconnected)
*
* @param projectRoot - Optional project root directory (defaults to cwd)
* @returns Promise that resolves to true if connected, false if disconnected
*/
export async function isCopilotConnected(projectRoot?: string): Promise<boolean> {
const markerPath = getMarkerPath(projectRoot);
try {
await fs.access(markerPath);
return false; // Marker exists = disconnected
} catch {
return true; // Marker doesn't exist = connected
}
}

View File

@@ -273,56 +273,12 @@ class DevServerService {
}
}
/**
* Parse a custom command string into cmd and args
* Handles quoted strings with spaces (e.g., "my command" arg1 arg2)
*/
private parseCustomCommand(command: string): { cmd: string; args: string[] } {
const tokens: string[] = [];
let current = '';
let inQuote = false;
let quoteChar = '';
for (let i = 0; i < command.length; i++) {
const char = command[i];
if (inQuote) {
if (char === quoteChar) {
inQuote = false;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
inQuote = true;
quoteChar = char;
} else if (char === ' ') {
if (current) {
tokens.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
tokens.push(current);
}
const [cmd, ...args] = tokens;
return { cmd: cmd || '', args };
}
/**
* Start a dev server for a worktree
* @param projectPath - The project root path
* @param worktreePath - The worktree directory path
* @param customCommand - Optional custom command to run instead of auto-detected dev command
*/
async startDevServer(
projectPath: string,
worktreePath: string,
customCommand?: string
worktreePath: string
): Promise<{
success: boolean;
result?: {
@@ -355,41 +311,22 @@ class DevServerService {
};
}
// Determine the dev command to use
let devCommand: { cmd: string; args: string[] };
// Check for package.json
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Normalize custom command: trim whitespace and treat empty strings as undefined
const normalizedCustomCommand = customCommand?.trim();
if (normalizedCustomCommand) {
// Use the provided custom command
devCommand = this.parseCustomCommand(normalizedCustomCommand);
if (!devCommand.cmd) {
return {
success: false,
error: 'Invalid custom command: command cannot be empty',
};
}
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
} else {
// Check for package.json when auto-detecting
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Get dev command from package manager detection
const detectedCommand = await this.getDevCommand(worktreePath);
if (!detectedCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
devCommand = detectedCommand;
// Get dev command
const devCommand = await this.getDevCommand(worktreePath);
if (!devCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
// Find available port

View File

@@ -169,10 +169,9 @@ export class EventHookService {
}
// Build context for variable substitution
// Use loaded featureName (from feature.title) or fall back to payload.featureName
const context: HookContext = {
featureId: payload.featureId,
featureName: featureName || payload.featureName,
featureName: payload.featureName,
projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message,

View File

@@ -1,373 +0,0 @@
/**
* ExecutionService - Feature execution lifecycle coordination
*/
import path from 'path';
import type { Feature } from '@automaker/types';
import { createLogger, classifyError, loadContextFiles, recordMemoryUsage } from '@automaker/utils';
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
import { getFeatureDir } from '@automaker/platform';
import { ProviderFactory } from '../providers/provider-factory.js';
import * as secureFs from '../lib/secure-fs.js';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
filterClaudeMdFromContext,
} from '../lib/settings-helpers.js';
import { validateWorkingDirectory } from '../lib/sdk-options.js';
import { extractSummary } from './spec-parser.js';
import type { TypedEventBus } from './typed-event-bus.js';
import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js';
import type { WorktreeResolver } from './worktree-resolver.js';
import type { SettingsService } from './settings-service.js';
import type { PipelineContext } from './pipeline-orchestrator.js';
import { pipelineService } from './pipeline-service.js';
// Re-export callback types from execution-types.ts for backward compatibility
export type {
RunAgentFn,
ExecutePipelineFn,
UpdateFeatureStatusFn,
LoadFeatureFn,
GetPlanningPromptPrefixFn,
SaveFeatureSummaryFn,
RecordLearningsFn,
ContextExistsFn,
ResumeFeatureFn,
TrackFailureFn,
SignalPauseFn,
RecordSuccessFn,
SaveExecutionStateFn,
LoadContextFilesFn,
} from './execution-types.js';
import type {
RunAgentFn,
ExecutePipelineFn,
UpdateFeatureStatusFn,
LoadFeatureFn,
GetPlanningPromptPrefixFn,
SaveFeatureSummaryFn,
RecordLearningsFn,
ContextExistsFn,
ResumeFeatureFn,
TrackFailureFn,
SignalPauseFn,
RecordSuccessFn,
SaveExecutionStateFn,
LoadContextFilesFn,
} from './execution-types.js';
const logger = createLogger('ExecutionService');
export class ExecutionService {
constructor(
private eventBus: TypedEventBus,
private concurrencyManager: ConcurrencyManager,
private worktreeResolver: WorktreeResolver,
private settingsService: SettingsService | null,
// Callback dependencies for delegation
private runAgentFn: RunAgentFn,
private executePipelineFn: ExecutePipelineFn,
private updateFeatureStatusFn: UpdateFeatureStatusFn,
private loadFeatureFn: LoadFeatureFn,
private getPlanningPromptPrefixFn: GetPlanningPromptPrefixFn,
private saveFeatureSummaryFn: SaveFeatureSummaryFn,
private recordLearningsFn: RecordLearningsFn,
private contextExistsFn: ContextExistsFn,
private resumeFeatureFn: ResumeFeatureFn,
private trackFailureFn: TrackFailureFn,
private signalPauseFn: SignalPauseFn,
private recordSuccessFn: RecordSuccessFn,
private saveExecutionStateFn: SaveExecutionStateFn,
private loadContextFilesFn: LoadContextFilesFn
) {}
private acquireRunningFeature(options: {
featureId: string;
projectPath: string;
isAutoMode: boolean;
allowReuse?: boolean;
}): RunningFeature {
return this.concurrencyManager.acquire(options);
}
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
this.concurrencyManager.release(featureId, options);
}
private extractTitleFromDescription(description: string | undefined): string {
if (!description?.trim()) return 'Untitled Feature';
const firstLine = description.split('\n')[0].trim();
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
}
buildFeaturePrompt(
feature: Feature,
taskExecutionPrompts: {
implementationInstructions: string;
playwrightVerificationInstructions: string;
}
): string {
const title = this.extractTitleFromDescription(feature.description);
let prompt = `## Feature Implementation Task
**Feature ID:** ${feature.id}
**Title:** ${title}
**Description:** ${feature.description}
`;
if (feature.spec) {
prompt += `
**Specification:**
${feature.spec}
`;
}
if (feature.imagePaths && feature.imagePaths.length > 0) {
const imagesList = feature.imagePaths
.map((img, idx) => {
const imgPath = typeof img === 'string' ? img : img.path;
const filename =
typeof img === 'string'
? imgPath.split('/').pop()
: img.filename || imgPath.split('/').pop();
const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*';
return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${imgPath}`;
})
.join('\n');
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
}
prompt += feature.skipTests
? `\n${taskExecutionPrompts.implementationInstructions}`
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
return prompt;
}
async executeFeature(
projectPath: string,
featureId: string,
useWorktrees = false,
isAutoMode = false,
providedWorktreePath?: string,
options?: { continuationPrompt?: string; _calledInternally?: boolean }
): Promise<void> {
const tempRunningFeature = this.acquireRunningFeature({
featureId,
projectPath,
isAutoMode,
allowReuse: options?._calledInternally,
});
const abortController = tempRunningFeature.abortController;
if (isAutoMode) await this.saveExecutionStateFn(projectPath);
let feature: Feature | null = null;
try {
validateWorkingDirectory(projectPath);
feature = await this.loadFeatureFn(projectPath, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
if (!options?.continuationPrompt) {
if (feature.planSpec?.status === 'approved') {
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
continuationPrompt = continuationPrompt
.replace(/\{\{userFeedback\}\}/g, '')
.replace(/\{\{approvedPlan\}\}/g, feature.planSpec.content || '');
return await this.executeFeature(
projectPath,
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
{ continuationPrompt, _calledInternally: true }
);
}
if (await this.contextExistsFn(projectPath, featureId)) {
return await this.resumeFeatureFn(projectPath, featureId, useWorktrees, true);
}
}
let worktreePath: string | null = null;
const branchName = feature.branchName;
if (useWorktrees && branchName) {
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
}
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
validateWorkingDirectory(workDir);
tempRunningFeature.worktreePath = worktreePath;
tempRunningFeature.branchName = branchName ?? null;
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath,
branchName: feature.branchName ?? null,
feature: {
id: featureId,
title: feature.title || 'Loading...',
description: feature.description || 'Feature is starting',
},
});
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[ExecutionService]'
);
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
let prompt: string;
const contextResult = await this.loadContextFilesFn({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
taskContext: {
title: feature.title ?? '',
description: feature.description ?? '',
},
});
const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
if (options?.continuationPrompt) {
prompt = options.continuationPrompt;
} else {
prompt =
(await this.getPlanningPromptPrefixFn(feature)) +
this.buildFeaturePrompt(feature, prompts.taskExecution);
if (feature.planningMode && feature.planningMode !== 'skip') {
this.eventBus.emitAutoModeEvent('planning_started', {
featureId: feature.id,
mode: feature.planningMode,
message: `Starting ${feature.planningMode} planning phase`,
});
}
}
const imagePaths = feature.imagePaths?.map((img) =>
typeof img === 'string' ? img : img.path
);
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
tempRunningFeature.model = model;
tempRunningFeature.provider = ProviderFactory.getProviderNameForModel(model);
await this.runAgentFn(
workDir,
featureId,
prompt,
abortController,
projectPath,
imagePaths,
model,
{
projectPath,
planningMode: feature.planningMode,
requirePlanApproval: feature.requirePlanApproval,
systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
branchName: feature.branchName ?? null,
}
);
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
const sortedSteps = [...(pipelineConfig?.steps || [])]
.sort((a, b) => a.order - b.order)
.filter((step) => !excludedStepIds.has(step.id));
if (sortedSteps.length > 0) {
await this.executePipelineFn({
projectPath,
featureId,
feature,
steps: sortedSteps,
workDir,
worktreePath,
branchName: feature.branchName ?? null,
abortController,
autoLoadClaudeMd,
testAttempts: 0,
maxTestAttempts: 5,
});
}
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
this.recordSuccessFn();
try {
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
let agentOutput = '';
try {
agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string;
} catch {
/* */
}
if (agentOutput) {
const summary = extractSummary(agentOutput);
if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary);
}
if (contextResult.memoryFiles.length > 0 && agentOutput) {
await recordMemoryUsage(
projectPath,
contextResult.memoryFiles,
agentOutput,
true,
secureFs as Parameters<typeof recordMemoryUsage>[4]
);
}
await this.recordLearningsFn(projectPath, feature, agentOutput);
} catch {
/* learnings recording failed */
}
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: `Feature completed in ${Math.round((Date.now() - tempRunningFeature.startTime) / 1000)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
projectPath,
model: tempRunningFeature.model,
provider: tempRunningFeature.provider,
});
} catch (error) {
const errorInfo = classifyError(error);
if (errorInfo.isAbort) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: false,
message: 'Feature stopped by user',
projectPath,
});
} else {
logger.error(`Feature ${featureId} failed:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
});
if (this.trackFailureFn({ type: errorInfo.type, message: errorInfo.message })) {
this.signalPauseFn({ type: errorInfo.type, message: errorInfo.message });
}
}
} finally {
this.releaseRunningFeature(featureId);
if (isAutoMode && projectPath) await this.saveExecutionStateFn(projectPath);
}
}
async stopFeature(featureId: string): Promise<boolean> {
const running = this.concurrencyManager.getRunningFeature(featureId);
if (!running) return false;
running.abortController.abort();
this.releaseRunningFeature(featureId, { force: true });
return true;
}
}

View File

@@ -1,212 +0,0 @@
/**
* Execution Types - Type definitions for ExecutionService and related services
*
* Contains callback types used by ExecutionService for dependency injection,
* allowing the service to delegate to other services without circular dependencies.
*/
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
import type { loadContextFiles } from '@automaker/utils';
import type { PipelineContext } from './pipeline-orchestrator.js';
// =============================================================================
// ExecutionService Callback Types
// =============================================================================
/**
* Function to run the agent with a prompt
*/
export type RunAgentFn = (
workDir: string,
featureId: string,
prompt: string,
abortController: AbortController,
projectPath: string,
imagePaths?: string[],
model?: string,
options?: {
projectPath?: string;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
previousContent?: string;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
thinkingLevel?: ThinkingLevel;
branchName?: string | null;
}
) => Promise<void>;
/**
* Function to execute pipeline steps
*/
export type ExecutePipelineFn = (context: PipelineContext) => Promise<void>;
/**
* Function to update feature status
*/
export type UpdateFeatureStatusFn = (
projectPath: string,
featureId: string,
status: string
) => Promise<void>;
/**
* Function to load a feature by ID
*/
export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise<Feature | null>;
/**
* Function to get the planning prompt prefix based on feature's planning mode
*/
export type GetPlanningPromptPrefixFn = (feature: Feature) => Promise<string>;
/**
* Function to save a feature summary
*/
export type SaveFeatureSummaryFn = (
projectPath: string,
featureId: string,
summary: string
) => Promise<void>;
/**
* Function to record learnings from a completed feature
*/
export type RecordLearningsFn = (
projectPath: string,
feature: Feature,
agentOutput: string
) => Promise<void>;
/**
* Function to check if context exists for a feature
*/
export type ContextExistsFn = (projectPath: string, featureId: string) => Promise<boolean>;
/**
* Function to resume a feature (continues from saved context or starts fresh)
*/
export type ResumeFeatureFn = (
projectPath: string,
featureId: string,
useWorktrees: boolean,
_calledInternally: boolean
) => Promise<void>;
/**
* Function to track failure and check if pause threshold is reached
* Returns true if auto-mode should pause
*/
export type TrackFailureFn = (errorInfo: { type: string; message: string }) => boolean;
/**
* Function to signal that auto-mode should pause due to failures
*/
export type SignalPauseFn = (errorInfo: { type: string; message: string }) => void;
/**
* Function to record a successful execution (resets failure tracking)
*/
export type RecordSuccessFn = () => void;
/**
* Function to save execution state
*/
export type SaveExecutionStateFn = (projectPath: string) => Promise<void>;
/**
* Type alias for loadContextFiles function
*/
export type LoadContextFilesFn = typeof loadContextFiles;
// =============================================================================
// PipelineOrchestrator Callback Types
// =============================================================================
/**
* Function to build feature prompt
*/
export type BuildFeaturePromptFn = (
feature: Feature,
prompts: { implementationInstructions: string; playwrightVerificationInstructions: string }
) => string;
/**
* Function to execute a feature
*/
export type ExecuteFeatureFn = (
projectPath: string,
featureId: string,
useWorktrees: boolean,
useScreenshots: boolean,
model?: string,
options?: { _calledInternally?: boolean }
) => Promise<void>;
/**
* Function to run agent (for PipelineOrchestrator)
*/
export type PipelineRunAgentFn = (
workDir: string,
featureId: string,
prompt: string,
abortController: AbortController,
projectPath: string,
imagePaths?: string[],
model?: string,
options?: Record<string, unknown>
) => Promise<void>;
// =============================================================================
// AutoLoopCoordinator Callback Types
// =============================================================================
/**
* Function to execute a feature in auto-loop
*/
export type AutoLoopExecuteFeatureFn = (
projectPath: string,
featureId: string,
useWorktrees: boolean,
isAutoMode: boolean
) => Promise<void>;
/**
* Function to load pending features for a worktree
*/
export type LoadPendingFeaturesFn = (
projectPath: string,
branchName: string | null
) => Promise<Feature[]>;
/**
* Function to save execution state for auto-loop
*/
export type AutoLoopSaveExecutionStateFn = (
projectPath: string,
branchName: string | null,
maxConcurrency: number
) => Promise<void>;
/**
* Function to clear execution state
*/
export type ClearExecutionStateFn = (
projectPath: string,
branchName: string | null
) => Promise<void>;
/**
* Function to reset stuck features
*/
export type ResetStuckFeaturesFn = (projectPath: string) => Promise<void>;
/**
* Function to check if a feature is finished
*/
export type IsFeatureFinishedFn = (feature: Feature) => boolean;
/**
* Function to check if a feature is running
*/
export type IsFeatureRunningFn = (featureId: string) => boolean;

View File

@@ -1,540 +0,0 @@
/**
* Feature Export Service - Handles exporting and importing features in JSON/YAML formats
*
* Provides functionality to:
* - Export single features to JSON or YAML format
* - Export multiple features (bulk export)
* - Import features from JSON or YAML data
* - Validate import data for compatibility
*/
import { createLogger } from '@automaker/utils';
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
import type { Feature, FeatureExport, FeatureImport, FeatureImportResult } from '@automaker/types';
import { FeatureLoader } from './feature-loader.js';
const logger = createLogger('FeatureExportService');
/** Current export format version */
export const FEATURE_EXPORT_VERSION = '1.0.0';
/** Supported export formats */
export type ExportFormat = 'json' | 'yaml';
/** Options for exporting features */
export interface ExportOptions {
/** Format to export in (default: 'json') */
format?: ExportFormat;
/** Whether to include description history (default: true) */
includeHistory?: boolean;
/** Whether to include plan spec (default: true) */
includePlanSpec?: boolean;
/** Optional metadata to include */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
/** Who/what is performing the export */
exportedBy?: string;
/** Pretty print output (default: true) */
prettyPrint?: boolean;
}
/** Options for bulk export */
export interface BulkExportOptions extends ExportOptions {
/** Filter by category */
category?: string;
/** Filter by status */
status?: string;
/** Feature IDs to include (if not specified, exports all) */
featureIds?: string[];
}
/** Result of a bulk export */
export interface BulkExportResult {
/** Export format version */
version: string;
/** ISO date string when the export was created */
exportedAt: string;
/** Number of features exported */
count: number;
/** The exported features */
features: FeatureExport[];
/** Export metadata */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
}
/**
* FeatureExportService - Manages feature export and import operations
*/
export class FeatureExportService {
private featureLoader: FeatureLoader;
constructor(featureLoader?: FeatureLoader) {
this.featureLoader = featureLoader || new FeatureLoader();
}
/**
* Export a single feature to the specified format
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature to export
* @param options - Export options
* @returns Promise resolving to the exported feature string
*/
async exportFeature(
projectPath: string,
featureId: string,
options: ExportOptions = {}
): Promise<string> {
const feature = await this.featureLoader.get(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
return this.exportFeatureData(feature, options);
}
/**
* Export feature data to the specified format (without fetching from disk)
*
* @param feature - The feature to export
* @param options - Export options
* @returns The exported feature string
*/
exportFeatureData(feature: Feature, options: ExportOptions = {}): string {
const {
format = 'json',
includeHistory = true,
includePlanSpec = true,
metadata,
exportedBy,
prettyPrint = true,
} = options;
// Prepare feature data, optionally excluding some fields
const featureData = this.prepareFeatureForExport(feature, {
includeHistory,
includePlanSpec,
});
const exportData: FeatureExport = {
version: FEATURE_EXPORT_VERSION,
feature: featureData,
exportedAt: new Date().toISOString(),
...(exportedBy ? { exportedBy } : {}),
...(metadata ? { metadata } : {}),
};
return this.serialize(exportData, format, prettyPrint);
}
/**
* Export multiple features to the specified format
*
* @param projectPath - Path to the project
* @param options - Bulk export options
* @returns Promise resolving to the exported features string
*/
async exportFeatures(projectPath: string, options: BulkExportOptions = {}): Promise<string> {
const {
format = 'json',
category,
status,
featureIds,
includeHistory = true,
includePlanSpec = true,
metadata,
prettyPrint = true,
} = options;
// Get all features
let features = await this.featureLoader.getAll(projectPath);
// Apply filters
if (featureIds && featureIds.length > 0) {
const idSet = new Set(featureIds);
features = features.filter((f) => idSet.has(f.id));
}
if (category) {
features = features.filter((f) => f.category === category);
}
if (status) {
features = features.filter((f) => f.status === status);
}
// Generate timestamp once for consistent export time across all features
const exportedAt = new Date().toISOString();
// Prepare feature exports
const featureExports: FeatureExport[] = features.map((feature) => ({
version: FEATURE_EXPORT_VERSION,
feature: this.prepareFeatureForExport(feature, { includeHistory, includePlanSpec }),
exportedAt,
}));
const bulkExport: BulkExportResult = {
version: FEATURE_EXPORT_VERSION,
exportedAt,
count: featureExports.length,
features: featureExports,
...(metadata ? { metadata } : {}),
};
logger.info(`Exported ${featureExports.length} features from ${projectPath}`);
return this.serialize(bulkExport, format, prettyPrint);
}
/**
* Import a feature from JSON or YAML data
*
* @param projectPath - Path to the project
* @param importData - Import configuration
* @returns Promise resolving to the import result
*/
async importFeature(
projectPath: string,
importData: FeatureImport
): Promise<FeatureImportResult> {
const warnings: string[] = [];
const errors: string[] = [];
try {
// Extract feature from data (handle both raw Feature and wrapped FeatureExport)
const feature = this.extractFeatureFromImport(importData.data);
if (!feature) {
return {
success: false,
importedAt: new Date().toISOString(),
errors: ['Invalid import data: could not extract feature'],
};
}
// Validate required fields
const validationErrors = this.validateFeature(feature);
if (validationErrors.length > 0) {
return {
success: false,
importedAt: new Date().toISOString(),
errors: validationErrors,
};
}
// Determine the feature ID to use
const featureId = importData.newId || feature.id || this.featureLoader.generateFeatureId();
// Check for existing feature
const existingFeature = await this.featureLoader.get(projectPath, featureId);
if (existingFeature && !importData.overwrite) {
return {
success: false,
importedAt: new Date().toISOString(),
errors: [`Feature with ID ${featureId} already exists. Set overwrite: true to replace.`],
};
}
// Prepare feature for import
const featureToImport: Feature = {
...feature,
id: featureId,
// Optionally override category
...(importData.targetCategory ? { category: importData.targetCategory } : {}),
// Clear branch info if not preserving
...(importData.preserveBranchInfo ? {} : { branchName: undefined }),
};
// Clear runtime-specific fields that shouldn't be imported
delete featureToImport.titleGenerating;
delete featureToImport.error;
// Handle image paths - they won't be valid after import
if (featureToImport.imagePaths && featureToImport.imagePaths.length > 0) {
warnings.push(
`Feature had ${featureToImport.imagePaths.length} image path(s) that were cleared during import. Images must be re-attached.`
);
featureToImport.imagePaths = [];
}
// Handle text file paths - they won't be valid after import
if (featureToImport.textFilePaths && featureToImport.textFilePaths.length > 0) {
warnings.push(
`Feature had ${featureToImport.textFilePaths.length} text file path(s) that were cleared during import. Files must be re-attached.`
);
featureToImport.textFilePaths = [];
}
// Create or update the feature
if (existingFeature) {
await this.featureLoader.update(projectPath, featureId, featureToImport);
logger.info(`Updated feature ${featureId} via import`);
} else {
await this.featureLoader.create(projectPath, featureToImport);
logger.info(`Created feature ${featureId} via import`);
}
return {
success: true,
featureId,
importedAt: new Date().toISOString(),
warnings: warnings.length > 0 ? warnings : undefined,
wasOverwritten: !!existingFeature,
};
} catch (error) {
logger.error('Failed to import feature:', error);
return {
success: false,
importedAt: new Date().toISOString(),
errors: [`Import failed: ${error instanceof Error ? error.message : String(error)}`],
};
}
}
/**
* Import multiple features from JSON or YAML data
*
* @param projectPath - Path to the project
* @param data - Raw JSON or YAML string, or parsed data
* @param options - Import options applied to all features
* @returns Promise resolving to array of import results
*/
async importFeatures(
projectPath: string,
data: string | BulkExportResult,
options: Omit<FeatureImport, 'data'> = {}
): Promise<FeatureImportResult[]> {
let bulkData: BulkExportResult;
// Parse if string
if (typeof data === 'string') {
const parsed = this.parseImportData(data);
if (!parsed || !this.isBulkExport(parsed)) {
return [
{
success: false,
importedAt: new Date().toISOString(),
errors: ['Invalid bulk import data: expected BulkExportResult format'],
},
];
}
bulkData = parsed as BulkExportResult;
} else {
bulkData = data;
}
// Import each feature
const results: FeatureImportResult[] = [];
for (const featureExport of bulkData.features) {
const result = await this.importFeature(projectPath, {
data: featureExport,
...options,
});
results.push(result);
}
const successCount = results.filter((r) => r.success).length;
logger.info(`Bulk import complete: ${successCount}/${results.length} features imported`);
return results;
}
/**
* Parse import data from JSON or YAML string
*
* @param data - Raw JSON or YAML string
* @returns Parsed data or null if parsing fails
*/
parseImportData(data: string): Feature | FeatureExport | BulkExportResult | null {
const trimmed = data.trim();
// Try JSON first
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return JSON.parse(trimmed);
} catch {
// Fall through to YAML
}
}
// Try YAML
try {
return yamlParse(trimmed);
} catch (error) {
logger.error('Failed to parse import data:', error);
return null;
}
}
/**
* Detect the format of import data
*
* @param data - Raw string data
* @returns Detected format or null if unknown
*/
detectFormat(data: string): ExportFormat | null {
const trimmed = data.trim();
// JSON detection
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
JSON.parse(trimmed);
return 'json';
} catch {
// Not valid JSON
}
}
// YAML detection (if it parses and wasn't JSON)
try {
yamlParse(trimmed);
return 'yaml';
} catch {
// Not valid YAML either
}
return null;
}
/**
* Prepare a feature for export by optionally removing fields
*/
private prepareFeatureForExport(
feature: Feature,
options: { includeHistory?: boolean; includePlanSpec?: boolean }
): Feature {
const { includeHistory = true, includePlanSpec = true } = options;
// Clone to avoid modifying original
const exported: Feature = { ...feature };
// Remove transient fields that shouldn't be exported
delete exported.titleGenerating;
delete exported.error;
// Optionally exclude history
if (!includeHistory) {
delete exported.descriptionHistory;
}
// Optionally exclude plan spec
if (!includePlanSpec) {
delete exported.planSpec;
}
return exported;
}
/**
* Extract a Feature from import data (handles both raw and wrapped formats)
*/
private extractFeatureFromImport(data: Feature | FeatureExport): Feature | null {
if (!data || typeof data !== 'object') {
return null;
}
// Check if it's a FeatureExport wrapper
if ('version' in data && 'feature' in data && 'exportedAt' in data) {
const exportData = data as FeatureExport;
return exportData.feature;
}
// Assume it's a raw Feature
return data as Feature;
}
/**
* Check if parsed data is a bulk export
*/
isBulkExport(data: unknown): data is BulkExportResult {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
return 'version' in obj && 'features' in obj && Array.isArray(obj.features);
}
/**
* Check if parsed data is a single FeatureExport
*/
isFeatureExport(data: unknown): data is FeatureExport {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
return (
'version' in obj &&
'feature' in obj &&
'exportedAt' in obj &&
typeof obj.feature === 'object' &&
obj.feature !== null &&
'id' in (obj.feature as Record<string, unknown>)
);
}
/**
* Check if parsed data is a raw Feature
*/
isRawFeature(data: unknown): data is Feature {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
// A raw feature has 'id' but not the 'version' + 'feature' wrapper of FeatureExport
return 'id' in obj && !('feature' in obj && 'version' in obj);
}
/**
* Validate a feature has required fields
*/
private validateFeature(feature: Feature): string[] {
const errors: string[] = [];
if (!feature.description && !feature.title) {
errors.push('Feature must have at least a title or description');
}
if (!feature.category) {
errors.push('Feature must have a category');
}
return errors;
}
/**
* Serialize export data to string (handles both single feature and bulk exports)
*/
private serialize<T extends FeatureExport | BulkExportResult>(
data: T,
format: ExportFormat,
prettyPrint: boolean
): string {
if (format === 'yaml') {
return yamlStringify(data, {
indent: 2,
lineWidth: 120,
});
}
return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
}
}
// Singleton instance
let featureExportServiceInstance: FeatureExportService | null = null;
/**
* Get the singleton feature export service instance
*/
export function getFeatureExportService(): FeatureExportService {
if (!featureExportServiceInstance) {
featureExportServiceInstance = new FeatureExportService();
}
return featureExportServiceInstance;
}

View File

@@ -1,442 +0,0 @@
/**
* FeatureStateManager - Manages feature status updates with proper persistence
*
* Extracted from AutoModeService to provide a standalone service for:
* - Updating feature status with proper disk persistence
* - Handling corrupted JSON with backup recovery
* - Emitting events AFTER successful persistence (prevent stale data on refresh)
* - Resetting stuck features after server restart
*
* Key behaviors:
* - Persist BEFORE emit (Pitfall 2 from research)
* - Use readJsonWithRecovery for all reads
* - markInterrupted preserves pipeline_* statuses
*/
import path from 'path';
import type { Feature, ParsedTask, PlanSpec } from '@automaker/types';
import {
atomicWriteJson,
readJsonWithRecovery,
logRecoveryWarning,
DEFAULT_BACKUP_COUNT,
createLogger,
} from '@automaker/utils';
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import { getNotificationService } from './notification-service.js';
import { FeatureLoader } from './feature-loader.js';
const logger = createLogger('FeatureStateManager');
/**
* FeatureStateManager handles feature status updates with persistence guarantees.
*
* This service is responsible for:
* 1. Updating feature status and persisting to disk BEFORE emitting events
* 2. Handling corrupted JSON with automatic backup recovery
* 3. Resetting stuck features after server restarts
* 4. Managing justFinishedAt timestamps for UI badges
*/
export class FeatureStateManager {
private events: EventEmitter;
private featureLoader: FeatureLoader;
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
this.events = events;
this.featureLoader = featureLoader;
}
/**
* Load a feature from disk with recovery support
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature to load
* @returns The feature data, or null if not found/recoverable
*/
async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return null;
}
}
/**
* Update feature status with proper persistence and event ordering.
*
* IMPORTANT: Persists to disk BEFORE emitting events to prevent stale data
* on client refresh (Pitfall 2 from research).
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature to update
* @param status - New status value
*/
async updateFeatureStatus(projectPath: string, featureId: string, status: string): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try {
// Use recovery-enabled read for corrupted file handling
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.updatedAt = new Date().toISOString();
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
// Badge will show for 2 minutes after this timestamp
if (status === 'waiting_approval') {
feature.justFinishedAt = new Date().toISOString();
} else {
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
}
// PERSIST BEFORE EMIT (Pitfall 2)
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);
}
}
/**
* Mark a feature as interrupted due to server restart or other interruption.
*
* This is a convenience helper that updates the feature status to 'interrupted',
* indicating the feature was in progress but execution was disrupted (e.g., server
* restart, process crash, or manual stop). Features with this status can be
* resumed later using the resume functionality.
*
* Note: Features with pipeline_* statuses are preserved rather than overwritten
* to 'interrupted'. This ensures that resumePipelineFeature() can pick up from
* the correct pipeline step after a restart.
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature to mark as interrupted
* @param reason - Optional reason for the interruption (logged for debugging)
*/
async markFeatureInterrupted(
projectPath: string,
featureId: string,
reason?: string
): Promise<void> {
// Load the feature to check its current status
const feature = await this.loadFeature(projectPath, featureId);
const currentStatus = feature?.status;
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
if (currentStatus && currentStatus.startsWith('pipeline_')) {
logger.info(
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
);
return;
}
if (reason) {
logger.info(`Marking feature ${featureId} as interrupted: ${reason}`);
} else {
logger.info(`Marking feature ${featureId} as interrupted`);
}
await this.updateFeatureStatus(projectPath, featureId, 'interrupted');
}
/**
* Reset features that were stuck in transient states due to server crash.
* Called when auto mode is enabled to clean up from previous session.
*
* Resets:
* - in_progress features back to ready (if has plan) or backlog (if no plan)
* - generating planSpec status back to pending
* - in_progress tasks back to pending
*
* @param projectPath - The project path to reset features for
*/
async resetStuckFeatures(projectPath: string): Promise<void> {
const featuresDir = getFeaturesDir(projectPath);
try {
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
const feature = result.data;
if (!feature) continue;
let needsUpdate = false;
// Reset in_progress features back to ready/backlog
if (feature.status === 'in_progress') {
const hasApprovedPlan = feature.planSpec?.status === 'approved';
feature.status = hasApprovedPlan ? 'ready' : 'backlog';
needsUpdate = true;
logger.info(
`[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}`
);
}
// Reset generating planSpec status back to pending (spec generation was interrupted)
if (feature.planSpec?.status === 'generating') {
feature.planSpec.status = 'pending';
needsUpdate = true;
logger.info(
`[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending`
);
}
// Reset any in_progress tasks back to pending (task execution was interrupted)
if (feature.planSpec?.tasks) {
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'pending';
needsUpdate = true;
logger.info(
`[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending`
);
// Clear currentTaskId if it points to this reverted task
if (feature.planSpec?.currentTaskId === task.id) {
feature.planSpec.currentTaskId = undefined;
logger.info(
`[resetStuckFeatures] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})`
);
}
}
}
}
if (needsUpdate) {
feature.updatedAt = new Date().toISOString();
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
}
}
} catch (error) {
// If features directory doesn't exist, that's fine
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error);
}
}
}
/**
* Update the planSpec of a feature with partial updates.
*
* @param projectPath - The project path
* @param featureId - The feature ID
* @param updates - Partial PlanSpec updates to apply
*/
async updateFeaturePlanSpec(
projectPath: string,
featureId: string,
updates: Partial<PlanSpec>
): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try {
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
if (!feature.planSpec) {
feature.planSpec = {
status: 'pending',
version: 1,
reviewedByUser: false,
};
}
// Capture old content BEFORE applying updates for version comparison
const oldContent = feature.planSpec.content;
// Apply updates
Object.assign(feature.planSpec, updates);
// If content is being updated and it's different from old content, increment version
if (updates.content && updates.content !== oldContent) {
feature.planSpec.version = (feature.planSpec.version || 0) + 1;
}
feature.updatedAt = new Date().toISOString();
// PERSIST BEFORE EMIT
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
} catch (error) {
logger.error(`Failed to update planSpec for ${featureId}:`, error);
}
}
/**
* Save the extracted summary to a feature's summary field.
* This is called after agent execution completes to save a summary
* extracted from the agent's output using <summary> tags.
*
* @param projectPath - The project path
* @param featureId - The feature ID
* @param summary - The summary text to save
*/
async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try {
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.summary = summary;
feature.updatedAt = new Date().toISOString();
// PERSIST BEFORE EMIT
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
// Emit event for UI update
this.emitAutoModeEvent('auto_mode_summary', {
featureId,
projectPath,
summary,
});
} catch (error) {
logger.error(`Failed to save summary for ${featureId}:`, error);
}
}
/**
* Update the status of a specific task within planSpec.tasks
*
* @param projectPath - The project path
* @param featureId - The feature ID
* @param taskId - The task ID to update
* @param status - The new task status
*/
async updateTaskStatus(
projectPath: string,
featureId: string,
taskId: string,
status: ParsedTask['status']
): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try {
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 || !feature.planSpec?.tasks) {
logger.warn(`Feature ${featureId} not found or has no tasks`);
return;
}
// Find and update the task
const task = feature.planSpec.tasks.find((t) => t.id === taskId);
if (task) {
task.status = status;
feature.updatedAt = new Date().toISOString();
// PERSIST BEFORE EMIT
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
// Emit event for UI update
this.emitAutoModeEvent('auto_mode_task_status', {
featureId,
projectPath,
taskId,
status,
tasks: feature.planSpec.tasks,
});
}
} catch (error) {
logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error);
}
}
/**
* Emit an auto-mode event via the event emitter
*
* @param eventType - The event type (e.g., 'auto_mode_summary')
* @param data - The event payload
*/
private emitAutoModeEvent(eventType: string, data: Record<string, unknown>): void {
// Wrap the event in auto-mode:event format expected by the client
this.events.emit('auto-mode:event', {
type: eventType,
...data,
});
}
}

View File

@@ -23,9 +23,7 @@ import type {
SendMessageOptions,
PromptCategory,
IdeationPrompt,
IdeationContextSources,
} from '@automaker/types';
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
import {
getIdeationDir,
getIdeasDir,
@@ -34,22 +32,16 @@ import {
getIdeationSessionsDir,
getIdeationSessionPath,
getIdeationAnalysisPath,
getAppSpecPath,
ensureIdeationDir,
} from '@automaker/platform';
import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js';
import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import type { SettingsService } from './settings-service.js';
import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString, resolvePhaseModel } from '@automaker/model-resolver';
import { resolveModelString } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types';
import {
getPromptCustomization,
getProviderByModelId,
getPhaseModelWithOverrides,
} from '../lib/settings-helpers.js';
import { getPromptCustomization } from '../lib/settings-helpers.js';
const logger = createLogger('IdeationService');
@@ -216,27 +208,7 @@ export class IdeationService {
);
// Resolve model alias to canonical identifier (with prefix)
let modelId = resolveModelString(options?.model ?? 'sonnet');
// Try to find a provider for this model (e.g., GLM, MiniMax models)
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
let credentials = await this.settingsService?.getCredentials();
if (this.settingsService && options?.model) {
const providerResult = await getProviderByModelId(
options.model,
this.settingsService,
'[IdeationService]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
// Use resolved model from provider if available (maps to Claude model)
if (providerResult.resolvedModel) {
modelId = providerResult.resolvedModel;
}
credentials = providerResult.credentials ?? credentials;
}
}
const modelId = resolveModelString(options?.model ?? 'sonnet');
// Create SDK options
const sdkOptions = createChatOptions({
@@ -251,6 +223,9 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get credentials for API calls (uses hardcoded model, no phase setting)
const credentials = await this.settingsService?.getCredentials();
const executeOptions: ExecuteOptions = {
prompt: message,
model: bareModel,
@@ -260,7 +235,6 @@ export class IdeationService {
maxTurns: 1, // Single turn for ideation
abortController: activeSession.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
@@ -642,12 +616,8 @@ export class IdeationService {
projectPath: string,
promptId: string,
category: IdeaCategory,
count: number = 10,
contextSources?: IdeationContextSources
count: number = 10
): Promise<AnalysisSuggestion[]> {
const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20);
// Merge with defaults for backward compatibility
const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources };
validateWorkingDirectory(projectPath);
// Get the prompt
@@ -664,26 +634,16 @@ export class IdeationService {
});
try {
// Load context files (respecting toggle settings)
// Load context files
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
includeContextFiles: sources.useContextFiles,
includeMemory: sources.useMemoryFiles,
});
// Build context from multiple sources
let contextPrompt = contextResult.formattedPrompt;
// Add app spec context if enabled
if (sources.useAppSpec) {
const appSpecContext = await this.buildAppSpecContext(projectPath);
if (appSpecContext) {
contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext;
}
}
// If no context was found, try to gather basic project info
// If no context files, try to gather basic project info
if (!contextPrompt) {
const projectInfo = await this.gatherBasicProjectInfo(projectPath);
if (projectInfo) {
@@ -691,11 +651,8 @@ export class IdeationService {
}
}
// Gather existing features and ideas to prevent duplicates (respecting toggle settings)
const existingWorkContext = await this.gatherExistingWorkContext(projectPath, {
includeFeatures: sources.useExistingFeatures,
includeIdeas: sources.useExistingIdeas,
});
// Gather existing features and ideas to prevent duplicates
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
@@ -705,28 +662,12 @@ export class IdeationService {
prompts.ideation.suggestionsSystemPrompt,
contextPrompt,
category,
suggestionCount,
count,
existingWorkContext
);
// Get model from phase settings with provider info (ideationModel)
const phaseResult = await getPhaseModelWithOverrides(
'ideationModel',
this.settingsService,
projectPath,
'[IdeationService]'
);
const resolved = resolvePhaseModel(phaseResult.phaseModel);
// resolvePhaseModel already resolves model aliases internally - no need to call resolveModelString again
const modelId = resolved.model;
const claudeCompatibleProvider = phaseResult.provider;
const credentials = phaseResult.credentials;
logger.info(
'generateSuggestions using model:',
modelId,
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
);
// Resolve model alias to canonical identifier (with prefix)
const modelId = resolveModelString('sonnet');
// Create SDK options
const sdkOptions = createChatOptions({
@@ -741,6 +682,9 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get credentials for API calls (uses hardcoded model, no phase setting)
const credentials = await this.settingsService?.getCredentials();
const executeOptions: ExecuteOptions = {
prompt: prompt.prompt,
model: bareModel,
@@ -751,8 +695,6 @@ export class IdeationService {
// Disable all tools - we just want text generation, not codebase analysis
allowedTools: [],
abortController: new AbortController(),
readOnly: true, // Suggestions only need to return JSON, never write files
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
@@ -772,11 +714,7 @@ export class IdeationService {
}
// Parse the response into structured suggestions
const suggestions = this.parseSuggestionsFromResponse(
responseText,
category,
suggestionCount
);
const suggestions = this.parseSuggestionsFromResponse(responseText, category);
// Emit complete event
this.events.emit('ideation:suggestions', {
@@ -839,47 +777,40 @@ ${contextSection}${existingWorkSection}`;
*/
private parseSuggestionsFromResponse(
response: string,
category: IdeaCategory,
count: number
category: IdeaCategory
): AnalysisSuggestion[] {
try {
// Try to extract JSON from the response
const jsonMatch = response.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
logger.warn('No JSON array found in response, falling back to text parsing');
return this.parseTextResponse(response, category, count);
return this.parseTextResponse(response, category);
}
const parsed = JSON.parse(jsonMatch[0]);
if (!Array.isArray(parsed)) {
return this.parseTextResponse(response, category, count);
return this.parseTextResponse(response, category);
}
return parsed
.map((item: any, index: number) => ({
id: this.generateId('sug'),
category,
title: item.title || `Suggestion ${index + 1}`,
description: item.description || '',
rationale: item.rationale || '',
priority: item.priority || 'medium',
relatedFiles: item.relatedFiles || [],
}))
.slice(0, count);
return parsed.map((item: any, index: number) => ({
id: this.generateId('sug'),
category,
title: item.title || `Suggestion ${index + 1}`,
description: item.description || '',
rationale: item.rationale || '',
priority: item.priority || 'medium',
relatedFiles: item.relatedFiles || [],
}));
} catch (error) {
logger.warn('Failed to parse JSON response:', error);
return this.parseTextResponse(response, category, count);
return this.parseTextResponse(response, category);
}
}
/**
* Fallback: parse text response into suggestions
*/
private parseTextResponse(
response: string,
category: IdeaCategory,
count: number
): AnalysisSuggestion[] {
private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] {
const suggestions: AnalysisSuggestion[] = [];
// Try to find numbered items or headers
@@ -939,7 +870,7 @@ ${contextSection}${existingWorkSection}`;
});
}
return suggestions.slice(0, count);
return suggestions.slice(0, 5); // Max 5 suggestions
}
// ============================================================================
@@ -1377,68 +1308,6 @@ ${contextSection}${existingWorkSection}`;
return descriptions[category] || '';
}
/**
* Build context from app_spec.txt for suggestion generation
* Extracts project name, overview, capabilities, and implemented features
*/
private async buildAppSpecContext(projectPath: string): Promise<string> {
try {
const specPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
const parts: string[] = [];
parts.push('## App Specification');
// Extract project name
const projectNames = extractXmlElements(specContent, 'project_name');
if (projectNames.length > 0 && projectNames[0]) {
parts.push(`**Project:** ${projectNames[0]}`);
}
// Extract overview
const overviews = extractXmlElements(specContent, 'overview');
if (overviews.length > 0 && overviews[0]) {
parts.push(`**Overview:** ${overviews[0]}`);
}
// Extract core capabilities
const capabilities = extractXmlElements(specContent, 'capability');
if (capabilities.length > 0) {
parts.push('**Core Capabilities:**');
for (const cap of capabilities) {
parts.push(`- ${cap}`);
}
}
// Extract implemented features
const implementedFeatures = extractImplementedFeatures(specContent);
if (implementedFeatures.length > 0) {
parts.push('**Implemented Features:**');
for (const feature of implementedFeatures) {
if (feature.description) {
parts.push(`- ${feature.name}: ${feature.description}`);
} else {
parts.push(`- ${feature.name}`);
}
}
}
// Only return content if we extracted something meaningful
if (parts.length > 1) {
return parts.join('\n');
}
return '';
} catch (error) {
// If file doesn't exist, return empty string silently
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return '';
}
// For other errors, log and return empty string
logger.warn('Failed to build app spec context:', error);
return '';
}
}
/**
* Gather basic project information for context when no context files exist
*/
@@ -1534,15 +1403,11 @@ ${contextSection}${existingWorkSection}`;
* Gather existing features and ideas to prevent duplicate suggestions
* Returns a concise list of titles grouped by status to avoid polluting context
*/
private async gatherExistingWorkContext(
projectPath: string,
options?: { includeFeatures?: boolean; includeIdeas?: boolean }
): Promise<string> {
const { includeFeatures = true, includeIdeas = true } = options ?? {};
private async gatherExistingWorkContext(projectPath: string): Promise<string> {
const parts: string[] = [];
// Load existing features from the board
if (includeFeatures && this.featureLoader) {
if (this.featureLoader) {
try {
const features = await this.featureLoader.getAll(projectPath);
if (features.length > 0) {
@@ -1590,36 +1455,34 @@ ${contextSection}${existingWorkSection}`;
}
// Load existing ideas
if (includeIdeas) {
try {
const ideas = await this.getIdeas(projectPath);
// Filter out archived ideas
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
try {
const ideas = await this.getIdeas(projectPath);
// Filter out archived ideas
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
if (activeIdeas.length > 0) {
parts.push('## Existing Ideas (Do NOT regenerate these)');
parts.push(
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
);
if (activeIdeas.length > 0) {
parts.push('## Existing Ideas (Do NOT regenerate these)');
parts.push(
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
);
// Group by category for organization
const byCategory: Record<string, string[]> = {};
for (const idea of activeIdeas) {
const cat = idea.category || 'feature';
if (!byCategory[cat]) {
byCategory[cat] = [];
}
byCategory[cat].push(idea.title);
// Group by category for organization
const byCategory: Record<string, string[]> = {};
for (const idea of activeIdeas) {
const cat = idea.category || 'feature';
if (!byCategory[cat]) {
byCategory[cat] = [];
}
for (const [category, titles] of Object.entries(byCategory)) {
parts.push(`**${category}:** ${titles.join(', ')}`);
}
parts.push('');
byCategory[cat].push(idea.title);
}
} catch (error) {
logger.warn('Failed to load existing ideas:', error);
for (const [category, titles] of Object.entries(byCategory)) {
parts.push(`**${category}:** ${titles.join(', ')}`);
}
parts.push('');
}
} catch (error) {
logger.warn('Failed to load existing ideas:', error);
}
return parts.join('\n');

View File

@@ -1,571 +0,0 @@
/**
* PipelineOrchestrator - Pipeline step execution and coordination
*/
import path from 'path';
import type {
Feature,
PipelineStep,
PipelineConfig,
FeatureStatusWithPipeline,
} from '@automaker/types';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
import * as secureFs from '../lib/secure-fs.js';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
filterClaudeMdFromContext,
} from '../lib/settings-helpers.js';
import { validateWorkingDirectory } from '../lib/sdk-options.js';
import type { TypedEventBus } from './typed-event-bus.js';
import type { FeatureStateManager } from './feature-state-manager.js';
import type { AgentExecutor } from './agent-executor.js';
import type { WorktreeResolver } from './worktree-resolver.js';
import type { SettingsService } from './settings-service.js';
import type { ConcurrencyManager } from './concurrency-manager.js';
import { pipelineService } from './pipeline-service.js';
import type { TestRunnerService, TestRunStatus } from './test-runner-service.js';
import type {
PipelineContext,
PipelineStatusInfo,
StepResult,
MergeResult,
UpdateFeatureStatusFn,
BuildFeaturePromptFn,
ExecuteFeatureFn,
RunAgentFn,
} from './pipeline-types.js';
// Re-export types for backward compatibility
export type {
PipelineContext,
PipelineStatusInfo,
StepResult,
MergeResult,
UpdateFeatureStatusFn,
BuildFeaturePromptFn,
ExecuteFeatureFn,
RunAgentFn,
} from './pipeline-types.js';
const logger = createLogger('PipelineOrchestrator');
export class PipelineOrchestrator {
constructor(
private eventBus: TypedEventBus,
private featureStateManager: FeatureStateManager,
private agentExecutor: AgentExecutor,
private testRunnerService: TestRunnerService,
private worktreeResolver: WorktreeResolver,
private concurrencyManager: ConcurrencyManager,
private settingsService: SettingsService | null,
private updateFeatureStatusFn: UpdateFeatureStatusFn,
private loadContextFilesFn: typeof loadContextFiles,
private buildFeaturePromptFn: BuildFeaturePromptFn,
private executeFeatureFn: ExecuteFeatureFn,
private runAgentFn: RunAgentFn,
private serverPort = 3008
) {}
async executePipeline(ctx: PipelineContext): Promise<void> {
const { projectPath, featureId, feature, steps, workDir, abortController, autoLoadClaudeMd } =
ctx;
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const contextResult = await this.loadContextFilesFn({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
taskContext: { title: feature.title ?? '', description: feature.description ?? '' },
});
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
const contextPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
let previousContext = '';
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
/* */
}
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (abortController.signal.aborted) throw new Error('Pipeline execution aborted');
await this.updateFeatureStatusFn(projectPath, featureId, `pipeline_${step.id}`);
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName: feature.branchName ?? null,
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
projectPath,
});
this.eventBus.emitAutoModeEvent('pipeline_step_started', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
await this.runAgentFn(
workDir,
featureId,
this.buildPipelineStepPrompt(step, feature, previousContext, prompts.taskExecution),
abortController,
projectPath,
undefined,
model,
{
projectPath,
planningMode: 'skip',
requirePlanApproval: false,
previousContent: previousContext,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
}
);
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
/* */
}
this.eventBus.emitAutoModeEvent('pipeline_step_complete', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
}
if (ctx.branchName) {
const mergeResult = await this.attemptMerge(ctx);
if (!mergeResult.success && mergeResult.hasConflicts) return;
}
}
buildPipelineStepPrompt(
step: PipelineStep,
feature: Feature,
previousContext: string,
taskPrompts: { implementationInstructions: string; playwrightVerificationInstructions: string }
): string {
let prompt = `## Pipeline Step: ${step.name}\n\nThis is an automated pipeline step.\n\n### Feature Context\n${this.buildFeaturePromptFn(feature, taskPrompts)}\n\n`;
if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`;
return (
prompt +
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.`
);
}
async detectPipelineStatus(
projectPath: string,
featureId: string,
currentStatus: FeatureStatusWithPipeline
): Promise<PipelineStatusInfo> {
const isPipeline = pipelineService.isPipelineStatus(currentStatus);
if (!isPipeline)
return {
isPipeline: false,
stepId: null,
stepIndex: -1,
totalSteps: 0,
step: null,
config: null,
};
const stepId = pipelineService.getStepIdFromStatus(currentStatus);
if (!stepId)
return {
isPipeline: true,
stepId: null,
stepIndex: -1,
totalSteps: 0,
step: null,
config: null,
};
const config = await pipelineService.getPipelineConfig(projectPath);
if (!config || config.steps.length === 0)
return { isPipeline: true, stepId, stepIndex: -1, totalSteps: 0, step: null, config: null };
const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order);
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
return {
isPipeline: true,
stepId,
stepIndex,
totalSteps: sortedSteps.length,
step: stepIndex === -1 ? null : sortedSteps[stepIndex],
config,
};
}
async resumePipeline(
projectPath: string,
feature: Feature,
useWorktrees: boolean,
pipelineInfo: PipelineStatusInfo
): Promise<void> {
const featureId = feature.id;
const contextPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
let hasContext = false;
try {
await secureFs.access(contextPath);
hasContext = true;
} catch {
/* No context */
}
if (!hasContext) {
logger.warn(`No context for feature ${featureId}, restarting pipeline`);
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
return this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, {
_calledInternally: true,
});
}
if (pipelineInfo.stepIndex === -1) {
logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`);
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline step no longer exists',
projectPath,
});
return;
}
if (!pipelineInfo.config) throw new Error('Pipeline config is null but stepIndex is valid');
return this.resumeFromStep(
projectPath,
feature,
useWorktrees,
pipelineInfo.stepIndex,
pipelineInfo.config
);
}
/** Resume from a specific step index */
async resumeFromStep(
projectPath: string,
feature: Feature,
useWorktrees: boolean,
startFromStepIndex: number,
pipelineConfig: PipelineConfig
): Promise<void> {
const featureId = feature.id;
const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length)
throw new Error(`Invalid step index: ${startFromStepIndex}`);
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
let currentStep = allSortedSteps[startFromStepIndex];
if (excludedStepIds.has(currentStep.id)) {
const nextStatus = pipelineService.getNextStatus(
`pipeline_${currentStep.id}`,
pipelineConfig,
feature.skipTests ?? false,
feature.excludedPipelineSteps
);
if (!pipelineService.isPipelineStatus(nextStatus)) {
await this.updateFeatureStatusFn(projectPath, featureId, nextStatus);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline completed (remaining steps excluded)',
projectPath,
});
return;
}
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId);
if (nextStepIndex === -1) throw new Error(`Next step ${nextStepId} not found`);
startFromStepIndex = nextStepIndex;
}
const stepsToExecute = allSortedSteps
.slice(startFromStepIndex)
.filter((step) => !excludedStepIds.has(step.id));
if (stepsToExecute.length === 0) {
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline completed (all steps excluded)',
projectPath,
});
return;
}
const runningEntry = this.concurrencyManager.acquire({
featureId,
projectPath,
isAutoMode: false,
allowReuse: true,
});
const abortController = runningEntry.abortController;
runningEntry.branchName = feature.branchName ?? null;
try {
validateWorkingDirectory(projectPath);
let worktreePath: string | null = null;
const branchName = feature.branchName;
if (useWorktrees && branchName) {
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
}
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
validateWorkingDirectory(workDir);
runningEntry.worktreePath = worktreePath;
runningEntry.branchName = branchName ?? null;
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath,
branchName: branchName ?? null,
feature: {
id: featureId,
title: feature.title || 'Resuming Pipeline',
description: feature.description,
},
});
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
const context: PipelineContext = {
projectPath,
featureId,
feature,
steps: stepsToExecute,
workDir,
worktreePath,
branchName: branchName ?? null,
abortController,
autoLoadClaudeMd,
testAttempts: 0,
maxTestAttempts: 5,
};
await this.executePipeline(context);
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
logger.info(`Pipeline resume completed for feature ${featureId}`);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline resumed successfully',
projectPath,
});
} catch (error) {
const errorInfo = classifyError(error);
if (errorInfo.isAbort) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: false,
message: 'Pipeline stopped by user',
projectPath,
});
} else {
logger.error(`Pipeline resume failed for ${featureId}:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
});
}
} finally {
this.concurrencyManager.release(featureId);
}
}
/** Execute test step with agent fix loop (REQ-F07) */
async executeTestStep(context: PipelineContext, testCommand: string): Promise<StepResult> {
const { featureId, projectPath, workDir, abortController, maxTestAttempts } = context;
for (let attempt = 1; attempt <= maxTestAttempts; attempt++) {
if (abortController.signal.aborted)
return { success: false, message: 'Test execution aborted' };
logger.info(`Running tests for ${featureId} (attempt ${attempt}/${maxTestAttempts})`);
const testResult = await this.testRunnerService.startTests(workDir, { command: testCommand });
if (!testResult.success || !testResult.result?.sessionId)
return {
success: false,
testsPassed: false,
message: testResult.error || 'Failed to start tests',
};
const completionResult = await this.waitForTestCompletion(testResult.result.sessionId);
if (completionResult.status === 'passed') return { success: true, testsPassed: true };
const sessionOutput = this.testRunnerService.getSessionOutput(testResult.result.sessionId);
const scrollback = sessionOutput.result?.output || '';
this.eventBus.emitAutoModeEvent('pipeline_test_failed', {
featureId,
attempt,
maxAttempts: maxTestAttempts,
failedTests: this.extractFailedTestNames(scrollback),
projectPath,
});
if (attempt < maxTestAttempts) {
const fixPrompt = `## Test Failures - Please Fix\n\n${this.buildTestFailureSummary(scrollback)}\n\nFix the failing tests without modifying test code unless clearly wrong.`;
await this.runAgentFn(
workDir,
featureId,
fixPrompt,
abortController,
projectPath,
undefined,
undefined,
{ projectPath, planningMode: 'skip', requirePlanApproval: false }
);
}
}
return {
success: false,
testsPassed: false,
message: `Tests failed after ${maxTestAttempts} attempts`,
};
}
/** Wait for test completion */
private async waitForTestCompletion(
sessionId: string
): Promise<{ status: TestRunStatus; exitCode: number | null; duration: number }> {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const session = this.testRunnerService.getSession(sessionId);
if (session && session.status !== 'running' && session.status !== 'pending') {
clearInterval(checkInterval);
resolve({
status: session.status,
exitCode: session.exitCode,
duration: session.finishedAt
? session.finishedAt.getTime() - session.startedAt.getTime()
: 0,
});
}
}, 1000);
setTimeout(() => {
clearInterval(checkInterval);
resolve({ status: 'failed', exitCode: null, duration: 600000 });
}, 600000);
});
}
/** Attempt to merge feature branch (REQ-F05) */
async attemptMerge(context: PipelineContext): Promise<MergeResult> {
const { projectPath, featureId, branchName, worktreePath, feature } = context;
if (!branchName) return { success: false, error: 'No branch name for merge' };
logger.info(`Attempting auto-merge for feature ${featureId} (branch: ${branchName})`);
try {
const response = await fetch(`http://localhost:${this.serverPort}/api/worktree/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath,
branchName,
worktreePath,
targetBranch: 'main',
options: { deleteWorktreeAndBranch: false },
}),
});
const data = (await response.json()) as {
success: boolean;
hasConflicts?: boolean;
error?: string;
};
if (!response.ok) {
if (data.hasConflicts) {
await this.updateFeatureStatusFn(projectPath, featureId, 'merge_conflict');
this.eventBus.emitAutoModeEvent('pipeline_merge_conflict', {
featureId,
branchName,
projectPath,
});
return { success: false, hasConflicts: true, needsAgentResolution: true };
}
return { success: false, error: data.error };
}
logger.info(`Auto-merge successful for feature ${featureId}`);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName,
passes: true,
message: 'Pipeline completed and merged',
projectPath,
});
return { success: true };
} catch (error) {
logger.error(`Merge failed for ${featureId}:`, error);
return { success: false, error: (error as Error).message };
}
}
/** Build a concise test failure summary for the agent */
buildTestFailureSummary(scrollback: string): string {
const lines = scrollback.split('\n');
const failedTests: string[] = [];
let passCount = 0,
failCount = 0;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) {
const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/);
if (match) failedTests.push(match[1].trim());
failCount++;
} else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) passCount++;
if (trimmed.match(/^>\s+.*\.(test|spec)\./)) failedTests.push(trimmed.replace(/^>\s+/, ''));
if (
trimmed.includes('AssertionError') ||
trimmed.includes('toBe') ||
trimmed.includes('toEqual')
)
failedTests.push(trimmed);
}
const unique = [...new Set(failedTests)].slice(0, 10);
return `Test Results: ${passCount} passed, ${failCount} failed.\n\nFailed tests:\n${unique.map((t) => `- ${t}`).join('\n')}\n\nOutput (last 2000 chars):\n${scrollback.slice(-2000)}`;
}
/** Extract failed test names from scrollback */
private extractFailedTestNames(scrollback: string): string[] {
const failedTests: string[] = [];
for (const line of scrollback.split('\n')) {
const trimmed = line.trim();
if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) {
const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/);
if (match) failedTests.push(match[1].trim());
}
}
return [...new Set(failedTests)].slice(0, 20);
}
}

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