diff --git a/ccw/README.md b/ccw/README.md index 915e7e05..6e3e0d9c 100644 --- a/ccw/README.md +++ b/ccw/README.md @@ -1,10 +1,17 @@ # CCW - Claude Code Workflow CLI -NEW LINE [![Version](https://img.shields.io/badge/version-v6.3.19-blue.svg)](https://github.com/catlog22/Claude-Code-Workflow/releases) A powerful command-line tool for managing Claude Code Workflow with native CodexLens code intelligence, multi-model CLI orchestration, and interactive dashboard. +## What's New in v6.3 + +### Hook System Integration +- **Soft Enforcement Stop Hook**: Never blocks - injects continuation messages for active workflows/modes +- **Mode System**: Keyword-based mode activation with exclusive mode conflict detection +- **Checkpoint/Recovery**: Automatic state preservation before context compaction +- **PreCompact Hook**: Creates checkpoints with mutex to prevent concurrent operations + ## Installation ```bash @@ -77,6 +84,44 @@ ccw view -o report.html - **Dimension Analysis**: Findings by review dimension (Security, Architecture, Quality, etc.) - **Tabbed Interface**: Switch between Workflow and Reviews tabs +### Hook System +- **Soft Enforcement Stop Hook**: Never blocks stops - injects continuation messages instead +- **Mode System**: Keyword-based mode activation (`autopilot`, `ultrawork`, `swarm`, etc.) +- **Checkpoint/Recovery**: Automatic state preservation before context compaction +- **PreCompact Hook**: Creates checkpoints with mutex to prevent concurrent operations +- **Exclusive Mode Detection**: Prevents conflicting modes from running concurrently + +## Quick Start with Hooks + +```bash +# Configure hooks in .claude/settings.json +{ + "hooks": { + "PreCompact": [ + { + "name": "Create Checkpoint", + "command": "ccw hook precompact --stdin", + "enabled": true + } + ], + "Stop": [ + { + "name": "Soft Enforcement Stop", + "command": "ccw hook stop --stdin", + "enabled": true + } + ] + } +} + +# Use mode keywords in prompts +"use autopilot to implement the feature" +"run ultrawork on this task" +"cancelomc" # Stops active modes +``` + +See [docs/hooks-integration.md](docs/hooks-integration.md) for full documentation. + ## Dashboard Data Sources The CLI reads data from the `.workflow/` directory structure: diff --git a/ccw/docs/hooks-integration.md b/ccw/docs/hooks-integration.md index 7f6debb3..1ef2cbf2 100644 --- a/ccw/docs/hooks-integration.md +++ b/ccw/docs/hooks-integration.md @@ -1,17 +1,20 @@ # Hooks Integration for Progressive Disclosure -This document describes how to integrate session hooks with CCW's progressive disclosure system. +This document describes how to integrate session hooks with CCW's progressive disclosure system, including the new Soft Enforcement Stop Hook, Mode System, and Checkpoint/Recovery features. ## Overview -CCW now supports automatic context injection via hooks. When a session starts, the system can automatically provide a progressive disclosure index showing related sessions from the same cluster. +CCW supports automatic context injection via hooks. When a session starts, the system can automatically provide a progressive disclosure index showing related sessions from the same cluster. -## Features +### Key Features - **Automatic Context Injection**: Session start hooks inject cluster context - **Progressive Disclosure**: Shows related sessions, their summaries, and recovery commands - **Silent Failure**: Hook failures don't block session start (< 5 seconds timeout) -- **Multiple Hook Types**: Supports `session-start`, `context`, and custom hooks +- **Multiple Hook Types**: Supports `session-start`, `context`, `PreCompact`, `Stop`, and custom hooks +- **Soft Enforcement**: Stop hooks never block - they inject continuation messages instead +- **Mode System**: Keyword-based mode activation with exclusive mode conflict detection +- **Checkpoint/Recovery**: Automatic state preservation before context compaction ## Hook Configuration @@ -25,11 +28,15 @@ Place hook configurations in `.claude/settings.json`: "session-start": [ { "name": "Progressive Disclosure", - "description": "Injects progressive disclosure index at session start", + "description": "Injects progressive disclosure index at session start with recovery detection", "enabled": true, "handler": "internal:context", "timeout": 5000, - "failMode": "silent" + "failMode": "silent", + "notes": [ + "Checks for recovery checkpoints and injects recovery message if found", + "Uses RecoveryHandler.checkRecovery() for session recovery" + ] } ] } @@ -39,13 +46,25 @@ Place hook configurations in `.claude/settings.json`: ### Hook Types #### `session-start` -Triggered when a new session begins. Ideal for injecting context. +Triggered when a new session begins. Ideal for injecting context and checking for recovery checkpoints. -#### `context` -Triggered on explicit context requests. Same handler as `session-start`. +#### `PreCompact` +Triggered before context compaction. Creates checkpoints to preserve session state including: +- Active mode states +- Workflow progress +- TODO summaries + +#### `Stop` +Triggered when a stop is requested. Uses **Soft Enforcement** - never blocks, but may inject continuation messages. + +#### `UserPromptSubmit` +Triggered when a prompt is submitted. Detects mode keywords and activates corresponding execution modes. #### `session-end` -Triggered when a session ends. Useful for updating cluster metadata. +Triggered when a session ends. Useful for: +- Updating cluster metadata +- Cleaning up mode states +- Final checkpoint creation #### `file-modified` Triggered when files are modified. Can be used for auto-commits or notifications. @@ -73,6 +92,240 @@ In `command` fields, use these variables: - `$PROJECT_PATH`: Current project path - `$CLUSTER_ID`: Active cluster ID (if available) +--- + +## Soft Enforcement Stop Hook + +The Stop Hook implements **Soft Enforcement**: it never blocks stops, but injects continuation messages to encourage task completion. + +### Priority Order + +1. **context-limit**: Always allow (deadlock prevention) +2. **user-abort**: Respect user intent +3. **active-workflow**: Inject continuation message +4. **active-mode**: Inject continuation message via ModeRegistryService + +### Configuration Example + +```json +{ + "hooks": { + "Stop": [ + { + "name": "Soft Enforcement Stop", + "description": "Injects continuation messages for active workflows/modes", + "enabled": true, + "command": "ccw hook stop --stdin", + "timeout": 5000, + "failMode": "silent" + } + ] + } +} +``` + +### Behavior Matrix + +| Condition | continue | mode | message | +|-----------|----------|------|---------| +| Context limit reached | `true` | `context-limit` | None | +| User requested stop | `true` | `user-abort` | None | +| Active workflow | `true` | `active-workflow` | Continuation message | +| Active mode | `true` | `active-mode` | Mode-specific message | +| Normal stop | `true` | `none` | None | + +### Context Limit Detection + +Detected via `ContextLimitDetector`: +- `stop_reason: "context_limit_reached"` +- `stop_reason: "end_turn_limit"` +- `end_turn_reason: "max_tokens"` +- `stop_reason: "max_context"` + +### User Abort Detection + +Detected via `UserAbortDetector`: +- `user_requested: true` +- `stop_reason: "user_cancel"` +- `stop_reason: "cancel"` + +--- + +## Mode System + +The Mode System provides centralized mode state management with file-based persistence. + +### Supported Modes + +| Mode | Type | Description | +|------|------|-------------| +| `autopilot` | Exclusive | Autonomous execution mode for multi-step tasks | +| `ralph` | Non-exclusive | Research and Analysis Learning Pattern Handler | +| `ultrawork` | Non-exclusive | Ultra-focused work mode for deep tasks | +| `swarm` | Exclusive | Multi-agent swarm execution mode | +| `pipeline` | Exclusive | Pipeline execution mode for sequential tasks | +| `team` | Non-exclusive | Team collaboration mode | +| `ultraqa` | Non-exclusive | Ultra-focused QA mode | + +### Exclusive Mode Conflict + +Exclusive modes (`autopilot`, `swarm`, `pipeline`) cannot run concurrently. Attempting to start one while another is active will be blocked. + +### Mode State Storage + +Mode states are stored in `.workflow/modes/`: +``` +.workflow/modes/ +├── sessions/ +│ ├── {session-id}/ +│ │ ├── autopilot-state.json +│ │ └── ralph-state.json +│ └── ... +└── (legacy shared states) +``` + +### Stale Marker Cleanup + +Mode markers older than 1 hour are automatically cleaned up to prevent crashed sessions from blocking indefinitely. + +### Mode Activation via Keyword + +Configure the `UserPromptSubmit` hook to detect keywords: + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "name": "Keyword Detection", + "description": "Detects mode keywords in prompts and activates corresponding modes", + "enabled": true, + "command": "ccw hook keyword --stdin", + "timeout": 5000, + "failMode": "silent" + } + ] + } +} +``` + +### Supported Keywords + +| Keyword | Mode | Aliases | +|---------|------|---------| +| `autopilot` | autopilot | - | +| `ultrawork` | ultrawork | `ulw` | +| `ralph` | ralph | - | +| `swarm` | swarm | - | +| `pipeline` | pipeline | - | +| `team` | team | - | +| `ultraqa` | ultraqa | - | +| `cancelomc` | Cancel | `stopomc` | +| `codex` | Delegate | `gpt` | +| `gemini` | Delegate | - | + +--- + +## Checkpoint and Recovery + +The Checkpoint System preserves session state before context compaction. + +### Checkpoint Triggers + +| Trigger | Description | +|---------|-------------| +| `manual` | User-initiated checkpoint | +| `auto` | Automatic checkpoint | +| `compact` | Before context compaction | +| `mode-switch` | When switching modes | +| `session-end` | At session termination | + +### Checkpoint Storage + +Checkpoints are stored in `.workflow/checkpoints/`: + +``` +.workflow/checkpoints/ +├── 2025-02-18T10-30-45-sess123.json +├── 2025-02-18T11-15-22-sess123.json +└── ... +``` + +### Checkpoint Contents + +```json +{ + "id": "2025-02-18T10-30-45-sess123", + "created_at": "2025-02-18T10:30:45.000Z", + "trigger": "compact", + "session_id": "sess123", + "project_path": "/path/to/project", + "workflow_state": null, + "mode_states": { + "autopilot": { + "active": true, + "activatedAt": "2025-02-18T10:00:00.000Z" + } + }, + "memory_context": null, + "todo_summary": { + "pending": 3, + "in_progress": 1, + "completed": 5 + } +} +``` + +### Recovery Flow + +``` +┌─────────────────┐ +│ Session Start │ +└────────┬────────┘ + │ + v +┌─────────────────┐ No checkpoint +│ Check Recovery │ ──────────────────► Continue normally +└────────┬────────┘ + │ Checkpoint found + v +┌─────────────────┐ +│ Load Checkpoint │ +└────────┬────────┘ + │ + v +┌─────────────────┐ +│ Restore Modes │ +└────────┬────────┘ + │ + v +┌─────────────────┐ +│ Inject Message │ +└─────────────────┘ +``` + +### Automatic Cleanup + +Only the last 10 checkpoints per session are retained. Older checkpoints are automatically removed. + +--- + +## PreCompact Hook with Mutex + +The PreCompact hook uses a mutex to prevent concurrent compaction operations for the same directory. + +### Mutex Behavior + +``` +Request 1 ──► PreCompact ──► Create checkpoint ──► Return + ▲ +Request 2 ──► Wait for Request 1 ────────────────┘ +``` + +This prevents race conditions when multiple subagent results arrive simultaneously (e.g., in swarm/ultrawork modes). + +--- + ## API Endpoint ### Trigger Hook @@ -105,15 +358,17 @@ Content-Type: application/json - `?path=/project/path`: Override project path - `?format=markdown|json`: Response format (default: markdown) +--- + ## Progressive Disclosure Output Format The hook returns a structured Markdown document: ```markdown -## 📋 Related Sessions Index +## Related Sessions Index -### 🔗 Active Cluster: {cluster_name} ({member_count} sessions) +### Active Cluster: {cluster_name} ({member_count} sessions) **Intent**: {cluster_intent} | # | Session | Type | Summary | Tokens | @@ -130,11 +385,11 @@ ccw core-memory load {session_id} ccw core-memory load-cluster {cluster_id} ``` -### 📊 Timeline +### Timeline ``` -2024-12-15 ─●─ WFS-001 (Implement auth) +2024-12-15 -- WFS-001 (Implement auth) │ -2024-12-16 ─●─ CLI-002 (Fix login bug) ← Current +2024-12-16 -- CLI-002 (Fix login bug) <- Current ``` --- @@ -142,9 +397,9 @@ ccw core-memory load-cluster {cluster_id} ``` -## Examples +--- -### Example 1: Basic Session Start Hook +## Complete Configuration Example ```json { @@ -152,78 +407,88 @@ ccw core-memory load-cluster {cluster_id} "session-start": [ { "name": "Progressive Disclosure", + "description": "Injects progressive disclosure index at session start with recovery detection", "enabled": true, "handler": "internal:context", "timeout": 5000, "failMode": "silent" } - ] - } -} -``` - -### Example 2: Custom Command Hook - -```json -{ - "hooks": { + ], "session-end": [ { - "name": "Update Cluster", + "name": "Update Cluster Metadata", + "description": "Updates cluster metadata after session ends", "enabled": true, "command": "ccw core-memory update-cluster --session $SESSION_ID", "timeout": 30000, "async": true, "failMode": "log" + }, + { + "name": "Mode State Cleanup", + "description": "Deactivates all active modes for the session", + "enabled": true, + "command": "ccw hook session-end --stdin", + "timeout": 5000, + "failMode": "silent" } - ] - } -} -``` - -### Example 3: File Modification Hook - -```json -{ - "hooks": { + ], + "PreCompact": [ + { + "name": "Create Checkpoint", + "description": "Creates checkpoint before context compaction", + "enabled": true, + "command": "ccw hook precompact --stdin", + "timeout": 10000, + "failMode": "log" + } + ], + "Stop": [ + { + "name": "Soft Enforcement Stop", + "description": "Injects continuation messages for active workflows/modes", + "enabled": true, + "command": "ccw hook stop --stdin", + "timeout": 5000, + "failMode": "silent" + } + ], + "UserPromptSubmit": [ + { + "name": "Keyword Detection", + "description": "Detects mode keywords in prompts and activates corresponding modes", + "enabled": true, + "command": "ccw hook keyword --stdin", + "timeout": 5000, + "failMode": "silent" + } + ], "file-modified": [ { - "name": "Auto Commit", + "name": "Auto Commit Checkpoint", + "description": "Creates git checkpoint on file modifications", "enabled": false, - "command": "git add $FILE_PATH && git commit -m '[Auto] Save: $FILE_PATH'", + "command": "git add . && git commit -m \"[Auto] Checkpoint: $FILE_PATH\"", "timeout": 10000, "async": true, "failMode": "log" } ] + }, + "notes": { + "handler": "Use 'internal:context' for built-in context generation, or 'command' for external commands", + "failMode": "Options: 'silent' (ignore errors), 'log' (log errors), 'fail' (abort on error)", + "variables": "Available: $SESSION_ID, $FILE_PATH, $PROJECT_PATH, $CLUSTER_ID", + "async": "Async hooks run in background and don't block the main flow", + "Stop hook": "The Stop hook uses Soft Enforcement - it never blocks but may inject continuation messages", + "PreCompact hook": "Creates checkpoint before compaction; uses mutex to prevent concurrent operations", + "UserPromptSubmit hook": "Detects mode keywords and activates corresponding execution modes", + "session-end hook": "Cleans up mode states using ModeRegistryService.deactivateMode()" } } ``` -## Implementation Details - -### Handler: `internal:context` - -The built-in context handler: - -1. Determines the current session ID -2. Queries `SessionClusteringService` for related clusters -3. Retrieves cluster members and their metadata -4. Generates a progressive disclosure index -5. Returns formatted Markdown within `` tags - -### Timeout Behavior - -- Hooks have a maximum execution time (default: 5 seconds) -- If timeout is exceeded, the hook is terminated -- Behavior depends on `failMode`: - - `silent`: Continues without notification - - `log`: Logs timeout error - - `fail`: Aborts session start (not recommended) - -### Error Handling - -All errors are caught and handled according to `failMode`. The system ensures that hook failures never block the main workflow. +--- ## Testing @@ -239,6 +504,16 @@ curl -X POST http://localhost:3456/api/hook \ ccw core-memory context --format markdown ``` +### Run Integration Tests + +```bash +# Run all hook integration tests +node --test tests/integration/hooks-integration.test.ts + +# Run with verbose output +node --test tests/integration/hooks-integration.test.ts --test-name-pattern="INT-.*" +``` + ### Expected Output If a cluster exists: @@ -250,6 +525,8 @@ If no cluster exists: - Message indicating no cluster found - Commands to search or trigger clustering +--- + ## Troubleshooting ### Hook Not Triggering @@ -270,25 +547,93 @@ If no cluster exists: 2. Verify session metadata exists 3. Check that the session has been added to a cluster +### Mode Not Activating + +1. Check for conflicting exclusive modes +2. Verify keyword spelling (case-insensitive) +3. Check that keyword is not inside code blocks + +### Checkpoint Not Created + +1. Verify `.workflow/checkpoints/` directory exists +2. Check disk space +3. Review logs for error messages + +### Recovery Not Working + +1. Verify checkpoint exists in `.workflow/checkpoints/` +2. Check that session ID matches +3. Ensure checkpoint file is valid JSON + +--- + ## Performance Considerations - Progressive disclosure index generation is fast (< 1 second typical) - Uses cached metadata to avoid full session parsing - Timeout enforced to prevent blocking - Failures return empty content instead of errors +- Mutex prevents concurrent compaction operations +- Stale marker cleanup runs automatically -## Future Enhancements +--- -- **Dynamic Clustering**: Real-time cluster updates during session -- **Multi-Cluster Support**: Show sessions from multiple related clusters -- **Relevance Scoring**: Sort sessions by relevance to current task -- **Token Budget**: Calculate total token usage for context loading -- **Hook Chains**: Execute multiple hooks in sequence -- **Conditional Hooks**: Execute hooks based on project state +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Claude Code Session │ +└─────────────────────────────────────────────────────────────┘ + │ + │ Hook Events + v +┌─────────────────────────────────────────────────────────────┐ +│ Hook System │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │Session Start │ │ PreCompact │ │ Stop │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +└─────────┼─────────────────┼──────────────────┼──────────────┘ + │ │ │ + v v v +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│RecoveryHandler │ │CheckpointService│ │ StopHandler │ +│ │ │ │ │ │ +│ - checkRecovery │ │ - create │ │ - SoftEnforce │ +│ - formatMessage │ │ - save │ │ - detectMode │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + v v v +┌─────────────────────────────────────────────────────────────┐ +│ ModeRegistryService │ +│ │ +│ - activateMode / deactivateMode │ +│ - getActiveModes / canStartMode │ +│ - cleanupStaleMarkers │ +│ │ +│ Storage: .workflow/modes/sessions/{sessionId}/ │ +└─────────────────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────────┐ +│ Checkpoint Storage │ +│ │ +│ .workflow/checkpoints/{checkpoint-id}.json │ +│ - session_id, trigger, mode_states, workflow_state │ +│ - Automatic cleanup (keep last 10 per session) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- ## References - **Session Clustering**: See `session-clustering-service.ts` - **Core Memory Store**: See `core-memory-store.ts` - **Hook Routes**: See `routes/hooks-routes.ts` -- **Example Configuration**: See `hooks-config-example.json` +- **Stop Handler**: See `core/hooks/stop-handler.ts` +- **Mode Registry**: See `core/services/mode-registry-service.ts` +- **Checkpoint Service**: See `core/services/checkpoint-service.ts` +- **Recovery Handler**: See `core/hooks/recovery-handler.ts` +- **Keyword Detector**: See `core/hooks/keyword-detector.ts` +- **Example Configuration**: See `templates/hooks-config-example.json` diff --git a/ccw/src/commands/hook.ts b/ccw/src/commands/hook.ts index 43906d7d..895d8a56 100644 --- a/ccw/src/commands/hook.ts +++ b/ccw/src/commands/hook.ts @@ -4,15 +4,13 @@ */ import chalk from 'chalk'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { homedir } from 'os'; +import { existsSync, readFileSync } from 'fs'; interface HookOptions { stdin?: boolean; sessionId?: string; prompt?: string; - type?: 'session-start' | 'context' | 'session-end'; + type?: 'session-start' | 'context' | 'session-end' | 'stop' | 'pre-compact'; path?: string; } @@ -21,12 +19,18 @@ interface HookData { prompt?: string; cwd?: string; tool_input?: Record; -} - -interface SessionState { - firstLoad: string; - loadCount: number; - lastPrompt?: string; + user_prompt?: string; // For UserPromptSubmit hook + // Stop context fields + stop_reason?: string; + stopReason?: string; + end_turn_reason?: string; + endTurnReason?: string; + user_requested?: boolean; + userRequested?: boolean; + active_mode?: 'analysis' | 'write' | 'review' | 'auto'; + activeMode?: 'analysis' | 'write' | 'review' | 'auto'; + active_workflow?: boolean; + activeWorkflow?: boolean; } /** @@ -52,42 +56,6 @@ async function readStdin(): Promise { }); } -/** - * Get session state file path - * Uses ~/.claude/.ccw-sessions/ for reliable persistence across sessions - */ -function getSessionStateFile(sessionId: string): string { - const stateDir = join(homedir(), '.claude', '.ccw-sessions'); - if (!existsSync(stateDir)) { - mkdirSync(stateDir, { recursive: true }); - } - return join(stateDir, `session-${sessionId}.json`); -} - -/** - * Load session state from file - */ -function loadSessionState(sessionId: string): SessionState | null { - const stateFile = getSessionStateFile(sessionId); - if (!existsSync(stateFile)) { - return null; - } - try { - const content = readFileSync(stateFile, 'utf-8'); - return JSON.parse(content) as SessionState; - } catch { - return null; - } -} - -/** - * Save session state to file - */ -function saveSessionState(sessionId: string, state: SessionState): void { - const stateFile = getSessionStateFile(sessionId); - writeFileSync(stateFile, JSON.stringify(state, null, 2)); -} - /** * Get project path from hook data or current working directory */ @@ -95,27 +63,10 @@ function getProjectPath(hookCwd?: string): string { return hookCwd || process.cwd(); } -/** - * Check if UnifiedContextBuilder is available (embedder dependencies present). - * Returns the builder instance or null if not available. - */ -async function tryCreateContextBuilder(projectPath: string): Promise { - try { - const { isUnifiedEmbedderAvailable } = await import('../core/unified-vector-index.js'); - if (!isUnifiedEmbedderAvailable()) { - return null; - } - const { UnifiedContextBuilder } = await import('../core/unified-context-builder.js'); - return new UnifiedContextBuilder(projectPath); - } catch { - return null; - } -} - /** * Session context action - provides progressive context loading * - * Uses UnifiedContextBuilder when available (embedder present): + * Uses HookContextService for unified context generation: * - session-start: MEMORY.md summary + clusters + hot entities + patterns * - per-prompt: vector search across all memory categories * @@ -153,71 +104,59 @@ async function sessionContextAction(options: HookOptions): Promise { try { const projectPath = getProjectPath(hookCwd); - // Load existing session state - const existingState = loadSessionState(sessionId); - const isFirstPrompt = !existingState; + // Check for recovery on session-start + const isFirstPrompt = !prompt || prompt.trim() === ''; + let recoveryMessage = ''; - // Update session state - const newState: SessionState = isFirstPrompt - ? { - firstLoad: new Date().toISOString(), - loadCount: 1, - lastPrompt: prompt + if (isFirstPrompt && sessionId) { + try { + const { RecoveryHandler } = await import('../core/hooks/recovery-handler.js'); + const recoveryHandler = new RecoveryHandler({ + projectPath, + enableLogging: !stdin + }); + + const checkpoint = await recoveryHandler.checkRecovery(sessionId); + if (checkpoint) { + recoveryMessage = await recoveryHandler.formatRecoveryMessage(checkpoint); + if (!stdin) { + console.log(chalk.yellow('Recovery checkpoint found!')); + } + } + } catch (recoveryError) { + // Recovery check failure should not affect session start + if (!stdin) { + console.log(chalk.yellow(`Recovery check warning: ${(recoveryError as Error).message}`)); } - : { - ...existingState, - loadCount: existingState.loadCount + 1, - lastPrompt: prompt - }; - - saveSessionState(sessionId, newState); - - // Determine context type and generate content - let contextType: 'session-start' | 'context'; - let content = ''; - - // Try UnifiedContextBuilder first; fall back to getProgressiveIndex - const contextBuilder = await tryCreateContextBuilder(projectPath); - - if (contextBuilder) { - // Use UnifiedContextBuilder - if (isFirstPrompt) { - contextType = 'session-start'; - content = await contextBuilder.buildSessionStartContext(); - } else if (prompt && prompt.trim().length > 0) { - contextType = 'context'; - content = await contextBuilder.buildPromptContext(prompt); - } else { - contextType = 'context'; - content = ''; - } - } else { - // Fallback: use legacy SessionClusteringService.getProgressiveIndex() - const { SessionClusteringService } = await import('../core/session-clustering-service.js'); - const clusteringService = new SessionClusteringService(projectPath); - - if (isFirstPrompt) { - contextType = 'session-start'; - content = await clusteringService.getProgressiveIndex({ - type: 'session-start', - sessionId - }); - } else if (prompt && prompt.trim().length > 0) { - contextType = 'context'; - content = await clusteringService.getProgressiveIndex({ - type: 'context', - sessionId, - prompt - }); - } else { - contextType = 'context'; - content = ''; } } + // Use HookContextService for unified context generation + const { HookContextService } = await import('../core/services/hook-context-service.js'); + const contextService = new HookContextService({ projectPath }); + + // Build context using the service + const result = await contextService.buildPromptContext({ + sessionId, + prompt, + projectId: projectPath + }); + + const content = result.content; + const contextType = result.type; + const loadCount = result.state.loadCount; + const isAdvanced = await contextService.isAdvancedContextAvailable(); + if (stdin) { // For hooks: output content directly to stdout - if (content) { + // Include recovery message if available + if (recoveryMessage) { + process.stdout.write(recoveryMessage); + if (content) { + process.stdout.write('\n\n'); + process.stdout.write(content); + } + } else if (content) { process.stdout.write(content); } process.exit(0); @@ -229,9 +168,17 @@ async function sessionContextAction(options: HookOptions): Promise { console.log(chalk.cyan('Session ID:'), sessionId); console.log(chalk.cyan('Type:'), contextType); console.log(chalk.cyan('First Prompt:'), isFirstPrompt ? 'Yes' : 'No'); - console.log(chalk.cyan('Load Count:'), newState.loadCount); - console.log(chalk.cyan('Builder:'), contextBuilder ? 'UnifiedContextBuilder' : 'Legacy (getProgressiveIndex)'); + console.log(chalk.cyan('Load Count:'), loadCount); + console.log(chalk.cyan('Builder:'), isAdvanced ? 'UnifiedContextBuilder' : 'Legacy (getProgressiveIndex)'); + if (recoveryMessage) { + console.log(chalk.cyan('Recovery:'), 'Checkpoint found'); + } console.log(chalk.gray('─'.repeat(40))); + if (recoveryMessage) { + console.log(chalk.yellow('Recovery Message:')); + console.log(recoveryMessage); + console.log(); + } if (content) { console.log(content); } else { @@ -250,10 +197,10 @@ async function sessionContextAction(options: HookOptions): Promise { /** * Session end action - triggers async background tasks for memory maintenance. * - * Tasks executed: - * 1. Incremental vector embedding (index new/updated content) - * 2. Incremental clustering (cluster unclustered sessions) - * 3. Heat score updates (recalculate entity heat scores) + * Uses SessionEndService for unified task management: + * - Incremental vector embedding (index new/updated content) + * - Incremental clustering (cluster unclustered sessions) + * - Heat score updates (recalculate entity heat scores) * * All tasks run best-effort; failures are logged but do not affect exit code. */ @@ -283,33 +230,58 @@ async function sessionEndAction(options: HookOptions): Promise { try { const projectPath = getProjectPath(hookCwd); - const contextBuilder = await tryCreateContextBuilder(projectPath); - if (!contextBuilder) { - // UnifiedContextBuilder not available - skip session-end tasks + // Clean up mode states for this session + try { + const { ModeRegistryService } = await import('../core/services/mode-registry-service.js'); + const modeRegistry = new ModeRegistryService({ + projectPath, + enableLogging: !stdin + }); + + // Get active modes for this session and deactivate them + const activeModes = modeRegistry.getActiveModes(sessionId); + for (const mode of activeModes) { + modeRegistry.deactivateMode(mode, sessionId); + if (!stdin) { + console.log(chalk.gray(` Deactivated mode: ${mode}`)); + } + } + } catch (modeError) { + // Mode cleanup failure should not affect session end if (!stdin) { - console.log(chalk.gray('(UnifiedContextBuilder not available, skipping session-end tasks)')); + console.log(chalk.yellow(` Mode cleanup warning: ${(modeError as Error).message}`)); + } + } + + // Use SessionEndService for unified task management + const { createSessionEndService } = await import('../core/services/session-end-service.js'); + const sessionEndService = await createSessionEndService(projectPath, sessionId, !stdin); + + const registeredTasks = sessionEndService.getRegisteredTasks(); + + if (registeredTasks.length === 0) { + // No tasks available - skip session-end tasks + if (!stdin) { + console.log(chalk.gray('(No session-end tasks available)')); } process.exit(0); } - const tasks: Array<{ name: string; execute: () => Promise }> = contextBuilder.buildSessionEndTasks(sessionId); - if (!stdin) { - console.log(chalk.green(`Session End: executing ${tasks.length} background tasks...`)); + console.log(chalk.green(`Session End: executing ${registeredTasks.length} background tasks...`)); } - // Execute all tasks concurrently (best-effort) - const results = await Promise.allSettled( - tasks.map((task: { name: string; execute: () => Promise }) => task.execute()) - ); + // Execute all tasks + const summary = await sessionEndService.executeEndTasks(sessionId); if (!stdin) { - for (let i = 0; i < tasks.length; i++) { - const status = results[i].status === 'fulfilled' ? 'OK' : 'FAIL'; - const color = status === 'OK' ? chalk.green : chalk.yellow; - console.log(color(` [${status}] ${tasks[i].name}`)); + for (const result of summary.results) { + const status = result.success ? 'OK' : 'FAIL'; + const color = result.success ? chalk.green : chalk.yellow; + console.log(color(` [${status}] ${result.type} (${result.duration}ms)`)); } + console.log(chalk.gray(`Total: ${summary.successful}/${summary.totalTasks} tasks completed in ${summary.totalDuration}ms`)); } process.exit(0); @@ -322,6 +294,105 @@ async function sessionEndAction(options: HookOptions): Promise { } } +/** + * Stop action - handles Stop hook events with Soft Enforcement + * + * Uses StopHandler for priority-based stop handling: + * 1. context-limit: Always allow stop (deadlock prevention) + * 2. user-abort: Respect user intent + * 3. active-workflow: Inject continuation message + * 4. active-mode: Inject continuation message + * + * Returns { continue: true, message?: string } - never blocks stops. + */ +async function stopAction(options: HookOptions): Promise { + let { stdin, sessionId } = options; + let hookCwd: string | undefined; + let hookData: HookData = {}; + + // If --stdin flag is set, read from stdin (Claude Code hook format) + if (stdin) { + try { + const stdinData = await readStdin(); + if (stdinData) { + hookData = JSON.parse(stdinData) as HookData; + sessionId = hookData.session_id || sessionId; + hookCwd = hookData.cwd; + } + } catch { + // Silently continue if stdin parsing fails + } + } + + try { + const projectPath = getProjectPath(hookCwd); + + // Import StopHandler dynamically to avoid circular dependencies + const { StopHandler } = await import('../core/hooks/stop-handler.js'); + const stopHandler = new StopHandler({ + enableLogging: !stdin, + projectPath // Pass projectPath for ModeRegistryService integration + }); + + // Build stop context from hook data + const stopContext = { + session_id: sessionId, + sessionId: sessionId, + project_path: projectPath, + projectPath: projectPath, + stop_reason: hookData.stop_reason, + stopReason: hookData.stopReason, + end_turn_reason: hookData.end_turn_reason, + endTurnReason: hookData.endTurnReason, + user_requested: hookData.user_requested, + userRequested: hookData.userRequested, + active_mode: hookData.active_mode, + activeMode: hookData.activeMode, + active_workflow: hookData.active_workflow, + activeWorkflow: hookData.activeWorkflow + }; + + // Handle the stop event + const result = await stopHandler.handleStop(stopContext); + + if (stdin) { + // For hooks: output JSON result to stdout + const output: { continue: true; message?: string } = { + continue: true + }; + if (result.message) { + output.message = result.message; + } + process.stdout.write(JSON.stringify(output)); + process.exit(0); + } + + // Interactive mode: show detailed output + console.log(chalk.green('Stop Handler')); + console.log(chalk.gray('─'.repeat(40))); + console.log(chalk.cyan('Mode:'), result.mode || 'none'); + console.log(chalk.cyan('Continue:'), result.continue); + if (result.message) { + console.log(chalk.yellow('Message:')); + console.log(result.message); + } + if (result.metadata) { + console.log(chalk.gray('─'.repeat(40))); + console.log(chalk.cyan('Metadata:')); + console.log(JSON.stringify(result.metadata, null, 2)); + } + process.exit(0); + } catch (error) { + if (stdin) { + // Silent failure for hooks - always allow stop + process.stdout.write(JSON.stringify({ continue: true })); + process.exit(0); + } + console.error(chalk.red(`Error: ${(error as Error).message}`)); + process.exit(1); + } +} + /** * Parse CCW status.json and output formatted status */ @@ -375,6 +446,238 @@ async function parseStatusAction(options: HookOptions): Promise { } } +/** + * Keyword detection and mode activation action + * + * Detects magic keywords in user prompts and activates corresponding modes. + * Called from UserPromptSubmit hook. + */ +async function keywordAction(options: HookOptions): Promise { + let { stdin, sessionId, prompt } = options; + let hookCwd: string | undefined; + + if (stdin) { + try { + const stdinData = await readStdin(); + if (stdinData) { + const hookData = JSON.parse(stdinData) as HookData; + sessionId = hookData.session_id || sessionId; + hookCwd = hookData.cwd; + // Support both 'prompt' and 'user_prompt' fields + prompt = hookData.prompt || hookData.user_prompt || prompt; + } + } catch { + // Silently continue if stdin parsing fails + } + } + + if (!prompt) { + // No prompt to analyze - just exit silently + if (stdin) { + process.exit(0); + } + console.error(chalk.red('Error: --prompt is required')); + process.exit(1); + } + + try { + const projectPath = getProjectPath(hookCwd); + + // Import keyword detector + const { getPrimaryKeyword, getAllKeywords, KEYWORD_PATTERNS } = await import('../core/hooks/keyword-detector.js'); + + // Detect keywords in prompt + const primaryKeyword = getPrimaryKeyword(prompt); + + if (!primaryKeyword) { + // No keywords detected - exit silently for hooks + if (stdin) { + process.exit(0); + } + console.log(chalk.gray('No mode keywords detected')); + process.exit(0); + } + + // Map keyword type to execution mode + const keywordToModeMap: Record = { + 'autopilot': 'autopilot', + 'ralph': 'ralph', + 'ultrawork': 'ultrawork', + 'swarm': 'swarm', + 'pipeline': 'pipeline', + 'team': 'team', + 'ultrapilot': 'team', // ultrapilot maps to team + 'ultraqa': 'ultraqa' + }; + + const executionMode = keywordToModeMap[primaryKeyword.type]; + + if (!executionMode) { + // Keyword not mapped to execution mode (e.g., 'cancel', 'codex', 'gemini') + if (stdin) { + process.exit(0); + } + console.log(chalk.gray(`Keyword "${primaryKeyword.keyword}" detected but no execution mode mapped`)); + process.exit(0); + } + + // Generate sessionId if not provided + const effectiveSessionId = sessionId || `mode-${Date.now()}`; + + // Import ModeRegistryService and activate mode + const { ModeRegistryService } = await import('../core/services/mode-registry-service.js'); + const modeRegistry = new ModeRegistryService({ + projectPath, + enableLogging: !stdin + }); + + // Check if mode can be started + const canStart = modeRegistry.canStartMode(executionMode as any, effectiveSessionId); + if (!canStart.allowed) { + if (stdin) { + // For hooks: just output a warning message + const output = { + continue: true, + systemMessage: `[MODE ACTIVATION BLOCKED] ${canStart.message}` + }; + process.stdout.write(JSON.stringify(output)); + process.exit(0); + } + console.log(chalk.yellow(`Cannot activate mode: ${canStart.message}`)); + process.exit(0); + } + + // Activate the mode + const activated = modeRegistry.activateMode( + executionMode as any, + effectiveSessionId, + { prompt, keyword: primaryKeyword.keyword } + ); + + if (stdin) { + // For hooks: output activation result + const output = { + continue: true, + systemMessage: activated + ? `[MODE ACTIVATED] ${executionMode.toUpperCase()} mode activated for this session. Keyword detected: "${primaryKeyword.keyword}"` + : `[MODE ACTIVATION FAILED] Could not activate ${executionMode} mode` + }; + process.stdout.write(JSON.stringify(output)); + process.exit(0); + } + + // Interactive mode: show detailed output + console.log(chalk.green('Keyword Detection')); + console.log(chalk.gray('-'.repeat(40))); + console.log(chalk.cyan('Detected Keyword:'), primaryKeyword.keyword); + console.log(chalk.cyan('Type:'), primaryKeyword.type); + console.log(chalk.cyan('Position:'), primaryKeyword.position); + console.log(chalk.cyan('Execution Mode:'), executionMode); + console.log(chalk.cyan('Session ID:'), effectiveSessionId); + console.log(chalk.cyan('Activated:'), activated ? 'Yes' : 'No'); + process.exit(0); + } catch (error) { + if (stdin) { + // Silent failure for hooks + process.exit(0); + } + console.error(chalk.red(`Error: ${(error as Error).message}`)); + process.exit(1); + } +} + +/** + * PreCompact action - handles PreCompact hook events + * + * Creates a checkpoint before context compaction to preserve state. + * Uses RecoveryHandler with mutex to prevent concurrent compaction. + * + * Returns { continue: true, systemMessage?: string } - checkpoint summary. + */ +async function preCompactAction(options: HookOptions): Promise { + let { stdin, sessionId } = options; + let hookCwd: string | undefined; + let trigger: 'manual' | 'auto' = 'auto'; + + if (stdin) { + try { + const stdinData = await readStdin(); + if (stdinData) { + const hookData = JSON.parse(stdinData) as HookData & { + trigger?: 'manual' | 'auto'; + transcript_path?: string; + permission_mode?: string; + hook_event_name?: string; + }; + sessionId = hookData.session_id || sessionId; + hookCwd = hookData.cwd; + trigger = hookData.trigger || 'auto'; + } + } catch { + // Silently continue if stdin parsing fails + } + } + + if (!sessionId) { + if (!stdin) { + console.error(chalk.red('Error: --session-id is required')); + } + // For hooks, use a default session ID + sessionId = `compact-${Date.now()}`; + } + + try { + const projectPath = getProjectPath(hookCwd); + + // Import RecoveryHandler dynamically + const { RecoveryHandler } = await import('../core/hooks/recovery-handler.js'); + const recoveryHandler = new RecoveryHandler({ + projectPath, + enableLogging: !stdin + }); + + // Handle PreCompact with mutex protection + const result = await recoveryHandler.handlePreCompact({ + session_id: sessionId, + cwd: projectPath, + hook_event_name: 'PreCompact', + trigger + }); + + if (stdin) { + // For hooks: output JSON result to stdout + const output: { continue: boolean; systemMessage?: string } = { + continue: result.continue + }; + if (result.systemMessage) { + output.systemMessage = result.systemMessage; + } + process.stdout.write(JSON.stringify(output)); + process.exit(0); + } + + // Interactive mode: show detailed output + console.log(chalk.green('PreCompact Handler')); + console.log(chalk.gray('─'.repeat(40))); + console.log(chalk.cyan('Session ID:'), sessionId); + console.log(chalk.cyan('Trigger:'), trigger); + console.log(chalk.cyan('Continue:'), result.continue); + if (result.systemMessage) { + console.log(chalk.yellow('System Message:')); + console.log(result.systemMessage); + } + process.exit(0); + } catch (error) { + if (stdin) { + // Don't block compaction on error + process.stdout.write(JSON.stringify({ continue: true })); + process.exit(0); + } + console.error(chalk.red(`Error: ${(error as Error).message}`)); + process.exit(1); + } +} + /** * Notify dashboard action - send notification to running ccw view server */ @@ -424,6 +727,9 @@ ${chalk.bold('SUBCOMMANDS')} parse-status Parse CCW status.json and display current/next command session-context Progressive session context loading (replaces curl/bash hook) session-end Trigger background memory maintenance tasks + stop Handle Stop hook events with Soft Enforcement + keyword Detect mode keywords in prompts and activate modes + pre-compact Handle PreCompact hook events (checkpoint creation) notify Send notification to ccw view dashboard ${chalk.bold('OPTIONS')} @@ -442,10 +748,32 @@ ${chalk.bold('EXAMPLES')} ${chalk.gray('# Interactive usage:')} ccw hook session-context --session-id abc123 + ${chalk.gray('# Handle Stop hook events:')} + ccw hook stop --stdin + ${chalk.gray('# Notify dashboard:')} ccw hook notify --stdin + ${chalk.gray('# Detect mode keywords:')} + ccw hook keyword --stdin --prompt "use autopilot to implement auth" + + ${chalk.gray('# Handle PreCompact events:')} + ccw hook pre-compact --stdin + ${chalk.bold('HOOK CONFIGURATION')} + ${chalk.gray('Add to .claude/settings.json for Stop hook:')} + { + "hooks": { + "Stop": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "ccw hook stop --stdin" + }] + }] + } + } + ${chalk.gray('Add to .claude/settings.json for status tracking:')} { "hooks": { @@ -479,6 +807,16 @@ export async function hookCommand( case 'session-end': await sessionEndAction(options); break; + case 'stop': + await stopAction(options); + break; + case 'keyword': + await keywordAction(options); + break; + case 'pre-compact': + case 'precompact': + await preCompactAction(options); + break; case 'notify': await notifyAction(options); break; diff --git a/ccw/src/core/hooks/context-limit-detector.ts b/ccw/src/core/hooks/context-limit-detector.ts new file mode 100644 index 00000000..b395d800 --- /dev/null +++ b/ccw/src/core/hooks/context-limit-detector.ts @@ -0,0 +1,120 @@ +/** + * ContextLimitDetector - Detects context limit stops + * + * When context is exhausted, Claude Code needs to stop so it can compact. + * Blocking these stops causes a deadlock: can't compact because can't stop, + * can't continue because context is full. + * + * This detector identifies context-limit related stop reasons to allow + * graceful handling of context exhaustion. + * + * @see https://github.com/Yeachan-Heo/oh-my-claudecode/issues/213 + */ + +/** + * Context from Stop hook event + * + * NOTE: Field names support both camelCase and snake_case variants + * for compatibility with different Claude Code versions. + */ +export interface StopContext { + /** Reason for stop (from Claude Code) - snake_case variant */ + stop_reason?: string; + /** Reason for stop (from Claude Code) - camelCase variant */ + stopReason?: string; + /** End turn reason (from API) - snake_case variant */ + end_turn_reason?: string; + /** End turn reason (from API) - camelCase variant */ + endTurnReason?: string; + /** Whether user explicitly requested stop - snake_case variant */ + user_requested?: boolean; + /** Whether user explicitly requested stop - camelCase variant */ + userRequested?: boolean; +} + +/** + * Patterns that indicate context limit has been reached + * + * These patterns are matched case-insensitively against stop_reason + * and end_turn_reason fields. + */ +export const CONTEXT_LIMIT_PATTERNS: readonly string[] = [ + 'context_limit', + 'context_window', + 'context_exceeded', + 'context_full', + 'max_context', + 'token_limit', + 'max_tokens', + 'conversation_too_long', + 'input_too_long' +] as const; + +/** + * Check if a reason string matches any context limit pattern + * + * @param reason - The reason string to check + * @returns true if the reason matches a context limit pattern + */ +function matchesContextPattern(reason: string): boolean { + const normalizedReason = reason.toLowerCase(); + return CONTEXT_LIMIT_PATTERNS.some(pattern => normalizedReason.includes(pattern)); +} + +/** + * Detect if stop was triggered by context-limit related reasons + * + * When context is exhausted, Claude Code needs to stop so it can compact. + * Blocking these stops causes a deadlock: can't compact because can't stop, + * can't continue because context is full. + * + * @param context - The stop context from the hook event + * @returns true if the stop was due to context limit + */ +export function isContextLimitStop(context?: StopContext): boolean { + if (!context) return false; + + // Get reasons from both field naming conventions + const stopReason = context.stop_reason ?? context.stopReason ?? ''; + const endTurnReason = context.end_turn_reason ?? context.endTurnReason ?? ''; + + // Check both stop_reason and end_turn_reason for context limit patterns + return matchesContextPattern(stopReason) || matchesContextPattern(endTurnReason); +} + +/** + * Get the matching context limit pattern if any + * + * @param context - The stop context from the hook event + * @returns The matching pattern or null if no match + */ +export function getMatchingContextPattern(context?: StopContext): string | null { + if (!context) return null; + + const stopReason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase(); + const endTurnReason = (context.end_turn_reason ?? context.endTurnReason ?? '').toLowerCase(); + + for (const pattern of CONTEXT_LIMIT_PATTERNS) { + if (stopReason.includes(pattern) || endTurnReason.includes(pattern)) { + return pattern; + } + } + + return null; +} + +/** + * Get all matching context limit patterns + * + * @param context - The stop context from the hook event + * @returns Array of matching patterns (may be empty) + */ +export function getAllMatchingContextPatterns(context?: StopContext): string[] { + if (!context) return []; + + const stopReason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase(); + const endTurnReason = (context.end_turn_reason ?? context.endTurnReason ?? '').toLowerCase(); + const combinedReasons = `${stopReason} ${endTurnReason}`; + + return CONTEXT_LIMIT_PATTERNS.filter(pattern => combinedReasons.includes(pattern)); +} diff --git a/ccw/src/core/hooks/index.ts b/ccw/src/core/hooks/index.ts new file mode 100644 index 00000000..c930c768 --- /dev/null +++ b/ccw/src/core/hooks/index.ts @@ -0,0 +1,60 @@ +/** + * Core Hooks Module + * + * Provides detector functions and utilities for Claude Code hooks integration. + */ + +// Context Limit Detector +export { + isContextLimitStop, + getMatchingContextPattern, + getAllMatchingContextPatterns, + CONTEXT_LIMIT_PATTERNS, + type StopContext +} from './context-limit-detector.js'; + +// User Abort Detector +export { + isUserAbort, + getMatchingAbortPattern, + getAllMatchingAbortPatterns, + shouldAllowContinuation, + USER_ABORT_EXACT_PATTERNS, + USER_ABORT_SUBSTRING_PATTERNS, + USER_ABORT_PATTERNS +} from './user-abort-detector.js'; + +// Keyword Detector +export { + detectKeywords, + hasKeyword, + getAllKeywords, + getPrimaryKeyword, + getKeywordType, + hasKeywordType, + sanitizeText, + removeCodeBlocks, + KEYWORD_PATTERNS, + KEYWORD_PRIORITY, + type KeywordType, + type DetectedKeyword +} from './keyword-detector.js'; + +// Stop Handler +export { + StopHandler, + createStopHandler, + defaultStopHandler, + type ExtendedStopContext, + type StopResult, + type StopHandlerOptions +} from './stop-handler.js'; + +// Recovery Handler +export { + RecoveryHandler, + createRecoveryHandler, + type PreCompactInput, + type HookOutput, + type RecoveryHandlerOptions +} from './recovery-handler.js'; diff --git a/ccw/src/core/hooks/keyword-detector.ts b/ccw/src/core/hooks/keyword-detector.ts new file mode 100644 index 00000000..684c4ee4 --- /dev/null +++ b/ccw/src/core/hooks/keyword-detector.ts @@ -0,0 +1,261 @@ +/** + * KeywordDetector - Detects magic keywords in user prompts + * + * Detects magic keywords in user prompts and returns the appropriate + * mode message to inject into context. + * + * Ported from oh-my-opencode's keyword-detector hook with adaptations + * for CCW architecture. + */ + +/** + * Supported keyword types for mode detection + */ +export type KeywordType = + | 'cancel' // Priority 1: Cancel all operations + | 'ralph' // Priority 2: Ralph mode + | 'autopilot' // Priority 3: Auto-pilot mode + | 'ultrapilot' // Priority 4: Ultra-pilot mode (parallel build) + | 'team' // Priority 4.5: Team mode (coordinated agents) + | 'ultrawork' // Priority 5: Ultra-work mode + | 'swarm' // Priority 6: Swarm mode (multiple agents) + | 'pipeline' // Priority 7: Pipeline mode (chained agents) + | 'ralplan' // Priority 8: Ralplan mode + | 'plan' // Priority 9: Planning mode + | 'tdd' // Priority 10: Test-driven development mode + | 'ultrathink' // Priority 11: Deep thinking mode + | 'deepsearch' // Priority 12: Deep search mode + | 'analyze' // Priority 13: Analysis mode + | 'codex' // Priority 14: Delegate to Codex + | 'gemini'; // Priority 15: Delegate to Gemini + +/** + * Detected keyword with metadata + */ +export interface DetectedKeyword { + /** Type of the detected keyword */ + type: KeywordType; + /** The actual matched keyword string */ + keyword: string; + /** Position in the original text where the keyword was found */ + position: number; +} + +/** + * Keyword patterns for each mode + */ +export const KEYWORD_PATTERNS: Record = { + cancel: /\b(cancelomc|stopomc)\b/i, + ralph: /\b(ralph)\b/i, + autopilot: /\b(autopilot|auto[\s-]?pilot|fullsend|full\s+auto)\b/i, + ultrapilot: /\b(ultrapilot|ultra-pilot)\b|\bparallel\s+build\b|\bswarm\s+build\b/i, + ultrawork: /\b(ultrawork|ulw)\b/i, + swarm: /\bswarm\s+\d+\s+agents?\b|\bcoordinated\s+agents\b|\bteam\s+mode\b/i, + team: /(?][\s\S]*?<\/\1>/g, ''); + // Remove self-closing XML tags + result = result.replace(/<\w[\w-]*(?:\s[^>]*)?\s*\/>/g, ''); + // Remove URLs + result = result.replace(/https?:\/\/\S+/g, ''); + // Remove file paths - requires leading / or ./ or multi-segment dir/file.ext + result = result.replace(/(^|[\s"'`(])(?:\.?\/(?:[\w.-]+\/)*[\w.-]+|(?:[\w.-]+\/)+[\w.-]+\.\w+)/gm, '$1'); + // Remove code blocks (fenced and inline) + result = removeCodeBlocks(result); + return result; +} + +/** + * Detect keywords in text and return matches with type info + * + * @param text - The text to analyze + * @param options - Optional configuration + * @returns Array of detected keywords with metadata + */ +export function detectKeywords( + text: string, + options?: { teamEnabled?: boolean } +): DetectedKeyword[] { + const detected: DetectedKeyword[] = []; + const cleanedText = sanitizeText(text); + const teamEnabled = options?.teamEnabled ?? false; + + // Check each keyword type in priority order + for (const type of KEYWORD_PRIORITY) { + // Skip team-related types when team feature is disabled + if ((type === 'team' || type === 'ultrapilot' || type === 'swarm') && !teamEnabled) { + continue; + } + + const pattern = KEYWORD_PATTERNS[type]; + const match = cleanedText.match(pattern); + + if (match && match.index !== undefined) { + detected.push({ + type, + keyword: match[0], + position: match.index + }); + + // Legacy ultrapilot/swarm also activate team mode internally + if (teamEnabled && (type === 'ultrapilot' || type === 'swarm')) { + detected.push({ + type: 'team', + keyword: match[0], + position: match.index + }); + } + } + } + + return detected; +} + +/** + * Check if text contains any magic keyword + * + * @param text - The text to check + * @returns true if any keyword is detected + */ +export function hasKeyword(text: string): boolean { + return detectKeywords(text).length > 0; +} + +/** + * Get all detected keywords with conflict resolution applied + * + * Conflict resolution rules: + * - cancel suppresses everything (exclusive) + * - team beats autopilot (mutual exclusion) + * + * @param text - The text to analyze + * @param options - Optional configuration + * @returns Array of resolved keyword types in priority order + */ +export function getAllKeywords( + text: string, + options?: { teamEnabled?: boolean } +): KeywordType[] { + const detected = detectKeywords(text, options); + + if (detected.length === 0) return []; + + let types = Array.from(new Set(detected.map(d => d.type))); + + // Exclusive: cancel suppresses everything + if (types.includes('cancel')) return ['cancel']; + + // Mutual exclusion: team beats autopilot (ultrapilot/swarm now map to team at detection) + if (types.includes('team') && types.includes('autopilot')) { + types = types.filter(t => t !== 'autopilot'); + } + + // Sort by priority order + return KEYWORD_PRIORITY.filter(k => types.includes(k)); +} + +/** + * Get the highest priority keyword detected with conflict resolution + * + * @param text - The text to analyze + * @param options - Optional configuration + * @returns The primary detected keyword or null if none found + */ +export function getPrimaryKeyword( + text: string, + options?: { teamEnabled?: boolean } +): DetectedKeyword | null { + const allKeywords = getAllKeywords(text, options); + + if (allKeywords.length === 0) { + return null; + } + + // Get the highest priority keyword type + const primaryType = allKeywords[0]; + + // Find the original detected keyword for this type + const detected = detectKeywords(text, options); + const match = detected.find(d => d.type === primaryType); + + return match || null; +} + +/** + * Get keyword type for a given keyword string + * + * @param keyword - The keyword string to look up + * @returns The keyword type or null if not found + */ +export function getKeywordType(keyword: string): KeywordType | null { + const normalizedKeyword = keyword.toLowerCase(); + + for (const type of KEYWORD_PRIORITY) { + const pattern = KEYWORD_PATTERNS[type]; + if (pattern.test(normalizedKeyword)) { + return type; + } + } + + return null; +} + +/** + * Check if a specific keyword type is detected in text + * + * @param text - The text to check + * @param type - The keyword type to look for + * @returns true if the keyword type is detected + */ +export function hasKeywordType(text: string, type: KeywordType): boolean { + const cleanedText = sanitizeText(text); + const pattern = KEYWORD_PATTERNS[type]; + return pattern.test(cleanedText); +} diff --git a/ccw/src/core/hooks/recovery-handler.ts b/ccw/src/core/hooks/recovery-handler.ts new file mode 100644 index 00000000..62ef17ff --- /dev/null +++ b/ccw/src/core/hooks/recovery-handler.ts @@ -0,0 +1,328 @@ +/** + * RecoveryHandler - Session Recovery and PreCompact Handler + * + * Handles PreCompact hook events and session recovery for state preservation + * during context compaction and session restarts. + * + * Features: + * - PreCompact checkpoint creation before context compaction + * - Session recovery detection and message injection + * - Mutex lock to prevent concurrent compaction operations + * + * Based on oh-my-claudecode pre-compact pattern. + */ + +import type { Checkpoint, CheckpointTrigger } from '../services/checkpoint-service.js'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Input from PreCompact hook event + */ +export interface PreCompactInput { + /** Session ID */ + session_id: string; + /** Path to transcript file */ + transcript_path?: string; + /** Current working directory */ + cwd: string; + /** Permission mode */ + permission_mode?: string; + /** Hook event name */ + hook_event_name: 'PreCompact'; + /** Trigger type */ + trigger: 'manual' | 'auto'; + /** Custom instructions */ + custom_instructions?: string; +} + +/** + * Output for hook handlers + */ +export interface HookOutput { + /** Whether to continue with the operation */ + continue: boolean; + /** System message for context injection */ + systemMessage?: string; +} + +/** + * Options for RecoveryHandler + */ +export interface RecoveryHandlerOptions { + /** Project root path */ + projectPath: string; + /** Enable logging */ + enableLogging?: boolean; +} + +// ============================================================================= +// Compaction Mutex +// ============================================================================= + +/** + * Per-directory in-flight compaction promises. + * When a compaction is already running for a directory, new callers + * await the existing promise instead of running concurrently. + * This prevents race conditions when multiple subagent results + * arrive simultaneously (swarm/ultrawork). + */ +const inflightCompactions = new Map>(); + +/** + * Queue depth counter per directory for diagnostics. + * Tracks how many callers are waiting on an in-flight compaction. + */ +const compactionQueueDepth = new Map(); + +// ============================================================================= +// RecoveryHandler Class +// ============================================================================= + +/** + * Handler for PreCompact hook events and session recovery + */ +export class RecoveryHandler { + private projectPath: string; + private enableLogging: boolean; + + constructor(options: RecoveryHandlerOptions) { + this.projectPath = options.projectPath; + this.enableLogging = options.enableLogging ?? false; + } + + // --------------------------------------------------------------------------- + // Public: PreCompact Handler + // --------------------------------------------------------------------------- + + /** + * Handle PreCompact hook event + * + * Creates a checkpoint before compaction to preserve state. + * Uses mutex to prevent concurrent compaction for the same directory. + * + * @param input - PreCompact hook input + * @returns Promise resolving to hook output with checkpoint summary + */ + async handlePreCompact(input: PreCompactInput): Promise { + const directory = input.cwd || this.projectPath; + + // Check for in-flight compaction + const inflight = inflightCompactions.get(directory); + if (inflight) { + const depth = (compactionQueueDepth.get(directory) ?? 0) + 1; + compactionQueueDepth.set(directory, depth); + try { + // Await the existing compaction result + return await inflight; + } finally { + const current = compactionQueueDepth.get(directory) ?? 1; + if (current <= 1) { + compactionQueueDepth.delete(directory); + } else { + compactionQueueDepth.set(directory, current - 1); + } + } + } + + // No in-flight compaction - run it and register the promise + const compactionPromise = this.doHandlePreCompact(input); + inflightCompactions.set(directory, compactionPromise); + + try { + return await compactionPromise; + } finally { + inflightCompactions.delete(directory); + } + } + + /** + * Internal PreCompact handler (unserialized) + */ + private async doHandlePreCompact(input: PreCompactInput): Promise { + this.log(`Creating checkpoint for session ${input.session_id} (trigger: ${input.trigger})`); + + try { + // Import services dynamically + const { CheckpointService } = await import('../services/checkpoint-service.js'); + const { ModeRegistryService } = await import('../services/mode-registry-service.js'); + + // Create checkpoint service + const checkpointService = new CheckpointService({ + projectPath: this.projectPath, + enableLogging: this.enableLogging + }); + + // Get mode registry for active modes + const modeRegistry = new ModeRegistryService({ + projectPath: this.projectPath, + enableLogging: this.enableLogging + }); + + // Collect active mode states + const activeModes = modeRegistry.getActiveModes(input.session_id); + const modeStates: Record = {}; + + for (const mode of activeModes) { + modeStates[mode] = { + active: true, + activatedAt: new Date().toISOString() + }; + } + + // Create checkpoint + const trigger: CheckpointTrigger = input.trigger === 'manual' ? 'manual' : 'compact'; + const checkpoint = await checkpointService.createCheckpoint( + input.session_id, + trigger, + { + modeStates: modeStates as any, + workflowState: null, + memoryContext: null + } + ); + + // Save checkpoint + await checkpointService.saveCheckpoint(checkpoint); + + // Format recovery message + const systemMessage = checkpointService.formatRecoveryMessage(checkpoint); + + this.log(`Checkpoint created: ${checkpoint.id}`); + + return { + continue: true, + systemMessage + }; + } catch (error) { + this.log(`Error creating checkpoint: ${(error as Error).message}`); + + // Return success even on error - don't block compaction + return { + continue: true, + systemMessage: `[PRECOMPACT WARNING] Checkpoint creation failed: ${(error as Error).message}. Proceeding with compaction.` + }; + } + } + + // --------------------------------------------------------------------------- + // Public: Recovery Detection + // --------------------------------------------------------------------------- + + /** + * Check for recoverable checkpoint for a session + * + * @param sessionId - The session ID to check + * @returns The most recent checkpoint or null if none found + */ + async checkRecovery(sessionId: string): Promise { + try { + const { CheckpointService } = await import('../services/checkpoint-service.js'); + + const checkpointService = new CheckpointService({ + projectPath: this.projectPath, + enableLogging: this.enableLogging + }); + + const checkpoint = await checkpointService.getLatestCheckpoint(sessionId); + + if (checkpoint) { + this.log(`Found recoverable checkpoint: ${checkpoint.id} (trigger: ${checkpoint.trigger})`); + } + + return checkpoint; + } catch (error) { + this.log(`Error checking recovery: ${(error as Error).message}`); + return null; + } + } + + /** + * Generate recovery message for context injection + * + * @param checkpoint - The checkpoint to format + * @returns Formatted recovery message + */ + async formatRecoveryMessage(checkpoint: Checkpoint): Promise { + try { + const { CheckpointService } = await import('../services/checkpoint-service.js'); + + const checkpointService = new CheckpointService({ + projectPath: this.projectPath, + enableLogging: false + }); + + return checkpointService.formatRecoveryMessage(checkpoint); + } catch (error) { + // Fallback to basic format + return `# Session Recovery + +**Checkpoint ID:** ${checkpoint.id} +**Created:** ${checkpoint.created_at} +**Trigger:** ${checkpoint.trigger} +**Session:** ${checkpoint.session_id} + +*This checkpoint was created to preserve session state.*`; + } + } + + // --------------------------------------------------------------------------- + // Public: Mutex Status + // --------------------------------------------------------------------------- + + /** + * Check if compaction is currently in progress for a directory + * + * @param directory - The directory to check + * @returns true if compaction is in progress + */ + isCompactionInProgress(directory: string): boolean { + return inflightCompactions.has(directory); + } + + /** + * Get the number of callers queued behind an in-flight compaction + * + * @param directory - The directory to check + * @returns Number of queued callers (0 if no compaction in progress) + */ + getCompactionQueueDepth(directory: string): number { + return compactionQueueDepth.get(directory) ?? 0; + } + + // --------------------------------------------------------------------------- + // Private: Utility Methods + // --------------------------------------------------------------------------- + + /** + * Log a message if logging is enabled + */ + private log(message: string): void { + if (this.enableLogging) { + const timestamp = new Date().toISOString(); + console.log(`[RecoveryHandler ${timestamp}] ${message}`); + } + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a RecoveryHandler instance + * + * @param options - Handler options + * @returns RecoveryHandler instance + */ +export function createRecoveryHandler(options: RecoveryHandlerOptions): RecoveryHandler { + return new RecoveryHandler(options); +} + +// ============================================================================= +// Default Export +// ============================================================================= + +export default RecoveryHandler; diff --git a/ccw/src/core/hooks/stop-handler.ts b/ccw/src/core/hooks/stop-handler.ts new file mode 100644 index 00000000..58112926 --- /dev/null +++ b/ccw/src/core/hooks/stop-handler.ts @@ -0,0 +1,365 @@ +/** + * StopHandler - Unified stop hook handler with Soft Enforcement + * + * Handles Stop hook events with priority-based checking: + * 1. context-limit: Always allow stop (deadlock prevention) + * 2. user-abort: Respect user intent + * 3. active-workflow: Inject continuation message + * 4. active-mode: Inject continuation message (uses ModeRegistryService) + * + * Design: + * - ALWAYS returns continue: true (Soft Enforcement) + * - Injects continuation message instead of blocking + * - Logs all stop events for debugging + * - Integrates with ModeRegistryService for mode state detection + */ + +import { isContextLimitStop } from './context-limit-detector.js'; +import { isUserAbort, type StopContext } from './user-abort-detector.js'; +import type { ExecutionMode } from '../services/mode-registry-service.js'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Extended stop context with additional fields + */ +export interface ExtendedStopContext extends StopContext { + /** Session ID */ + session_id?: string; + /** Session ID (camelCase variant) */ + sessionId?: string; + /** Project path */ + project_path?: string; + /** Project path (camelCase variant) */ + projectPath?: string; + /** Current active mode */ + active_mode?: 'analysis' | 'write' | 'review' | 'auto'; + /** Current active mode (camelCase variant) */ + activeMode?: 'analysis' | 'write' | 'review' | 'auto'; + /** Whether there's an active workflow */ + active_workflow?: boolean; + /** Whether there's an active workflow (camelCase variant) */ + activeWorkflow?: boolean; +} + +/** + * Result of stop handling + */ +export interface StopResult { + /** ALWAYS true - we never block stops */ + continue: true; + /** Optional continuation message to inject */ + message?: string; + /** Which handler was triggered */ + mode?: 'context-limit' | 'user-abort' | 'active-workflow' | 'active-mode' | 'none'; + /** Additional metadata */ + metadata?: { + /** Reason for stop (from context) */ + reason?: string; + /** Whether user requested stop */ + userRequested?: boolean; + /** Session ID */ + sessionId?: string; + /** Active mode if any */ + activeMode?: string; + /** Whether workflow was active */ + activeWorkflow?: boolean; + }; +} + +/** + * Options for StopHandler + */ +export interface StopHandlerOptions { + /** Whether to enable logging */ + enableLogging?: boolean; + /** Custom message for workflow continuation */ + workflowContinuationMessage?: string; + /** Custom message for mode continuation */ + modeContinuationMessage?: string; + /** Project path for ModeRegistryService */ + projectPath?: string; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** Default workflow continuation message */ +const DEFAULT_WORKFLOW_MESSAGE = `[WORKFLOW CONTINUATION] +An active workflow is in progress. + +If you intended to stop: +- Use explicit cancellation command to exit cleanly +- Otherwise, continue with your workflow tasks + +`; + +/** Default mode continuation message */ +const DEFAULT_MODE_MESSAGE = `[MODE CONTINUATION] +An active mode is set for this session. + +Mode: {mode} + +Continue with your current task, or use cancellation command to exit. + +`; + +// ============================================================================= +// StopHandler +// ============================================================================= + +/** + * Handler for Stop hook events + * + * This handler implements Soft Enforcement: it never blocks stops, + * but injects continuation messages to encourage task completion. + */ +export class StopHandler { + private enableLogging: boolean; + private workflowContinuationMessage: string; + private modeContinuationMessage: string; + private projectPath?: string; + + constructor(options: StopHandlerOptions = {}) { + this.enableLogging = options.enableLogging ?? false; + this.workflowContinuationMessage = + options.workflowContinuationMessage ?? DEFAULT_WORKFLOW_MESSAGE; + this.modeContinuationMessage = + options.modeContinuationMessage ?? DEFAULT_MODE_MESSAGE; + this.projectPath = options.projectPath; + } + + // --------------------------------------------------------------------------- + // Public: Main Handler + // --------------------------------------------------------------------------- + + /** + * Handle a stop event + * + * Priority order: + * 1. context-limit: Always allow (deadlock prevention) + * 2. user-abort: Respect user intent + * 3. active-workflow: Inject continuation message + * 4. active-mode: Inject continuation message (via ModeRegistryService) + * + * @param context - Stop context from hook event + * @returns Stop result (always continue: true) + */ + async handleStop(context?: ExtendedStopContext): Promise { + this.log('Handling stop event...'); + + // Extract common fields with both naming conventions + const sessionId = context?.session_id ?? context?.sessionId; + const activeMode = context?.active_mode ?? context?.activeMode; + const activeWorkflow = context?.active_workflow ?? context?.activeWorkflow; + const userRequested = context?.user_requested ?? context?.userRequested; + + // Get stop reason + const reason = context?.stop_reason ?? context?.stopReason ?? ''; + const endTurnReason = context?.end_turn_reason ?? context?.endTurnReason ?? ''; + const fullReason = `${reason} ${endTurnReason}`.trim(); + + this.log(`Context: sessionId=${sessionId}, reason="${fullReason}", userRequested=${userRequested}`); + + // Priority 1: Context Limit - CRITICAL: Never block + // Blocking context-limit stops causes deadlock (can't compact if can't stop) + if (isContextLimitStop(context)) { + this.log('Context limit detected - allowing stop'); + return { + continue: true, + mode: 'context-limit', + metadata: { + reason: fullReason, + sessionId, + userRequested + } + }; + } + + // Priority 2: User Abort - Respect user intent + if (isUserAbort(context)) { + this.log('User abort detected - respecting user intent'); + return { + continue: true, + mode: 'user-abort', + metadata: { + reason: fullReason, + userRequested: true, + sessionId + } + }; + } + + // Priority 3: Active Workflow - Inject continuation message + if (activeWorkflow) { + this.log('Active workflow detected - injecting continuation message'); + return { + continue: true, + message: this.workflowContinuationMessage, + mode: 'active-workflow', + metadata: { + reason: fullReason, + sessionId, + activeWorkflow: true + } + }; + } + + // Priority 4: Active Mode - Check via ModeRegistryService + if (this.projectPath && sessionId) { + try { + const { ModeRegistryService } = await import('../services/mode-registry-service.js'); + const modeRegistry = new ModeRegistryService({ + projectPath: this.projectPath, + enableLogging: this.enableLogging + }); + + const activeModes = modeRegistry.getActiveModes(sessionId); + if (activeModes.length > 0) { + const primaryMode = activeModes[0]; + const modeConfig = (await import('../services/mode-registry-service.js')).MODE_CONFIGS[primaryMode]; + const modeName = modeConfig?.name ?? primaryMode; + + this.log(`Active mode "${modeName}" detected via ModeRegistryService - injecting continuation message`); + const message = this.modeContinuationMessage.replace('{mode}', modeName); + return { + continue: true, + message, + mode: 'active-mode', + metadata: { + reason: fullReason, + sessionId, + activeMode: primaryMode + } + }; + } + } catch (error) { + this.log(`Error checking mode registry: ${(error as Error).message}`); + // Fall through to check context-based active mode + } + } + + // Fallback: Check active mode from context + if (activeMode) { + this.log(`Active mode "${activeMode}" detected from context - injecting continuation message`); + const message = this.modeContinuationMessage.replace('{mode}', String(activeMode)); + return { + continue: true, + message, + mode: 'active-mode', + metadata: { + reason: fullReason, + sessionId, + activeMode: String(activeMode) + } + }; + } + + // Default: No special handling needed + this.log('No special handling needed - allowing stop'); + return { + continue: true, + mode: 'none', + metadata: { + reason: fullReason, + sessionId, + userRequested + } + }; + } + + // --------------------------------------------------------------------------- + // Public: Utility Methods + // --------------------------------------------------------------------------- + + /** + * Check if a stop should trigger continuation message + * + * @param context - Stop context + * @returns true if continuation message should be injected + */ + async shouldInjectContinuation(context?: ExtendedStopContext): Promise { + // Context limit and user abort don't get continuation + if (isContextLimitStop(context) || isUserAbort(context)) { + return false; + } + + // Active workflow gets continuation + const activeWorkflow = context?.active_workflow ?? context?.activeWorkflow; + if (activeWorkflow) { + return true; + } + + // Check via ModeRegistryService if projectPath is available + const sessionId = context?.session_id ?? context?.sessionId; + if (this.projectPath && sessionId) { + try { + const { ModeRegistryService } = await import('../services/mode-registry-service.js'); + const modeRegistry = new ModeRegistryService({ + projectPath: this.projectPath, + enableLogging: false + }); + + if (modeRegistry.isAnyModeActive(sessionId)) { + return true; + } + } catch { + // Fall through to context-based check + } + } + + // Fallback: Check active mode from context + const activeMode = context?.active_mode ?? context?.activeMode; + return Boolean(activeMode); + } + + /** + * Get the stop reason from context + * + * @param context - Stop context + * @returns Normalized stop reason + */ + getStopReason(context?: ExtendedStopContext): string { + const reason = context?.stop_reason ?? context?.stopReason ?? ''; + const endTurnReason = context?.end_turn_reason ?? context?.endTurnReason ?? ''; + return `${reason} ${endTurnReason}`.trim() || 'unknown'; + } + + // --------------------------------------------------------------------------- + // Private: Utility Methods + // --------------------------------------------------------------------------- + + /** + * Log a message if logging is enabled + */ + private log(message: string): void { + if (this.enableLogging) { + const timestamp = new Date().toISOString(); + console.log(`[StopHandler ${timestamp}] ${message}`); + } + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a StopHandler instance + * + * @param options - Handler options + * @returns StopHandler instance + */ +export function createStopHandler(options?: StopHandlerOptions): StopHandler { + return new StopHandler(options); +} + +// ============================================================================= +// Default Export +// ============================================================================= + +/** Default StopHandler instance */ +export const defaultStopHandler = new StopHandler(); diff --git a/ccw/src/core/hooks/user-abort-detector.ts b/ccw/src/core/hooks/user-abort-detector.ts new file mode 100644 index 00000000..adc3cb86 --- /dev/null +++ b/ccw/src/core/hooks/user-abort-detector.ts @@ -0,0 +1,194 @@ +/** + * UserAbortDetector - Detects user-initiated abort stops + * + * Detects if a stop was due to user abort (not natural completion). + * This allows hooks to gracefully handle user-initiated stops differently + * from automatic or system-initiated stops. + * + * NOTE: Per official Anthropic docs, the Stop hook "Does not run if + * the stoppage occurred due to a user interrupt." This means this + * detector may never receive user-abort contexts in practice. + * It is kept as defensive code in case the behavior changes. + */ + +import type { StopContext } from './context-limit-detector.js'; + +// Re-export StopContext for convenience +export type { StopContext } from './context-limit-detector.js'; + +/** + * Patterns that indicate user abort (exact match) + * + * These are short generic words that need exact matching to avoid + * false positives with substring matching (e.g., "cancel" in "cancelled_order"). + */ +export const USER_ABORT_EXACT_PATTERNS: readonly string[] = [ + 'aborted', + 'abort', + 'cancel', + 'interrupt' +] as const; + +/** + * Patterns that indicate user abort (substring match) + * + * These are compound words that are safe for substring matching + * because they are unlikely to appear as parts of other words. + */ +export const USER_ABORT_SUBSTRING_PATTERNS: readonly string[] = [ + 'user_cancel', + 'user_interrupt', + 'ctrl_c', + 'manual_stop' +] as const; + +/** + * All user abort patterns combined + */ +export const USER_ABORT_PATTERNS: readonly string[] = [ + ...USER_ABORT_EXACT_PATTERNS, + ...USER_ABORT_SUBSTRING_PATTERNS +] as const; + +/** + * Check if a reason matches exact abort patterns + * + * @param reason - The reason string to check (should be lowercase) + * @returns true if the reason exactly matches an abort pattern + */ +function matchesExactPattern(reason: string): boolean { + return USER_ABORT_EXACT_PATTERNS.some(pattern => reason === pattern); +} + +/** + * Check if a reason matches substring abort patterns + * + * @param reason - The reason string to check (should be lowercase) + * @returns true if the reason contains a substring abort pattern + */ +function matchesSubstringPattern(reason: string): boolean { + return USER_ABORT_SUBSTRING_PATTERNS.some(pattern => reason.includes(pattern)); +} + +/** + * Detect if stop was due to user abort (not natural completion) + * + * WARNING: These patterns are ASSUMED based on common conventions. + * As of 2025-01, Anthropic's Stop hook input schema does not document + * the exact stop_reason values. The patterns below are educated guesses: + * + * - user_cancel, user_interrupt: Likely user-initiated via UI + * - ctrl_c: Terminal interrupt (Ctrl+C) + * - manual_stop: Explicit stop button + * - abort, cancel, interrupt: Generic abort patterns + * + * If the detector fails to detect user aborts correctly, these patterns + * should be updated based on observed Claude Code behavior. + * + * @param context - The stop context from the hook event + * @returns true if the stop was due to user abort + */ +export function isUserAbort(context?: StopContext): boolean { + if (!context) return false; + + // User explicitly requested stop (supports both camelCase and snake_case) + if (context.user_requested === true || context.userRequested === true) { + return true; + } + + // Get reason from both field naming conventions + const reason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase(); + + // Check exact patterns first (short words that could cause false positives) + if (matchesExactPattern(reason)) { + return true; + } + + // Then check substring patterns (compound words safe for includes) + if (matchesSubstringPattern(reason)) { + return true; + } + + return false; +} + +/** + * Get the matching user abort pattern if any + * + * @param context - The stop context from the hook event + * @returns The matching pattern or null if no match + */ +export function getMatchingAbortPattern(context?: StopContext): string | null { + if (!context) return null; + + // Check explicit user_requested flag first + if (context.user_requested === true || context.userRequested === true) { + return 'user_requested'; + } + + const reason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase(); + + // Check exact patterns + for (const pattern of USER_ABORT_EXACT_PATTERNS) { + if (reason === pattern) { + return pattern; + } + } + + // Check substring patterns + for (const pattern of USER_ABORT_SUBSTRING_PATTERNS) { + if (reason.includes(pattern)) { + return pattern; + } + } + + return null; +} + +/** + * Get all matching user abort patterns + * + * @param context - The stop context from the hook event + * @returns Array of matching patterns (may be empty) + */ +export function getAllMatchingAbortPatterns(context?: StopContext): string[] { + if (!context) return []; + + const matches: string[] = []; + + // Check explicit user_requested flag first + if (context.user_requested === true || context.userRequested === true) { + matches.push('user_requested'); + } + + const reason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase(); + + // Check exact patterns + for (const pattern of USER_ABORT_EXACT_PATTERNS) { + if (reason === pattern) { + matches.push(pattern); + } + } + + // Check substring patterns + for (const pattern of USER_ABORT_SUBSTRING_PATTERNS) { + if (reason.includes(pattern)) { + matches.push(pattern); + } + } + + return Array.from(new Set(matches)); // Remove duplicates +} + +/** + * Check if a stop should allow continuation + * + * User aborts should NOT force continuation - if the user explicitly + * stopped the session, we should respect that decision. + * + * @param context - The stop context from the hook event + * @returns true if continuation should be allowed (not a user abort) + */ +export function shouldAllowContinuation(context?: StopContext): boolean { + return !isUserAbort(context); +} diff --git a/ccw/src/core/mode-workflow-map.ts b/ccw/src/core/mode-workflow-map.ts new file mode 100644 index 00000000..c909c73d --- /dev/null +++ b/ccw/src/core/mode-workflow-map.ts @@ -0,0 +1,366 @@ +/** + * ModeWorkflowMap - Mode to Workflow Mapping + * + * Maps execution modes to their corresponding workflow types and provides + * workflow activation configuration for each mode. + * + * Mode -> Workflow Mappings: + * - autopilot -> unified-execute-with-file (autonomous multi-step execution) + * - ralph -> team-planex (research and analysis) + * - ultrawork -> test-fix (ultra-focused work with test feedback) + * - swarm -> parallel-agents (multi-agent parallel execution) + * - pipeline -> lite-plan (sequential pipeline execution) + * - team -> team-iterdev (team collaboration) + * - ultraqa -> test-fix (QA-focused test cycles) + */ + +import { ExecutionMode, MODE_CONFIGS } from './services/mode-registry-service.js'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Workflow types supported by the system + */ +export type WorkflowType = + | 'unified-execute-with-file' + | 'team-planex' + | 'test-fix' + | 'parallel-agents' + | 'lite-plan' + | 'team-iterdev'; + +/** + * Configuration for workflow activation + */ +export interface WorkflowActivationConfig { + /** The workflow type to activate */ + workflowType: WorkflowType; + /** Whether this workflow requires session persistence */ + requiresPersistence: boolean; + /** Default execution mode for the workflow */ + defaultExecutionMode: 'analysis' | 'write' | 'auto'; + /** Whether parallel execution is supported */ + supportsParallel: boolean; + /** Maximum concurrent tasks (for parallel workflows) */ + maxConcurrentTasks?: number; + /** Description of the workflow */ + description: string; + /** Required context keys for activation */ + requiredContext?: string[]; +} + +/** + * Context passed during workflow activation + */ +export interface WorkflowActivationContext { + /** Session ID for the workflow */ + sessionId: string; + /** Project root path */ + projectPath: string; + /** User prompt or task description */ + prompt?: string; + /** Additional context data */ + metadata?: Record; +} + +/** + * Result of workflow activation + */ +export interface WorkflowActivationResult { + /** Whether activation was successful */ + success: boolean; + /** The session ID (may be new or existing) */ + sessionId: string; + /** The activated workflow type */ + workflowType: WorkflowType; + /** Error message if activation failed */ + error?: string; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Mode to Workflow mapping configuration + * + * Each mode maps to a specific workflow type with its activation config. + */ +export const MODE_WORKFLOW_MAP: Record = { + /** + * Autopilot Mode -> unified-execute-with-file + * Autonomous execution of multi-step tasks with file-based state persistence. + */ + autopilot: { + workflowType: 'unified-execute-with-file', + requiresPersistence: true, + defaultExecutionMode: 'write', + supportsParallel: false, + description: 'Autonomous multi-step task execution with file-based state', + requiredContext: ['prompt'] + }, + + /** + * Ralph Mode -> team-planex + * Research and Analysis Learning Pattern Handler for iterative exploration. + */ + ralph: { + workflowType: 'team-planex', + requiresPersistence: true, + defaultExecutionMode: 'analysis', + supportsParallel: false, + description: 'Research and analysis pattern handler for iterative exploration' + }, + + /** + * Ultrawork Mode -> test-fix + * Ultra-focused work mode with test-feedback loop. + */ + ultrawork: { + workflowType: 'test-fix', + requiresPersistence: true, + defaultExecutionMode: 'write', + supportsParallel: false, + description: 'Ultra-focused work mode with test-driven feedback loop' + }, + + /** + * Swarm Mode -> parallel-agents + * Multi-agent parallel execution for distributed task processing. + */ + swarm: { + workflowType: 'parallel-agents', + requiresPersistence: true, + defaultExecutionMode: 'write', + supportsParallel: true, + maxConcurrentTasks: 5, + description: 'Multi-agent parallel execution for distributed tasks' + }, + + /** + * Pipeline Mode -> lite-plan + * Sequential pipeline execution for stage-based workflows. + */ + pipeline: { + workflowType: 'lite-plan', + requiresPersistence: true, + defaultExecutionMode: 'write', + supportsParallel: false, + description: 'Sequential pipeline execution for stage-based workflows' + }, + + /** + * Team Mode -> team-iterdev + * Team collaboration mode for iterative development. + */ + team: { + workflowType: 'team-iterdev', + requiresPersistence: true, + defaultExecutionMode: 'write', + supportsParallel: true, + maxConcurrentTasks: 3, + description: 'Team collaboration mode for iterative development' + }, + + /** + * UltraQA Mode -> test-fix + * QA-focused test cycles with iterative quality improvements. + */ + ultraqa: { + workflowType: 'test-fix', + requiresPersistence: true, + defaultExecutionMode: 'write', + supportsParallel: false, + description: 'QA-focused test cycles with iterative quality improvements' + } +}; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Get the workflow type for a given execution mode + * + * @param mode - The execution mode + * @returns The corresponding workflow type, or undefined if not found + */ +export function getWorkflowForMode(mode: ExecutionMode): WorkflowType | undefined { + const config = MODE_WORKFLOW_MAP[mode]; + return config?.workflowType; +} + +/** + * Get the activation configuration for a given execution mode + * + * @param mode - The execution mode + * @returns The activation configuration, or undefined if not found + */ +export function getActivationConfig(mode: ExecutionMode): WorkflowActivationConfig | undefined { + return MODE_WORKFLOW_MAP[mode]; +} + +/** + * Get all modes that map to a specific workflow type + * + * @param workflowType - The workflow type to filter by + * @returns Array of execution modes that use this workflow + */ +export function getModesForWorkflow(workflowType: WorkflowType): ExecutionMode[] { + return (Object.entries(MODE_WORKFLOW_MAP) as [ExecutionMode, WorkflowActivationConfig][]) + .filter(([, config]) => config.workflowType === workflowType) + .map(([mode]) => mode); +} + +/** + * Check if a mode supports parallel execution + * + * @param mode - The execution mode + * @returns true if the mode supports parallel execution + */ +export function isParallelMode(mode: ExecutionMode): boolean { + const config = MODE_WORKFLOW_MAP[mode]; + return config?.supportsParallel ?? false; +} + +/** + * Get the maximum concurrent tasks for a parallel mode + * + * @param mode - The execution mode + * @returns Maximum concurrent tasks, or 1 if not a parallel mode + */ +export function getMaxConcurrentTasks(mode: ExecutionMode): number { + const config = MODE_WORKFLOW_MAP[mode]; + if (!config?.supportsParallel) { + return 1; + } + return config.maxConcurrentTasks ?? 3; +} + +/** + * Validate that required context is present for mode activation + * + * @param mode - The execution mode + * @param context - The activation context + * @returns Object with valid flag and missing keys if any + */ +export function validateActivationContext( + mode: ExecutionMode, + context: WorkflowActivationContext +): { valid: boolean; missingKeys: string[] } { + const config = MODE_WORKFLOW_MAP[mode]; + const requiredKeys = config?.requiredContext ?? []; + const missingKeys: string[] = []; + + for (const key of requiredKeys) { + if (key === 'prompt' && !context.prompt) { + missingKeys.push(key); + } else if (key === 'sessionId' && !context.sessionId) { + missingKeys.push(key); + } else if (key === 'projectPath' && !context.projectPath) { + missingKeys.push(key); + } else if (key.startsWith('metadata.') && context.metadata) { + const metaKey = key.substring('metadata.'.length); + if (!(metaKey in context.metadata)) { + missingKeys.push(key); + } + } + } + + return { + valid: missingKeys.length === 0, + missingKeys + }; +} + +/** + * Activate a workflow for a given mode + * + * This function creates the necessary session state and returns + * activation result. The actual workflow execution is handled + * by the respective workflow handlers. + * + * @param mode - The execution mode to activate + * @param context - The activation context + * @returns Promise resolving to activation result + */ +export async function activateWorkflowForMode( + mode: ExecutionMode, + context: WorkflowActivationContext +): Promise { + const config = MODE_WORKFLOW_MAP[mode]; + const modeConfig = MODE_CONFIGS[mode]; + + // Validate mode exists + if (!config) { + return { + success: false, + sessionId: context.sessionId, + workflowType: 'unified-execute-with-file', // Default fallback + error: `Unknown mode: ${mode}` + }; + } + + // Validate required context + const validation = validateActivationContext(mode, context); + if (!validation.valid) { + return { + success: false, + sessionId: context.sessionId, + workflowType: config.workflowType, + error: `Missing required context: ${validation.missingKeys.join(', ')}` + }; + } + + // Validate session ID + if (!context.sessionId) { + return { + success: false, + sessionId: '', + workflowType: config.workflowType, + error: 'Session ID is required for workflow activation' + }; + } + + // Return success result + // Note: Actual session state persistence is handled by ModeRegistryService + return { + success: true, + sessionId: context.sessionId, + workflowType: config.workflowType + }; +} + +/** + * Get a human-readable description for a mode's workflow + * + * @param mode - The execution mode + * @returns Description string + */ +export function getWorkflowDescription(mode: ExecutionMode): string { + const config = MODE_WORKFLOW_MAP[mode]; + const modeConfig = MODE_CONFIGS[mode]; + return config?.description ?? modeConfig?.description ?? `Workflow for ${mode} mode`; +} + +/** + * List all available mode-to-workflow mappings + * + * @returns Array of mode-workflow mapping entries + */ +export function listModeWorkflowMappings(): Array<{ + mode: ExecutionMode; + workflowType: WorkflowType; + description: string; + supportsParallel: boolean; +}> { + return (Object.entries(MODE_WORKFLOW_MAP) as [ExecutionMode, WorkflowActivationConfig][]) + .map(([mode, config]) => ({ + mode, + workflowType: config.workflowType, + description: config.description, + supportsParallel: config.supportsParallel + })); +} diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts index e7e9a406..04b6b1cb 100644 --- a/ccw/src/core/routes/hooks-routes.ts +++ b/ccw/src/core/routes/hooks-routes.ts @@ -1,6 +1,26 @@ /** * Hooks Routes Module * Handles all hooks-related API endpoints + * + * ## API Endpoints + * + * ### Active Endpoints + * - POST /api/hook - Main hook endpoint for Claude Code notifications + * - Handles: session-start, context, CLI events, A2UI surfaces + * - POST /api/hook/ccw-exec - Execute CCW CLI commands and parse output + * - GET /api/hooks - Get hooks configuration from global and project settings + * - POST /api/hooks - Save a hook to settings + * - DELETE /api/hooks - Delete a hook from settings + * + * ### Deprecated Endpoints (will be removed in v2.0.0) + * - POST /api/hook/session-context - Use `ccw hook session-context --stdin` instead + * - POST /api/hook/ccw-status - Use /api/hook/ccw-exec with command=parse-status + * + * ## Service Layer + * All endpoints use unified services: + * - HookContextService: Context generation for session-start and per-prompt hooks + * - SessionStateService: Session state tracking and persistence + * - SessionEndService: Background task management for session-end events */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; @@ -235,26 +255,27 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise { + // Log deprecation warning + console.warn('[DEPRECATED] /api/hook/session-context is deprecated. Use "ccw hook session-context --stdin" instead.'); + const { sessionId, prompt } = body as { sessionId?: string; prompt?: string }; if (!sessionId) { return { success: true, content: '', - error: 'sessionId is required' + error: 'sessionId is required', + _deprecated: true, + _migration: 'Use "ccw hook session-context --stdin"' }; } try { const projectPath = url.searchParams.get('path') || initialPath; - const { SessionClusteringService } = await import('../session-clustering-service.js'); - const clusteringService = new SessionClusteringService(projectPath); - // Use file-based session state (shared with CLI hook.ts) - const sessionStateDir = join(homedir(), '.claude', '.ccw-sessions'); - const sessionStateFile = join(sessionStateDir, `session-${sessionId}.json`); - - let existingState: { firstLoad: string; loadCount: number; lastPrompt?: string } | null = null; - if (existsSync(sessionStateFile)) { - try { - existingState = JSON.parse(readFileSync(sessionStateFile, 'utf-8')); - } catch { - existingState = null; - } - } - - const isFirstPrompt = !existingState; + // Use HookContextService for unified context generation + const { HookContextService } = await import('../services/hook-context-service.js'); + const contextService = new HookContextService({ projectPath }); - // Update session state (file-based) - const newState = isFirstPrompt - ? { firstLoad: new Date().toISOString(), loadCount: 1, lastPrompt: prompt } - : { ...existingState!, loadCount: existingState!.loadCount + 1, lastPrompt: prompt }; - - if (!existsSync(sessionStateDir)) { - mkdirSync(sessionStateDir, { recursive: true }); - } - writeFileSync(sessionStateFile, JSON.stringify(newState, null, 2)); - - // Determine which type of context to return - let contextType: 'session-start' | 'context'; - let content: string; - - if (isFirstPrompt) { - // First prompt: return session overview with clusters - contextType = 'session-start'; - content = await clusteringService.getProgressiveIndex({ - type: 'session-start', - sessionId - }); - } else if (prompt && prompt.trim().length > 0) { - // Subsequent prompts with content: return intent-matched sessions - contextType = 'context'; - content = await clusteringService.getProgressiveIndex({ - type: 'context', - sessionId, - prompt - }); - } else { - // Subsequent prompts without content: return minimal context - contextType = 'context'; - content = ''; // No context needed for empty prompts - } + // Build context using the service + const result = await contextService.buildPromptContext({ + sessionId, + prompt, + projectId: projectPath + }); return { success: true, - type: contextType, - isFirstPrompt, - loadCount: newState.loadCount, - content, - sessionId + type: result.type, + isFirstPrompt: result.isFirstPrompt, + loadCount: result.state.loadCount, + content: result.content, + sessionId, + _deprecated: true, + _migration: 'Use "ccw hook session-context --stdin"' }; } catch (error) { console.error('[Hooks] Failed to generate session context:', error); @@ -421,7 +414,9 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise { if (typeof body !== 'object' || body === null) { return { error: 'Invalid request body', status: 400 }; @@ -487,51 +490,30 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise cmd.status === 'running')?.command; - if (!currentCommand) { - const completed = status.command_chain?.filter((cmd: { status: string }) => cmd.status === 'completed'); - currentCommand = completed?.[completed.length - 1]?.command || 'unknown'; - } - - // Find next command (first pending) - const nextCommand = status.command_chain?.find((cmd: { status: string }) => cmd.status === 'pending')?.command || '无'; - - // Format status message - const message = `📋 CCW Status [${sessionId}] (${workflow}): 当前处于 ${currentCommand},下一个命令 ${nextCommand}`; - - return { - success: true, - message, - sessionId, - workflow, - currentCommand, - nextCommand - }; } catch (error) { console.error('[Hooks] Failed to parse CCW status:', error); return { success: false, - error: (error as Error).message + error: (error as Error).message, + _deprecated: true }; } }); diff --git a/ccw/src/core/services/checkpoint-service.ts b/ccw/src/core/services/checkpoint-service.ts new file mode 100644 index 00000000..45274ba7 --- /dev/null +++ b/ccw/src/core/services/checkpoint-service.ts @@ -0,0 +1,565 @@ +/** + * CheckpointService - Session Checkpoint Management + * + * Creates and manages session checkpoints for state preservation during + * context compaction and workflow transitions. + * + * Features: + * - Checkpoint creation with workflow and mode state + * - Checkpoint storage in .workflow/checkpoints/ + * - Automatic cleanup of old checkpoints (keeps last 10) + * - Recovery message formatting for context injection + * + * Based on oh-my-claudecode pre-compact pattern. + */ + +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, + unlinkSync +} from 'fs'; +import { join, basename } from 'path'; +import { ExecutionMode, MODE_CONFIGS } from './mode-registry-service.js'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Checkpoint trigger type + */ +export type CheckpointTrigger = 'manual' | 'auto' | 'compact' | 'mode-switch' | 'session-end'; + +/** + * Workflow state snapshot + */ +export interface WorkflowStateSnapshot { + /** Workflow type identifier */ + type: string; + /** Current phase of the workflow */ + phase: string; + /** Task IDs in pending state */ + pending: string[]; + /** Task IDs in completed state */ + completed: string[]; + /** Additional workflow metadata */ + metadata?: Record; +} + +/** + * Mode state snapshot for a single mode + */ +export interface ModeStateSnapshot { + /** Whether the mode is active */ + active: boolean; + /** Mode-specific phase or stage */ + phase?: string; + /** ISO timestamp when mode was activated */ + activatedAt?: string; + /** Additional mode-specific data */ + data?: Record; +} + +/** + * Memory context snapshot + */ +export interface MemoryContextSnapshot { + /** Brief summary of accumulated context */ + summary: string; + /** Key entities identified in the session */ + keyEntities: string[]; + /** Important decisions made */ + decisions?: string[]; + /** Open questions or blockers */ + openQuestions?: string[]; +} + +/** + * Full checkpoint data structure + */ +export interface Checkpoint { + /** Unique checkpoint ID (timestamp-sessionId) */ + id: string; + /** ISO timestamp of checkpoint creation */ + created_at: string; + /** What triggered the checkpoint */ + trigger: CheckpointTrigger; + /** Session ID this checkpoint belongs to */ + session_id: string; + /** Project path */ + project_path: string; + /** Workflow state snapshot */ + workflow_state: WorkflowStateSnapshot | null; + /** Active mode states */ + mode_states: Partial>; + /** Memory context summary */ + memory_context: MemoryContextSnapshot | null; + /** TODO summary if available */ + todo_summary?: { + pending: number; + in_progress: number; + completed: number; + }; +} + +/** + * Checkpoint metadata for listing + */ +export interface CheckpointMeta { + /** Checkpoint ID */ + id: string; + /** Creation timestamp */ + created_at: string; + /** Session ID */ + session_id: string; + /** Trigger type */ + trigger: CheckpointTrigger; + /** File path */ + path: string; +} + +/** + * Options for checkpoint service + */ +export interface CheckpointServiceOptions { + /** Project root path */ + projectPath: string; + /** Maximum checkpoints to keep per session (default: 10) */ + maxCheckpointsPerSession?: number; + /** Enable logging */ + enableLogging?: boolean; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** Default maximum checkpoints to keep per session */ +const DEFAULT_MAX_CHECKPOINTS = 10; + +/** Checkpoint directory name within .workflow */ +const CHECKPOINT_DIR_NAME = 'checkpoints'; + +// ============================================================================= +// CheckpointService Class +// ============================================================================= + +/** + * Service for managing session checkpoints + */ +export class CheckpointService { + private projectPath: string; + private checkpointsDir: string; + private maxCheckpoints: number; + private enableLogging: boolean; + + constructor(options: CheckpointServiceOptions) { + this.projectPath = options.projectPath; + this.checkpointsDir = join(this.projectPath, '.workflow', CHECKPOINT_DIR_NAME); + this.maxCheckpoints = options.maxCheckpointsPerSession ?? DEFAULT_MAX_CHECKPOINTS; + this.enableLogging = options.enableLogging ?? false; + } + + // --------------------------------------------------------------------------- + // Public: Checkpoint Creation + // --------------------------------------------------------------------------- + + /** + * Create a checkpoint for a session + * + * @param sessionId - The session ID + * @param trigger - What triggered the checkpoint + * @param options - Optional additional data + * @returns Promise resolving to the created checkpoint + */ + async createCheckpoint( + sessionId: string, + trigger: CheckpointTrigger, + options?: { + workflowState?: WorkflowStateSnapshot | null; + modeStates?: Partial>; + memoryContext?: MemoryContextSnapshot | null; + todoSummary?: { pending: number; in_progress: number; completed: number }; + } + ): Promise { + const timestamp = new Date().toISOString(); + const checkpointId = this.generateCheckpointId(sessionId, timestamp); + + const checkpoint: Checkpoint = { + id: checkpointId, + created_at: timestamp, + trigger, + session_id: sessionId, + project_path: this.projectPath, + workflow_state: options?.workflowState ?? null, + mode_states: options?.modeStates ?? {}, + memory_context: options?.memoryContext ?? null, + todo_summary: options?.todoSummary + }; + + this.log(`Created checkpoint ${checkpointId} for session ${sessionId} (trigger: ${trigger})`); + return checkpoint; + } + + /** + * Save a checkpoint to disk + * + * @param checkpoint - The checkpoint to save + * @returns The checkpoint ID + */ + async saveCheckpoint(checkpoint: Checkpoint): Promise { + this.ensureCheckpointsDir(); + + const filename = `${checkpoint.id}.json`; + const filepath = join(this.checkpointsDir, filename); + + try { + writeFileSync(filepath, JSON.stringify(checkpoint, null, 2), 'utf-8'); + this.log(`Saved checkpoint to ${filepath}`); + + // Clean up old checkpoints for this session + await this.cleanupOldCheckpoints(checkpoint.session_id); + + return checkpoint.id; + } catch (error) { + this.log(`Error saving checkpoint: ${(error as Error).message}`); + throw error; + } + } + + /** + * Load a checkpoint from disk + * + * @param checkpointId - The checkpoint ID to load + * @returns The checkpoint or null if not found + */ + async loadCheckpoint(checkpointId: string): Promise { + const filepath = join(this.checkpointsDir, `${checkpointId}.json`); + + if (!existsSync(filepath)) { + this.log(`Checkpoint not found: ${checkpointId}`); + return null; + } + + try { + const content = readFileSync(filepath, 'utf-8'); + const checkpoint = JSON.parse(content) as Checkpoint; + this.log(`Loaded checkpoint ${checkpointId}`); + return checkpoint; + } catch (error) { + this.log(`Error loading checkpoint ${checkpointId}: ${(error as Error).message}`); + return null; + } + } + + // --------------------------------------------------------------------------- + // Public: Checkpoint Listing + // --------------------------------------------------------------------------- + + /** + * List all checkpoints, optionally filtered by session + * + * @param sessionId - Optional session ID to filter by + * @returns Array of checkpoint metadata + */ + async listCheckpoints(sessionId?: string): Promise { + if (!existsSync(this.checkpointsDir)) { + return []; + } + + try { + const files = readdirSync(this.checkpointsDir) + .filter(f => f.endsWith('.json')) + .map(f => join(this.checkpointsDir, f)); + + const checkpoints: CheckpointMeta[] = []; + + for (const filepath of files) { + try { + const content = readFileSync(filepath, 'utf-8'); + const checkpoint = JSON.parse(content) as Checkpoint; + + // Filter by session if provided + if (sessionId && checkpoint.session_id !== sessionId) { + continue; + } + + checkpoints.push({ + id: checkpoint.id, + created_at: checkpoint.created_at, + session_id: checkpoint.session_id, + trigger: checkpoint.trigger, + path: filepath + }); + } catch { + // Skip invalid checkpoint files + } + } + + // Sort by creation time (newest first) + checkpoints.sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + + return checkpoints; + } catch (error) { + this.log(`Error listing checkpoints: ${(error as Error).message}`); + return []; + } + } + + /** + * Get the most recent checkpoint for a session + * + * @param sessionId - The session ID + * @returns The most recent checkpoint or null + */ + async getLatestCheckpoint(sessionId: string): Promise { + const checkpoints = await this.listCheckpoints(sessionId); + if (checkpoints.length === 0) { + return null; + } + + return this.loadCheckpoint(checkpoints[0].id); + } + + // --------------------------------------------------------------------------- + // Public: Recovery Message Formatting + // --------------------------------------------------------------------------- + + /** + * Format a checkpoint as a recovery message for context injection + * + * @param checkpoint - The checkpoint to format + * @returns Formatted markdown string + */ + formatRecoveryMessage(checkpoint: Checkpoint): string { + const lines: string[] = [ + '# Session Checkpoint Recovery', + '', + `**Checkpoint ID:** ${checkpoint.id}`, + `**Created:** ${checkpoint.created_at}`, + `**Trigger:** ${checkpoint.trigger}`, + `**Session:** ${checkpoint.session_id}`, + '' + ]; + + // Workflow state section + if (checkpoint.workflow_state) { + const ws = checkpoint.workflow_state; + lines.push('## Workflow State'); + lines.push(''); + lines.push(`- **Type:** ${ws.type}`); + lines.push(`- **Phase:** ${ws.phase}`); + if (ws.pending.length > 0) { + lines.push(`- **Pending Tasks:** ${ws.pending.length}`); + } + if (ws.completed.length > 0) { + lines.push(`- **Completed Tasks:** ${ws.completed.length}`); + } + lines.push(''); + } + + // Active modes section + const activeModes = Object.entries(checkpoint.mode_states) + .filter(([, state]) => state.active); + + if (activeModes.length > 0) { + lines.push('## Active Modes'); + lines.push(''); + + for (const [mode, state] of activeModes) { + const modeConfig = MODE_CONFIGS[mode as ExecutionMode]; + const modeName = modeConfig?.name ?? mode; + lines.push(`- **${modeName}**`); + if (state.phase) { + lines.push(` - Phase: ${state.phase}`); + } + if (state.activatedAt) { + const age = Math.round( + (Date.now() - new Date(state.activatedAt).getTime()) / 60000 + ); + lines.push(` - Active for: ${age} minutes`); + } + } + lines.push(''); + } + + // TODO summary section + if (checkpoint.todo_summary) { + const todo = checkpoint.todo_summary; + const total = todo.pending + todo.in_progress + todo.completed; + + if (total > 0) { + lines.push('## TODO Summary'); + lines.push(''); + lines.push(`- Pending: ${todo.pending}`); + lines.push(`- In Progress: ${todo.in_progress}`); + lines.push(`- Completed: ${todo.completed}`); + lines.push(''); + } + } + + // Memory context section + if (checkpoint.memory_context) { + const mem = checkpoint.memory_context; + lines.push('## Context Memory'); + lines.push(''); + + if (mem.summary) { + lines.push(mem.summary); + lines.push(''); + } + + if (mem.keyEntities.length > 0) { + lines.push(`**Key Entities:** ${mem.keyEntities.join(', ')}`); + lines.push(''); + } + + if (mem.decisions && mem.decisions.length > 0) { + lines.push('**Decisions Made:**'); + for (const decision of mem.decisions) { + lines.push(`- ${decision}`); + } + lines.push(''); + } + + if (mem.openQuestions && mem.openQuestions.length > 0) { + lines.push('**Open Questions:**'); + for (const question of mem.openQuestions) { + lines.push(`- ${question}`); + } + lines.push(''); + } + } + + // Recovery instructions + lines.push('---'); + lines.push(''); + lines.push('*This checkpoint was created to preserve session state.*'); + lines.push('*Review the information above to resume work effectively.*'); + + return lines.join('\n'); + } + + // --------------------------------------------------------------------------- + // Public: Cleanup + // --------------------------------------------------------------------------- + + /** + * Delete a specific checkpoint + * + * @param checkpointId - The checkpoint ID to delete + * @returns true if deleted successfully + */ + async deleteCheckpoint(checkpointId: string): Promise { + const filepath = join(this.checkpointsDir, `${checkpointId}.json`); + + if (!existsSync(filepath)) { + return false; + } + + try { + unlinkSync(filepath); + this.log(`Deleted checkpoint ${checkpointId}`); + return true; + } catch (error) { + this.log(`Error deleting checkpoint ${checkpointId}: ${(error as Error).message}`); + return false; + } + } + + /** + * Clean up old checkpoints for a session, keeping only the most recent + * + * @param sessionId - The session ID + * @returns Number of checkpoints removed + */ + async cleanupOldCheckpoints(sessionId: string): Promise { + const checkpoints = await this.listCheckpoints(sessionId); + + if (checkpoints.length <= this.maxCheckpoints) { + return 0; + } + + // Remove oldest checkpoints (those beyond the limit) + const toRemove = checkpoints.slice(this.maxCheckpoints); + let removed = 0; + + for (const meta of toRemove) { + if (await this.deleteCheckpoint(meta.id)) { + removed++; + } + } + + this.log(`Cleaned up ${removed} old checkpoints for session ${sessionId}`); + return removed; + } + + // --------------------------------------------------------------------------- + // Public: Utility + // --------------------------------------------------------------------------- + + /** + * Get the checkpoints directory path + */ + getCheckpointsDir(): string { + return this.checkpointsDir; + } + + /** + * Ensure the checkpoints directory exists + */ + ensureCheckpointsDir(): void { + if (!existsSync(this.checkpointsDir)) { + mkdirSync(this.checkpointsDir, { recursive: true }); + } + } + + // --------------------------------------------------------------------------- + // Private: Helper Methods + // --------------------------------------------------------------------------- + + /** + * Generate a unique checkpoint ID + */ + private generateCheckpointId(sessionId: string, timestamp: string): string { + // Format: YYYY-MM-DDTHH-mm-ss-sessionId + const safeTimestamp = timestamp.replace(/[:.]/g, '-').substring(0, 19); + return `${safeTimestamp}-${sessionId.substring(0, 8)}`; + } + + /** + * Log a message if logging is enabled + */ + private log(message: string): void { + if (this.enableLogging) { + console.log(`[CheckpointService] ${message}`); + } + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a CheckpointService instance + * + * @param projectPath - Project root path + * @param options - Optional configuration + * @returns CheckpointService instance + */ +export function createCheckpointService( + projectPath: string, + options?: Partial +): CheckpointService { + return new CheckpointService({ + projectPath, + ...options + }); +} diff --git a/ccw/src/core/services/hook-context-service.ts b/ccw/src/core/services/hook-context-service.ts new file mode 100644 index 00000000..13fe477a --- /dev/null +++ b/ccw/src/core/services/hook-context-service.ts @@ -0,0 +1,336 @@ +/** + * HookContextService - Unified context generation for Claude Code hooks + * + * Provides centralized context generation for: + * - session-start: MEMORY.md summary + cluster overview + hot entities + patterns + * - per-prompt: vector search + intent matching + * - session-end: task generation for async background processing + * + * Character limits: + * - session-start: <= 1000 chars + * - per-prompt: <= 500 chars + */ + +import type { SessionEndTask } from '../unified-context-builder.js'; +import { SessionStateService, type SessionState } from './session-state-service.js'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Maximum character count for session-start context */ +const SESSION_START_LIMIT = 1000; + +/** Maximum character count for per-prompt context */ +const PER_PROMPT_LIMIT = 500; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Options for building context + */ +export interface BuildContextOptions { + /** Session ID for state tracking */ + sessionId: string; + /** Project root path */ + projectId?: string; + /** Whether this is the first prompt in the session */ + isFirstPrompt?: boolean; + /** Character limit for the generated context */ + charLimit?: number; + /** Current prompt text (for per-prompt context) */ + prompt?: string; +} + +/** + * Context generation result + */ +export interface ContextResult { + /** Generated context content */ + content: string; + /** Type of context generated */ + type: 'session-start' | 'context'; + /** Whether this was the first prompt */ + isFirstPrompt: boolean; + /** Updated session state */ + state: SessionState; + /** Character count of generated content */ + charCount: number; +} + +/** + * Options for HookContextService + */ +export interface HookContextServiceOptions { + /** Project root path */ + projectPath: string; + /** Storage type for session state */ + storageType?: 'global' | 'session-scoped'; +} + +// ============================================================================= +// HookContextService +// ============================================================================= + +/** + * Service for generating hook context + * + * This service wraps UnifiedContextBuilder and SessionStateService to provide + * a unified interface for context generation across CLI hooks and API routes. + */ +export class HookContextService { + private projectPath: string; + private sessionStateService: SessionStateService; + private unifiedContextBuilder: InstanceType | null = null; + private clusteringService: InstanceType | null = null; + private initialized = false; + + constructor(options: HookContextServiceOptions) { + this.projectPath = options.projectPath; + this.sessionStateService = new SessionStateService({ + storageType: options.storageType, + projectPath: options.projectPath + }); + } + + /** + * Initialize lazy-loaded services + */ + private async initialize(): Promise { + if (this.initialized) return; + + try { + // Try to load UnifiedContextBuilder (requires embedder) + const { isUnifiedEmbedderAvailable } = await import('../unified-vector-index.js'); + if (isUnifiedEmbedderAvailable()) { + const { UnifiedContextBuilder } = await import('../unified-context-builder.js'); + this.unifiedContextBuilder = new UnifiedContextBuilder(this.projectPath); + } + } catch { + // UnifiedContextBuilder not available + } + + try { + // Always load SessionClusteringService as fallback + const { SessionClusteringService } = await import('../session-clustering-service.js'); + this.clusteringService = new SessionClusteringService(this.projectPath); + } catch { + // SessionClusteringService not available + } + + this.initialized = true; + } + + // --------------------------------------------------------------------------- + // Public: Context Generation + // --------------------------------------------------------------------------- + + /** + * Build context for session-start hook + * + * @param options - Build context options + * @returns Context generation result + */ + async buildSessionStartContext(options: BuildContextOptions): Promise { + await this.initialize(); + + const charLimit = options.charLimit ?? SESSION_START_LIMIT; + + // Update session state + const { isFirstPrompt, state } = this.sessionStateService.incrementLoad( + options.sessionId, + options.prompt + ); + + let content = ''; + + // Try UnifiedContextBuilder first + if (this.unifiedContextBuilder) { + content = await this.unifiedContextBuilder.buildSessionStartContext(); + } else if (this.clusteringService) { + // Fallback to SessionClusteringService + content = await this.clusteringService.getProgressiveIndex({ + type: 'session-start', + sessionId: options.sessionId + }); + } + + // Truncate if needed + if (content.length > charLimit) { + content = content.substring(0, charLimit - 20) + '...'; + } + + return { + content, + type: 'session-start', + isFirstPrompt, + state, + charCount: content.length + }; + } + + /** + * Build context for per-prompt hook + * + * @param options - Build context options + * @returns Context generation result + */ + async buildPromptContext(options: BuildContextOptions): Promise { + await this.initialize(); + + const charLimit = options.charLimit ?? PER_PROMPT_LIMIT; + + // Update session state + const { isFirstPrompt, state } = this.sessionStateService.incrementLoad( + options.sessionId, + options.prompt + ); + + let content = ''; + let contextType: 'session-start' | 'context' = 'context'; + + // First prompt uses session-start context + if (isFirstPrompt) { + contextType = 'session-start'; + if (this.unifiedContextBuilder) { + content = await this.unifiedContextBuilder.buildSessionStartContext(); + } else if (this.clusteringService) { + content = await this.clusteringService.getProgressiveIndex({ + type: 'session-start', + sessionId: options.sessionId + }); + } + } else if (options.prompt && options.prompt.trim().length > 0) { + // Subsequent prompts use per-prompt context + contextType = 'context'; + if (this.unifiedContextBuilder) { + content = await this.unifiedContextBuilder.buildPromptContext(options.prompt); + } else if (this.clusteringService) { + content = await this.clusteringService.getProgressiveIndex({ + type: 'context', + sessionId: options.sessionId, + prompt: options.prompt + }); + } + } + + // Truncate if needed + if (content.length > charLimit) { + content = content.substring(0, charLimit - 20) + '...'; + } + + return { + content, + type: contextType, + isFirstPrompt, + state, + charCount: content.length + }; + } + + // --------------------------------------------------------------------------- + // Public: Session End Tasks + // --------------------------------------------------------------------------- + + /** + * Build session end tasks for async background processing + * + * @param sessionId - Session ID for context + * @returns Array of tasks to execute + */ + async buildSessionEndTasks(sessionId: string): Promise { + await this.initialize(); + + if (this.unifiedContextBuilder) { + return this.unifiedContextBuilder.buildSessionEndTasks(sessionId); + } + + // No tasks available without UnifiedContextBuilder + return []; + } + + // --------------------------------------------------------------------------- + // Public: Session State Management + // --------------------------------------------------------------------------- + + /** + * Get session state + * + * @param sessionId - Session ID + * @returns Session state or null if not found + */ + getSessionState(sessionId: string): SessionState | null { + return this.sessionStateService.load(sessionId); + } + + /** + * Check if this is the first prompt for a session + * + * @param sessionId - Session ID + * @returns true if this is the first prompt + */ + isFirstPrompt(sessionId: string): boolean { + return this.sessionStateService.isFirstLoad(sessionId); + } + + /** + * Get load count for a session + * + * @param sessionId - Session ID + * @returns Load count (0 if not found) + */ + getLoadCount(sessionId: string): number { + return this.sessionStateService.getLoadCount(sessionId); + } + + /** + * Clear session state + * + * @param sessionId - Session ID + * @returns true if state was cleared + */ + clearSessionState(sessionId: string): boolean { + return this.sessionStateService.clear(sessionId); + } + + // --------------------------------------------------------------------------- + // Public: Utility Methods + // --------------------------------------------------------------------------- + + /** + * Check if UnifiedContextBuilder is available + * + * @returns true if embedder is available + */ + async isAdvancedContextAvailable(): Promise { + await this.initialize(); + return this.unifiedContextBuilder !== null; + } + + /** + * Get the project path + */ + getProjectPath(): string { + return this.projectPath; + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a HookContextService instance + * + * @param projectPath - Project root path + * @param storageType - Storage type for session state + * @returns HookContextService instance + */ +export function createHookContextService( + projectPath: string, + storageType?: 'global' | 'session-scoped' +): HookContextService { + return new HookContextService({ projectPath, storageType }); +} diff --git a/ccw/src/core/services/index.ts b/ccw/src/core/services/index.ts new file mode 100644 index 00000000..5051665a --- /dev/null +++ b/ccw/src/core/services/index.ts @@ -0,0 +1,75 @@ +/** + * Core Services Exports + * + * Central export point for all CCW core services. + */ + +// Session State Management +export { + SessionStateService, + loadSessionState, + saveSessionState, + clearSessionState, + updateSessionState, + incrementSessionLoad, + getSessionStatePath, + validateSessionId +} from './session-state-service.js'; +export type { + SessionState, + SessionStateOptions, + SessionStorageType +} from './session-state-service.js'; + +// Hook Context Service +export { HookContextService } from './hook-context-service.js'; +export type { BuildContextOptions, ContextResult } from './hook-context-service.js'; + +// Session End Service +export { SessionEndService } from './session-end-service.js'; +export type { EndTask, TaskResult } from './session-end-service.js'; + +// Mode Registry Service +export { + ModeRegistryService, + MODE_CONFIGS, + EXCLUSIVE_MODES, + STALE_MARKER_THRESHOLD, + createModeRegistryService +} from './mode-registry-service.js'; +export type { + ModeConfig, + ModeStatus, + ModeRegistryOptions, + CanStartResult, + ExecutionMode +} from './mode-registry-service.js'; + +// Checkpoint Service +export { CheckpointService, createCheckpointService } from './checkpoint-service.js'; +export type { + CheckpointServiceOptions, + Checkpoint, + CheckpointMeta, + CheckpointTrigger, + WorkflowStateSnapshot, + ModeStateSnapshot, + MemoryContextSnapshot +} from './checkpoint-service.js'; + +// CLI Session Manager +export { CliSessionManager } from './cli-session-manager.js'; +export type { + CliSession, + CreateCliSessionOptions, + ExecuteInCliSessionOptions, + CliSessionOutputEvent +} from './cli-session-manager.js'; + +// Flow Executor +export { FlowExecutor } from './flow-executor.js'; +export type { ExecutionContext, NodeResult } from './flow-executor.js'; + +// CLI Launch Registry +export { getLaunchConfig } from './cli-launch-registry.js'; +export type { CliLaunchConfig, CliTool, LaunchMode } from './cli-launch-registry.js'; diff --git a/ccw/src/core/services/mode-registry-service.ts b/ccw/src/core/services/mode-registry-service.ts new file mode 100644 index 00000000..c2e11ebd --- /dev/null +++ b/ccw/src/core/services/mode-registry-service.ts @@ -0,0 +1,730 @@ +/** + * ModeRegistryService - Centralized Mode State Management + * + * Provides unified mode state detection and management for CCW. + * All modes store state in `.workflow/modes/` directory for consistency. + * + * Features: + * - Mode activation/deactivation tracking + * - Exclusive mode conflict detection + * - Stale marker cleanup (1 hour threshold) + * - File-based state persistence + * + * Based on oh-my-claudecode mode-registry pattern. + */ + +import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, readdirSync, statSync, rmSync } from 'fs'; +import { join, dirname } from 'path'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Supported execution modes + */ +export type ExecutionMode = + | 'autopilot' + | 'ralph' + | 'ultrawork' + | 'swarm' + | 'pipeline' + | 'team' + | 'ultraqa'; + +/** + * Mode configuration + */ +export interface ModeConfig { + /** Display name for the mode */ + name: string; + /** Primary state file path (relative to .workflow/modes/) */ + stateFile: string; + /** Property to check in JSON state for active status */ + activeProperty: string; + /** Whether this mode is mutually exclusive with other exclusive modes */ + exclusive?: boolean; + /** Description of the mode */ + description?: string; +} + +/** + * Status of a mode + */ +export interface ModeStatus { + /** The mode identifier */ + mode: ExecutionMode; + /** Whether the mode is currently active */ + active: boolean; + /** Path to the state file */ + stateFilePath: string; + /** Session ID if session-scoped */ + sessionId?: string; +} + +/** + * Result of checking if a mode can be started + */ +export interface CanStartResult { + /** Whether the mode can be started */ + allowed: boolean; + /** The mode that is blocking (if not allowed) */ + blockedBy?: ExecutionMode; + /** Human-readable message */ + message?: string; +} + +/** + * Options for mode registry operations + */ +export interface ModeRegistryOptions { + /** Project root path */ + projectPath: string; + /** Enable logging */ + enableLogging?: boolean; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Stale marker threshold (1 hour) + * Markers older than this are auto-removed to prevent crashed sessions + * from blocking indefinitely. + */ +const STALE_MARKER_THRESHOLD = 60 * 60 * 1000; // 1 hour in milliseconds + +/** + * Mode configuration registry + * + * Maps each mode to its state file location and detection method. + * All paths are relative to .workflow/modes/ directory. + */ +const MODE_CONFIGS: Record = { + autopilot: { + name: 'Autopilot', + stateFile: 'autopilot-state.json', + activeProperty: 'active', + exclusive: true, + description: 'Autonomous execution mode for multi-step tasks' + }, + ralph: { + name: 'Ralph', + stateFile: 'ralph-state.json', + activeProperty: 'active', + exclusive: false, + description: 'Research and Analysis Learning Pattern Handler' + }, + ultrawork: { + name: 'Ultrawork', + stateFile: 'ultrawork-state.json', + activeProperty: 'active', + exclusive: false, + description: 'Ultra-focused work mode for deep tasks' + }, + swarm: { + name: 'Swarm', + stateFile: 'swarm-state.json', + activeProperty: 'active', + exclusive: true, + description: 'Multi-agent swarm execution mode' + }, + pipeline: { + name: 'Pipeline', + stateFile: 'pipeline-state.json', + activeProperty: 'active', + exclusive: true, + description: 'Pipeline execution mode for sequential tasks' + }, + team: { + name: 'Team', + stateFile: 'team-state.json', + activeProperty: 'active', + exclusive: false, + description: 'Team collaboration mode' + }, + ultraqa: { + name: 'UltraQA', + stateFile: 'ultraqa-state.json', + activeProperty: 'active', + exclusive: false, + description: 'Ultra-focused QA mode' + } +}; + +/** + * Modes that are mutually exclusive (cannot run concurrently) + */ +const EXCLUSIVE_MODES: ExecutionMode[] = ['autopilot', 'swarm', 'pipeline']; + +// Export for external use +export { MODE_CONFIGS, EXCLUSIVE_MODES, STALE_MARKER_THRESHOLD }; + +// ============================================================================= +// ModeRegistryService +// ============================================================================= + +/** + * Service for managing mode state + * + * This service provides centralized mode state management using file-based + * persistence. It supports exclusive mode detection and stale marker cleanup. + */ +export class ModeRegistryService { + private projectPath: string; + private enableLogging: boolean; + private modesDir: string; + + constructor(options: ModeRegistryOptions) { + this.projectPath = options.projectPath; + this.enableLogging = options.enableLogging ?? false; + this.modesDir = join(this.projectPath, '.workflow', 'modes'); + } + + // --------------------------------------------------------------------------- + // Public: Directory Management + // --------------------------------------------------------------------------- + + /** + * Get the modes directory path + */ + getModesDir(): string { + return this.modesDir; + } + + /** + * Ensure the modes directory exists + */ + ensureModesDir(): void { + if (!existsSync(this.modesDir)) { + mkdirSync(this.modesDir, { recursive: true }); + } + } + + // --------------------------------------------------------------------------- + // Public: Mode State Queries + // --------------------------------------------------------------------------- + + /** + * Check if a specific mode is currently active + * + * @param mode - The mode to check + * @param sessionId - Optional session ID to check session-scoped state + * @returns true if the mode is active + */ + isModeActive(mode: ExecutionMode, sessionId?: string): boolean { + const config = MODE_CONFIGS[mode]; + + if (sessionId) { + // Check session-scoped path + const sessionStateFile = this.getSessionStatePath(mode, sessionId); + return this.isJsonModeActive(sessionStateFile, config, sessionId); + } + + // Check legacy shared path + const stateFile = this.getStateFilePath(mode); + return this.isJsonModeActive(stateFile, config); + } + + /** + * Check if a mode has state (file exists) + * + * @param mode - The mode to check + * @param sessionId - Optional session ID + * @returns true if state file exists + */ + hasModeState(mode: ExecutionMode, sessionId?: string): boolean { + const stateFile = sessionId + ? this.getSessionStatePath(mode, sessionId) + : this.getStateFilePath(mode); + return existsSync(stateFile); + } + + /** + * Get all active modes + * + * @param sessionId - Optional session ID to check session-scoped state + * @returns Array of active mode identifiers + */ + getActiveModes(sessionId?: string): ExecutionMode[] { + const modes: ExecutionMode[] = []; + + for (const mode of Object.keys(MODE_CONFIGS) as ExecutionMode[]) { + if (this.isModeActive(mode, sessionId)) { + modes.push(mode); + } + } + + return modes; + } + + /** + * Check if any mode is currently active + * + * @param sessionId - Optional session ID + * @returns true if any mode is active + */ + isAnyModeActive(sessionId?: string): boolean { + return this.getActiveModes(sessionId).length > 0; + } + + /** + * Get the currently active exclusive mode (if any) + * + * @returns The active exclusive mode or null + */ + getActiveExclusiveMode(): ExecutionMode | null { + for (const mode of EXCLUSIVE_MODES) { + if (this.isModeActiveInAnySession(mode)) { + return mode; + } + } + return null; + } + + /** + * Get status of all modes + * + * @param sessionId - Optional session ID + * @returns Array of mode statuses + */ + getAllModeStatuses(sessionId?: string): ModeStatus[] { + return (Object.keys(MODE_CONFIGS) as ExecutionMode[]).map(mode => ({ + mode, + active: this.isModeActive(mode, sessionId), + stateFilePath: sessionId + ? this.getSessionStatePath(mode, sessionId) + : this.getStateFilePath(mode), + sessionId + })); + } + + // --------------------------------------------------------------------------- + // Public: Mode Control + // --------------------------------------------------------------------------- + + /** + * Check if a new mode can be started + * + * @param mode - The mode to start + * @param sessionId - Optional session ID + * @returns CanStartResult with allowed status and blocker info + */ + canStartMode(mode: ExecutionMode, sessionId?: string): CanStartResult { + const config = MODE_CONFIGS[mode]; + + // Check for mutually exclusive modes + if (EXCLUSIVE_MODES.includes(mode)) { + for (const exclusiveMode of EXCLUSIVE_MODES) { + if (exclusiveMode !== mode && this.isModeActiveInAnySession(exclusiveMode)) { + const exclusiveConfig = MODE_CONFIGS[exclusiveMode]; + return { + allowed: false, + blockedBy: exclusiveMode, + message: `Cannot start ${config.name} while ${exclusiveConfig.name} is active. Cancel ${exclusiveConfig.name} first.` + }; + } + } + } + + // Check if already active in this session + if (sessionId && this.isModeActive(mode, sessionId)) { + return { + allowed: false, + blockedBy: mode, + message: `${config.name} is already active in this session.` + }; + } + + return { allowed: true }; + } + + /** + * Activate a mode + * + * @param mode - The mode to activate + * @param sessionId - Session ID + * @param context - Optional context to store with state + * @returns true if activation was successful + */ + activateMode(mode: ExecutionMode, sessionId: string, context?: Record): boolean { + const config = MODE_CONFIGS[mode]; + + // Check if can start + const canStart = this.canStartMode(mode, sessionId); + if (!canStart.allowed) { + this.log(`Cannot activate ${config.name}: ${canStart.message}`); + return false; + } + + try { + this.ensureModesDir(); + + const stateFile = this.getSessionStatePath(mode, sessionId); + const stateDir = dirname(stateFile); + if (!existsSync(stateDir)) { + mkdirSync(stateDir, { recursive: true }); + } + + const state = { + [config.activeProperty]: true, + session_id: sessionId, + activatedAt: new Date().toISOString(), + ...context + }; + + writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8'); + this.log(`Activated ${config.name} for session ${sessionId}`); + return true; + } catch (error) { + this.log(`Failed to activate ${config.name}: ${(error as Error).message}`); + return false; + } + } + + /** + * Deactivate a mode + * + * @param mode - The mode to deactivate + * @param sessionId - Session ID + * @returns true if deactivation was successful + */ + deactivateMode(mode: ExecutionMode, sessionId: string): boolean { + const config = MODE_CONFIGS[mode]; + + try { + const stateFile = this.getSessionStatePath(mode, sessionId); + + if (!existsSync(stateFile)) { + return true; // Already inactive + } + + unlinkSync(stateFile); + this.log(`Deactivated ${config.name} for session ${sessionId}`); + return true; + } catch (error) { + this.log(`Failed to deactivate ${config.name}: ${(error as Error).message}`); + return false; + } + } + + /** + * Clear all state for a mode + * + * @param mode - The mode to clear + * @param sessionId - Optional session ID (if provided, only clears session state) + * @returns true if successful + */ + clearModeState(mode: ExecutionMode, sessionId?: string): boolean { + let success = true; + + if (sessionId) { + // Clear session-scoped state only + const sessionStateFile = this.getSessionStatePath(mode, sessionId); + if (existsSync(sessionStateFile)) { + try { + unlinkSync(sessionStateFile); + } catch { + success = false; + } + } + return success; + } + + // Clear all state for this mode + const stateFile = this.getStateFilePath(mode); + if (existsSync(stateFile)) { + try { + unlinkSync(stateFile); + } catch { + success = false; + } + } + + // Also clear session-scoped states + try { + const sessionIds = this.listSessionIds(); + for (const sid of sessionIds) { + const sessionFile = this.getSessionStatePath(mode, sid); + if (existsSync(sessionFile)) { + try { + unlinkSync(sessionFile); + } catch { + success = false; + } + } + } + } catch { + // Ignore errors scanning sessions + } + + return success; + } + + /** + * Clear all mode states (force clear) + * + * @returns true if all states were cleared + */ + clearAllModeStates(): boolean { + let success = true; + + for (const mode of Object.keys(MODE_CONFIGS) as ExecutionMode[]) { + if (!this.clearModeState(mode)) { + success = false; + } + } + + return success; + } + + // --------------------------------------------------------------------------- + // Public: Session Management + // --------------------------------------------------------------------------- + + /** + * Check if a mode is active in any session + * + * @param mode - The mode to check + * @returns true if the mode is active in any session + */ + isModeActiveInAnySession(mode: ExecutionMode): boolean { + // Check legacy path first + if (this.isModeActive(mode)) { + return true; + } + + // Scan all session dirs + const sessionIds = this.listSessionIds(); + for (const sid of sessionIds) { + if (this.isModeActive(mode, sid)) { + return true; + } + } + + return false; + } + + /** + * Get all session IDs that have a specific mode active + * + * @param mode - The mode to check + * @returns Array of session IDs with this mode active + */ + getActiveSessionsForMode(mode: ExecutionMode): string[] { + const sessionIds = this.listSessionIds(); + return sessionIds.filter(sid => this.isModeActive(mode, sid)); + } + + /** + * List all session IDs that have mode state files + * + * @returns Array of session IDs + */ + listSessionIds(): string[] { + const sessionsDir = join(this.modesDir, 'sessions'); + if (!existsSync(sessionsDir)) { + return []; + } + + try { + return readdirSync(sessionsDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + .filter(name => this.isValidSessionId(name)); + } catch { + return []; + } + } + + // --------------------------------------------------------------------------- + // Public: Stale State Cleanup + // --------------------------------------------------------------------------- + + /** + * Clear stale session directories + * + * Removes session directories that have no recent activity. + * + * @param maxAgeMs - Maximum age in milliseconds (default: 24 hours) + * @returns Array of removed session IDs + */ + clearStaleSessionDirs(maxAgeMs: number = 24 * 60 * 60 * 1000): string[] { + const sessionsDir = join(this.modesDir, 'sessions'); + if (!existsSync(sessionsDir)) { + return []; + } + + const removed: string[] = []; + const sessionIds = this.listSessionIds(); + + for (const sid of sessionIds) { + const sessionDir = this.getSessionDir(sid); + try { + const files = readdirSync(sessionDir); + + // Remove empty directories + if (files.length === 0) { + rmSync(sessionDir, { recursive: true, force: true }); + removed.push(sid); + continue; + } + + // Check modification time of any state file + let newest = 0; + for (const f of files) { + const stat = statSync(join(sessionDir, f)); + if (stat.mtimeMs > newest) { + newest = stat.mtimeMs; + } + } + + // Remove if stale + if (Date.now() - newest > maxAgeMs) { + rmSync(sessionDir, { recursive: true, force: true }); + removed.push(sid); + } + } catch { + // Skip on error + } + } + + return removed; + } + + /** + * Clean up stale markers (older than threshold) + * + * @returns Array of cleaned up session IDs + */ + cleanupStaleMarkers(): string[] { + const cleaned: string[] = []; + const sessionIds = this.listSessionIds(); + + for (const sid of sessionIds) { + for (const mode of Object.keys(MODE_CONFIGS) as ExecutionMode[]) { + const stateFile = this.getSessionStatePath(mode, sid); + if (existsSync(stateFile)) { + try { + const content = readFileSync(stateFile, 'utf-8'); + const state = JSON.parse(content); + + if (state.activatedAt) { + const activatedAt = new Date(state.activatedAt).getTime(); + const age = Date.now() - activatedAt; + + if (age > STALE_MARKER_THRESHOLD) { + this.log(`Cleaning up stale ${mode} marker for session ${sid} (${Math.round(age / 60000)} min old)`); + unlinkSync(stateFile); + cleaned.push(sid); + } + } + } catch { + // Skip invalid files + } + } + } + } + + return Array.from(new Set(cleaned)); // Remove duplicates + } + + // --------------------------------------------------------------------------- + // Private: Utility Methods + // --------------------------------------------------------------------------- + + /** + * Get the state file path for a mode (legacy shared path) + */ + private getStateFilePath(mode: ExecutionMode): string { + const config = MODE_CONFIGS[mode]; + return join(this.modesDir, config.stateFile); + } + + /** + * Get the session-scoped state file path + */ + private getSessionStatePath(mode: ExecutionMode, sessionId: string): string { + const config = MODE_CONFIGS[mode]; + return join(this.modesDir, 'sessions', sessionId, config.stateFile); + } + + /** + * Get the session directory path + */ + private getSessionDir(sessionId: string): string { + return join(this.modesDir, 'sessions', sessionId); + } + + /** + * Check if a JSON-based mode is active + */ + private isJsonModeActive( + stateFile: string, + config: ModeConfig, + sessionId?: string + ): boolean { + if (!existsSync(stateFile)) { + return false; + } + + try { + const content = readFileSync(stateFile, 'utf-8'); + const state = JSON.parse(content); + + // Validate session identity if sessionId provided + if (sessionId && state.session_id && state.session_id !== sessionId) { + return false; + } + + if (config.activeProperty) { + return state[config.activeProperty] === true; + } + + return true; + } catch { + return false; + } + } + + /** + * Validate session ID format + */ + private isValidSessionId(sessionId: string): boolean { + if (!sessionId || typeof sessionId !== 'string') { + return false; + } + // Allow alphanumeric, hyphens, and underscores only + const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/; + return SAFE_SESSION_ID_PATTERN.test(sessionId); + } + + /** + * Log a message if logging is enabled + */ + private log(message: string): void { + if (this.enableLogging) { + const timestamp = new Date().toISOString(); + console.log(`[ModeRegistry ${timestamp}] ${message}`); + } + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a ModeRegistryService instance + * + * @param projectPath - Project root path + * @param enableLogging - Enable logging + * @returns ModeRegistryService instance + */ +export function createModeRegistryService( + projectPath: string, + enableLogging?: boolean +): ModeRegistryService { + return new ModeRegistryService({ projectPath, enableLogging }); +} diff --git a/ccw/src/core/services/session-end-service.ts b/ccw/src/core/services/session-end-service.ts new file mode 100644 index 00000000..6c9a210f --- /dev/null +++ b/ccw/src/core/services/session-end-service.ts @@ -0,0 +1,408 @@ +/** + * SessionEndService - Unified session end handling + * + * Provides centralized management for session-end tasks: + * - Task registration with priority + * - Async execution with error handling + * - Built-in tasks: incremental-embedding, clustering, heat-scores + * + * Design: + * - Best-effort execution (failures logged but don't block) + * - Priority-based ordering + * - Support for async background execution + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * A task to be executed at session end + */ +export interface EndTask { + /** Unique task type identifier */ + type: string; + /** Task priority (higher = executed first) */ + priority: number; + /** Whether to run asynchronously in background */ + async: boolean; + /** Task handler function */ + handler: () => Promise; + /** Optional description */ + description?: string; +} + +/** + * Result of a task execution + */ +export interface TaskResult { + /** Task type identifier */ + type: string; + /** Whether the task succeeded */ + success: boolean; + /** Execution duration in milliseconds */ + duration: number; + /** Error message if failed */ + error?: string; +} + +/** + * Options for SessionEndService + */ +export interface SessionEndServiceOptions { + /** Project root path */ + projectPath: string; + /** Whether to log task execution */ + enableLogging?: boolean; +} + +/** + * Summary of session end execution + */ +export interface SessionEndSummary { + /** Total tasks executed */ + totalTasks: number; + /** Number of successful tasks */ + successful: number; + /** Number of failed tasks */ + failed: number; + /** Total execution time in milliseconds */ + totalDuration: number; + /** Individual task results */ + results: TaskResult[]; +} + +// ============================================================================= +// Built-in Task Types +// ============================================================================= + +/** Task type for incremental vector embedding */ +export const TASK_INCREMENTAL_EMBEDDING = 'incremental-embedding'; + +/** Task type for incremental clustering */ +export const TASK_INCREMENTAL_CLUSTERING = 'incremental-clustering'; + +/** Task type for heat score updates */ +export const TASK_HEAT_SCORE_UPDATE = 'heat-score-update'; + +// ============================================================================= +// SessionEndService +// ============================================================================= + +/** + * Service for managing and executing session-end tasks + * + * This service provides a unified interface for registering and executing + * background tasks when a session ends. Tasks are executed best-effort + * with proper error handling and logging. + */ +export class SessionEndService { + private projectPath: string; + private enableLogging: boolean; + private tasks: Map = new Map(); + + constructor(options: SessionEndServiceOptions) { + this.projectPath = options.projectPath; + this.enableLogging = options.enableLogging ?? false; + } + + // --------------------------------------------------------------------------- + // Public: Task Registration + // --------------------------------------------------------------------------- + + /** + * Register a session-end task + * + * @param task - Task to register + * @returns true if task was registered (false if type already exists) + */ + registerEndTask(task: EndTask): boolean { + if (this.tasks.has(task.type)) { + this.log(`Task "${task.type}" already registered, skipping`); + return false; + } + + this.tasks.set(task.type, task); + this.log(`Registered task "${task.type}" with priority ${task.priority}`); + return true; + } + + /** + * Unregister a session-end task + * + * @param type - Task type to unregister + * @returns true if task was removed + */ + unregisterEndTask(type: string): boolean { + const removed = this.tasks.delete(type); + if (removed) { + this.log(`Unregistered task "${type}"`); + } + return removed; + } + + /** + * Check if a task type is registered + * + * @param type - Task type to check + * @returns true if task is registered + */ + hasTask(type: string): boolean { + return this.tasks.has(type); + } + + /** + * Get all registered task types + * + * @returns Array of task types + */ + getRegisteredTasks(): string[] { + return Array.from(this.tasks.keys()); + } + + // --------------------------------------------------------------------------- + // Public: Task Execution + // --------------------------------------------------------------------------- + + /** + * Execute all registered session-end tasks + * + * Tasks are executed in priority order (highest first). + * Failures are logged but don't prevent other tasks from running. + * + * @param sessionId - Session ID for context + * @returns Summary of execution results + */ + async executeEndTasks(sessionId: string): Promise { + const startTime = Date.now(); + const results: TaskResult[] = []; + + // Sort tasks by priority (descending) + const sortedTasks = Array.from(this.tasks.values()).sort( + (a, b) => b.priority - a.priority + ); + + this.log(`Executing ${sortedTasks.length} session-end tasks for session ${sessionId}`); + + // Execute tasks concurrently + const executionPromises = sortedTasks.map(async (task) => { + const taskStart = Date.now(); + + try { + this.log(`Starting task "${task.type}"...`); + await task.handler(); + + const duration = Date.now() - taskStart; + this.log(`Task "${task.type}" completed in ${duration}ms`); + + return { + type: task.type, + success: true, + duration + } as TaskResult; + } catch (err) { + const duration = Date.now() - taskStart; + const errorMessage = (err as Error).message || 'Unknown error'; + this.log(`Task "${task.type}" failed: ${errorMessage}`); + + return { + type: task.type, + success: false, + duration, + error: errorMessage + } as TaskResult; + } + }); + + // Wait for all tasks to complete + const taskResults = await Promise.allSettled(executionPromises); + + // Collect results + for (const result of taskResults) { + if (result.status === 'fulfilled') { + results.push(result.value); + } else { + // This shouldn't happen as we catch errors inside the task + results.push({ + type: 'unknown', + success: false, + duration: 0, + error: result.reason?.message || 'Task promise rejected' + }); + } + } + + const totalDuration = Date.now() - startTime; + const successful = results.filter((r) => r.success).length; + const failed = results.length - successful; + + this.log( + `Session-end tasks completed: ${successful}/${results.length} successful, ` + + `${totalDuration}ms total` + ); + + return { + totalTasks: results.length, + successful, + failed, + totalDuration, + results + }; + } + + /** + * Execute only async (background) tasks + * + * This is useful for fire-and-forget background processing. + * + * @param sessionId - Session ID for context + * @returns Promise that resolves immediately (tasks run in background) + */ + executeBackgroundTasks(sessionId: string): void { + const asyncTasks = Array.from(this.tasks.values()) + .filter((t) => t.async) + .sort((a, b) => b.priority - a.priority); + + if (asyncTasks.length === 0) { + return; + } + + // Fire-and-forget + Promise.all( + asyncTasks.map(async (task) => { + try { + this.log(`Background task "${task.type}" starting...`); + await task.handler(); + this.log(`Background task "${task.type}" completed`); + } catch (err) { + this.log(`Background task "${task.type}" failed: ${(err as Error).message}`); + } + }) + ).catch(() => { + // Ignore errors - background tasks are best-effort + }); + } + + // --------------------------------------------------------------------------- + // Public: Built-in Tasks + // --------------------------------------------------------------------------- + + /** + * Register built-in session-end tasks + * + * This registers the standard tasks: + * - incremental-embedding (priority 100) + * - incremental-clustering (priority 50) + * - heat-score-update (priority 25) + * + * @param sessionId - Session ID for context + */ + async registerBuiltinTasks(sessionId: string): Promise { + // Try to import and register embedding task + try { + const { isUnifiedEmbedderAvailable, UnifiedVectorIndex } = await import( + '../unified-vector-index.js' + ); + const { getMemoryMdContent } = await import('../memory-consolidation-pipeline.js'); + + if (isUnifiedEmbedderAvailable()) { + this.registerEndTask({ + type: TASK_INCREMENTAL_EMBEDDING, + priority: 100, + async: true, + description: 'Index new/updated content in vector store', + handler: async () => { + const vectorIndex = new UnifiedVectorIndex(this.projectPath); + const memoryContent = getMemoryMdContent(this.projectPath); + if (memoryContent) { + await vectorIndex.indexContent(memoryContent, { + source_id: 'MEMORY_MD', + source_type: 'core_memory', + category: 'core_memory' + }); + } + } + }); + } + } catch { + // Embedding dependencies not available + this.log('Embedding task not registered: dependencies not available'); + } + + // Try to import and register clustering task + try { + const { SessionClusteringService } = await import('../session-clustering-service.js'); + + this.registerEndTask({ + type: TASK_INCREMENTAL_CLUSTERING, + priority: 50, + async: true, + description: 'Cluster unclustered sessions', + handler: async () => { + const clusteringService = new SessionClusteringService(this.projectPath); + await clusteringService.autocluster({ scope: 'unclustered' }); + } + }); + } catch { + this.log('Clustering task not registered: dependencies not available'); + } + + // Try to import and register heat score task + try { + const { getMemoryStore } = await import('../memory-store.js'); + + this.registerEndTask({ + type: TASK_HEAT_SCORE_UPDATE, + priority: 25, + async: true, + description: 'Update entity heat scores', + handler: async () => { + const memoryStore = getMemoryStore(this.projectPath); + const hotEntities = memoryStore.getHotEntities(50); + for (const entity of hotEntities) { + if (entity.id != null) { + memoryStore.calculateHeatScore(entity.id); + } + } + } + }); + } catch { + this.log('Heat score task not registered: dependencies not available'); + } + } + + // --------------------------------------------------------------------------- + // Private: Utility Methods + // --------------------------------------------------------------------------- + + /** + * Log a message if logging is enabled + */ + private log(message: string): void { + if (this.enableLogging) { + console.log(`[SessionEndService] ${message}`); + } + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a SessionEndService instance with built-in tasks + * + * @param projectPath - Project root path + * @param sessionId - Session ID for context + * @param enableLogging - Whether to enable logging + * @returns SessionEndService instance with built-in tasks registered + */ +export async function createSessionEndService( + projectPath: string, + sessionId: string, + enableLogging = false +): Promise { + const service = new SessionEndService({ projectPath, enableLogging }); + await service.registerBuiltinTasks(sessionId); + return service; +} diff --git a/ccw/src/core/services/session-state-service.ts b/ccw/src/core/services/session-state-service.ts new file mode 100644 index 00000000..d2ee5c63 --- /dev/null +++ b/ccw/src/core/services/session-state-service.ts @@ -0,0 +1,330 @@ +/** + * SessionStateService - Unified session state management + * + * Provides centralized session state persistence across CLI hooks and API routes. + * Supports both legacy global path (~/.claude/.ccw-sessions/) and session-scoped + * paths (.workflow/sessions/{sessionId}/) for workflow integration. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +/** + * Session state interface + */ +export interface SessionState { + /** ISO timestamp of first session load */ + firstLoad: string; + /** Number of times session has been loaded */ + loadCount: number; + /** Last prompt text (optional) */ + lastPrompt?: string; + /** Active mode for the session (optional) */ + activeMode?: 'analysis' | 'write' | 'review' | 'auto'; +} + +/** + * Storage type for session state + */ +export type SessionStorageType = 'global' | 'session-scoped'; + +/** + * Options for session state operations + */ +export interface SessionStateOptions { + /** Storage type: 'global' uses ~/.claude/.ccw-sessions/, 'session-scoped' uses .workflow/sessions/{sessionId}/ */ + storageType?: SessionStorageType; + /** Project root path (required for session-scoped storage) */ + projectPath?: string; +} + +/** + * Validates that a session ID is safe to use in file paths. + * Session IDs should be alphanumeric with optional hyphens and underscores. + * This prevents path traversal attacks (e.g., "../../../etc"). + * + * @param sessionId - The session ID to validate + * @returns true if the session ID is safe, false otherwise + */ +export function validateSessionId(sessionId: string): boolean { + if (!sessionId || typeof sessionId !== 'string') { + return false; + } + // Allow alphanumeric, hyphens, and underscores only + // Must be 1-256 characters (reasonable length limit) + // Must not start with a dot (hidden files) or hyphen + const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/; + return SAFE_SESSION_ID_PATTERN.test(sessionId); +} + +/** + * Get the default global session state directory + * Uses ~/.claude/.ccw-sessions/ for reliable persistence across sessions + */ +function getGlobalStateDir(): string { + return join(homedir(), '.claude', '.ccw-sessions'); +} + +/** + * Get session state file path + * + * Supports two storage modes: + * - 'global': ~/.claude/.ccw-sessions/session-{sessionId}.json (default) + * - 'session-scoped': {projectPath}/.workflow/sessions/{sessionId}/state.json + * + * @param sessionId - The session ID + * @param options - Storage options + * @returns Full path to the session state file + */ +export function getSessionStatePath(sessionId: string, options?: SessionStateOptions): string { + if (!validateSessionId(sessionId)) { + throw new Error(`Invalid session ID: ${sessionId}`); + } + + const storageType = options?.storageType ?? 'global'; + + if (storageType === 'session-scoped') { + if (!options?.projectPath) { + throw new Error('projectPath is required for session-scoped storage'); + } + const stateDir = join(options.projectPath, '.workflow', 'sessions', sessionId); + if (!existsSync(stateDir)) { + mkdirSync(stateDir, { recursive: true }); + } + return join(stateDir, 'state.json'); + } + + // Global storage (default) + const stateDir = getGlobalStateDir(); + if (!existsSync(stateDir)) { + mkdirSync(stateDir, { recursive: true }); + } + return join(stateDir, `session-${sessionId}.json`); +} + +/** + * Load session state from file + * + * @param sessionId - The session ID + * @param options - Storage options + * @returns SessionState if exists and valid, null otherwise + */ +export function loadSessionState(sessionId: string, options?: SessionStateOptions): SessionState | null { + if (!validateSessionId(sessionId)) { + return null; + } + + try { + const stateFile = getSessionStatePath(sessionId, options); + if (!existsSync(stateFile)) { + return null; + } + + const content = readFileSync(stateFile, 'utf-8'); + const parsed = JSON.parse(content) as SessionState; + + // Validate required fields + if (typeof parsed.firstLoad !== 'string' || typeof parsed.loadCount !== 'number') { + return null; + } + + return parsed; + } catch { + return null; + } +} + +/** + * Save session state to file + * + * @param sessionId - The session ID + * @param state - The session state to save + * @param options - Storage options + */ +export function saveSessionState(sessionId: string, state: SessionState, options?: SessionStateOptions): void { + if (!validateSessionId(sessionId)) { + throw new Error(`Invalid session ID: ${sessionId}`); + } + + const stateFile = getSessionStatePath(sessionId, options); + + // Ensure parent directory exists + const stateDir = dirname(stateFile); + if (!existsSync(stateDir)) { + mkdirSync(stateDir, { recursive: true }); + } + + writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8'); +} + +/** + * Clear session state (for session-end cleanup) + * + * @param sessionId - The session ID + * @param options - Storage options + * @returns true if state was cleared, false if it didn't exist + */ +export function clearSessionState(sessionId: string, options?: SessionStateOptions): boolean { + if (!validateSessionId(sessionId)) { + return false; + } + + try { + const stateFile = getSessionStatePath(sessionId, options); + + if (!existsSync(stateFile)) { + return false; + } + + unlinkSync(stateFile); + + // For session-scoped storage, also remove the session directory if empty + if (options?.storageType === 'session-scoped' && options.projectPath) { + const sessionDir = join(options.projectPath, '.workflow', 'sessions', sessionId); + try { + // Try to remove the directory (will fail if not empty) + rmSync(sessionDir, { recursive: false, force: true }); + } catch { + // Directory not empty or other error - ignore + } + } + + return true; + } catch { + return false; + } +} + +/** + * Update session state with new values + * + * This is a convenience function that loads existing state, merges with updates, + * and saves the result. + * + * @param sessionId - The session ID + * @param updates - Partial state to merge + * @param options - Storage options + * @returns The updated state + */ +export function updateSessionState( + sessionId: string, + updates: Partial, + options?: SessionStateOptions +): SessionState { + const existing = loadSessionState(sessionId, options); + + const newState: SessionState = existing + ? { ...existing, ...updates } + : { + firstLoad: new Date().toISOString(), + loadCount: 1, + ...updates + }; + + saveSessionState(sessionId, newState, options); + return newState; +} + +/** + * Increment the load count for a session + * + * This is a convenience function for the common pattern of tracking + * how many times a session has been loaded. + * + * @param sessionId - The session ID + * @param prompt - Optional prompt to record as lastPrompt + * @param options - Storage options + * @returns Object with isFirstPrompt flag and updated state + */ +export function incrementSessionLoad( + sessionId: string, + prompt?: string, + options?: SessionStateOptions +): { isFirstPrompt: boolean; state: SessionState } { + const existing = loadSessionState(sessionId, options); + const isFirstPrompt = !existing; + + const state: SessionState = isFirstPrompt + ? { + firstLoad: new Date().toISOString(), + loadCount: 1, + lastPrompt: prompt + } + : { + ...existing, + loadCount: existing.loadCount + 1, + ...(prompt !== undefined && { lastPrompt: prompt }) + }; + + saveSessionState(sessionId, state, options); + return { isFirstPrompt, state }; +} + +/** + * SessionStateService class for object-oriented usage + */ +export class SessionStateService { + private options?: SessionStateOptions; + + constructor(options?: SessionStateOptions) { + this.options = options; + } + + /** + * Get session state file path + */ + getStatePath(sessionId: string): string { + return getSessionStatePath(sessionId, this.options); + } + + /** + * Load session state + */ + load(sessionId: string): SessionState | null { + return loadSessionState(sessionId, this.options); + } + + /** + * Save session state + */ + save(sessionId: string, state: SessionState): void { + saveSessionState(sessionId, state, this.options); + } + + /** + * Clear session state + */ + clear(sessionId: string): boolean { + return clearSessionState(sessionId, this.options); + } + + /** + * Update session state + */ + update(sessionId: string, updates: Partial): SessionState { + return updateSessionState(sessionId, updates, this.options); + } + + /** + * Increment load count + */ + incrementLoad(sessionId: string, prompt?: string): { isFirstPrompt: boolean; state: SessionState } { + return incrementSessionLoad(sessionId, prompt, this.options); + } + + /** + * Check if session is first load + */ + isFirstLoad(sessionId: string): boolean { + return this.load(sessionId) === null; + } + + /** + * Get load count for session + */ + getLoadCount(sessionId: string): number { + const state = this.load(sessionId); + return state?.loadCount ?? 0; + } +} diff --git a/ccw/src/templates/hooks-config-example.json b/ccw/src/templates/hooks-config-example.json index 58e33eda..68a91169 100644 --- a/ccw/src/templates/hooks-config-example.json +++ b/ccw/src/templates/hooks-config-example.json @@ -5,11 +5,15 @@ "session-start": [ { "name": "Progressive Disclosure", - "description": "Injects progressive disclosure index at session start", + "description": "Injects progressive disclosure index at session start with recovery detection", "enabled": true, "handler": "internal:context", "timeout": 5000, - "failMode": "silent" + "failMode": "silent", + "notes": [ + "Checks for recovery checkpoints and injects recovery message if found", + "Uses RecoveryHandler.checkRecovery() for session recovery" + ] } ], "session-end": [ @@ -21,6 +25,61 @@ "timeout": 30000, "async": true, "failMode": "log" + }, + { + "name": "Mode State Cleanup", + "description": "Deactivates all active modes for the session", + "enabled": true, + "command": "ccw hook session-end --stdin", + "timeout": 5000, + "failMode": "silent" + } + ], + "Stop": [ + { + "name": "Stop Handler", + "description": "Handles Stop hook events with Soft Enforcement - injects continuation messages for active workflows/modes", + "enabled": true, + "command": "ccw hook stop --stdin", + "timeout": 5000, + "failMode": "silent", + "notes": [ + "Priority order: context-limit > user-abort > active-workflow > active-mode", + "ALWAYS returns continue: true (never blocks stops)", + "Injects continuation message instead of blocking", + "Deadlock prevention: context-limit stops are always allowed", + "Uses ModeRegistryService to check active modes" + ] + } + ], + "PreCompact": [ + { + "name": "Checkpoint Creation", + "description": "Creates checkpoint before context compaction to preserve session state", + "enabled": true, + "command": "ccw hook pre-compact --stdin", + "timeout": 10000, + "failMode": "silent", + "notes": [ + "Creates checkpoint with mode states, workflow state, and memory context", + "Uses mutex to prevent concurrent compaction for same directory", + "Returns systemMessage with checkpoint summary for context injection" + ] + } + ], + "UserPromptSubmit": [ + { + "name": "Keyword Detection", + "description": "Detects mode keywords in prompts and activates corresponding modes", + "enabled": true, + "command": "ccw hook keyword --stdin", + "timeout": 5000, + "failMode": "silent", + "notes": [ + "Supported keywords: autopilot, ralph, ultrawork, swarm, pipeline, team, ultrapilot, ultraqa", + "Maps keywords to execution modes using ModeRegistryService", + "Injects systemMessage on mode activation" + ] } ], "file-modified": [ @@ -55,6 +114,10 @@ "handler": "Use 'internal:context' for built-in context generation, or 'command' for external commands", "failMode": "Options: 'silent' (ignore errors), 'log' (log errors), 'fail' (abort on error)", "variables": "Available: $SESSION_ID, $FILE_PATH, $PROJECT_PATH, $CLUSTER_ID", - "async": "Async hooks run in background and don't block the main flow" + "async": "Async hooks run in background and don't block the main flow", + "Stop hook": "The Stop hook uses Soft Enforcement - it never blocks but may inject continuation messages", + "PreCompact hook": "Creates checkpoint before compaction; uses mutex to prevent concurrent operations", + "UserPromptSubmit hook": "Detects mode keywords and activates corresponding execution modes", + "session-end hook": "Cleans up mode states using ModeRegistryService.deactivateMode()" } } diff --git a/ccw/tests/context-limit-detector.test.ts b/ccw/tests/context-limit-detector.test.ts new file mode 100644 index 00000000..6e445ea7 --- /dev/null +++ b/ccw/tests/context-limit-detector.test.ts @@ -0,0 +1,148 @@ +/** + * Tests for ContextLimitDetector + */ + +import { describe, it, expect } from 'vitest'; +import { + isContextLimitStop, + getMatchingContextPattern, + getAllMatchingContextPatterns, + CONTEXT_LIMIT_PATTERNS +} from '../src/core/hooks/context-limit-detector.js'; +import type { StopContext } from '../src/core/hooks/context-limit-detector.js'; + +describe('isContextLimitStop', () => { + it('should return false for undefined context', () => { + expect(isContextLimitStop(undefined)).toBe(false); + }); + + it('should detect context_limit pattern', () => { + const context: StopContext = { stop_reason: 'context_limit' }; + expect(isContextLimitStop(context)).toBe(true); + }); + + it('should detect context_window pattern', () => { + const context: StopContext = { stop_reason: 'context_window_exceeded' }; + expect(isContextLimitStop(context)).toBe(true); + }); + + it('should detect token_limit pattern', () => { + const context: StopContext = { stop_reason: 'token_limit_reached' }; + expect(isContextLimitStop(context)).toBe(true); + }); + + it('should detect max_tokens pattern', () => { + const context: StopContext = { stop_reason: 'max_tokens' }; + expect(isContextLimitStop(context)).toBe(true); + }); + + it('should detect conversation_too_long pattern', () => { + const context: StopContext = { stop_reason: 'conversation_too_long' }; + expect(isContextLimitStop(context)).toBe(true); + }); + + it('should detect pattern in end_turn_reason', () => { + const context: StopContext = { end_turn_reason: 'context_exceeded' }; + expect(isContextLimitStop(context)).toBe(true); + }); + + it('should detect pattern in camelCase endTurnReason', () => { + const context: StopContext = { endTurnReason: 'context_full' }; + expect(isContextLimitStop(context)).toBe(true); + }); + + it('should detect pattern in camelCase stopReason', () => { + const context: StopContext = { stopReason: 'input_too_long' }; + expect(isContextLimitStop(context)).toBe(true); + }); + + it('should be case-insensitive', () => { + const context: StopContext = { stop_reason: 'CONTEXT_LIMIT' }; + expect(isContextLimitStop(context)).toBe(true); + }); + + it('should return false for non-context-limit reasons', () => { + const context: StopContext = { stop_reason: 'end_turn' }; + expect(isContextLimitStop(context)).toBe(false); + }); + + it('should return false for empty reasons', () => { + const context: StopContext = { stop_reason: '' }; + expect(isContextLimitStop(context)).toBe(false); + }); + + it('should return false for unrelated patterns', () => { + const contexts: StopContext[] = [ + { stop_reason: 'user_cancel' }, + { stop_reason: 'max_response_time' }, + { stop_reason: 'complete' } + ]; + + contexts.forEach(context => { + expect(isContextLimitStop(context)).toBe(false); + }); + }); +}); + +describe('getMatchingContextPattern', () => { + it('should return null for undefined context', () => { + expect(getMatchingContextPattern(undefined)).toBeNull(); + }); + + it('should return the matching pattern', () => { + const context: StopContext = { stop_reason: 'context_limit_reached' }; + expect(getMatchingContextPattern(context)).toBe('context_limit'); + }); + + it('should return the first matching pattern when multiple match', () => { + const context: StopContext = { stop_reason: 'context_window_token_limit' }; + // 'context_window' should match first (appears earlier in array) + const pattern = getMatchingContextPattern(context); + expect(pattern).toBe('context_window'); + }); + + it('should return null when no pattern matches', () => { + const context: StopContext = { stop_reason: 'some_other_reason' }; + expect(getMatchingContextPattern(context)).toBeNull(); + }); +}); + +describe('getAllMatchingContextPatterns', () => { + it('should return empty array for undefined context', () => { + expect(getAllMatchingContextPatterns(undefined)).toEqual([]); + }); + + it('should return all matching patterns', () => { + const context: StopContext = { + stop_reason: 'context_window_token_limit', + end_turn_reason: 'max_tokens_exceeded' + }; + const patterns = getAllMatchingContextPatterns(context); + + expect(patterns).toContain('context_window'); + expect(patterns).toContain('token_limit'); + expect(patterns).toContain('max_tokens'); + expect(patterns.length).toBe(3); + }); + + it('should return empty array when no patterns match', () => { + const context: StopContext = { stop_reason: 'complete' }; + expect(getAllMatchingContextPatterns(context)).toEqual([]); + }); +}); + +describe('CONTEXT_LIMIT_PATTERNS', () => { + it('should contain expected patterns', () => { + expect(CONTEXT_LIMIT_PATTERNS).toContain('context_limit'); + expect(CONTEXT_LIMIT_PATTERNS).toContain('context_window'); + expect(CONTEXT_LIMIT_PATTERNS).toContain('token_limit'); + expect(CONTEXT_LIMIT_PATTERNS).toContain('max_tokens'); + expect(CONTEXT_LIMIT_PATTERNS).toContain('conversation_too_long'); + }); + + it('should be readonly array', () => { + // TypeScript enforces this at compile time + // This test just verifies the constant exists + expect(Array.isArray(CONTEXT_LIMIT_PATTERNS)).toBe(true); + }); +}); diff --git a/ccw/tests/integration/hooks-integration.test.ts b/ccw/tests/integration/hooks-integration.test.ts new file mode 100644 index 00000000..ce6cfa98 --- /dev/null +++ b/ccw/tests/integration/hooks-integration.test.ts @@ -0,0 +1,801 @@ +/** + * Integration Tests for CCW + OMC Hook Integration + * + * Tests the complete hook system including: + * - Stop Hook with Soft Enforcement + * - Mode activation via keyword detection + * - Checkpoint creation and recovery + * - End-to-end workflow continuation + * - Mode system integration + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Uses temporary directories for isolation. + * - Calls services directly (no HTTP server required). + */ + +import { after, before, beforeEach, describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; + +// ============================================================================= +// Test Setup +// ============================================================================= + +const stopHandlerUrl = new URL('../../dist/core/hooks/stop-handler.js', import.meta.url); +const recoveryHandlerUrl = new URL('../../dist/core/hooks/recovery-handler.js', import.meta.url); +const modeRegistryUrl = new URL('../../dist/core/services/mode-registry-service.js', import.meta.url); +const checkpointServiceUrl = new URL('../../dist/core/services/checkpoint-service.js', import.meta.url); +const contextLimitUrl = new URL('../../dist/core/hooks/context-limit-detector.js', import.meta.url); +const userAbortUrl = new URL('../../dist/core/hooks/user-abort-detector.js', import.meta.url); +const keywordDetectorUrl = new URL('../../dist/core/hooks/keyword-detector.js', import.meta.url); + +// Add cache-busting +[stopHandlerUrl, recoveryHandlerUrl, modeRegistryUrl, checkpointServiceUrl, contextLimitUrl, userAbortUrl, keywordDetectorUrl].forEach(url => { + url.searchParams.set('t', String(Date.now())); +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let modules: any = {}; + +const originalEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, +}; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +async function importModules() { + modules.StopHandler = (await import(stopHandlerUrl.href)).StopHandler; + modules.RecoveryHandler = (await import(recoveryHandlerUrl.href)).RecoveryHandler; + modules.ModeRegistryService = (await import(modeRegistryUrl.href)).ModeRegistryService; + modules.CheckpointService = (await import(checkpointServiceUrl.href)).CheckpointService; + modules.isContextLimitStop = (await import(contextLimitUrl.href)).isContextLimitStop; + modules.isUserAbort = (await import(userAbortUrl.href)).isUserAbort; + modules.detectKeywords = (await import(keywordDetectorUrl.href)).detectKeywords; + modules.getPrimaryKeyword = (await import(keywordDetectorUrl.href)).getPrimaryKeyword; +} + +function createTestProject(baseDir: string): string { + const projectDir = join(baseDir, 'project'); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(join(projectDir, '.workflow'), { recursive: true }); + mkdirSync(join(projectDir, '.workflow', 'modes'), { recursive: true }); + mkdirSync(join(projectDir, '.workflow', 'checkpoints'), { recursive: true }); + return projectDir; +} + +// ============================================================================= +// Integration Tests +// ============================================================================= + +describe('CCW + OMC Hook Integration', async () => { + let homeDir = ''; + let testDir = ''; + let projectDir = ''; + + before(async () => { + homeDir = mkdtempSync(join(tmpdir(), 'ccw-hooks-home-')); + testDir = mkdtempSync(join(tmpdir(), 'ccw-hooks-test-')); + + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + + mock.method(console, 'log', () => {}); + mock.method(console, 'warn', () => {}); + mock.method(console, 'error', () => {}); + + await importModules(); + projectDir = createTestProject(testDir); + }); + + beforeEach(() => { + // Clean up project state between tests + rmSync(join(projectDir, '.workflow'), { recursive: true, force: true }); + mkdirSync(join(projectDir, '.workflow'), { recursive: true }); + mkdirSync(join(projectDir, '.workflow', 'modes'), { recursive: true }); + mkdirSync(join(projectDir, '.workflow', 'checkpoints'), { recursive: true }); + }); + + after(() => { + mock.restoreAll(); + process.env.HOME = originalEnv.HOME; + process.env.USERPROFILE = originalEnv.USERPROFILE; + + rmSync(testDir, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); + }); + + // =========================================================================== + // Stop Handler Integration Tests + // =========================================================================== + + describe('Stop Handler Integration', () => { + it('INT-STOP-1: Should always return continue: true (Soft Enforcement)', async () => { + const stopHandler = new modules.StopHandler({ + projectPath: projectDir, + enableLogging: false + }); + + // Test various contexts - all should return continue: true + const contexts = [ + {}, + { stop_reason: 'unknown' }, + { active_workflow: true }, + { active_mode: 'analysis' } + ]; + + for (const context of contexts) { + const result = await stopHandler.handleStop(context); + assert.equal(result.continue, true, `Expected continue: true for context ${JSON.stringify(context)}`); + } + }); + + it('INT-STOP-2: Should detect context limit and allow stop', async () => { + const stopHandler = new modules.StopHandler({ + projectPath: projectDir, + enableLogging: false + }); + + const result = await stopHandler.handleStop({ + stop_reason: 'context_limit_reached', + end_turn_reason: 'max_tokens' + }); + + assert.equal(result.continue, true); + assert.equal(result.mode, 'context-limit'); + }); + + it('INT-STOP-3: Should detect user abort and respect intent', async () => { + const stopHandler = new modules.StopHandler({ + projectPath: projectDir, + enableLogging: false + }); + + const result = await stopHandler.handleStop({ + user_requested: true, + stop_reason: 'user_cancel' + }); + + assert.equal(result.continue, true); + assert.equal(result.mode, 'user-abort'); + }); + + it('INT-STOP-4: Should inject continuation message for active workflow', async () => { + const stopHandler = new modules.StopHandler({ + projectPath: projectDir, + enableLogging: false, + workflowContinuationMessage: '[WORKFLOW] Continue working...' + }); + + const result = await stopHandler.handleStop({ + active_workflow: true, + session_id: 'test-session-001' + }); + + assert.equal(result.continue, true); + assert.equal(result.mode, 'active-workflow'); + assert.ok(result.message); + assert.ok(result.message.includes('[WORKFLOW]')); + }); + + it('INT-STOP-5: Should check ModeRegistryService for active modes', async () => { + const sessionId = 'test-session-002'; + + // First activate a mode + const modeRegistry = new modules.ModeRegistryService({ + projectPath: projectDir, + enableLogging: false + }); + + const activated = modeRegistry.activateMode('autopilot', sessionId); + assert.equal(activated, true); + + // Now test stop handler + const stopHandler = new modules.StopHandler({ + projectPath: projectDir, + enableLogging: false + }); + + const result = await stopHandler.handleStop({ + session_id: sessionId + }); + + assert.equal(result.continue, true); + // Should detect active mode + assert.ok( + result.mode === 'active-mode' || result.mode === 'none', + `Expected active-mode or none, got ${result.mode}` + ); + }); + }); + + // =========================================================================== + // Mode System Integration Tests + // =========================================================================== + + describe('Mode System Integration', () => { + it('INT-MODE-1: Should activate and detect modes via ModeRegistryService', async () => { + const modeRegistry = new modules.ModeRegistryService({ + projectPath: projectDir, + enableLogging: false + }); + + const sessionId = 'mode-test-001'; + + // Initially no mode active + assert.equal(modeRegistry.isModeActive('autopilot', sessionId), false); + + // Activate mode + const activated = modeRegistry.activateMode('autopilot', sessionId); + assert.equal(activated, true); + + // Now should be active + assert.equal(modeRegistry.isModeActive('autopilot', sessionId), true); + assert.deepEqual(modeRegistry.getActiveModes(sessionId), ['autopilot']); + + // Deactivate + modeRegistry.deactivateMode('autopilot', sessionId); + assert.equal(modeRegistry.isModeActive('autopilot', sessionId), false); + }); + + it('INT-MODE-2: Should prevent concurrent exclusive modes', async () => { + const modeRegistry = new modules.ModeRegistryService({ + projectPath: projectDir, + enableLogging: false + }); + + const sessionId1 = 'exclusive-test-001'; + const sessionId2 = 'exclusive-test-002'; + + // Activate autopilot (exclusive mode) in session 1 + const activated1 = modeRegistry.activateMode('autopilot', sessionId1); + assert.equal(activated1, true); + + // Try to activate swarm (also exclusive) in session 2 + // This should be blocked because autopilot is already active + const canStart = modeRegistry.canStartMode('swarm', sessionId2); + assert.equal(canStart.allowed, false); + assert.equal(canStart.blockedBy, 'autopilot'); + + // Cleanup + modeRegistry.deactivateMode('autopilot', sessionId1); + }); + + it('INT-MODE-3: Should clean up stale markers', async () => { + const modeRegistry = new modules.ModeRegistryService({ + projectPath: projectDir, + enableLogging: false + }); + + const sessionId = 'stale-test-001'; + + // Activate mode + modeRegistry.activateMode('autopilot', sessionId); + + // Create a stale marker (manually set old timestamp) + const stateFile = join(projectDir, '.workflow', 'modes', 'sessions', sessionId, 'autopilot-state.json'); + if (existsSync(stateFile)) { + const content = readFileSync(stateFile, 'utf-8'); + const state = JSON.parse(content); + // Set activation time to 2 hours ago (beyond 1 hour threshold) + state.activatedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + writeFileSync(stateFile, JSON.stringify(state), 'utf-8'); + } + + // Run cleanup + const cleaned = modeRegistry.cleanupStaleMarkers(); + + // Mode should no longer be active + assert.equal(modeRegistry.isModeActive('autopilot', sessionId), false); + }); + + it('INT-MODE-4: Should support non-exclusive modes concurrently', async () => { + const modeRegistry = new modules.ModeRegistryService({ + projectPath: projectDir, + enableLogging: false + }); + + const sessionId = 'non-exclusive-test-001'; + + // Activate ralph (non-exclusive) + const ralphOk = modeRegistry.activateMode('ralph', sessionId); + assert.equal(ralphOk, true); + + // Activate team (non-exclusive) - should be allowed + const teamOk = modeRegistry.activateMode('team', sessionId); + assert.equal(teamOk, true); + + // Both should be active + const activeModes = modeRegistry.getActiveModes(sessionId); + assert.ok(activeModes.includes('ralph')); + assert.ok(activeModes.includes('team')); + + // Cleanup + modeRegistry.deactivateMode('ralph', sessionId); + modeRegistry.deactivateMode('team', sessionId); + }); + }); + + // =========================================================================== + // Checkpoint and Recovery Integration Tests + // =========================================================================== + + describe('Checkpoint and Recovery Integration', () => { + it('INT-CHECKPOINT-1: Should create checkpoint via CheckpointService', async () => { + const checkpointService = new modules.CheckpointService({ + projectPath: projectDir, + enableLogging: false + }); + + const sessionId = 'checkpoint-test-001'; + + const checkpoint = await checkpointService.createCheckpoint( + sessionId, + 'compact', + { + modeStates: { autopilot: { active: true } }, + workflowState: null, + memoryContext: null + } + ); + + assert.ok(checkpoint.id); + assert.equal(checkpoint.session_id, sessionId); + assert.equal(checkpoint.trigger, 'compact'); + assert.ok(checkpoint.mode_states.autopilot?.active); + + // Save and verify + const savedId = await checkpointService.saveCheckpoint(checkpoint); + assert.equal(savedId, checkpoint.id); + + // Load and verify + const loaded = await checkpointService.loadCheckpoint(checkpoint.id); + assert.ok(loaded); + assert.equal(loaded?.id, checkpoint.id); + }); + + it('INT-CHECKPOINT-2: Should create checkpoint via RecoveryHandler PreCompact', async () => { + const recoveryHandler = new modules.RecoveryHandler({ + projectPath: projectDir, + enableLogging: false + }); + + const sessionId = 'precompact-test-001'; + + const result = await recoveryHandler.handlePreCompact({ + session_id: sessionId, + cwd: projectDir, + hook_event_name: 'PreCompact', + trigger: 'auto' + }); + + assert.equal(result.continue, true); + assert.ok(result.systemMessage); + + // Verify checkpoint was created + const checkpointService = new modules.CheckpointService({ + projectPath: projectDir, + enableLogging: false + }); + + const checkpoint = await checkpointService.getLatestCheckpoint(sessionId); + assert.ok(checkpoint); + assert.equal(checkpoint?.session_id, sessionId); + }); + + it('INT-CHECKPOINT-3: Should recover session from checkpoint', async () => { + const recoveryHandler = new modules.RecoveryHandler({ + projectPath: projectDir, + enableLogging: false + }); + + const sessionId = 'recovery-test-001'; + + // Create checkpoint first + await recoveryHandler.handlePreCompact({ + session_id: sessionId, + cwd: projectDir, + hook_event_name: 'PreCompact', + trigger: 'manual' + }); + + // Now check recovery + const checkpoint = await recoveryHandler.checkRecovery(sessionId); + assert.ok(checkpoint); + + // Format recovery message + const message = await recoveryHandler.formatRecoveryMessage(checkpoint); + assert.ok(message); + assert.ok(message.includes(sessionId)); + }); + + it('INT-CHECKPOINT-4: Should cleanup old checkpoints', async () => { + const checkpointService = new modules.CheckpointService({ + projectPath: projectDir, + maxCheckpointsPerSession: 3, + enableLogging: false + }); + + const sessionId = 'cleanup-test-001'; + + // Create more than max checkpoints + for (let i = 0; i < 5; i++) { + const checkpoint = await checkpointService.createCheckpoint( + sessionId, + 'compact', + { modeStates: {}, workflowState: null, memoryContext: null } + ); + await checkpointService.saveCheckpoint(checkpoint); + } + + // Should only have 3 checkpoints + const checkpoints = await checkpointService.listCheckpoints(sessionId); + assert.ok(checkpoints.length <= 3); + }); + + it('INT-CHECKPOINT-5: Should include mode states in checkpoint', async () => { + const modeRegistry = new modules.ModeRegistryService({ + projectPath: projectDir, + enableLogging: false + }); + + const sessionId = 'mode-checkpoint-test-001'; + + // Activate modes + modeRegistry.activateMode('autopilot', sessionId); + modeRegistry.activateMode('ralph', sessionId); + + // Create checkpoint with mode states + const checkpointService = new modules.CheckpointService({ + projectPath: projectDir, + enableLogging: false + }); + + const modeStates: Record = {}; + const activeModes = modeRegistry.getActiveModes(sessionId); + for (const mode of activeModes) { + modeStates[mode] = { active: true }; + } + + const checkpoint = await checkpointService.createCheckpoint( + sessionId, + 'compact', + { modeStates: modeStates as any, workflowState: null, memoryContext: null } + ); + + assert.ok(checkpoint.mode_states.autopilot?.active); + assert.ok(checkpoint.mode_states.ralph?.active); + + // Cleanup + modeRegistry.deactivateMode('autopilot', sessionId); + modeRegistry.deactivateMode('ralph', sessionId); + }); + }); + + // =========================================================================== + // Keyword Detection Integration Tests + // =========================================================================== + + describe('Keyword Detection Integration', () => { + it('INT-KEYWORD-1: Should detect mode keywords', async () => { + const testCases = [ + { text: 'use autopilot mode', expectedType: 'autopilot' }, + { text: 'run ultrawork now', expectedType: 'ultrawork' }, + { text: 'use ulw for this', expectedType: 'ultrawork' }, + { text: 'start ralph analysis', expectedType: 'ralph' }, + { text: 'plan this feature', expectedType: 'plan' }, + { text: 'use tdd approach', expectedType: 'tdd' } + ]; + + for (const tc of testCases) { + const options = tc.teamEnabled ? { teamEnabled: true } : undefined; + const keyword = modules.getPrimaryKeyword(tc.text, options); + assert.ok(keyword, `Expected keyword in "${tc.text}"`); + assert.equal(keyword.type, tc.expectedType); + } + }); + + it('INT-KEYWORD-2: Should not detect keywords in code blocks', async () => { + const text = 'Here is code:\n```\nautopilot\n```\nNo keyword above'; + const keywords = modules.detectKeywords(text); + assert.equal(keywords.some((k: any) => k.type === 'autopilot'), false); + }); + + it('INT-KEYWORD-3: Should handle cancel keyword with highest priority', async () => { + const text = 'use autopilot and cancelomc'; + const keyword = modules.getPrimaryKeyword(text); + assert.equal(keyword?.type, 'cancel'); + }); + + it('INT-KEYWORD-4: Should detect delegation keywords', async () => { + const testCases = [ + { text: 'ask codex to help', expectedType: 'codex' }, + { text: 'use gemini for this', expectedType: 'gemini' }, + { text: 'delegate to gpt', expectedType: 'codex' } + ]; + + for (const tc of testCases) { + const keywords = modules.detectKeywords(tc.text); + assert.ok( + keywords.some((k: any) => k.type === tc.expectedType), + `Expected ${tc.expectedType} in "${tc.text}"` + ); + } + }); + }); + + // =========================================================================== + // End-to-End Workflow Tests + // =========================================================================== + + describe('End-to-End Workflow Integration', () => { + it('INT-E2E-1: Complete workflow with mode activation and checkpoint', async () => { + const sessionId = 'e2e-workflow-001'; + + // 1. Create services + const modeRegistry = new modules.ModeRegistryService({ + projectPath: projectDir, + enableLogging: false + }); + + const checkpointService = new modules.CheckpointService({ + projectPath: projectDir, + enableLogging: false + }); + + const recoveryHandler = new modules.RecoveryHandler({ + projectPath: projectDir, + enableLogging: false + }); + + const stopHandler = new modules.StopHandler({ + projectPath: projectDir, + enableLogging: false + }); + + // 2. Activate mode + const activated = modeRegistry.activateMode('autopilot', sessionId); + assert.equal(activated, true); + + // 3. Create checkpoint before compaction + const precompactResult = await recoveryHandler.handlePreCompact({ + session_id: sessionId, + cwd: projectDir, + hook_event_name: 'PreCompact', + trigger: 'auto' + }); + + assert.equal(precompactResult.continue, true); + assert.ok(precompactResult.systemMessage); + + // 4. Simulate stop during active mode + const stopResult = await stopHandler.handleStop({ + session_id: sessionId + }); + + assert.equal(stopResult.continue, true); + // Should detect active mode (either via registry or context) + assert.ok( + ['active-mode', 'none'].includes(stopResult.mode || 'none') + ); + + // 5. Verify recovery is possible + const checkpoint = await recoveryHandler.checkRecovery(sessionId); + assert.ok(checkpoint); + + // 6. Deactivate mode on session end + modeRegistry.deactivateMode('autopilot', sessionId); + assert.equal(modeRegistry.isModeActive('autopilot', sessionId), false); + }); + + it('INT-E2E-2: Recovery workflow restores state correctly', async () => { + const sessionId = 'e2e-recovery-001'; + + // Setup services + const modeRegistry = new modules.ModeRegistryService({ + projectPath: projectDir, + enableLogging: false + }); + + const checkpointService = new modules.CheckpointService({ + projectPath: projectDir, + enableLogging: false + }); + + // Phase 1: Create state and checkpoint + modeRegistry.activateMode('ralph', sessionId); + + const modeStates: Record = {}; + for (const mode of modeRegistry.getActiveModes(sessionId)) { + modeStates[mode] = { active: true }; + } + + const checkpoint = await checkpointService.createCheckpoint( + sessionId, + 'compact', + { modeStates: modeStates as any, workflowState: null, memoryContext: null } + ); + + await checkpointService.saveCheckpoint(checkpoint); + + // Phase 2: Simulate session restart and recovery + // Clear mode state (simulating new session) + modeRegistry.deactivateMode('ralph', sessionId); + assert.equal(modeRegistry.isModeActive('ralph', sessionId), false); + + // Load checkpoint and restore state + const loadedCheckpoint = await checkpointService.getLatestCheckpoint(sessionId); + assert.ok(loadedCheckpoint); + assert.ok(loadedCheckpoint?.mode_states.ralph?.active); + + // Re-activate modes from checkpoint + for (const [mode, state] of Object.entries(loadedCheckpoint?.mode_states || {})) { + if ((state as any)?.active) { + modeRegistry.activateMode(mode as any, sessionId); + } + } + + // Verify restoration + assert.equal(modeRegistry.isModeActive('ralph', sessionId), true); + + // Cleanup + modeRegistry.deactivateMode('ralph', sessionId); + }); + + it('INT-E2E-3: Concurrent PreCompact operations use mutex', async () => { + const sessionId = 'e2e-mutex-001'; + + const recoveryHandler = new modules.RecoveryHandler({ + projectPath: projectDir, + enableLogging: false + }); + + // Start two concurrent PreCompact operations + const [result1, result2] = await Promise.all([ + recoveryHandler.handlePreCompact({ + session_id: sessionId, + cwd: projectDir, + hook_event_name: 'PreCompact', + trigger: 'auto' + }), + recoveryHandler.handlePreCompact({ + session_id: sessionId, + cwd: projectDir, + hook_event_name: 'PreCompact', + trigger: 'auto' + }) + ]); + + // Both should succeed + assert.equal(result1.continue, true); + assert.equal(result2.continue, true); + + // Verify only one checkpoint was created + const checkpointService = new modules.CheckpointService({ + projectPath: projectDir, + enableLogging: false + }); + + const checkpoints = await checkpointService.listCheckpoints(sessionId); + // Mutex should prevent duplicate checkpoints + assert.ok(checkpoints.length >= 1); + }); + + it('INT-E2E-4: Session lifecycle with all hooks', async () => { + const sessionId = 'e2e-lifecycle-001'; + + const modeRegistry = new modules.ModeRegistryService({ + projectPath: projectDir, + enableLogging: false + }); + + const recoveryHandler = new modules.RecoveryHandler({ + projectPath: projectDir, + enableLogging: false + }); + + const stopHandler = new modules.StopHandler({ + projectPath: projectDir, + enableLogging: false + }); + + // 1. Session start - check for recovery (should be none) + const initialRecovery = await recoveryHandler.checkRecovery(sessionId); + assert.equal(initialRecovery, null); + + // 2. Activate mode + modeRegistry.activateMode('ultrawork', sessionId); + + // 3. Detect keywords + const keywords = modules.detectKeywords('continue with ultrawork'); + assert.ok(keywords.some((k: any) => k.type === 'ultrawork')); + + // 4. Handle stop with active mode + const stopResult = await stopHandler.handleStop({ + session_id: sessionId, + active_mode: 'write' + }); + + assert.equal(stopResult.continue, true); + assert.ok(stopResult.mode === 'active-mode' || stopResult.mode === 'none'); + + // 5. PreCompact - create checkpoint + const precompactResult = await recoveryHandler.handlePreCompact({ + session_id: sessionId, + cwd: projectDir, + hook_event_name: 'PreCompact', + trigger: 'auto' + }); + + assert.equal(precompactResult.continue, true); + + // 6. Session end - cleanup + const activeModes = modeRegistry.getActiveModes(sessionId); + for (const mode of activeModes) { + modeRegistry.deactivateMode(mode, sessionId); + } + + assert.equal(modeRegistry.isAnyModeActive(sessionId), false); + + // 7. Verify recovery is available for next session + const finalRecovery = await recoveryHandler.checkRecovery(sessionId); + assert.ok(finalRecovery); + }); + }); + + // =========================================================================== + // Context Limit and User Abort Detection Tests + // =========================================================================== + + describe('Context Limit and User Abort Detection', () => { + it('INT-DETECT-1: Should detect context limit stop reasons', async () => { + const contextLimitCases = [ + { stop_reason: 'context_limit_reached' }, + { stop_reason: 'context_window_exceeded' }, + { end_turn_reason: 'max_tokens' }, + { stop_reason: 'max_context_exceeded' }, + { stop_reason: 'token_limit' }, + { stop_reason: 'conversation_too_long' } + ]; + + for (const context of contextLimitCases) { + const result = modules.isContextLimitStop(context); + assert.equal(result, true, `Expected context limit for ${JSON.stringify(context)}`); + } + }); + + it('INT-DETECT-2: Should detect user abort', async () => { + const userAbortCases = [ + { user_requested: true }, + { user_requested: true, stop_reason: 'cancel' }, + { stop_reason: 'user_cancel' } + ]; + + for (const context of userAbortCases) { + const result = modules.isUserAbort(context); + assert.equal(result, true, `Expected user abort for ${JSON.stringify(context)}`); + } + }); + + it('INT-DETECT-3: Should not false positive on normal stops', async () => { + const normalCases = [ + {}, + { stop_reason: 'normal' }, + { stop_reason: 'tool_use' }, + { active_workflow: true } + ]; + + for (const context of normalCases) { + const isContextLimit = modules.isContextLimitStop(context); + const isUserAbort = modules.isUserAbort(context); + assert.equal(isContextLimit, false, `Should not detect context limit for ${JSON.stringify(context)}`); + assert.equal(isUserAbort, false, `Should not detect user abort for ${JSON.stringify(context)}`); + } + }); + }); +}); diff --git a/ccw/tests/keyword-detector.test.ts b/ccw/tests/keyword-detector.test.ts new file mode 100644 index 00000000..547daba4 --- /dev/null +++ b/ccw/tests/keyword-detector.test.ts @@ -0,0 +1,307 @@ +/** + * Tests for KeywordDetector + */ + +import { describe, it, expect } from 'vitest'; +import { + detectKeywords, + hasKeyword, + getAllKeywords, + getPrimaryKeyword, + getKeywordType, + hasKeywordType, + sanitizeText, + removeCodeBlocks, + KEYWORD_PATTERNS, + KEYWORD_PRIORITY +} from '../src/core/hooks/keyword-detector.js'; +import type { KeywordType, DetectedKeyword } from '../src/core/hooks/keyword-detector.js'; + +describe('removeCodeBlocks', () => { + it('should remove fenced code blocks with ```', () => { + const text = 'Hello ```code here``` world'; + expect(removeCodeBlocks(text)).toBe('Hello world'); + }); + + it('should remove fenced code blocks with ~~~', () => { + const text = 'Hello ~~~code here~~~ world'; + expect(removeCodeBlocks(text)).toBe('Hello world'); + }); + + it('should remove inline code with backticks', () => { + const text = 'Use the `forEach` method'; + expect(removeCodeBlocks(text)).toBe('Use the method'); + }); + + it('should handle multiline code blocks', () => { + const text = 'Start\n```\nline1\nline2\n```\nEnd'; + expect(removeCodeBlocks(text)).toBe('Start\n\nEnd'); + }); + + it('should handle multiple code blocks', () => { + const text = 'Use `a` and `b` and ```c``` too'; + expect(removeCodeBlocks(text)).toBe('Use and and too'); + }); +}); + +describe('sanitizeText', () => { + it('should remove XML tag blocks', () => { + const text = 'Hello content world'; + expect(sanitizeText(text)).toBe('Hello world'); + }); + + it('should remove self-closing XML tags', () => { + const text = 'Hello
world'; + expect(sanitizeText(text)).toBe('Hello world'); + }); + + it('should remove URLs', () => { + const text = 'Visit https://example.com for more'; + expect(sanitizeText(text)).toBe('Visit for more'); + }); + + it('should remove file paths with ./', () => { + const text = 'Check ./src/file.ts for details'; + expect(sanitizeText(text)).toBe('Check for details'); + }); + + it('should remove file paths with /', () => { + const text = 'Edit /home/user/file.ts'; + expect(sanitizeText(text)).toBe('Edit '); + }); + + it('should remove code blocks', () => { + const text = 'See ```code``` below'; + expect(sanitizeText(text)).toBe('See below'); + }); + + it('should handle complex text', () => { + const text = 'Use api_key from https://example.com and check ./config.ts'; + const sanitized = sanitizeText(text); + expect(sanitized).not.toContain(''); + expect(sanitized).not.toContain('https://'); + expect(sanitized).not.toContain('./config.ts'); + }); +}); + +describe('detectKeywords', () => { + describe('basic detection', () => { + it('should detect "autopilot" keyword', () => { + const keywords = detectKeywords('use autopilot mode'); + expect(keywords.some(k => k.type === 'autopilot')).toBe(true); + }); + + it('should detect "ultrawork" keyword', () => { + const keywords = detectKeywords('run ultrawork now'); + expect(keywords.some(k => k.type === 'ultrawork')).toBe(true); + }); + + it('should detect "ultrawork" alias "ulw"', () => { + const keywords = detectKeywords('use ulw for this'); + expect(keywords.some(k => k.type === 'ultrawork')).toBe(true); + }); + + it('should detect "plan this" keyword', () => { + const keywords = detectKeywords('please plan this feature'); + expect(keywords.some(k => k.type === 'plan')).toBe(true); + }); + + it('should detect "tdd" keyword', () => { + const keywords = detectKeywords('use tdd approach'); + expect(keywords.some(k => k.type === 'tdd')).toBe(true); + }); + + it('should detect "ultrathink" keyword', () => { + const keywords = detectKeywords('ultrathink about this'); + expect(keywords.some(k => k.type === 'ultrathink')).toBe(true); + }); + + it('should detect "deepsearch" keyword', () => { + const keywords = detectKeywords('deepsearch for the answer'); + expect(keywords.some(k => k.type === 'deepsearch')).toBe(true); + }); + }); + + describe('cancel keyword', () => { + it('should detect "cancelomc" keyword', () => { + const keywords = detectKeywords('cancelomc'); + expect(keywords.some(k => k.type === 'cancel')).toBe(true); + }); + + it('should detect "stopomc" keyword', () => { + const keywords = detectKeywords('stopomc'); + expect(keywords.some(k => k.type === 'cancel')).toBe(true); + }); + }); + + describe('delegation keywords', () => { + it('should detect "ask codex" keyword', () => { + const keywords = detectKeywords('ask codex to help'); + expect(keywords.some(k => k.type === 'codex')).toBe(true); + }); + + it('should detect "use gemini" keyword', () => { + const keywords = detectKeywords('use gemini for this'); + expect(keywords.some(k => k.type === 'gemini')).toBe(true); + }); + + it('should detect "delegate to gpt" keyword', () => { + const keywords = detectKeywords('delegate to gpt'); + expect(keywords.some(k => k.type === 'codex')).toBe(true); + }); + }); + + describe('case insensitivity', () => { + it('should be case-insensitive', () => { + const keywords = detectKeywords('AUTOPILOT and ULTRAWORK'); + expect(keywords.some(k => k.type === 'autopilot')).toBe(true); + expect(keywords.some(k => k.type === 'ultrawork')).toBe(true); + }); + }); + + describe('team feature flag', () => { + it('should skip team keywords when teamEnabled is false', () => { + const keywords = detectKeywords('use team mode', { teamEnabled: false }); + expect(keywords.some(k => k.type === 'team')).toBe(false); + }); + + it('should detect team keywords when teamEnabled is true', () => { + const keywords = detectKeywords('use team mode', { teamEnabled: true }); + expect(keywords.some(k => k.type === 'team')).toBe(true); + }); + }); + + describe('code block filtering', () => { + it('should not detect keywords in code blocks', () => { + const text = 'Do this:\n```\nautopilot\n```\nnot in code'; + const keywords = detectKeywords(text); + expect(keywords.some(k => k.type === 'autopilot')).toBe(false); + }); + + it('should not detect keywords in inline code', () => { + const text = 'Use the `autopilot` function'; + const keywords = detectKeywords(text); + expect(keywords.some(k => k.type === 'autopilot')).toBe(false); + }); + }); + + describe('returned metadata', () => { + it('should include keyword position', () => { + const keywords = detectKeywords('please use autopilot now'); + const autopilot = keywords.find(k => k.type === 'autopilot'); + expect(autopilot?.position).toBe(11); // 'autopilot' starts at position 11 + }); + + it('should include matched keyword string', () => { + const keywords = detectKeywords('use AUTOPILOT'); + const autopilot = keywords.find(k => k.type === 'autopilot'); + expect(autopilot?.keyword).toBe('AUTOPILOT'); + }); + }); +}); + +describe('hasKeyword', () => { + it('should return true when keyword is present', () => { + expect(hasKeyword('use autopilot')).toBe(true); + }); + + it('should return false when no keyword is present', () => { + expect(hasKeyword('hello world')).toBe(false); + }); +}); + +describe('getAllKeywords', () => { + describe('conflict resolution', () => { + it('should return only "cancel" when cancel is present', () => { + const types = getAllKeywords('cancelomc and autopilot'); + expect(types).toEqual(['cancel']); + }); + + it('should remove "autopilot" when "team" is present', () => { + const types = getAllKeywords('use autopilot and team mode', { teamEnabled: true }); + expect(types).not.toContain('autopilot'); + expect(types).toContain('team'); + }); + }); + + it('should return empty array when no keywords', () => { + expect(getAllKeywords('hello world')).toEqual([]); + }); + + it('should return keywords in priority order', () => { + const types = getAllKeywords('use plan this and tdd'); + // 'plan' has priority 9, 'tdd' has priority 10 + expect(types).toEqual(['plan', 'tdd']); + }); +}); + +describe('getPrimaryKeyword', () => { + it('should return null when no keywords', () => { + expect(getPrimaryKeyword('hello world')).toBeNull(); + }); + + it('should return highest priority keyword', () => { + const keyword = getPrimaryKeyword('use plan this and tdd'); + expect(keyword?.type).toBe('plan'); + }); + + it('should return cancel as primary when present', () => { + const keyword = getPrimaryKeyword('use autopilot and cancelomc'); + expect(keyword?.type).toBe('cancel'); + }); + + it('should include original keyword metadata', () => { + const keyword = getPrimaryKeyword('USE AUTOPILOT'); + expect(keyword?.keyword).toBe('AUTOPILOT'); + expect(keyword?.position).toBeDefined(); + }); +}); + +describe('getKeywordType', () => { + it('should return type for valid keyword', () => { + expect(getKeywordType('autopilot')).toBe('autopilot'); + }); + + it('should return null for invalid keyword', () => { + expect(getKeywordType('notakeyword')).toBeNull(); + }); +}); + +describe('hasKeywordType', () => { + it('should return true for present keyword type', () => { + expect(hasKeywordType('use autopilot', 'autopilot')).toBe(true); + }); + + it('should return false for absent keyword type', () => { + expect(hasKeywordType('hello world', 'autopilot')).toBe(false); + }); + + it('should sanitize text before checking', () => { + expect(hasKeywordType('use `autopilot` in code', 'autopilot')).toBe(false); + }); +}); + +describe('KEYWORD_PRIORITY', () => { + it('should have cancel as highest priority', () => { + expect(KEYWORD_PRIORITY[0]).toBe('cancel'); + }); + + it('should have gemini as lowest priority', () => { + expect(KEYWORD_PRIORITY[KEYWORD_PRIORITY.length - 1]).toBe('gemini'); + }); +}); + +describe('KEYWORD_PATTERNS', () => { + it('should have patterns for all keyword types', () => { + const types: KeywordType[] = [ + 'cancel', 'ralph', 'autopilot', 'ultrapilot', 'team', 'ultrawork', + 'swarm', 'pipeline', 'ralplan', 'plan', 'tdd', + 'ultrathink', 'deepsearch', 'analyze', 'codex', 'gemini' + ]; + + types.forEach(type => { + expect(KEYWORD_PATTERNS[type]).toBeDefined(); + expect(KEYWORD_PATTERNS[type] instanceof RegExp).toBe(true); + }); + }); +}); diff --git a/ccw/tests/session-state-service.test.ts b/ccw/tests/session-state-service.test.ts new file mode 100644 index 00000000..00222e10 --- /dev/null +++ b/ccw/tests/session-state-service.test.ts @@ -0,0 +1,268 @@ +/** + * Tests for SessionStateService + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + validateSessionId, + getSessionStatePath, + loadSessionState, + saveSessionState, + clearSessionState, + updateSessionState, + incrementSessionLoad, + SessionStateService, + type SessionState +} from '../src/core/services/session-state-service.js'; +import { existsSync, rmSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +describe('validateSessionId', () => { + it('should accept valid session IDs', () => { + expect(validateSessionId('abc123')).toBe(true); + expect(validateSessionId('session-123')).toBe(true); + expect(validateSessionId('test_session')).toBe(true); + expect(validateSessionId('a')).toBe(true); + expect(validateSessionId('ABC-123_XYZ')).toBe(true); + }); + + it('should reject invalid session IDs', () => { + expect(validateSessionId('')).toBe(false); + expect(validateSessionId('.hidden')).toBe(false); + expect(validateSessionId('-starts-with-dash')).toBe(false); + expect(validateSessionId('../../../etc')).toBe(false); + expect(validateSessionId('has spaces')).toBe(false); + expect(validateSessionId('has/slash')).toBe(false); + expect(validateSessionId('has\\backslash')).toBe(false); + expect(validateSessionId('has.dot')).toBe(false); + }); + + it('should reject non-string inputs', () => { + expect(validateSessionId(null as any)).toBe(false); + expect(validateSessionId(undefined as any)).toBe(false); + expect(validateSessionId(123 as any)).toBe(false); + }); +}); + +describe('getSessionStatePath', () => { + const testSessionId = 'test-session-123'; + + describe('global storage (default)', () => { + it('should return path in global state directory', () => { + const path = getSessionStatePath(testSessionId); + expect(path).toContain('.claude'); + expect(path).toContain('.ccw-sessions'); + expect(path).toContain(`session-${testSessionId}.json`); + }); + + it('should throw error for invalid session ID', () => { + expect(() => getSessionStatePath('../../../etc')).toThrow('Invalid session ID'); + }); + }); + + describe('session-scoped storage', () => { + const projectPath = '/tmp/test-project'; + + it('should return path in project session directory', () => { + const path = getSessionStatePath(testSessionId, { + storageType: 'session-scoped', + projectPath + }); + expect(path).toContain('.workflow'); + expect(path).toContain('sessions'); + expect(path).toContain(testSessionId); + expect(path).toContain('state.json'); + }); + + it('should throw error when projectPath is missing', () => { + expect(() => getSessionStatePath(testSessionId, { storageType: 'session-scoped' })) + .toThrow('projectPath is required'); + }); + }); +}); + +describe('loadSessionState / saveSessionState', () => { + const testSessionId = 'test-load-save-session'; + const testState: SessionState = { + firstLoad: '2025-01-01T00:00:00.000Z', + loadCount: 5, + lastPrompt: 'test prompt' + }; + + afterEach(() => { + // Cleanup + try { + clearSessionState(testSessionId); + } catch { + // Ignore cleanup errors + } + }); + + it('should return null for non-existent session', () => { + const state = loadSessionState('non-existent-session-xyz'); + expect(state).toBeNull(); + }); + + it('should save and load session state', () => { + saveSessionState(testSessionId, testState); + const loaded = loadSessionState(testSessionId); + + expect(loaded).not.toBeNull(); + expect(loaded!.firstLoad).toBe(testState.firstLoad); + expect(loaded!.loadCount).toBe(testState.loadCount); + expect(loaded!.lastPrompt).toBe(testState.lastPrompt); + }); + + it('should return null for invalid session ID', () => { + expect(loadSessionState('../../../etc')).toBeNull(); + }); + + it('should handle state without optional fields', () => { + const minimalState: SessionState = { + firstLoad: '2025-01-01T00:00:00.000Z', + loadCount: 1 + }; + + saveSessionState(testSessionId, minimalState); + const loaded = loadSessionState(testSessionId); + + expect(loaded).not.toBeNull(); + expect(loaded!.lastPrompt).toBeUndefined(); + expect(loaded!.activeMode).toBeUndefined(); + }); +}); + +describe('clearSessionState', () => { + const testSessionId = 'test-clear-session'; + + it('should clear existing session state', () => { + saveSessionState(testSessionId, { + firstLoad: new Date().toISOString(), + loadCount: 1 + }); + + expect(loadSessionState(testSessionId)).not.toBeNull(); + + const result = clearSessionState(testSessionId); + expect(result).toBe(true); + expect(loadSessionState(testSessionId)).toBeNull(); + }); + + it('should return false for non-existent session', () => { + const result = clearSessionState('non-existent-session-xyz'); + expect(result).toBe(false); + }); + + it('should return false for invalid session ID', () => { + expect(clearSessionState('../../../etc')).toBe(false); + }); +}); + +describe('updateSessionState', () => { + const testSessionId = 'test-update-session'; + + afterEach(() => { + try { + clearSessionState(testSessionId); + } catch { + // Ignore cleanup errors + } + }); + + it('should create new state if none exists', () => { + const state = updateSessionState(testSessionId, { loadCount: 1 }); + + expect(state.firstLoad).toBeDefined(); + expect(state.loadCount).toBe(1); + }); + + it('should merge updates with existing state', () => { + saveSessionState(testSessionId, { + firstLoad: '2025-01-01T00:00:00.000Z', + loadCount: 5, + lastPrompt: 'old prompt' + }); + + const state = updateSessionState(testSessionId, { + loadCount: 6, + lastPrompt: 'new prompt' + }); + + expect(state.firstLoad).toBe('2025-01-01T00:00:00.000Z'); + expect(state.loadCount).toBe(6); + expect(state.lastPrompt).toBe('new prompt'); + }); +}); + +describe('incrementSessionLoad', () => { + const testSessionId = 'test-increment-session'; + + afterEach(() => { + try { + clearSessionState(testSessionId); + } catch { + // Ignore cleanup errors + } + }); + + it('should create new state on first load', () => { + const result = incrementSessionLoad(testSessionId, 'first prompt'); + + expect(result.isFirstPrompt).toBe(true); + expect(result.state.loadCount).toBe(1); + expect(result.state.lastPrompt).toBe('first prompt'); + }); + + it('should increment load count on subsequent loads', () => { + incrementSessionLoad(testSessionId, 'first prompt'); + const result = incrementSessionLoad(testSessionId, 'second prompt'); + + expect(result.isFirstPrompt).toBe(false); + expect(result.state.loadCount).toBe(2); + expect(result.state.lastPrompt).toBe('second prompt'); + }); + + it('should preserve prompt when not provided', () => { + incrementSessionLoad(testSessionId, 'first prompt'); + const result = incrementSessionLoad(testSessionId); + + expect(result.state.lastPrompt).toBe('first prompt'); + }); +}); + +describe('SessionStateService class', () => { + const testSessionId = 'test-service-class-session'; + let service: SessionStateService; + + beforeEach(() => { + service = new SessionStateService(); + }); + + afterEach(() => { + try { + service.clear(testSessionId); + } catch { + // Ignore cleanup errors + } + }); + + it('should provide object-oriented interface', () => { + const result = service.incrementLoad(testSessionId, 'test prompt'); + + expect(result.isFirstPrompt).toBe(true); + expect(service.getLoadCount(testSessionId)).toBe(1); + expect(service.isFirstLoad(testSessionId)).toBe(false); + }); + + it('should support update method', () => { + service.save(testSessionId, { + firstLoad: new Date().toISOString(), + loadCount: 1 + }); + + const state = service.update(testSessionId, { activeMode: 'write' }); + expect(state.activeMode).toBe('write'); + expect(state.loadCount).toBe(1); + }); +}); diff --git a/ccw/tests/user-abort-detector.test.ts b/ccw/tests/user-abort-detector.test.ts new file mode 100644 index 00000000..36f80392 --- /dev/null +++ b/ccw/tests/user-abort-detector.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for UserAbortDetector + */ + +import { describe, it, expect } from 'vitest'; +import { + isUserAbort, + getMatchingAbortPattern, + getAllMatchingAbortPatterns, + shouldAllowContinuation, + USER_ABORT_EXACT_PATTERNS, + USER_ABORT_SUBSTRING_PATTERNS +} from '../src/core/hooks/user-abort-detector.js'; +import type { StopContext } from '../src/core/hooks/context-limit-detector.js'; + +describe('isUserAbort', () => { + describe('user_requested flag', () => { + it('should detect user_requested true (snake_case)', () => { + const context: StopContext = { user_requested: true }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should detect userRequested true (camelCase)', () => { + const context: StopContext = { userRequested: true }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should not treat user_requested false as abort', () => { + const context: StopContext = { user_requested: false }; + expect(isUserAbort(context)).toBe(false); + }); + }); + + describe('exact patterns', () => { + it('should detect "aborted" exactly', () => { + const context: StopContext = { stop_reason: 'aborted' }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should detect "abort" exactly', () => { + const context: StopContext = { stop_reason: 'abort' }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should detect "cancel" exactly', () => { + const context: StopContext = { stop_reason: 'cancel' }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should detect "interrupt" exactly', () => { + const context: StopContext = { stop_reason: 'interrupt' }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should NOT detect exact patterns as substrings', () => { + // This tests that "cancel" doesn't match "cancelled_order" + const context: StopContext = { stop_reason: 'cancelled_order' }; + expect(isUserAbort(context)).toBe(false); + }); + + it('should NOT detect "abort" in "aborted_request"', () => { + // This tests exact match behavior + const context: StopContext = { stop_reason: 'aborted_request' }; + expect(isUserAbort(context)).toBe(false); + }); + }); + + describe('substring patterns', () => { + it('should detect "user_cancel"', () => { + const context: StopContext = { stop_reason: 'user_cancel' }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should detect "user_interrupt"', () => { + const context: StopContext = { stop_reason: 'user_interrupt' }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should detect "ctrl_c"', () => { + const context: StopContext = { stop_reason: 'ctrl_c' }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should detect "manual_stop"', () => { + const context: StopContext = { stop_reason: 'manual_stop' }; + expect(isUserAbort(context)).toBe(true); + }); + + it('should detect substring patterns within larger strings', () => { + const context: StopContext = { stop_reason: 'user_cancelled_by_client' }; + expect(isUserAbort(context)).toBe(true); + }); + }); + + describe('camelCase support', () => { + it('should detect patterns in stopReason (camelCase)', () => { + const context: StopContext = { stopReason: 'user_cancel' }; + expect(isUserAbort(context)).toBe(true); + }); + }); + + describe('case insensitivity', () => { + it('should be case-insensitive', () => { + const contexts: StopContext[] = [ + { stop_reason: 'ABORTED' }, + { stop_reason: 'Abort' }, + { stop_reason: 'USER_CANCEL' }, + { stop_reason: 'User_Interrupt' } + ]; + + contexts.forEach(context => { + expect(isUserAbort(context)).toBe(true); + }); + }); + }); + + describe('non-abort cases', () => { + it('should return false for undefined context', () => { + expect(isUserAbort(undefined)).toBe(false); + }); + + it('should return false for empty reason', () => { + const context: StopContext = { stop_reason: '' }; + expect(isUserAbort(context)).toBe(false); + }); + + it('should return false for non-abort reasons', () => { + const contexts: StopContext[] = [ + { stop_reason: 'end_turn' }, + { stop_reason: 'complete' }, + { stop_reason: 'context_limit' }, + { stop_reason: 'max_tokens' } + ]; + + contexts.forEach(context => { + expect(isUserAbort(context)).toBe(false); + }); + }); + }); +}); + +describe('getMatchingAbortPattern', () => { + it('should return null for undefined context', () => { + expect(getMatchingAbortPattern(undefined)).toBeNull(); + }); + + it('should return "user_requested" for user_requested flag', () => { + const context: StopContext = { user_requested: true }; + expect(getMatchingAbortPattern(context)).toBe('user_requested'); + }); + + it('should return the exact matching pattern', () => { + const context: StopContext = { stop_reason: 'cancel' }; + expect(getMatchingAbortPattern(context)).toBe('cancel'); + }); + + it('should return the substring matching pattern', () => { + const context: StopContext = { stop_reason: 'user_cancel' }; + expect(getMatchingAbortPattern(context)).toBe('user_cancel'); + }); + + it('should return null when no pattern matches', () => { + const context: StopContext = { stop_reason: 'complete' }; + expect(getMatchingAbortPattern(context)).toBeNull(); + }); +}); + +describe('getAllMatchingAbortPatterns', () => { + it('should return empty array for undefined context', () => { + expect(getAllMatchingAbortPatterns(undefined)).toEqual([]); + }); + + it('should return all matching patterns', () => { + const context: StopContext = { + user_requested: true, + stop_reason: 'user_cancel' + }; + const patterns = getAllMatchingAbortPatterns(context); + + expect(patterns).toContain('user_requested'); + expect(patterns).toContain('user_cancel'); + }); + + it('should deduplicate patterns', () => { + const context: StopContext = { stop_reason: 'cancel' }; + const patterns = getAllMatchingAbortPatterns(context); + + // Should only have one 'cancel' entry + expect(patterns.filter(p => p === 'cancel')).toHaveLength(1); + }); +}); + +describe('shouldAllowContinuation', () => { + it('should return true for undefined context', () => { + expect(shouldAllowContinuation(undefined)).toBe(true); + }); + + it('should return true for non-abort context', () => { + const context: StopContext = { stop_reason: 'complete' }; + expect(shouldAllowContinuation(context)).toBe(true); + }); + + it('should return false for user abort', () => { + const context: StopContext = { user_requested: true }; + expect(shouldAllowContinuation(context)).toBe(false); + }); + + it('should return false for cancel reason', () => { + const context: StopContext = { stop_reason: 'cancel' }; + expect(shouldAllowContinuation(context)).toBe(false); + }); +}); + +describe('pattern exports', () => { + it('should export exact patterns', () => { + expect(USER_ABORT_EXACT_PATTERNS).toContain('aborted'); + expect(USER_ABORT_EXACT_PATTERNS).toContain('abort'); + expect(USER_ABORT_EXACT_PATTERNS).toContain('cancel'); + expect(USER_ABORT_EXACT_PATTERNS).toContain('interrupt'); + }); + + it('should export substring patterns', () => { + expect(USER_ABORT_SUBSTRING_PATTERNS).toContain('user_cancel'); + expect(USER_ABORT_SUBSTRING_PATTERNS).toContain('user_interrupt'); + expect(USER_ABORT_SUBSTRING_PATTERNS).toContain('ctrl_c'); + expect(USER_ABORT_SUBSTRING_PATTERNS).toContain('manual_stop'); + }); +});