Add comprehensive tests for keyword detection, session state management, and user abort detection

- Implement tests for KeywordDetector including keyword detection, sanitization, and priority handling.
- Add tests for SessionStateService covering session validation, loading, saving, and state updates.
- Create tests for UserAbortDetector to validate user abort detection logic and pattern matching.
This commit is contained in:
catlog22
2026-02-18 21:48:56 +08:00
parent 65762af254
commit 46d4b4edfd
23 changed files with 6992 additions and 329 deletions

View File

@@ -1,10 +1,17 @@
# CCW - Claude Code Workflow CLI # CCW - Claude Code Workflow CLI
NEW LINE
[![Version](https://img.shields.io/badge/version-v6.3.19-blue.svg)](https://github.com/catlog22/Claude-Code-Workflow/releases) [![Version](https://img.shields.io/badge/version-v6.3.19-blue.svg)](https://github.com/catlog22/Claude-Code-Workflow/releases)
A powerful command-line tool for managing Claude Code Workflow with native CodexLens code intelligence, multi-model CLI orchestration, and interactive dashboard. 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 ## Installation
```bash ```bash
@@ -77,6 +84,44 @@ ccw view -o report.html
- **Dimension Analysis**: Findings by review dimension (Security, Architecture, Quality, etc.) - **Dimension Analysis**: Findings by review dimension (Security, Architecture, Quality, etc.)
- **Tabbed Interface**: Switch between Workflow and Reviews tabs - **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 ## Dashboard Data Sources
The CLI reads data from the `.workflow/` directory structure: The CLI reads data from the `.workflow/` directory structure:

View File

@@ -1,17 +1,20 @@
# Hooks Integration for Progressive Disclosure # 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 ## 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 - **Automatic Context Injection**: Session start hooks inject cluster context
- **Progressive Disclosure**: Shows related sessions, their summaries, and recovery commands - **Progressive Disclosure**: Shows related sessions, their summaries, and recovery commands
- **Silent Failure**: Hook failures don't block session start (< 5 seconds timeout) - **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 ## Hook Configuration
@@ -25,11 +28,15 @@ Place hook configurations in `.claude/settings.json`:
"session-start": [ "session-start": [
{ {
"name": "Progressive Disclosure", "name": "Progressive Disclosure",
"description": "Injects progressive disclosure index at session start", "description": "Injects progressive disclosure index at session start with recovery detection",
"enabled": true, "enabled": true,
"handler": "internal:context", "handler": "internal:context",
"timeout": 5000, "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 ### Hook Types
#### `session-start` #### `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` #### `PreCompact`
Triggered on explicit context requests. Same handler as `session-start`. 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` #### `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` #### `file-modified`
Triggered when files are modified. Can be used for auto-commits or notifications. 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 - `$PROJECT_PATH`: Current project path
- `$CLUSTER_ID`: Active cluster ID (if available) - `$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 ## API Endpoint
### Trigger Hook ### Trigger Hook
@@ -105,15 +358,17 @@ Content-Type: application/json
- `?path=/project/path`: Override project path - `?path=/project/path`: Override project path
- `?format=markdown|json`: Response format (default: markdown) - `?format=markdown|json`: Response format (default: markdown)
---
## Progressive Disclosure Output Format ## Progressive Disclosure Output Format
The hook returns a structured Markdown document: The hook returns a structured Markdown document:
```markdown ```markdown
<ccw-session-context> <ccw-session-context>
## 📋 Related Sessions Index ## Related Sessions Index
### 🔗 Active Cluster: {cluster_name} ({member_count} sessions) ### Active Cluster: {cluster_name} ({member_count} sessions)
**Intent**: {cluster_intent} **Intent**: {cluster_intent}
| # | Session | Type | Summary | Tokens | | # | Session | Type | Summary | Tokens |
@@ -130,11 +385,11 @@ ccw core-memory load {session_id}
ccw core-memory load-cluster {cluster_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}
</ccw-session-context> </ccw-session-context>
``` ```
## Examples ---
### Example 1: Basic Session Start Hook ## Complete Configuration Example
```json ```json
{ {
@@ -152,78 +407,88 @@ ccw core-memory load-cluster {cluster_id}
"session-start": [ "session-start": [
{ {
"name": "Progressive Disclosure", "name": "Progressive Disclosure",
"description": "Injects progressive disclosure index at session start with recovery detection",
"enabled": true, "enabled": true,
"handler": "internal:context", "handler": "internal:context",
"timeout": 5000, "timeout": 5000,
"failMode": "silent" "failMode": "silent"
} }
] ],
}
}
```
### Example 2: Custom Command Hook
```json
{
"hooks": {
"session-end": [ "session-end": [
{ {
"name": "Update Cluster", "name": "Update Cluster Metadata",
"description": "Updates cluster metadata after session ends",
"enabled": true, "enabled": true,
"command": "ccw core-memory update-cluster --session $SESSION_ID", "command": "ccw core-memory update-cluster --session $SESSION_ID",
"timeout": 30000, "timeout": 30000,
"async": true, "async": true,
"failMode": "log" "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"
} }
] ],
} "PreCompact": [
} {
``` "name": "Create Checkpoint",
"description": "Creates checkpoint before context compaction",
### Example 3: File Modification Hook "enabled": true,
"command": "ccw hook precompact --stdin",
```json "timeout": 10000,
{ "failMode": "log"
"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"
}
],
"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": [ "file-modified": [
{ {
"name": "Auto Commit", "name": "Auto Commit Checkpoint",
"description": "Creates git checkpoint on file modifications",
"enabled": false, "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, "timeout": 10000,
"async": true, "async": true,
"failMode": "log" "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 `<ccw-session-context>` 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 ## Testing
@@ -239,6 +504,16 @@ curl -X POST http://localhost:3456/api/hook \
ccw core-memory context --format markdown 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 ### Expected Output
If a cluster exists: If a cluster exists:
@@ -250,6 +525,8 @@ If no cluster exists:
- Message indicating no cluster found - Message indicating no cluster found
- Commands to search or trigger clustering - Commands to search or trigger clustering
---
## Troubleshooting ## Troubleshooting
### Hook Not Triggering ### Hook Not Triggering
@@ -270,25 +547,93 @@ If no cluster exists:
2. Verify session metadata exists 2. Verify session metadata exists
3. Check that the session has been added to a cluster 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 ## Performance Considerations
- Progressive disclosure index generation is fast (< 1 second typical) - Progressive disclosure index generation is fast (< 1 second typical)
- Uses cached metadata to avoid full session parsing - Uses cached metadata to avoid full session parsing
- Timeout enforced to prevent blocking - Timeout enforced to prevent blocking
- Failures return empty content instead of errors - 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 ## Architecture Diagram
- **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 │ Claude Code Session │
- **Conditional Hooks**: Execute hooks based on project state └─────────────────────────────────────────────────────────────┘
│ 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 ## References
- **Session Clustering**: See `session-clustering-service.ts` - **Session Clustering**: See `session-clustering-service.ts`
- **Core Memory Store**: See `core-memory-store.ts` - **Core Memory Store**: See `core-memory-store.ts`
- **Hook Routes**: See `routes/hooks-routes.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`

View File

@@ -4,15 +4,13 @@
*/ */
import chalk from 'chalk'; import chalk from 'chalk';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { existsSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
interface HookOptions { interface HookOptions {
stdin?: boolean; stdin?: boolean;
sessionId?: string; sessionId?: string;
prompt?: string; prompt?: string;
type?: 'session-start' | 'context' | 'session-end'; type?: 'session-start' | 'context' | 'session-end' | 'stop' | 'pre-compact';
path?: string; path?: string;
} }
@@ -21,12 +19,18 @@ interface HookData {
prompt?: string; prompt?: string;
cwd?: string; cwd?: string;
tool_input?: Record<string, unknown>; tool_input?: Record<string, unknown>;
} user_prompt?: string; // For UserPromptSubmit hook
// Stop context fields
interface SessionState { stop_reason?: string;
firstLoad: string; stopReason?: string;
loadCount: number; end_turn_reason?: string;
lastPrompt?: 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<string> {
}); });
} }
/**
* 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 * Get project path from hook data or current working directory
*/ */
@@ -95,27 +63,10 @@ function getProjectPath(hookCwd?: string): string {
return hookCwd || process.cwd(); 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<any | null> {
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 * 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 * - session-start: MEMORY.md summary + clusters + hot entities + patterns
* - per-prompt: vector search across all memory categories * - per-prompt: vector search across all memory categories
* *
@@ -153,71 +104,59 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
try { try {
const projectPath = getProjectPath(hookCwd); const projectPath = getProjectPath(hookCwd);
// Load existing session state // Check for recovery on session-start
const existingState = loadSessionState(sessionId); const isFirstPrompt = !prompt || prompt.trim() === '';
const isFirstPrompt = !existingState; let recoveryMessage = '';
// Update session state if (isFirstPrompt && sessionId) {
const newState: SessionState = isFirstPrompt try {
? { const { RecoveryHandler } = await import('../core/hooks/recovery-handler.js');
firstLoad: new Date().toISOString(), const recoveryHandler = new RecoveryHandler({
loadCount: 1, projectPath,
lastPrompt: prompt 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) { if (stdin) {
// For hooks: output content directly to stdout // 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.stdout.write(content);
} }
process.exit(0); process.exit(0);
@@ -229,9 +168,17 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
console.log(chalk.cyan('Session ID:'), sessionId); console.log(chalk.cyan('Session ID:'), sessionId);
console.log(chalk.cyan('Type:'), contextType); console.log(chalk.cyan('Type:'), contextType);
console.log(chalk.cyan('First Prompt:'), isFirstPrompt ? 'Yes' : 'No'); console.log(chalk.cyan('First Prompt:'), isFirstPrompt ? 'Yes' : 'No');
console.log(chalk.cyan('Load Count:'), newState.loadCount); console.log(chalk.cyan('Load Count:'), loadCount);
console.log(chalk.cyan('Builder:'), contextBuilder ? 'UnifiedContextBuilder' : 'Legacy (getProgressiveIndex)'); console.log(chalk.cyan('Builder:'), isAdvanced ? 'UnifiedContextBuilder' : 'Legacy (getProgressiveIndex)');
if (recoveryMessage) {
console.log(chalk.cyan('Recovery:'), 'Checkpoint found');
}
console.log(chalk.gray('─'.repeat(40))); console.log(chalk.gray('─'.repeat(40)));
if (recoveryMessage) {
console.log(chalk.yellow('Recovery Message:'));
console.log(recoveryMessage);
console.log();
}
if (content) { if (content) {
console.log(content); console.log(content);
} else { } else {
@@ -250,10 +197,10 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
/** /**
* Session end action - triggers async background tasks for memory maintenance. * Session end action - triggers async background tasks for memory maintenance.
* *
* Tasks executed: * Uses SessionEndService for unified task management:
* 1. Incremental vector embedding (index new/updated content) * - Incremental vector embedding (index new/updated content)
* 2. Incremental clustering (cluster unclustered sessions) * - Incremental clustering (cluster unclustered sessions)
* 3. Heat score updates (recalculate entity heat scores) * - Heat score updates (recalculate entity heat scores)
* *
* All tasks run best-effort; failures are logged but do not affect exit code. * All tasks run best-effort; failures are logged but do not affect exit code.
*/ */
@@ -283,33 +230,58 @@ async function sessionEndAction(options: HookOptions): Promise<void> {
try { try {
const projectPath = getProjectPath(hookCwd); const projectPath = getProjectPath(hookCwd);
const contextBuilder = await tryCreateContextBuilder(projectPath);
if (!contextBuilder) { // Clean up mode states for this session
// UnifiedContextBuilder not available - skip session-end tasks 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) { 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); process.exit(0);
} }
const tasks: Array<{ name: string; execute: () => Promise<void> }> = contextBuilder.buildSessionEndTasks(sessionId);
if (!stdin) { 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) // Execute all tasks
const results = await Promise.allSettled( const summary = await sessionEndService.executeEndTasks(sessionId);
tasks.map((task: { name: string; execute: () => Promise<void> }) => task.execute())
);
if (!stdin) { if (!stdin) {
for (let i = 0; i < tasks.length; i++) { for (const result of summary.results) {
const status = results[i].status === 'fulfilled' ? 'OK' : 'FAIL'; const status = result.success ? 'OK' : 'FAIL';
const color = status === 'OK' ? chalk.green : chalk.yellow; const color = result.success ? chalk.green : chalk.yellow;
console.log(color(` [${status}] ${tasks[i].name}`)); 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); process.exit(0);
@@ -322,6 +294,105 @@ async function sessionEndAction(options: HookOptions): Promise<void> {
} }
} }
/**
* 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<void> {
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 * Parse CCW status.json and output formatted status
*/ */
@@ -375,6 +446,238 @@ async function parseStatusAction(options: HookOptions): Promise<void> {
} }
} }
/**
* 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<void> {
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<string, string> = {
'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<void> {
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 * 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 parse-status Parse CCW status.json and display current/next command
session-context Progressive session context loading (replaces curl/bash hook) session-context Progressive session context loading (replaces curl/bash hook)
session-end Trigger background memory maintenance tasks 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 notify Send notification to ccw view dashboard
${chalk.bold('OPTIONS')} ${chalk.bold('OPTIONS')}
@@ -442,10 +748,32 @@ ${chalk.bold('EXAMPLES')}
${chalk.gray('# Interactive usage:')} ${chalk.gray('# Interactive usage:')}
ccw hook session-context --session-id abc123 ccw hook session-context --session-id abc123
${chalk.gray('# Handle Stop hook events:')}
ccw hook stop --stdin
${chalk.gray('# Notify dashboard:')} ${chalk.gray('# Notify dashboard:')}
ccw hook notify --stdin 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.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:')} ${chalk.gray('Add to .claude/settings.json for status tracking:')}
{ {
"hooks": { "hooks": {
@@ -479,6 +807,16 @@ export async function hookCommand(
case 'session-end': case 'session-end':
await sessionEndAction(options); await sessionEndAction(options);
break; break;
case 'stop':
await stopAction(options);
break;
case 'keyword':
await keywordAction(options);
break;
case 'pre-compact':
case 'precompact':
await preCompactAction(options);
break;
case 'notify': case 'notify':
await notifyAction(options); await notifyAction(options);
break; break;

View File

@@ -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));
}

View File

@@ -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';

View File

@@ -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<KeywordType, RegExp> = {
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: /(?<!\b(?:my|the|our|a|his|her|their|its)\s)\bteam\b|\bcoordinated\s+team\b/i,
pipeline: /\bagent\s+pipeline\b|\bchain\s+agents\b/i,
ralplan: /\b(ralplan)\b/i,
plan: /\bplan\s+(this|the)\b/i,
tdd: /\b(tdd)\b|\btest\s+first\b/i,
ultrathink: /\b(ultrathink)\b/i,
deepsearch: /\b(deepsearch)\b|\bsearch\s+the\s+codebase\b|\bfind\s+in\s+(the\s+)?codebase\b/i,
analyze: /\b(deep[\s-]?analyze|deepanalyze)\b/i,
codex: /\b(ask|use|delegate\s+to)\s+(codex|gpt)\b/i,
gemini: /\b(ask|use|delegate\s+to)\s+gemini\b/i
};
/**
* Priority order for keyword detection
* Higher priority keywords are checked first and take precedence in conflict resolution
*/
export const KEYWORD_PRIORITY: KeywordType[] = [
'cancel', 'ralph', 'autopilot', 'ultrapilot', 'team', 'ultrawork',
'swarm', 'pipeline', 'ralplan', 'plan', 'tdd',
'ultrathink', 'deepsearch', 'analyze', 'codex', 'gemini'
];
/**
* Remove code blocks from text to prevent false positives
* Handles both fenced code blocks and inline code
*
* @param text - The text to clean
* @returns Text with code blocks removed
*/
export function removeCodeBlocks(text: string): string {
// Remove fenced code blocks (``` or ~~~)
let result = text.replace(/```[\s\S]*?```/g, '');
result = result.replace(/~~~[\s\S]*?~~~/g, '');
// Remove inline code (single backticks)
result = result.replace(/`[^`]+`/g, '');
return result;
}
/**
* Sanitize text for keyword detection by removing structural noise.
* Strips XML tags, URLs, file paths, and code blocks.
*
* @param text - The text to sanitize
* @returns Sanitized text ready for keyword detection
*/
export function sanitizeText(text: string): string {
// Remove XML tag blocks (opening + content + closing; tag names must match)
let result = text.replace(/<(\w[\w-]*)[\s>][\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);
}

View File

@@ -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<string, Promise<HookOutput>>();
/**
* Queue depth counter per directory for diagnostics.
* Tracks how many callers are waiting on an in-flight compaction.
*/
const compactionQueueDepth = new Map<string, number>();
// =============================================================================
// 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<HookOutput> {
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<HookOutput> {
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<string, { active: boolean; phase?: string; activatedAt?: string }> = {};
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<Checkpoint | null> {
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<string> {
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;

View File

@@ -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<StopResult> {
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<boolean> {
// 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();

View File

@@ -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);
}

View File

@@ -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<string, unknown>;
}
/**
* 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<ExecutionMode, WorkflowActivationConfig> = {
/**
* 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<WorkflowActivationResult> {
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
}));
}

View File

@@ -1,6 +1,26 @@
/** /**
* Hooks Routes Module * Hooks Routes Module
* Handles all hooks-related API endpoints * 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 { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
@@ -235,26 +255,27 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
if (type === 'session-start' || type === 'context') { if (type === 'session-start' || type === 'context') {
try { try {
const projectPath = url.searchParams.get('path') || initialPath; const projectPath = url.searchParams.get('path') || initialPath;
const { SessionClusteringService } = await import('../session-clustering-service.js');
const clusteringService = new SessionClusteringService(projectPath); // Use HookContextService for unified context generation
const { HookContextService } = await import('../services/hook-context-service.js');
const contextService = new HookContextService({ projectPath });
const format = url.searchParams.get('format') || 'markdown'; const format = url.searchParams.get('format') || 'markdown';
const prompt = typeof extraData.prompt === 'string' ? extraData.prompt : undefined;
// Pass type and prompt to getProgressiveIndex // Build context using the service
// session-start: returns recent sessions by time const result = await contextService.buildPromptContext({
// context: returns intent-matched sessions based on prompt sessionId: resolvedSessionId || '',
const index = await clusteringService.getProgressiveIndex({ prompt,
type: type as 'session-start' | 'context', projectId: projectPath
sessionId: resolvedSessionId,
prompt: typeof extraData.prompt === 'string' ? extraData.prompt : undefined // Pass user prompt for intent matching
}); });
// Return context directly // Return context directly
return { return {
success: true, success: true,
type: 'context', type: result.type,
format, format,
content: index, content: result.content,
sessionId: resolvedSessionId sessionId: resolvedSessionId
}; };
} catch (error) { } catch (error) {
@@ -336,84 +357,56 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
} }
// API: Unified Session Context endpoint (Progressive Disclosure) // API: Unified Session Context endpoint (Progressive Disclosure)
// DEPRECATED: Use CLI command `ccw hook session-context --stdin` instead. // @DEPRECATED - This endpoint is deprecated and will be removed in a future version.
// This endpoint now uses file-based state (shared with CLI) for consistency. // Migration: Use CLI command `ccw hook session-context --stdin` instead.
// This endpoint now uses HookContextService for consistency with CLI.
// - First prompt: returns cluster-based session overview // - First prompt: returns cluster-based session overview
// - Subsequent prompts: returns intent-matched sessions based on prompt // - Subsequent prompts: returns intent-matched sessions based on prompt
if (pathname === '/api/hook/session-context' && req.method === 'POST') { if (pathname === '/api/hook/session-context' && req.method === 'POST') {
// Add deprecation warning header
res.setHeader('X-Deprecated', 'true');
res.setHeader('X-Deprecation-Message', 'Use CLI command "ccw hook session-context --stdin" instead. This endpoint will be removed in v2.0.0');
res.setHeader('X-Migration-Guide', 'https://github.com/ccw-project/ccw/blob/main/docs/migration-hooks.md#session-context');
handlePostRequest(req, res, async (body) => { handlePostRequest(req, res, async (body) => {
// 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 }; const { sessionId, prompt } = body as { sessionId?: string; prompt?: string };
if (!sessionId) { if (!sessionId) {
return { return {
success: true, success: true,
content: '', content: '',
error: 'sessionId is required' error: 'sessionId is required',
_deprecated: true,
_migration: 'Use "ccw hook session-context --stdin"'
}; };
} }
try { try {
const projectPath = url.searchParams.get('path') || initialPath; 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) // Use HookContextService for unified context generation
const sessionStateDir = join(homedir(), '.claude', '.ccw-sessions'); const { HookContextService } = await import('../services/hook-context-service.js');
const sessionStateFile = join(sessionStateDir, `session-${sessionId}.json`); const contextService = new HookContextService({ projectPath });
let existingState: { firstLoad: string; loadCount: number; lastPrompt?: string } | null = null; // Build context using the service
if (existsSync(sessionStateFile)) { const result = await contextService.buildPromptContext({
try { sessionId,
existingState = JSON.parse(readFileSync(sessionStateFile, 'utf-8')); prompt,
} catch { projectId: projectPath
existingState = null; });
}
}
const isFirstPrompt = !existingState;
// 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
}
return { return {
success: true, success: true,
type: contextType, type: result.type,
isFirstPrompt, isFirstPrompt: result.isFirstPrompt,
loadCount: newState.loadCount, loadCount: result.state.loadCount,
content, content: result.content,
sessionId sessionId,
_deprecated: true,
_migration: 'Use "ccw hook session-context --stdin"'
}; };
} catch (error) { } catch (error) {
console.error('[Hooks] Failed to generate session context:', error); console.error('[Hooks] Failed to generate session context:', error);
@@ -421,7 +414,9 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
success: true, success: true,
content: '', content: '',
sessionId, sessionId,
error: (error as Error).message error: (error as Error).message,
_deprecated: true,
_migration: 'Use "ccw hook session-context --stdin"'
}; };
} }
}); });
@@ -474,8 +469,16 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
return true; return true;
} }
// API: Parse CCW status.json and return formatted status (fallback) // API: Parse CCW status.json and return formatted status
// @DEPRECATED - Use /api/hook/ccw-exec with command=parse-status instead.
// This endpoint is kept for backward compatibility but will be removed.
if (pathname === '/api/hook/ccw-status' && req.method === 'POST') { if (pathname === '/api/hook/ccw-status' && req.method === 'POST') {
// Add deprecation warning header
res.setHeader('X-Deprecated', 'true');
res.setHeader('X-Deprecation-Message', 'Use /api/hook/ccw-exec with command=parse-status instead. This endpoint will be removed in v2.0.0');
console.warn('[DEPRECATED] /api/hook/ccw-status is deprecated. Use /api/hook/ccw-exec instead.');
handlePostRequest(req, res, async (body) => { handlePostRequest(req, res, async (body) => {
if (typeof body !== 'object' || body === null) { if (typeof body !== 'object' || body === null) {
return { error: 'Invalid request body', status: 400 }; return { error: 'Invalid request body', status: 400 };
@@ -487,51 +490,30 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
return { error: 'filePath is required', status: 400 }; return { error: 'filePath is required', status: 400 };
} }
// Check if this is a CCW status.json file // Delegate to ccw-exec for unified handling
if (!filePath.includes('status.json') ||
!filePath.match(/\.(ccw|ccw-coordinator|ccw-debug)\//)) {
return { success: false, message: 'Not a CCW status file' };
}
try { try {
// Read and parse status.json const result = await executeCliCommand('ccw', ['hook', 'parse-status', filePath]);
if (!existsSync(filePath)) {
return { success: false, message: 'Status file not found' }; if (result.success) {
return {
success: true,
message: result.output,
_deprecated: true,
_migration: 'Use /api/hook/ccw-exec with command=parse-status'
};
} else {
return {
success: false,
error: result.error,
_deprecated: true
};
} }
const statusContent = readFileSync(filePath, 'utf8');
const status = JSON.parse(statusContent);
// Extract key information
const sessionId = status.session_id || 'unknown';
const workflow = status.workflow || status.mode || 'unknown';
// Find current command (running or last completed)
let currentCommand = status.command_chain?.find((cmd: { status: string }) => 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) { } catch (error) {
console.error('[Hooks] Failed to parse CCW status:', error); console.error('[Hooks] Failed to parse CCW status:', error);
return { return {
success: false, success: false,
error: (error as Error).message error: (error as Error).message,
_deprecated: true
}; };
} }
}); });

View File

@@ -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<string, unknown>;
}
/**
* 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<string, unknown>;
}
/**
* 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<Record<ExecutionMode, ModeStateSnapshot>>;
/** 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<Record<ExecutionMode, ModeStateSnapshot>>;
memoryContext?: MemoryContextSnapshot | null;
todoSummary?: { pending: number; in_progress: number; completed: number };
}
): Promise<Checkpoint> {
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<string> {
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<Checkpoint | null> {
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<CheckpointMeta[]> {
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<Checkpoint | null> {
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<boolean> {
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<number> {
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<CheckpointServiceOptions>
): CheckpointService {
return new CheckpointService({
projectPath,
...options
});
}

View File

@@ -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<typeof import('../unified-context-builder.js').UnifiedContextBuilder> | null = null;
private clusteringService: InstanceType<typeof import('../session-clustering-service.js').SessionClusteringService> | 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<void> {
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<ContextResult> {
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<ContextResult> {
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<SessionEndTask[]> {
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<boolean> {
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 });
}

View File

@@ -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';

View File

@@ -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<ExecutionMode, ModeConfig> = {
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<string, unknown>): 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 });
}

View File

@@ -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<void>;
/** 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<string, EndTask> = 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<SessionEndSummary> {
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<void> {
// 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<SessionEndService> {
const service = new SessionEndService({ projectPath, enableLogging });
await service.registerBuiltinTasks(sessionId);
return service;
}

View File

@@ -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<SessionState>,
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>): 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;
}
}

View File

@@ -5,11 +5,15 @@
"session-start": [ "session-start": [
{ {
"name": "Progressive Disclosure", "name": "Progressive Disclosure",
"description": "Injects progressive disclosure index at session start", "description": "Injects progressive disclosure index at session start with recovery detection",
"enabled": true, "enabled": true,
"handler": "internal:context", "handler": "internal:context",
"timeout": 5000, "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": [ "session-end": [
@@ -21,6 +25,61 @@
"timeout": 30000, "timeout": 30000,
"async": true, "async": true,
"failMode": "log" "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": [ "file-modified": [
@@ -55,6 +114,10 @@
"handler": "Use 'internal:context' for built-in context generation, or 'command' for external commands", "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)", "failMode": "Options: 'silent' (ignore errors), 'log' (log errors), 'fail' (abort on error)",
"variables": "Available: $SESSION_ID, $FILE_PATH, $PROJECT_PATH, $CLUSTER_ID", "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()"
} }
} }

View File

@@ -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);
});
});

View File

@@ -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<string, { active: boolean }> = {};
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<string, { active: boolean }> = {};
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)}`);
}
});
});
});

View File

@@ -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 <tag>content</tag> world';
expect(sanitizeText(text)).toBe('Hello world');
});
it('should remove self-closing XML tags', () => {
const text = 'Hello <br/> 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 <config>api_key</config> from https://example.com and check ./config.ts';
const sanitized = sanitizeText(text);
expect(sanitized).not.toContain('<config>');
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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});