mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -1,10 +1,17 @@
|
|||||||
# CCW - Claude Code Workflow CLI
|
# CCW - Claude Code Workflow CLI
|
||||||
NEW LINE
|
|
||||||
|
|
||||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
[](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:
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
120
ccw/src/core/hooks/context-limit-detector.ts
Normal file
120
ccw/src/core/hooks/context-limit-detector.ts
Normal 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));
|
||||||
|
}
|
||||||
60
ccw/src/core/hooks/index.ts
Normal file
60
ccw/src/core/hooks/index.ts
Normal 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';
|
||||||
261
ccw/src/core/hooks/keyword-detector.ts
Normal file
261
ccw/src/core/hooks/keyword-detector.ts
Normal 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);
|
||||||
|
}
|
||||||
328
ccw/src/core/hooks/recovery-handler.ts
Normal file
328
ccw/src/core/hooks/recovery-handler.ts
Normal 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;
|
||||||
365
ccw/src/core/hooks/stop-handler.ts
Normal file
365
ccw/src/core/hooks/stop-handler.ts
Normal 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();
|
||||||
194
ccw/src/core/hooks/user-abort-detector.ts
Normal file
194
ccw/src/core/hooks/user-abort-detector.ts
Normal 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);
|
||||||
|
}
|
||||||
366
ccw/src/core/mode-workflow-map.ts
Normal file
366
ccw/src/core/mode-workflow-map.ts
Normal 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
if (existsSync(sessionStateFile)) {
|
|
||||||
try {
|
|
||||||
existingState = JSON.parse(readFileSync(sessionStateFile, 'utf-8'));
|
|
||||||
} catch {
|
|
||||||
existingState = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFirstPrompt = !existingState;
|
|
||||||
|
|
||||||
// Update session state (file-based)
|
// Build context using the service
|
||||||
const newState = isFirstPrompt
|
const result = await contextService.buildPromptContext({
|
||||||
? { firstLoad: new Date().toISOString(), loadCount: 1, lastPrompt: prompt }
|
sessionId,
|
||||||
: { ...existingState!, loadCount: existingState!.loadCount + 1, lastPrompt: prompt };
|
prompt,
|
||||||
|
projectId: projectPath
|
||||||
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
565
ccw/src/core/services/checkpoint-service.ts
Normal file
565
ccw/src/core/services/checkpoint-service.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
336
ccw/src/core/services/hook-context-service.ts
Normal file
336
ccw/src/core/services/hook-context-service.ts
Normal 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 });
|
||||||
|
}
|
||||||
75
ccw/src/core/services/index.ts
Normal file
75
ccw/src/core/services/index.ts
Normal 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';
|
||||||
730
ccw/src/core/services/mode-registry-service.ts
Normal file
730
ccw/src/core/services/mode-registry-service.ts
Normal 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 });
|
||||||
|
}
|
||||||
408
ccw/src/core/services/session-end-service.ts
Normal file
408
ccw/src/core/services/session-end-service.ts
Normal 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;
|
||||||
|
}
|
||||||
330
ccw/src/core/services/session-state-service.ts
Normal file
330
ccw/src/core/services/session-state-service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
148
ccw/tests/context-limit-detector.test.ts
Normal file
148
ccw/tests/context-limit-detector.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
801
ccw/tests/integration/hooks-integration.test.ts
Normal file
801
ccw/tests/integration/hooks-integration.test.ts
Normal 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)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
307
ccw/tests/keyword-detector.test.ts
Normal file
307
ccw/tests/keyword-detector.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
268
ccw/tests/session-state-service.test.ts
Normal file
268
ccw/tests/session-state-service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
228
ccw/tests/user-abort-detector.test.ts
Normal file
228
ccw/tests/user-abort-detector.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user