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
[](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');
+ });
+});