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

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

View File

@@ -4,15 +4,13 @@
*/
import chalk from 'chalk';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync } from 'fs';
interface HookOptions {
stdin?: boolean;
sessionId?: string;
prompt?: string;
type?: 'session-start' | 'context' | 'session-end';
type?: 'session-start' | 'context' | 'session-end' | 'stop' | 'pre-compact';
path?: string;
}
@@ -21,12 +19,18 @@ interface HookData {
prompt?: string;
cwd?: string;
tool_input?: Record<string, unknown>;
}
interface SessionState {
firstLoad: string;
loadCount: number;
lastPrompt?: string;
user_prompt?: string; // For UserPromptSubmit hook
// Stop context fields
stop_reason?: string;
stopReason?: string;
end_turn_reason?: string;
endTurnReason?: string;
user_requested?: boolean;
userRequested?: boolean;
active_mode?: 'analysis' | 'write' | 'review' | 'auto';
activeMode?: 'analysis' | 'write' | 'review' | 'auto';
active_workflow?: boolean;
activeWorkflow?: boolean;
}
/**
@@ -52,42 +56,6 @@ async function readStdin(): Promise<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
*/
@@ -95,27 +63,10 @@ function getProjectPath(hookCwd?: string): string {
return hookCwd || process.cwd();
}
/**
* Check if UnifiedContextBuilder is available (embedder dependencies present).
* Returns the builder instance or null if not available.
*/
async function tryCreateContextBuilder(projectPath: string): Promise<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
*
* Uses UnifiedContextBuilder when available (embedder present):
* Uses HookContextService for unified context generation:
* - session-start: MEMORY.md summary + clusters + hot entities + patterns
* - per-prompt: vector search across all memory categories
*
@@ -153,71 +104,59 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
try {
const projectPath = getProjectPath(hookCwd);
// Load existing session state
const existingState = loadSessionState(sessionId);
const isFirstPrompt = !existingState;
// Check for recovery on session-start
const isFirstPrompt = !prompt || prompt.trim() === '';
let recoveryMessage = '';
// Update session state
const newState: SessionState = isFirstPrompt
? {
firstLoad: new Date().toISOString(),
loadCount: 1,
lastPrompt: prompt
if (isFirstPrompt && sessionId) {
try {
const { RecoveryHandler } = await import('../core/hooks/recovery-handler.js');
const recoveryHandler = new RecoveryHandler({
projectPath,
enableLogging: !stdin
});
const checkpoint = await recoveryHandler.checkRecovery(sessionId);
if (checkpoint) {
recoveryMessage = await recoveryHandler.formatRecoveryMessage(checkpoint);
if (!stdin) {
console.log(chalk.yellow('Recovery checkpoint found!'));
}
}
} catch (recoveryError) {
// Recovery check failure should not affect session start
if (!stdin) {
console.log(chalk.yellow(`Recovery check warning: ${(recoveryError as Error).message}`));
}
: {
...existingState,
loadCount: existingState.loadCount + 1,
lastPrompt: prompt
};
saveSessionState(sessionId, newState);
// Determine context type and generate content
let contextType: 'session-start' | 'context';
let content = '';
// Try UnifiedContextBuilder first; fall back to getProgressiveIndex
const contextBuilder = await tryCreateContextBuilder(projectPath);
if (contextBuilder) {
// Use UnifiedContextBuilder
if (isFirstPrompt) {
contextType = 'session-start';
content = await contextBuilder.buildSessionStartContext();
} else if (prompt && prompt.trim().length > 0) {
contextType = 'context';
content = await contextBuilder.buildPromptContext(prompt);
} else {
contextType = 'context';
content = '';
}
} else {
// Fallback: use legacy SessionClusteringService.getProgressiveIndex()
const { SessionClusteringService } = await import('../core/session-clustering-service.js');
const clusteringService = new SessionClusteringService(projectPath);
if (isFirstPrompt) {
contextType = 'session-start';
content = await clusteringService.getProgressiveIndex({
type: 'session-start',
sessionId
});
} else if (prompt && prompt.trim().length > 0) {
contextType = 'context';
content = await clusteringService.getProgressiveIndex({
type: 'context',
sessionId,
prompt
});
} else {
contextType = 'context';
content = '';
}
}
// Use HookContextService for unified context generation
const { HookContextService } = await import('../core/services/hook-context-service.js');
const contextService = new HookContextService({ projectPath });
// Build context using the service
const result = await contextService.buildPromptContext({
sessionId,
prompt,
projectId: projectPath
});
const content = result.content;
const contextType = result.type;
const loadCount = result.state.loadCount;
const isAdvanced = await contextService.isAdvancedContextAvailable();
if (stdin) {
// For hooks: output content directly to stdout
if (content) {
// Include recovery message if available
if (recoveryMessage) {
process.stdout.write(recoveryMessage);
if (content) {
process.stdout.write('\n\n');
process.stdout.write(content);
}
} else if (content) {
process.stdout.write(content);
}
process.exit(0);
@@ -229,9 +168,17 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
console.log(chalk.cyan('Session ID:'), sessionId);
console.log(chalk.cyan('Type:'), contextType);
console.log(chalk.cyan('First Prompt:'), isFirstPrompt ? 'Yes' : 'No');
console.log(chalk.cyan('Load Count:'), newState.loadCount);
console.log(chalk.cyan('Builder:'), contextBuilder ? 'UnifiedContextBuilder' : 'Legacy (getProgressiveIndex)');
console.log(chalk.cyan('Load Count:'), loadCount);
console.log(chalk.cyan('Builder:'), isAdvanced ? 'UnifiedContextBuilder' : 'Legacy (getProgressiveIndex)');
if (recoveryMessage) {
console.log(chalk.cyan('Recovery:'), 'Checkpoint found');
}
console.log(chalk.gray('─'.repeat(40)));
if (recoveryMessage) {
console.log(chalk.yellow('Recovery Message:'));
console.log(recoveryMessage);
console.log();
}
if (content) {
console.log(content);
} else {
@@ -250,10 +197,10 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
/**
* Session end action - triggers async background tasks for memory maintenance.
*
* Tasks executed:
* 1. Incremental vector embedding (index new/updated content)
* 2. Incremental clustering (cluster unclustered sessions)
* 3. Heat score updates (recalculate entity heat scores)
* Uses SessionEndService for unified task management:
* - Incremental vector embedding (index new/updated content)
* - Incremental clustering (cluster unclustered sessions)
* - Heat score updates (recalculate entity heat scores)
*
* All tasks run best-effort; failures are logged but do not affect exit code.
*/
@@ -283,33 +230,58 @@ async function sessionEndAction(options: HookOptions): Promise<void> {
try {
const projectPath = getProjectPath(hookCwd);
const contextBuilder = await tryCreateContextBuilder(projectPath);
if (!contextBuilder) {
// UnifiedContextBuilder not available - skip session-end tasks
// Clean up mode states for this session
try {
const { ModeRegistryService } = await import('../core/services/mode-registry-service.js');
const modeRegistry = new ModeRegistryService({
projectPath,
enableLogging: !stdin
});
// Get active modes for this session and deactivate them
const activeModes = modeRegistry.getActiveModes(sessionId);
for (const mode of activeModes) {
modeRegistry.deactivateMode(mode, sessionId);
if (!stdin) {
console.log(chalk.gray(` Deactivated mode: ${mode}`));
}
}
} catch (modeError) {
// Mode cleanup failure should not affect session end
if (!stdin) {
console.log(chalk.gray('(UnifiedContextBuilder not available, skipping session-end tasks)'));
console.log(chalk.yellow(` Mode cleanup warning: ${(modeError as Error).message}`));
}
}
// Use SessionEndService for unified task management
const { createSessionEndService } = await import('../core/services/session-end-service.js');
const sessionEndService = await createSessionEndService(projectPath, sessionId, !stdin);
const registeredTasks = sessionEndService.getRegisteredTasks();
if (registeredTasks.length === 0) {
// No tasks available - skip session-end tasks
if (!stdin) {
console.log(chalk.gray('(No session-end tasks available)'));
}
process.exit(0);
}
const tasks: Array<{ name: string; execute: () => Promise<void> }> = contextBuilder.buildSessionEndTasks(sessionId);
if (!stdin) {
console.log(chalk.green(`Session End: executing ${tasks.length} background tasks...`));
console.log(chalk.green(`Session End: executing ${registeredTasks.length} background tasks...`));
}
// Execute all tasks concurrently (best-effort)
const results = await Promise.allSettled(
tasks.map((task: { name: string; execute: () => Promise<void> }) => task.execute())
);
// Execute all tasks
const summary = await sessionEndService.executeEndTasks(sessionId);
if (!stdin) {
for (let i = 0; i < tasks.length; i++) {
const status = results[i].status === 'fulfilled' ? 'OK' : 'FAIL';
const color = status === 'OK' ? chalk.green : chalk.yellow;
console.log(color(` [${status}] ${tasks[i].name}`));
for (const result of summary.results) {
const status = result.success ? 'OK' : 'FAIL';
const color = result.success ? chalk.green : chalk.yellow;
console.log(color(` [${status}] ${result.type} (${result.duration}ms)`));
}
console.log(chalk.gray(`Total: ${summary.successful}/${summary.totalTasks} tasks completed in ${summary.totalDuration}ms`));
}
process.exit(0);
@@ -322,6 +294,105 @@ async function sessionEndAction(options: HookOptions): Promise<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
*/
@@ -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
*/
@@ -424,6 +727,9 @@ ${chalk.bold('SUBCOMMANDS')}
parse-status Parse CCW status.json and display current/next command
session-context Progressive session context loading (replaces curl/bash hook)
session-end Trigger background memory maintenance tasks
stop Handle Stop hook events with Soft Enforcement
keyword Detect mode keywords in prompts and activate modes
pre-compact Handle PreCompact hook events (checkpoint creation)
notify Send notification to ccw view dashboard
${chalk.bold('OPTIONS')}
@@ -442,10 +748,32 @@ ${chalk.bold('EXAMPLES')}
${chalk.gray('# Interactive usage:')}
ccw hook session-context --session-id abc123
${chalk.gray('# Handle Stop hook events:')}
ccw hook stop --stdin
${chalk.gray('# Notify dashboard:')}
ccw hook notify --stdin
${chalk.gray('# Detect mode keywords:')}
ccw hook keyword --stdin --prompt "use autopilot to implement auth"
${chalk.gray('# Handle PreCompact events:')}
ccw hook pre-compact --stdin
${chalk.bold('HOOK CONFIGURATION')}
${chalk.gray('Add to .claude/settings.json for Stop hook:')}
{
"hooks": {
"Stop": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "ccw hook stop --stdin"
}]
}]
}
}
${chalk.gray('Add to .claude/settings.json for status tracking:')}
{
"hooks": {
@@ -479,6 +807,16 @@ export async function hookCommand(
case 'session-end':
await sessionEndAction(options);
break;
case 'stop':
await stopAction(options);
break;
case 'keyword':
await keywordAction(options);
break;
case 'pre-compact':
case 'precompact':
await preCompactAction(options);
break;
case 'notify':
await notifyAction(options);
break;

View File

@@ -0,0 +1,120 @@
/**
* ContextLimitDetector - Detects context limit stops
*
* When context is exhausted, Claude Code needs to stop so it can compact.
* Blocking these stops causes a deadlock: can't compact because can't stop,
* can't continue because context is full.
*
* This detector identifies context-limit related stop reasons to allow
* graceful handling of context exhaustion.
*
* @see https://github.com/Yeachan-Heo/oh-my-claudecode/issues/213
*/
/**
* Context from Stop hook event
*
* NOTE: Field names support both camelCase and snake_case variants
* for compatibility with different Claude Code versions.
*/
export interface StopContext {
/** Reason for stop (from Claude Code) - snake_case variant */
stop_reason?: string;
/** Reason for stop (from Claude Code) - camelCase variant */
stopReason?: string;
/** End turn reason (from API) - snake_case variant */
end_turn_reason?: string;
/** End turn reason (from API) - camelCase variant */
endTurnReason?: string;
/** Whether user explicitly requested stop - snake_case variant */
user_requested?: boolean;
/** Whether user explicitly requested stop - camelCase variant */
userRequested?: boolean;
}
/**
* Patterns that indicate context limit has been reached
*
* These patterns are matched case-insensitively against stop_reason
* and end_turn_reason fields.
*/
export const CONTEXT_LIMIT_PATTERNS: readonly string[] = [
'context_limit',
'context_window',
'context_exceeded',
'context_full',
'max_context',
'token_limit',
'max_tokens',
'conversation_too_long',
'input_too_long'
] as const;
/**
* Check if a reason string matches any context limit pattern
*
* @param reason - The reason string to check
* @returns true if the reason matches a context limit pattern
*/
function matchesContextPattern(reason: string): boolean {
const normalizedReason = reason.toLowerCase();
return CONTEXT_LIMIT_PATTERNS.some(pattern => normalizedReason.includes(pattern));
}
/**
* Detect if stop was triggered by context-limit related reasons
*
* When context is exhausted, Claude Code needs to stop so it can compact.
* Blocking these stops causes a deadlock: can't compact because can't stop,
* can't continue because context is full.
*
* @param context - The stop context from the hook event
* @returns true if the stop was due to context limit
*/
export function isContextLimitStop(context?: StopContext): boolean {
if (!context) return false;
// Get reasons from both field naming conventions
const stopReason = context.stop_reason ?? context.stopReason ?? '';
const endTurnReason = context.end_turn_reason ?? context.endTurnReason ?? '';
// Check both stop_reason and end_turn_reason for context limit patterns
return matchesContextPattern(stopReason) || matchesContextPattern(endTurnReason);
}
/**
* Get the matching context limit pattern if any
*
* @param context - The stop context from the hook event
* @returns The matching pattern or null if no match
*/
export function getMatchingContextPattern(context?: StopContext): string | null {
if (!context) return null;
const stopReason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase();
const endTurnReason = (context.end_turn_reason ?? context.endTurnReason ?? '').toLowerCase();
for (const pattern of CONTEXT_LIMIT_PATTERNS) {
if (stopReason.includes(pattern) || endTurnReason.includes(pattern)) {
return pattern;
}
}
return null;
}
/**
* Get all matching context limit patterns
*
* @param context - The stop context from the hook event
* @returns Array of matching patterns (may be empty)
*/
export function getAllMatchingContextPatterns(context?: StopContext): string[] {
if (!context) return [];
const stopReason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase();
const endTurnReason = (context.end_turn_reason ?? context.endTurnReason ?? '').toLowerCase();
const combinedReasons = `${stopReason} ${endTurnReason}`;
return CONTEXT_LIMIT_PATTERNS.filter(pattern => combinedReasons.includes(pattern));
}

View File

@@ -0,0 +1,60 @@
/**
* Core Hooks Module
*
* Provides detector functions and utilities for Claude Code hooks integration.
*/
// Context Limit Detector
export {
isContextLimitStop,
getMatchingContextPattern,
getAllMatchingContextPatterns,
CONTEXT_LIMIT_PATTERNS,
type StopContext
} from './context-limit-detector.js';
// User Abort Detector
export {
isUserAbort,
getMatchingAbortPattern,
getAllMatchingAbortPatterns,
shouldAllowContinuation,
USER_ABORT_EXACT_PATTERNS,
USER_ABORT_SUBSTRING_PATTERNS,
USER_ABORT_PATTERNS
} from './user-abort-detector.js';
// Keyword Detector
export {
detectKeywords,
hasKeyword,
getAllKeywords,
getPrimaryKeyword,
getKeywordType,
hasKeywordType,
sanitizeText,
removeCodeBlocks,
KEYWORD_PATTERNS,
KEYWORD_PRIORITY,
type KeywordType,
type DetectedKeyword
} from './keyword-detector.js';
// Stop Handler
export {
StopHandler,
createStopHandler,
defaultStopHandler,
type ExtendedStopContext,
type StopResult,
type StopHandlerOptions
} from './stop-handler.js';
// Recovery Handler
export {
RecoveryHandler,
createRecoveryHandler,
type PreCompactInput,
type HookOutput,
type RecoveryHandlerOptions
} from './recovery-handler.js';

View File

@@ -0,0 +1,261 @@
/**
* KeywordDetector - Detects magic keywords in user prompts
*
* Detects magic keywords in user prompts and returns the appropriate
* mode message to inject into context.
*
* Ported from oh-my-opencode's keyword-detector hook with adaptations
* for CCW architecture.
*/
/**
* Supported keyword types for mode detection
*/
export type KeywordType =
| 'cancel' // Priority 1: Cancel all operations
| 'ralph' // Priority 2: Ralph mode
| 'autopilot' // Priority 3: Auto-pilot mode
| 'ultrapilot' // Priority 4: Ultra-pilot mode (parallel build)
| 'team' // Priority 4.5: Team mode (coordinated agents)
| 'ultrawork' // Priority 5: Ultra-work mode
| 'swarm' // Priority 6: Swarm mode (multiple agents)
| 'pipeline' // Priority 7: Pipeline mode (chained agents)
| 'ralplan' // Priority 8: Ralplan mode
| 'plan' // Priority 9: Planning mode
| 'tdd' // Priority 10: Test-driven development mode
| 'ultrathink' // Priority 11: Deep thinking mode
| 'deepsearch' // Priority 12: Deep search mode
| 'analyze' // Priority 13: Analysis mode
| 'codex' // Priority 14: Delegate to Codex
| 'gemini'; // Priority 15: Delegate to Gemini
/**
* Detected keyword with metadata
*/
export interface DetectedKeyword {
/** Type of the detected keyword */
type: KeywordType;
/** The actual matched keyword string */
keyword: string;
/** Position in the original text where the keyword was found */
position: number;
}
/**
* Keyword patterns for each mode
*/
export const KEYWORD_PATTERNS: Record<KeywordType, RegExp> = {
cancel: /\b(cancelomc|stopomc)\b/i,
ralph: /\b(ralph)\b/i,
autopilot: /\b(autopilot|auto[\s-]?pilot|fullsend|full\s+auto)\b/i,
ultrapilot: /\b(ultrapilot|ultra-pilot)\b|\bparallel\s+build\b|\bswarm\s+build\b/i,
ultrawork: /\b(ultrawork|ulw)\b/i,
swarm: /\bswarm\s+\d+\s+agents?\b|\bcoordinated\s+agents\b|\bteam\s+mode\b/i,
team: /(?<!\b(?:my|the|our|a|his|her|their|its)\s)\bteam\b|\bcoordinated\s+team\b/i,
pipeline: /\bagent\s+pipeline\b|\bchain\s+agents\b/i,
ralplan: /\b(ralplan)\b/i,
plan: /\bplan\s+(this|the)\b/i,
tdd: /\b(tdd)\b|\btest\s+first\b/i,
ultrathink: /\b(ultrathink)\b/i,
deepsearch: /\b(deepsearch)\b|\bsearch\s+the\s+codebase\b|\bfind\s+in\s+(the\s+)?codebase\b/i,
analyze: /\b(deep[\s-]?analyze|deepanalyze)\b/i,
codex: /\b(ask|use|delegate\s+to)\s+(codex|gpt)\b/i,
gemini: /\b(ask|use|delegate\s+to)\s+gemini\b/i
};
/**
* Priority order for keyword detection
* Higher priority keywords are checked first and take precedence in conflict resolution
*/
export const KEYWORD_PRIORITY: KeywordType[] = [
'cancel', 'ralph', 'autopilot', 'ultrapilot', 'team', 'ultrawork',
'swarm', 'pipeline', 'ralplan', 'plan', 'tdd',
'ultrathink', 'deepsearch', 'analyze', 'codex', 'gemini'
];
/**
* Remove code blocks from text to prevent false positives
* Handles both fenced code blocks and inline code
*
* @param text - The text to clean
* @returns Text with code blocks removed
*/
export function removeCodeBlocks(text: string): string {
// Remove fenced code blocks (``` or ~~~)
let result = text.replace(/```[\s\S]*?```/g, '');
result = result.replace(/~~~[\s\S]*?~~~/g, '');
// Remove inline code (single backticks)
result = result.replace(/`[^`]+`/g, '');
return result;
}
/**
* Sanitize text for keyword detection by removing structural noise.
* Strips XML tags, URLs, file paths, and code blocks.
*
* @param text - The text to sanitize
* @returns Sanitized text ready for keyword detection
*/
export function sanitizeText(text: string): string {
// Remove XML tag blocks (opening + content + closing; tag names must match)
let result = text.replace(/<(\w[\w-]*)[\s>][\s\S]*?<\/\1>/g, '');
// Remove self-closing XML tags
result = result.replace(/<\w[\w-]*(?:\s[^>]*)?\s*\/>/g, '');
// Remove URLs
result = result.replace(/https?:\/\/\S+/g, '');
// Remove file paths - requires leading / or ./ or multi-segment dir/file.ext
result = result.replace(/(^|[\s"'`(])(?:\.?\/(?:[\w.-]+\/)*[\w.-]+|(?:[\w.-]+\/)+[\w.-]+\.\w+)/gm, '$1');
// Remove code blocks (fenced and inline)
result = removeCodeBlocks(result);
return result;
}
/**
* Detect keywords in text and return matches with type info
*
* @param text - The text to analyze
* @param options - Optional configuration
* @returns Array of detected keywords with metadata
*/
export function detectKeywords(
text: string,
options?: { teamEnabled?: boolean }
): DetectedKeyword[] {
const detected: DetectedKeyword[] = [];
const cleanedText = sanitizeText(text);
const teamEnabled = options?.teamEnabled ?? false;
// Check each keyword type in priority order
for (const type of KEYWORD_PRIORITY) {
// Skip team-related types when team feature is disabled
if ((type === 'team' || type === 'ultrapilot' || type === 'swarm') && !teamEnabled) {
continue;
}
const pattern = KEYWORD_PATTERNS[type];
const match = cleanedText.match(pattern);
if (match && match.index !== undefined) {
detected.push({
type,
keyword: match[0],
position: match.index
});
// Legacy ultrapilot/swarm also activate team mode internally
if (teamEnabled && (type === 'ultrapilot' || type === 'swarm')) {
detected.push({
type: 'team',
keyword: match[0],
position: match.index
});
}
}
}
return detected;
}
/**
* Check if text contains any magic keyword
*
* @param text - The text to check
* @returns true if any keyword is detected
*/
export function hasKeyword(text: string): boolean {
return detectKeywords(text).length > 0;
}
/**
* Get all detected keywords with conflict resolution applied
*
* Conflict resolution rules:
* - cancel suppresses everything (exclusive)
* - team beats autopilot (mutual exclusion)
*
* @param text - The text to analyze
* @param options - Optional configuration
* @returns Array of resolved keyword types in priority order
*/
export function getAllKeywords(
text: string,
options?: { teamEnabled?: boolean }
): KeywordType[] {
const detected = detectKeywords(text, options);
if (detected.length === 0) return [];
let types = Array.from(new Set(detected.map(d => d.type)));
// Exclusive: cancel suppresses everything
if (types.includes('cancel')) return ['cancel'];
// Mutual exclusion: team beats autopilot (ultrapilot/swarm now map to team at detection)
if (types.includes('team') && types.includes('autopilot')) {
types = types.filter(t => t !== 'autopilot');
}
// Sort by priority order
return KEYWORD_PRIORITY.filter(k => types.includes(k));
}
/**
* Get the highest priority keyword detected with conflict resolution
*
* @param text - The text to analyze
* @param options - Optional configuration
* @returns The primary detected keyword or null if none found
*/
export function getPrimaryKeyword(
text: string,
options?: { teamEnabled?: boolean }
): DetectedKeyword | null {
const allKeywords = getAllKeywords(text, options);
if (allKeywords.length === 0) {
return null;
}
// Get the highest priority keyword type
const primaryType = allKeywords[0];
// Find the original detected keyword for this type
const detected = detectKeywords(text, options);
const match = detected.find(d => d.type === primaryType);
return match || null;
}
/**
* Get keyword type for a given keyword string
*
* @param keyword - The keyword string to look up
* @returns The keyword type or null if not found
*/
export function getKeywordType(keyword: string): KeywordType | null {
const normalizedKeyword = keyword.toLowerCase();
for (const type of KEYWORD_PRIORITY) {
const pattern = KEYWORD_PATTERNS[type];
if (pattern.test(normalizedKeyword)) {
return type;
}
}
return null;
}
/**
* Check if a specific keyword type is detected in text
*
* @param text - The text to check
* @param type - The keyword type to look for
* @returns true if the keyword type is detected
*/
export function hasKeywordType(text: string, type: KeywordType): boolean {
const cleanedText = sanitizeText(text);
const pattern = KEYWORD_PATTERNS[type];
return pattern.test(cleanedText);
}

View File

@@ -0,0 +1,328 @@
/**
* RecoveryHandler - Session Recovery and PreCompact Handler
*
* Handles PreCompact hook events and session recovery for state preservation
* during context compaction and session restarts.
*
* Features:
* - PreCompact checkpoint creation before context compaction
* - Session recovery detection and message injection
* - Mutex lock to prevent concurrent compaction operations
*
* Based on oh-my-claudecode pre-compact pattern.
*/
import type { Checkpoint, CheckpointTrigger } from '../services/checkpoint-service.js';
// =============================================================================
// Types
// =============================================================================
/**
* Input from PreCompact hook event
*/
export interface PreCompactInput {
/** Session ID */
session_id: string;
/** Path to transcript file */
transcript_path?: string;
/** Current working directory */
cwd: string;
/** Permission mode */
permission_mode?: string;
/** Hook event name */
hook_event_name: 'PreCompact';
/** Trigger type */
trigger: 'manual' | 'auto';
/** Custom instructions */
custom_instructions?: string;
}
/**
* Output for hook handlers
*/
export interface HookOutput {
/** Whether to continue with the operation */
continue: boolean;
/** System message for context injection */
systemMessage?: string;
}
/**
* Options for RecoveryHandler
*/
export interface RecoveryHandlerOptions {
/** Project root path */
projectPath: string;
/** Enable logging */
enableLogging?: boolean;
}
// =============================================================================
// Compaction Mutex
// =============================================================================
/**
* Per-directory in-flight compaction promises.
* When a compaction is already running for a directory, new callers
* await the existing promise instead of running concurrently.
* This prevents race conditions when multiple subagent results
* arrive simultaneously (swarm/ultrawork).
*/
const inflightCompactions = new Map<string, Promise<HookOutput>>();
/**
* Queue depth counter per directory for diagnostics.
* Tracks how many callers are waiting on an in-flight compaction.
*/
const compactionQueueDepth = new Map<string, number>();
// =============================================================================
// RecoveryHandler Class
// =============================================================================
/**
* Handler for PreCompact hook events and session recovery
*/
export class RecoveryHandler {
private projectPath: string;
private enableLogging: boolean;
constructor(options: RecoveryHandlerOptions) {
this.projectPath = options.projectPath;
this.enableLogging = options.enableLogging ?? false;
}
// ---------------------------------------------------------------------------
// Public: PreCompact Handler
// ---------------------------------------------------------------------------
/**
* Handle PreCompact hook event
*
* Creates a checkpoint before compaction to preserve state.
* Uses mutex to prevent concurrent compaction for the same directory.
*
* @param input - PreCompact hook input
* @returns Promise resolving to hook output with checkpoint summary
*/
async handlePreCompact(input: PreCompactInput): Promise<HookOutput> {
const directory = input.cwd || this.projectPath;
// Check for in-flight compaction
const inflight = inflightCompactions.get(directory);
if (inflight) {
const depth = (compactionQueueDepth.get(directory) ?? 0) + 1;
compactionQueueDepth.set(directory, depth);
try {
// Await the existing compaction result
return await inflight;
} finally {
const current = compactionQueueDepth.get(directory) ?? 1;
if (current <= 1) {
compactionQueueDepth.delete(directory);
} else {
compactionQueueDepth.set(directory, current - 1);
}
}
}
// No in-flight compaction - run it and register the promise
const compactionPromise = this.doHandlePreCompact(input);
inflightCompactions.set(directory, compactionPromise);
try {
return await compactionPromise;
} finally {
inflightCompactions.delete(directory);
}
}
/**
* Internal PreCompact handler (unserialized)
*/
private async doHandlePreCompact(input: PreCompactInput): Promise<HookOutput> {
this.log(`Creating checkpoint for session ${input.session_id} (trigger: ${input.trigger})`);
try {
// Import services dynamically
const { CheckpointService } = await import('../services/checkpoint-service.js');
const { ModeRegistryService } = await import('../services/mode-registry-service.js');
// Create checkpoint service
const checkpointService = new CheckpointService({
projectPath: this.projectPath,
enableLogging: this.enableLogging
});
// Get mode registry for active modes
const modeRegistry = new ModeRegistryService({
projectPath: this.projectPath,
enableLogging: this.enableLogging
});
// Collect active mode states
const activeModes = modeRegistry.getActiveModes(input.session_id);
const modeStates: Record<string, { active: boolean; phase?: string; activatedAt?: string }> = {};
for (const mode of activeModes) {
modeStates[mode] = {
active: true,
activatedAt: new Date().toISOString()
};
}
// Create checkpoint
const trigger: CheckpointTrigger = input.trigger === 'manual' ? 'manual' : 'compact';
const checkpoint = await checkpointService.createCheckpoint(
input.session_id,
trigger,
{
modeStates: modeStates as any,
workflowState: null,
memoryContext: null
}
);
// Save checkpoint
await checkpointService.saveCheckpoint(checkpoint);
// Format recovery message
const systemMessage = checkpointService.formatRecoveryMessage(checkpoint);
this.log(`Checkpoint created: ${checkpoint.id}`);
return {
continue: true,
systemMessage
};
} catch (error) {
this.log(`Error creating checkpoint: ${(error as Error).message}`);
// Return success even on error - don't block compaction
return {
continue: true,
systemMessage: `[PRECOMPACT WARNING] Checkpoint creation failed: ${(error as Error).message}. Proceeding with compaction.`
};
}
}
// ---------------------------------------------------------------------------
// Public: Recovery Detection
// ---------------------------------------------------------------------------
/**
* Check for recoverable checkpoint for a session
*
* @param sessionId - The session ID to check
* @returns The most recent checkpoint or null if none found
*/
async checkRecovery(sessionId: string): Promise<Checkpoint | null> {
try {
const { CheckpointService } = await import('../services/checkpoint-service.js');
const checkpointService = new CheckpointService({
projectPath: this.projectPath,
enableLogging: this.enableLogging
});
const checkpoint = await checkpointService.getLatestCheckpoint(sessionId);
if (checkpoint) {
this.log(`Found recoverable checkpoint: ${checkpoint.id} (trigger: ${checkpoint.trigger})`);
}
return checkpoint;
} catch (error) {
this.log(`Error checking recovery: ${(error as Error).message}`);
return null;
}
}
/**
* Generate recovery message for context injection
*
* @param checkpoint - The checkpoint to format
* @returns Formatted recovery message
*/
async formatRecoveryMessage(checkpoint: Checkpoint): Promise<string> {
try {
const { CheckpointService } = await import('../services/checkpoint-service.js');
const checkpointService = new CheckpointService({
projectPath: this.projectPath,
enableLogging: false
});
return checkpointService.formatRecoveryMessage(checkpoint);
} catch (error) {
// Fallback to basic format
return `# Session Recovery
**Checkpoint ID:** ${checkpoint.id}
**Created:** ${checkpoint.created_at}
**Trigger:** ${checkpoint.trigger}
**Session:** ${checkpoint.session_id}
*This checkpoint was created to preserve session state.*`;
}
}
// ---------------------------------------------------------------------------
// Public: Mutex Status
// ---------------------------------------------------------------------------
/**
* Check if compaction is currently in progress for a directory
*
* @param directory - The directory to check
* @returns true if compaction is in progress
*/
isCompactionInProgress(directory: string): boolean {
return inflightCompactions.has(directory);
}
/**
* Get the number of callers queued behind an in-flight compaction
*
* @param directory - The directory to check
* @returns Number of queued callers (0 if no compaction in progress)
*/
getCompactionQueueDepth(directory: string): number {
return compactionQueueDepth.get(directory) ?? 0;
}
// ---------------------------------------------------------------------------
// Private: Utility Methods
// ---------------------------------------------------------------------------
/**
* Log a message if logging is enabled
*/
private log(message: string): void {
if (this.enableLogging) {
const timestamp = new Date().toISOString();
console.log(`[RecoveryHandler ${timestamp}] ${message}`);
}
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a RecoveryHandler instance
*
* @param options - Handler options
* @returns RecoveryHandler instance
*/
export function createRecoveryHandler(options: RecoveryHandlerOptions): RecoveryHandler {
return new RecoveryHandler(options);
}
// =============================================================================
// Default Export
// =============================================================================
export default RecoveryHandler;

View File

@@ -0,0 +1,365 @@
/**
* StopHandler - Unified stop hook handler with Soft Enforcement
*
* Handles Stop hook events with priority-based checking:
* 1. context-limit: Always allow stop (deadlock prevention)
* 2. user-abort: Respect user intent
* 3. active-workflow: Inject continuation message
* 4. active-mode: Inject continuation message (uses ModeRegistryService)
*
* Design:
* - ALWAYS returns continue: true (Soft Enforcement)
* - Injects continuation message instead of blocking
* - Logs all stop events for debugging
* - Integrates with ModeRegistryService for mode state detection
*/
import { isContextLimitStop } from './context-limit-detector.js';
import { isUserAbort, type StopContext } from './user-abort-detector.js';
import type { ExecutionMode } from '../services/mode-registry-service.js';
// =============================================================================
// Types
// =============================================================================
/**
* Extended stop context with additional fields
*/
export interface ExtendedStopContext extends StopContext {
/** Session ID */
session_id?: string;
/** Session ID (camelCase variant) */
sessionId?: string;
/** Project path */
project_path?: string;
/** Project path (camelCase variant) */
projectPath?: string;
/** Current active mode */
active_mode?: 'analysis' | 'write' | 'review' | 'auto';
/** Current active mode (camelCase variant) */
activeMode?: 'analysis' | 'write' | 'review' | 'auto';
/** Whether there's an active workflow */
active_workflow?: boolean;
/** Whether there's an active workflow (camelCase variant) */
activeWorkflow?: boolean;
}
/**
* Result of stop handling
*/
export interface StopResult {
/** ALWAYS true - we never block stops */
continue: true;
/** Optional continuation message to inject */
message?: string;
/** Which handler was triggered */
mode?: 'context-limit' | 'user-abort' | 'active-workflow' | 'active-mode' | 'none';
/** Additional metadata */
metadata?: {
/** Reason for stop (from context) */
reason?: string;
/** Whether user requested stop */
userRequested?: boolean;
/** Session ID */
sessionId?: string;
/** Active mode if any */
activeMode?: string;
/** Whether workflow was active */
activeWorkflow?: boolean;
};
}
/**
* Options for StopHandler
*/
export interface StopHandlerOptions {
/** Whether to enable logging */
enableLogging?: boolean;
/** Custom message for workflow continuation */
workflowContinuationMessage?: string;
/** Custom message for mode continuation */
modeContinuationMessage?: string;
/** Project path for ModeRegistryService */
projectPath?: string;
}
// =============================================================================
// Constants
// =============================================================================
/** Default workflow continuation message */
const DEFAULT_WORKFLOW_MESSAGE = `[WORKFLOW CONTINUATION]
An active workflow is in progress.
If you intended to stop:
- Use explicit cancellation command to exit cleanly
- Otherwise, continue with your workflow tasks
`;
/** Default mode continuation message */
const DEFAULT_MODE_MESSAGE = `[MODE CONTINUATION]
An active mode is set for this session.
Mode: {mode}
Continue with your current task, or use cancellation command to exit.
`;
// =============================================================================
// StopHandler
// =============================================================================
/**
* Handler for Stop hook events
*
* This handler implements Soft Enforcement: it never blocks stops,
* but injects continuation messages to encourage task completion.
*/
export class StopHandler {
private enableLogging: boolean;
private workflowContinuationMessage: string;
private modeContinuationMessage: string;
private projectPath?: string;
constructor(options: StopHandlerOptions = {}) {
this.enableLogging = options.enableLogging ?? false;
this.workflowContinuationMessage =
options.workflowContinuationMessage ?? DEFAULT_WORKFLOW_MESSAGE;
this.modeContinuationMessage =
options.modeContinuationMessage ?? DEFAULT_MODE_MESSAGE;
this.projectPath = options.projectPath;
}
// ---------------------------------------------------------------------------
// Public: Main Handler
// ---------------------------------------------------------------------------
/**
* Handle a stop event
*
* Priority order:
* 1. context-limit: Always allow (deadlock prevention)
* 2. user-abort: Respect user intent
* 3. active-workflow: Inject continuation message
* 4. active-mode: Inject continuation message (via ModeRegistryService)
*
* @param context - Stop context from hook event
* @returns Stop result (always continue: true)
*/
async handleStop(context?: ExtendedStopContext): Promise<StopResult> {
this.log('Handling stop event...');
// Extract common fields with both naming conventions
const sessionId = context?.session_id ?? context?.sessionId;
const activeMode = context?.active_mode ?? context?.activeMode;
const activeWorkflow = context?.active_workflow ?? context?.activeWorkflow;
const userRequested = context?.user_requested ?? context?.userRequested;
// Get stop reason
const reason = context?.stop_reason ?? context?.stopReason ?? '';
const endTurnReason = context?.end_turn_reason ?? context?.endTurnReason ?? '';
const fullReason = `${reason} ${endTurnReason}`.trim();
this.log(`Context: sessionId=${sessionId}, reason="${fullReason}", userRequested=${userRequested}`);
// Priority 1: Context Limit - CRITICAL: Never block
// Blocking context-limit stops causes deadlock (can't compact if can't stop)
if (isContextLimitStop(context)) {
this.log('Context limit detected - allowing stop');
return {
continue: true,
mode: 'context-limit',
metadata: {
reason: fullReason,
sessionId,
userRequested
}
};
}
// Priority 2: User Abort - Respect user intent
if (isUserAbort(context)) {
this.log('User abort detected - respecting user intent');
return {
continue: true,
mode: 'user-abort',
metadata: {
reason: fullReason,
userRequested: true,
sessionId
}
};
}
// Priority 3: Active Workflow - Inject continuation message
if (activeWorkflow) {
this.log('Active workflow detected - injecting continuation message');
return {
continue: true,
message: this.workflowContinuationMessage,
mode: 'active-workflow',
metadata: {
reason: fullReason,
sessionId,
activeWorkflow: true
}
};
}
// Priority 4: Active Mode - Check via ModeRegistryService
if (this.projectPath && sessionId) {
try {
const { ModeRegistryService } = await import('../services/mode-registry-service.js');
const modeRegistry = new ModeRegistryService({
projectPath: this.projectPath,
enableLogging: this.enableLogging
});
const activeModes = modeRegistry.getActiveModes(sessionId);
if (activeModes.length > 0) {
const primaryMode = activeModes[0];
const modeConfig = (await import('../services/mode-registry-service.js')).MODE_CONFIGS[primaryMode];
const modeName = modeConfig?.name ?? primaryMode;
this.log(`Active mode "${modeName}" detected via ModeRegistryService - injecting continuation message`);
const message = this.modeContinuationMessage.replace('{mode}', modeName);
return {
continue: true,
message,
mode: 'active-mode',
metadata: {
reason: fullReason,
sessionId,
activeMode: primaryMode
}
};
}
} catch (error) {
this.log(`Error checking mode registry: ${(error as Error).message}`);
// Fall through to check context-based active mode
}
}
// Fallback: Check active mode from context
if (activeMode) {
this.log(`Active mode "${activeMode}" detected from context - injecting continuation message`);
const message = this.modeContinuationMessage.replace('{mode}', String(activeMode));
return {
continue: true,
message,
mode: 'active-mode',
metadata: {
reason: fullReason,
sessionId,
activeMode: String(activeMode)
}
};
}
// Default: No special handling needed
this.log('No special handling needed - allowing stop');
return {
continue: true,
mode: 'none',
metadata: {
reason: fullReason,
sessionId,
userRequested
}
};
}
// ---------------------------------------------------------------------------
// Public: Utility Methods
// ---------------------------------------------------------------------------
/**
* Check if a stop should trigger continuation message
*
* @param context - Stop context
* @returns true if continuation message should be injected
*/
async shouldInjectContinuation(context?: ExtendedStopContext): Promise<boolean> {
// Context limit and user abort don't get continuation
if (isContextLimitStop(context) || isUserAbort(context)) {
return false;
}
// Active workflow gets continuation
const activeWorkflow = context?.active_workflow ?? context?.activeWorkflow;
if (activeWorkflow) {
return true;
}
// Check via ModeRegistryService if projectPath is available
const sessionId = context?.session_id ?? context?.sessionId;
if (this.projectPath && sessionId) {
try {
const { ModeRegistryService } = await import('../services/mode-registry-service.js');
const modeRegistry = new ModeRegistryService({
projectPath: this.projectPath,
enableLogging: false
});
if (modeRegistry.isAnyModeActive(sessionId)) {
return true;
}
} catch {
// Fall through to context-based check
}
}
// Fallback: Check active mode from context
const activeMode = context?.active_mode ?? context?.activeMode;
return Boolean(activeMode);
}
/**
* Get the stop reason from context
*
* @param context - Stop context
* @returns Normalized stop reason
*/
getStopReason(context?: ExtendedStopContext): string {
const reason = context?.stop_reason ?? context?.stopReason ?? '';
const endTurnReason = context?.end_turn_reason ?? context?.endTurnReason ?? '';
return `${reason} ${endTurnReason}`.trim() || 'unknown';
}
// ---------------------------------------------------------------------------
// Private: Utility Methods
// ---------------------------------------------------------------------------
/**
* Log a message if logging is enabled
*/
private log(message: string): void {
if (this.enableLogging) {
const timestamp = new Date().toISOString();
console.log(`[StopHandler ${timestamp}] ${message}`);
}
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a StopHandler instance
*
* @param options - Handler options
* @returns StopHandler instance
*/
export function createStopHandler(options?: StopHandlerOptions): StopHandler {
return new StopHandler(options);
}
// =============================================================================
// Default Export
// =============================================================================
/** Default StopHandler instance */
export const defaultStopHandler = new StopHandler();

View File

@@ -0,0 +1,194 @@
/**
* UserAbortDetector - Detects user-initiated abort stops
*
* Detects if a stop was due to user abort (not natural completion).
* This allows hooks to gracefully handle user-initiated stops differently
* from automatic or system-initiated stops.
*
* NOTE: Per official Anthropic docs, the Stop hook "Does not run if
* the stoppage occurred due to a user interrupt." This means this
* detector may never receive user-abort contexts in practice.
* It is kept as defensive code in case the behavior changes.
*/
import type { StopContext } from './context-limit-detector.js';
// Re-export StopContext for convenience
export type { StopContext } from './context-limit-detector.js';
/**
* Patterns that indicate user abort (exact match)
*
* These are short generic words that need exact matching to avoid
* false positives with substring matching (e.g., "cancel" in "cancelled_order").
*/
export const USER_ABORT_EXACT_PATTERNS: readonly string[] = [
'aborted',
'abort',
'cancel',
'interrupt'
] as const;
/**
* Patterns that indicate user abort (substring match)
*
* These are compound words that are safe for substring matching
* because they are unlikely to appear as parts of other words.
*/
export const USER_ABORT_SUBSTRING_PATTERNS: readonly string[] = [
'user_cancel',
'user_interrupt',
'ctrl_c',
'manual_stop'
] as const;
/**
* All user abort patterns combined
*/
export const USER_ABORT_PATTERNS: readonly string[] = [
...USER_ABORT_EXACT_PATTERNS,
...USER_ABORT_SUBSTRING_PATTERNS
] as const;
/**
* Check if a reason matches exact abort patterns
*
* @param reason - The reason string to check (should be lowercase)
* @returns true if the reason exactly matches an abort pattern
*/
function matchesExactPattern(reason: string): boolean {
return USER_ABORT_EXACT_PATTERNS.some(pattern => reason === pattern);
}
/**
* Check if a reason matches substring abort patterns
*
* @param reason - The reason string to check (should be lowercase)
* @returns true if the reason contains a substring abort pattern
*/
function matchesSubstringPattern(reason: string): boolean {
return USER_ABORT_SUBSTRING_PATTERNS.some(pattern => reason.includes(pattern));
}
/**
* Detect if stop was due to user abort (not natural completion)
*
* WARNING: These patterns are ASSUMED based on common conventions.
* As of 2025-01, Anthropic's Stop hook input schema does not document
* the exact stop_reason values. The patterns below are educated guesses:
*
* - user_cancel, user_interrupt: Likely user-initiated via UI
* - ctrl_c: Terminal interrupt (Ctrl+C)
* - manual_stop: Explicit stop button
* - abort, cancel, interrupt: Generic abort patterns
*
* If the detector fails to detect user aborts correctly, these patterns
* should be updated based on observed Claude Code behavior.
*
* @param context - The stop context from the hook event
* @returns true if the stop was due to user abort
*/
export function isUserAbort(context?: StopContext): boolean {
if (!context) return false;
// User explicitly requested stop (supports both camelCase and snake_case)
if (context.user_requested === true || context.userRequested === true) {
return true;
}
// Get reason from both field naming conventions
const reason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase();
// Check exact patterns first (short words that could cause false positives)
if (matchesExactPattern(reason)) {
return true;
}
// Then check substring patterns (compound words safe for includes)
if (matchesSubstringPattern(reason)) {
return true;
}
return false;
}
/**
* Get the matching user abort pattern if any
*
* @param context - The stop context from the hook event
* @returns The matching pattern or null if no match
*/
export function getMatchingAbortPattern(context?: StopContext): string | null {
if (!context) return null;
// Check explicit user_requested flag first
if (context.user_requested === true || context.userRequested === true) {
return 'user_requested';
}
const reason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase();
// Check exact patterns
for (const pattern of USER_ABORT_EXACT_PATTERNS) {
if (reason === pattern) {
return pattern;
}
}
// Check substring patterns
for (const pattern of USER_ABORT_SUBSTRING_PATTERNS) {
if (reason.includes(pattern)) {
return pattern;
}
}
return null;
}
/**
* Get all matching user abort patterns
*
* @param context - The stop context from the hook event
* @returns Array of matching patterns (may be empty)
*/
export function getAllMatchingAbortPatterns(context?: StopContext): string[] {
if (!context) return [];
const matches: string[] = [];
// Check explicit user_requested flag first
if (context.user_requested === true || context.userRequested === true) {
matches.push('user_requested');
}
const reason = (context.stop_reason ?? context.stopReason ?? '').toLowerCase();
// Check exact patterns
for (const pattern of USER_ABORT_EXACT_PATTERNS) {
if (reason === pattern) {
matches.push(pattern);
}
}
// Check substring patterns
for (const pattern of USER_ABORT_SUBSTRING_PATTERNS) {
if (reason.includes(pattern)) {
matches.push(pattern);
}
}
return Array.from(new Set(matches)); // Remove duplicates
}
/**
* Check if a stop should allow continuation
*
* User aborts should NOT force continuation - if the user explicitly
* stopped the session, we should respect that decision.
*
* @param context - The stop context from the hook event
* @returns true if continuation should be allowed (not a user abort)
*/
export function shouldAllowContinuation(context?: StopContext): boolean {
return !isUserAbort(context);
}

View File

@@ -0,0 +1,366 @@
/**
* ModeWorkflowMap - Mode to Workflow Mapping
*
* Maps execution modes to their corresponding workflow types and provides
* workflow activation configuration for each mode.
*
* Mode -> Workflow Mappings:
* - autopilot -> unified-execute-with-file (autonomous multi-step execution)
* - ralph -> team-planex (research and analysis)
* - ultrawork -> test-fix (ultra-focused work with test feedback)
* - swarm -> parallel-agents (multi-agent parallel execution)
* - pipeline -> lite-plan (sequential pipeline execution)
* - team -> team-iterdev (team collaboration)
* - ultraqa -> test-fix (QA-focused test cycles)
*/
import { ExecutionMode, MODE_CONFIGS } from './services/mode-registry-service.js';
// =============================================================================
// Types
// =============================================================================
/**
* Workflow types supported by the system
*/
export type WorkflowType =
| 'unified-execute-with-file'
| 'team-planex'
| 'test-fix'
| 'parallel-agents'
| 'lite-plan'
| 'team-iterdev';
/**
* Configuration for workflow activation
*/
export interface WorkflowActivationConfig {
/** The workflow type to activate */
workflowType: WorkflowType;
/** Whether this workflow requires session persistence */
requiresPersistence: boolean;
/** Default execution mode for the workflow */
defaultExecutionMode: 'analysis' | 'write' | 'auto';
/** Whether parallel execution is supported */
supportsParallel: boolean;
/** Maximum concurrent tasks (for parallel workflows) */
maxConcurrentTasks?: number;
/** Description of the workflow */
description: string;
/** Required context keys for activation */
requiredContext?: string[];
}
/**
* Context passed during workflow activation
*/
export interface WorkflowActivationContext {
/** Session ID for the workflow */
sessionId: string;
/** Project root path */
projectPath: string;
/** User prompt or task description */
prompt?: string;
/** Additional context data */
metadata?: Record<string, unknown>;
}
/**
* Result of workflow activation
*/
export interface WorkflowActivationResult {
/** Whether activation was successful */
success: boolean;
/** The session ID (may be new or existing) */
sessionId: string;
/** The activated workflow type */
workflowType: WorkflowType;
/** Error message if activation failed */
error?: string;
}
// =============================================================================
// Constants
// =============================================================================
/**
* Mode to Workflow mapping configuration
*
* Each mode maps to a specific workflow type with its activation config.
*/
export const MODE_WORKFLOW_MAP: Record<ExecutionMode, WorkflowActivationConfig> = {
/**
* Autopilot Mode -> unified-execute-with-file
* Autonomous execution of multi-step tasks with file-based state persistence.
*/
autopilot: {
workflowType: 'unified-execute-with-file',
requiresPersistence: true,
defaultExecutionMode: 'write',
supportsParallel: false,
description: 'Autonomous multi-step task execution with file-based state',
requiredContext: ['prompt']
},
/**
* Ralph Mode -> team-planex
* Research and Analysis Learning Pattern Handler for iterative exploration.
*/
ralph: {
workflowType: 'team-planex',
requiresPersistence: true,
defaultExecutionMode: 'analysis',
supportsParallel: false,
description: 'Research and analysis pattern handler for iterative exploration'
},
/**
* Ultrawork Mode -> test-fix
* Ultra-focused work mode with test-feedback loop.
*/
ultrawork: {
workflowType: 'test-fix',
requiresPersistence: true,
defaultExecutionMode: 'write',
supportsParallel: false,
description: 'Ultra-focused work mode with test-driven feedback loop'
},
/**
* Swarm Mode -> parallel-agents
* Multi-agent parallel execution for distributed task processing.
*/
swarm: {
workflowType: 'parallel-agents',
requiresPersistence: true,
defaultExecutionMode: 'write',
supportsParallel: true,
maxConcurrentTasks: 5,
description: 'Multi-agent parallel execution for distributed tasks'
},
/**
* Pipeline Mode -> lite-plan
* Sequential pipeline execution for stage-based workflows.
*/
pipeline: {
workflowType: 'lite-plan',
requiresPersistence: true,
defaultExecutionMode: 'write',
supportsParallel: false,
description: 'Sequential pipeline execution for stage-based workflows'
},
/**
* Team Mode -> team-iterdev
* Team collaboration mode for iterative development.
*/
team: {
workflowType: 'team-iterdev',
requiresPersistence: true,
defaultExecutionMode: 'write',
supportsParallel: true,
maxConcurrentTasks: 3,
description: 'Team collaboration mode for iterative development'
},
/**
* UltraQA Mode -> test-fix
* QA-focused test cycles with iterative quality improvements.
*/
ultraqa: {
workflowType: 'test-fix',
requiresPersistence: true,
defaultExecutionMode: 'write',
supportsParallel: false,
description: 'QA-focused test cycles with iterative quality improvements'
}
};
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Get the workflow type for a given execution mode
*
* @param mode - The execution mode
* @returns The corresponding workflow type, or undefined if not found
*/
export function getWorkflowForMode(mode: ExecutionMode): WorkflowType | undefined {
const config = MODE_WORKFLOW_MAP[mode];
return config?.workflowType;
}
/**
* Get the activation configuration for a given execution mode
*
* @param mode - The execution mode
* @returns The activation configuration, or undefined if not found
*/
export function getActivationConfig(mode: ExecutionMode): WorkflowActivationConfig | undefined {
return MODE_WORKFLOW_MAP[mode];
}
/**
* Get all modes that map to a specific workflow type
*
* @param workflowType - The workflow type to filter by
* @returns Array of execution modes that use this workflow
*/
export function getModesForWorkflow(workflowType: WorkflowType): ExecutionMode[] {
return (Object.entries(MODE_WORKFLOW_MAP) as [ExecutionMode, WorkflowActivationConfig][])
.filter(([, config]) => config.workflowType === workflowType)
.map(([mode]) => mode);
}
/**
* Check if a mode supports parallel execution
*
* @param mode - The execution mode
* @returns true if the mode supports parallel execution
*/
export function isParallelMode(mode: ExecutionMode): boolean {
const config = MODE_WORKFLOW_MAP[mode];
return config?.supportsParallel ?? false;
}
/**
* Get the maximum concurrent tasks for a parallel mode
*
* @param mode - The execution mode
* @returns Maximum concurrent tasks, or 1 if not a parallel mode
*/
export function getMaxConcurrentTasks(mode: ExecutionMode): number {
const config = MODE_WORKFLOW_MAP[mode];
if (!config?.supportsParallel) {
return 1;
}
return config.maxConcurrentTasks ?? 3;
}
/**
* Validate that required context is present for mode activation
*
* @param mode - The execution mode
* @param context - The activation context
* @returns Object with valid flag and missing keys if any
*/
export function validateActivationContext(
mode: ExecutionMode,
context: WorkflowActivationContext
): { valid: boolean; missingKeys: string[] } {
const config = MODE_WORKFLOW_MAP[mode];
const requiredKeys = config?.requiredContext ?? [];
const missingKeys: string[] = [];
for (const key of requiredKeys) {
if (key === 'prompt' && !context.prompt) {
missingKeys.push(key);
} else if (key === 'sessionId' && !context.sessionId) {
missingKeys.push(key);
} else if (key === 'projectPath' && !context.projectPath) {
missingKeys.push(key);
} else if (key.startsWith('metadata.') && context.metadata) {
const metaKey = key.substring('metadata.'.length);
if (!(metaKey in context.metadata)) {
missingKeys.push(key);
}
}
}
return {
valid: missingKeys.length === 0,
missingKeys
};
}
/**
* Activate a workflow for a given mode
*
* This function creates the necessary session state and returns
* activation result. The actual workflow execution is handled
* by the respective workflow handlers.
*
* @param mode - The execution mode to activate
* @param context - The activation context
* @returns Promise resolving to activation result
*/
export async function activateWorkflowForMode(
mode: ExecutionMode,
context: WorkflowActivationContext
): Promise<WorkflowActivationResult> {
const config = MODE_WORKFLOW_MAP[mode];
const modeConfig = MODE_CONFIGS[mode];
// Validate mode exists
if (!config) {
return {
success: false,
sessionId: context.sessionId,
workflowType: 'unified-execute-with-file', // Default fallback
error: `Unknown mode: ${mode}`
};
}
// Validate required context
const validation = validateActivationContext(mode, context);
if (!validation.valid) {
return {
success: false,
sessionId: context.sessionId,
workflowType: config.workflowType,
error: `Missing required context: ${validation.missingKeys.join(', ')}`
};
}
// Validate session ID
if (!context.sessionId) {
return {
success: false,
sessionId: '',
workflowType: config.workflowType,
error: 'Session ID is required for workflow activation'
};
}
// Return success result
// Note: Actual session state persistence is handled by ModeRegistryService
return {
success: true,
sessionId: context.sessionId,
workflowType: config.workflowType
};
}
/**
* Get a human-readable description for a mode's workflow
*
* @param mode - The execution mode
* @returns Description string
*/
export function getWorkflowDescription(mode: ExecutionMode): string {
const config = MODE_WORKFLOW_MAP[mode];
const modeConfig = MODE_CONFIGS[mode];
return config?.description ?? modeConfig?.description ?? `Workflow for ${mode} mode`;
}
/**
* List all available mode-to-workflow mappings
*
* @returns Array of mode-workflow mapping entries
*/
export function listModeWorkflowMappings(): Array<{
mode: ExecutionMode;
workflowType: WorkflowType;
description: string;
supportsParallel: boolean;
}> {
return (Object.entries(MODE_WORKFLOW_MAP) as [ExecutionMode, WorkflowActivationConfig][])
.map(([mode, config]) => ({
mode,
workflowType: config.workflowType,
description: config.description,
supportsParallel: config.supportsParallel
}));
}

View File

@@ -1,6 +1,26 @@
/**
* Hooks Routes Module
* Handles all hooks-related API endpoints
*
* ## API Endpoints
*
* ### Active Endpoints
* - POST /api/hook - Main hook endpoint for Claude Code notifications
* - Handles: session-start, context, CLI events, A2UI surfaces
* - POST /api/hook/ccw-exec - Execute CCW CLI commands and parse output
* - GET /api/hooks - Get hooks configuration from global and project settings
* - POST /api/hooks - Save a hook to settings
* - DELETE /api/hooks - Delete a hook from settings
*
* ### Deprecated Endpoints (will be removed in v2.0.0)
* - POST /api/hook/session-context - Use `ccw hook session-context --stdin` instead
* - POST /api/hook/ccw-status - Use /api/hook/ccw-exec with command=parse-status
*
* ## Service Layer
* All endpoints use unified services:
* - HookContextService: Context generation for session-start and per-prompt hooks
* - SessionStateService: Session state tracking and persistence
* - SessionEndService: Background task management for session-end events
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
@@ -235,26 +255,27 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
if (type === 'session-start' || type === 'context') {
try {
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 prompt = typeof extraData.prompt === 'string' ? extraData.prompt : undefined;
// Pass type and prompt to getProgressiveIndex
// session-start: returns recent sessions by time
// context: returns intent-matched sessions based on prompt
const index = await clusteringService.getProgressiveIndex({
type: type as 'session-start' | 'context',
sessionId: resolvedSessionId,
prompt: typeof extraData.prompt === 'string' ? extraData.prompt : undefined // Pass user prompt for intent matching
// Build context using the service
const result = await contextService.buildPromptContext({
sessionId: resolvedSessionId || '',
prompt,
projectId: projectPath
});
// Return context directly
return {
success: true,
type: 'context',
type: result.type,
format,
content: index,
content: result.content,
sessionId: resolvedSessionId
};
} catch (error) {
@@ -336,84 +357,56 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
}
// API: Unified Session Context endpoint (Progressive Disclosure)
// DEPRECATED: Use CLI command `ccw hook session-context --stdin` instead.
// This endpoint now uses file-based state (shared with CLI) for consistency.
// @DEPRECATED - This endpoint is deprecated and will be removed in a future version.
// 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
// - Subsequent prompts: returns intent-matched sessions based on prompt
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) => {
// Log deprecation warning
console.warn('[DEPRECATED] /api/hook/session-context is deprecated. Use "ccw hook session-context --stdin" instead.');
const { sessionId, prompt } = body as { sessionId?: string; prompt?: string };
if (!sessionId) {
return {
success: true,
content: '',
error: 'sessionId is required'
error: 'sessionId is required',
_deprecated: true,
_migration: 'Use "ccw hook session-context --stdin"'
};
}
try {
const projectPath = url.searchParams.get('path') || initialPath;
const { SessionClusteringService } = await import('../session-clustering-service.js');
const clusteringService = new SessionClusteringService(projectPath);
// Use file-based session state (shared with CLI hook.ts)
const sessionStateDir = join(homedir(), '.claude', '.ccw-sessions');
const sessionStateFile = join(sessionStateDir, `session-${sessionId}.json`);
let existingState: { firstLoad: string; loadCount: number; lastPrompt?: string } | null = null;
if (existsSync(sessionStateFile)) {
try {
existingState = JSON.parse(readFileSync(sessionStateFile, 'utf-8'));
} catch {
existingState = null;
}
}
const isFirstPrompt = !existingState;
// Use HookContextService for unified context generation
const { HookContextService } = await import('../services/hook-context-service.js');
const contextService = new HookContextService({ projectPath });
// Update session state (file-based)
const newState = isFirstPrompt
? { firstLoad: new Date().toISOString(), loadCount: 1, lastPrompt: prompt }
: { ...existingState!, loadCount: existingState!.loadCount + 1, lastPrompt: prompt };
if (!existsSync(sessionStateDir)) {
mkdirSync(sessionStateDir, { recursive: true });
}
writeFileSync(sessionStateFile, JSON.stringify(newState, null, 2));
// Determine which type of context to return
let contextType: 'session-start' | 'context';
let content: string;
if (isFirstPrompt) {
// First prompt: return session overview with clusters
contextType = 'session-start';
content = await clusteringService.getProgressiveIndex({
type: 'session-start',
sessionId
});
} else if (prompt && prompt.trim().length > 0) {
// Subsequent prompts with content: return intent-matched sessions
contextType = 'context';
content = await clusteringService.getProgressiveIndex({
type: 'context',
sessionId,
prompt
});
} else {
// Subsequent prompts without content: return minimal context
contextType = 'context';
content = ''; // No context needed for empty prompts
}
// Build context using the service
const result = await contextService.buildPromptContext({
sessionId,
prompt,
projectId: projectPath
});
return {
success: true,
type: contextType,
isFirstPrompt,
loadCount: newState.loadCount,
content,
sessionId
type: result.type,
isFirstPrompt: result.isFirstPrompt,
loadCount: result.state.loadCount,
content: result.content,
sessionId,
_deprecated: true,
_migration: 'Use "ccw hook session-context --stdin"'
};
} catch (error) {
console.error('[Hooks] Failed to generate session context:', error);
@@ -421,7 +414,9 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
success: true,
content: '',
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;
}
// 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') {
// 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) => {
if (typeof body !== 'object' || body === null) {
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 };
}
// Check if this is a CCW status.json file
if (!filePath.includes('status.json') ||
!filePath.match(/\.(ccw|ccw-coordinator|ccw-debug)\//)) {
return { success: false, message: 'Not a CCW status file' };
}
// Delegate to ccw-exec for unified handling
try {
// Read and parse status.json
if (!existsSync(filePath)) {
return { success: false, message: 'Status file not found' };
const result = await executeCliCommand('ccw', ['hook', 'parse-status', filePath]);
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) {
console.error('[Hooks] Failed to parse CCW status:', error);
return {
success: false,
error: (error as Error).message
error: (error as Error).message,
_deprecated: true
};
}
});

View File

@@ -0,0 +1,565 @@
/**
* CheckpointService - Session Checkpoint Management
*
* Creates and manages session checkpoints for state preservation during
* context compaction and workflow transitions.
*
* Features:
* - Checkpoint creation with workflow and mode state
* - Checkpoint storage in .workflow/checkpoints/
* - Automatic cleanup of old checkpoints (keeps last 10)
* - Recovery message formatting for context injection
*
* Based on oh-my-claudecode pre-compact pattern.
*/
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
readdirSync,
statSync,
unlinkSync
} from 'fs';
import { join, basename } from 'path';
import { ExecutionMode, MODE_CONFIGS } from './mode-registry-service.js';
// =============================================================================
// Types
// =============================================================================
/**
* Checkpoint trigger type
*/
export type CheckpointTrigger = 'manual' | 'auto' | 'compact' | 'mode-switch' | 'session-end';
/**
* Workflow state snapshot
*/
export interface WorkflowStateSnapshot {
/** Workflow type identifier */
type: string;
/** Current phase of the workflow */
phase: string;
/** Task IDs in pending state */
pending: string[];
/** Task IDs in completed state */
completed: string[];
/** Additional workflow metadata */
metadata?: Record<string, unknown>;
}
/**
* Mode state snapshot for a single mode
*/
export interface ModeStateSnapshot {
/** Whether the mode is active */
active: boolean;
/** Mode-specific phase or stage */
phase?: string;
/** ISO timestamp when mode was activated */
activatedAt?: string;
/** Additional mode-specific data */
data?: Record<string, unknown>;
}
/**
* Memory context snapshot
*/
export interface MemoryContextSnapshot {
/** Brief summary of accumulated context */
summary: string;
/** Key entities identified in the session */
keyEntities: string[];
/** Important decisions made */
decisions?: string[];
/** Open questions or blockers */
openQuestions?: string[];
}
/**
* Full checkpoint data structure
*/
export interface Checkpoint {
/** Unique checkpoint ID (timestamp-sessionId) */
id: string;
/** ISO timestamp of checkpoint creation */
created_at: string;
/** What triggered the checkpoint */
trigger: CheckpointTrigger;
/** Session ID this checkpoint belongs to */
session_id: string;
/** Project path */
project_path: string;
/** Workflow state snapshot */
workflow_state: WorkflowStateSnapshot | null;
/** Active mode states */
mode_states: Partial<Record<ExecutionMode, ModeStateSnapshot>>;
/** Memory context summary */
memory_context: MemoryContextSnapshot | null;
/** TODO summary if available */
todo_summary?: {
pending: number;
in_progress: number;
completed: number;
};
}
/**
* Checkpoint metadata for listing
*/
export interface CheckpointMeta {
/** Checkpoint ID */
id: string;
/** Creation timestamp */
created_at: string;
/** Session ID */
session_id: string;
/** Trigger type */
trigger: CheckpointTrigger;
/** File path */
path: string;
}
/**
* Options for checkpoint service
*/
export interface CheckpointServiceOptions {
/** Project root path */
projectPath: string;
/** Maximum checkpoints to keep per session (default: 10) */
maxCheckpointsPerSession?: number;
/** Enable logging */
enableLogging?: boolean;
}
// =============================================================================
// Constants
// =============================================================================
/** Default maximum checkpoints to keep per session */
const DEFAULT_MAX_CHECKPOINTS = 10;
/** Checkpoint directory name within .workflow */
const CHECKPOINT_DIR_NAME = 'checkpoints';
// =============================================================================
// CheckpointService Class
// =============================================================================
/**
* Service for managing session checkpoints
*/
export class CheckpointService {
private projectPath: string;
private checkpointsDir: string;
private maxCheckpoints: number;
private enableLogging: boolean;
constructor(options: CheckpointServiceOptions) {
this.projectPath = options.projectPath;
this.checkpointsDir = join(this.projectPath, '.workflow', CHECKPOINT_DIR_NAME);
this.maxCheckpoints = options.maxCheckpointsPerSession ?? DEFAULT_MAX_CHECKPOINTS;
this.enableLogging = options.enableLogging ?? false;
}
// ---------------------------------------------------------------------------
// Public: Checkpoint Creation
// ---------------------------------------------------------------------------
/**
* Create a checkpoint for a session
*
* @param sessionId - The session ID
* @param trigger - What triggered the checkpoint
* @param options - Optional additional data
* @returns Promise resolving to the created checkpoint
*/
async createCheckpoint(
sessionId: string,
trigger: CheckpointTrigger,
options?: {
workflowState?: WorkflowStateSnapshot | null;
modeStates?: Partial<Record<ExecutionMode, ModeStateSnapshot>>;
memoryContext?: MemoryContextSnapshot | null;
todoSummary?: { pending: number; in_progress: number; completed: number };
}
): Promise<Checkpoint> {
const timestamp = new Date().toISOString();
const checkpointId = this.generateCheckpointId(sessionId, timestamp);
const checkpoint: Checkpoint = {
id: checkpointId,
created_at: timestamp,
trigger,
session_id: sessionId,
project_path: this.projectPath,
workflow_state: options?.workflowState ?? null,
mode_states: options?.modeStates ?? {},
memory_context: options?.memoryContext ?? null,
todo_summary: options?.todoSummary
};
this.log(`Created checkpoint ${checkpointId} for session ${sessionId} (trigger: ${trigger})`);
return checkpoint;
}
/**
* Save a checkpoint to disk
*
* @param checkpoint - The checkpoint to save
* @returns The checkpoint ID
*/
async saveCheckpoint(checkpoint: Checkpoint): Promise<string> {
this.ensureCheckpointsDir();
const filename = `${checkpoint.id}.json`;
const filepath = join(this.checkpointsDir, filename);
try {
writeFileSync(filepath, JSON.stringify(checkpoint, null, 2), 'utf-8');
this.log(`Saved checkpoint to ${filepath}`);
// Clean up old checkpoints for this session
await this.cleanupOldCheckpoints(checkpoint.session_id);
return checkpoint.id;
} catch (error) {
this.log(`Error saving checkpoint: ${(error as Error).message}`);
throw error;
}
}
/**
* Load a checkpoint from disk
*
* @param checkpointId - The checkpoint ID to load
* @returns The checkpoint or null if not found
*/
async loadCheckpoint(checkpointId: string): Promise<Checkpoint | null> {
const filepath = join(this.checkpointsDir, `${checkpointId}.json`);
if (!existsSync(filepath)) {
this.log(`Checkpoint not found: ${checkpointId}`);
return null;
}
try {
const content = readFileSync(filepath, 'utf-8');
const checkpoint = JSON.parse(content) as Checkpoint;
this.log(`Loaded checkpoint ${checkpointId}`);
return checkpoint;
} catch (error) {
this.log(`Error loading checkpoint ${checkpointId}: ${(error as Error).message}`);
return null;
}
}
// ---------------------------------------------------------------------------
// Public: Checkpoint Listing
// ---------------------------------------------------------------------------
/**
* List all checkpoints, optionally filtered by session
*
* @param sessionId - Optional session ID to filter by
* @returns Array of checkpoint metadata
*/
async listCheckpoints(sessionId?: string): Promise<CheckpointMeta[]> {
if (!existsSync(this.checkpointsDir)) {
return [];
}
try {
const files = readdirSync(this.checkpointsDir)
.filter(f => f.endsWith('.json'))
.map(f => join(this.checkpointsDir, f));
const checkpoints: CheckpointMeta[] = [];
for (const filepath of files) {
try {
const content = readFileSync(filepath, 'utf-8');
const checkpoint = JSON.parse(content) as Checkpoint;
// Filter by session if provided
if (sessionId && checkpoint.session_id !== sessionId) {
continue;
}
checkpoints.push({
id: checkpoint.id,
created_at: checkpoint.created_at,
session_id: checkpoint.session_id,
trigger: checkpoint.trigger,
path: filepath
});
} catch {
// Skip invalid checkpoint files
}
}
// Sort by creation time (newest first)
checkpoints.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
return checkpoints;
} catch (error) {
this.log(`Error listing checkpoints: ${(error as Error).message}`);
return [];
}
}
/**
* Get the most recent checkpoint for a session
*
* @param sessionId - The session ID
* @returns The most recent checkpoint or null
*/
async getLatestCheckpoint(sessionId: string): Promise<Checkpoint | null> {
const checkpoints = await this.listCheckpoints(sessionId);
if (checkpoints.length === 0) {
return null;
}
return this.loadCheckpoint(checkpoints[0].id);
}
// ---------------------------------------------------------------------------
// Public: Recovery Message Formatting
// ---------------------------------------------------------------------------
/**
* Format a checkpoint as a recovery message for context injection
*
* @param checkpoint - The checkpoint to format
* @returns Formatted markdown string
*/
formatRecoveryMessage(checkpoint: Checkpoint): string {
const lines: string[] = [
'# Session Checkpoint Recovery',
'',
`**Checkpoint ID:** ${checkpoint.id}`,
`**Created:** ${checkpoint.created_at}`,
`**Trigger:** ${checkpoint.trigger}`,
`**Session:** ${checkpoint.session_id}`,
''
];
// Workflow state section
if (checkpoint.workflow_state) {
const ws = checkpoint.workflow_state;
lines.push('## Workflow State');
lines.push('');
lines.push(`- **Type:** ${ws.type}`);
lines.push(`- **Phase:** ${ws.phase}`);
if (ws.pending.length > 0) {
lines.push(`- **Pending Tasks:** ${ws.pending.length}`);
}
if (ws.completed.length > 0) {
lines.push(`- **Completed Tasks:** ${ws.completed.length}`);
}
lines.push('');
}
// Active modes section
const activeModes = Object.entries(checkpoint.mode_states)
.filter(([, state]) => state.active);
if (activeModes.length > 0) {
lines.push('## Active Modes');
lines.push('');
for (const [mode, state] of activeModes) {
const modeConfig = MODE_CONFIGS[mode as ExecutionMode];
const modeName = modeConfig?.name ?? mode;
lines.push(`- **${modeName}**`);
if (state.phase) {
lines.push(` - Phase: ${state.phase}`);
}
if (state.activatedAt) {
const age = Math.round(
(Date.now() - new Date(state.activatedAt).getTime()) / 60000
);
lines.push(` - Active for: ${age} minutes`);
}
}
lines.push('');
}
// TODO summary section
if (checkpoint.todo_summary) {
const todo = checkpoint.todo_summary;
const total = todo.pending + todo.in_progress + todo.completed;
if (total > 0) {
lines.push('## TODO Summary');
lines.push('');
lines.push(`- Pending: ${todo.pending}`);
lines.push(`- In Progress: ${todo.in_progress}`);
lines.push(`- Completed: ${todo.completed}`);
lines.push('');
}
}
// Memory context section
if (checkpoint.memory_context) {
const mem = checkpoint.memory_context;
lines.push('## Context Memory');
lines.push('');
if (mem.summary) {
lines.push(mem.summary);
lines.push('');
}
if (mem.keyEntities.length > 0) {
lines.push(`**Key Entities:** ${mem.keyEntities.join(', ')}`);
lines.push('');
}
if (mem.decisions && mem.decisions.length > 0) {
lines.push('**Decisions Made:**');
for (const decision of mem.decisions) {
lines.push(`- ${decision}`);
}
lines.push('');
}
if (mem.openQuestions && mem.openQuestions.length > 0) {
lines.push('**Open Questions:**');
for (const question of mem.openQuestions) {
lines.push(`- ${question}`);
}
lines.push('');
}
}
// Recovery instructions
lines.push('---');
lines.push('');
lines.push('*This checkpoint was created to preserve session state.*');
lines.push('*Review the information above to resume work effectively.*');
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Public: Cleanup
// ---------------------------------------------------------------------------
/**
* Delete a specific checkpoint
*
* @param checkpointId - The checkpoint ID to delete
* @returns true if deleted successfully
*/
async deleteCheckpoint(checkpointId: string): Promise<boolean> {
const filepath = join(this.checkpointsDir, `${checkpointId}.json`);
if (!existsSync(filepath)) {
return false;
}
try {
unlinkSync(filepath);
this.log(`Deleted checkpoint ${checkpointId}`);
return true;
} catch (error) {
this.log(`Error deleting checkpoint ${checkpointId}: ${(error as Error).message}`);
return false;
}
}
/**
* Clean up old checkpoints for a session, keeping only the most recent
*
* @param sessionId - The session ID
* @returns Number of checkpoints removed
*/
async cleanupOldCheckpoints(sessionId: string): Promise<number> {
const checkpoints = await this.listCheckpoints(sessionId);
if (checkpoints.length <= this.maxCheckpoints) {
return 0;
}
// Remove oldest checkpoints (those beyond the limit)
const toRemove = checkpoints.slice(this.maxCheckpoints);
let removed = 0;
for (const meta of toRemove) {
if (await this.deleteCheckpoint(meta.id)) {
removed++;
}
}
this.log(`Cleaned up ${removed} old checkpoints for session ${sessionId}`);
return removed;
}
// ---------------------------------------------------------------------------
// Public: Utility
// ---------------------------------------------------------------------------
/**
* Get the checkpoints directory path
*/
getCheckpointsDir(): string {
return this.checkpointsDir;
}
/**
* Ensure the checkpoints directory exists
*/
ensureCheckpointsDir(): void {
if (!existsSync(this.checkpointsDir)) {
mkdirSync(this.checkpointsDir, { recursive: true });
}
}
// ---------------------------------------------------------------------------
// Private: Helper Methods
// ---------------------------------------------------------------------------
/**
* Generate a unique checkpoint ID
*/
private generateCheckpointId(sessionId: string, timestamp: string): string {
// Format: YYYY-MM-DDTHH-mm-ss-sessionId
const safeTimestamp = timestamp.replace(/[:.]/g, '-').substring(0, 19);
return `${safeTimestamp}-${sessionId.substring(0, 8)}`;
}
/**
* Log a message if logging is enabled
*/
private log(message: string): void {
if (this.enableLogging) {
console.log(`[CheckpointService] ${message}`);
}
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a CheckpointService instance
*
* @param projectPath - Project root path
* @param options - Optional configuration
* @returns CheckpointService instance
*/
export function createCheckpointService(
projectPath: string,
options?: Partial<CheckpointServiceOptions>
): CheckpointService {
return new CheckpointService({
projectPath,
...options
});
}

View File

@@ -0,0 +1,336 @@
/**
* HookContextService - Unified context generation for Claude Code hooks
*
* Provides centralized context generation for:
* - session-start: MEMORY.md summary + cluster overview + hot entities + patterns
* - per-prompt: vector search + intent matching
* - session-end: task generation for async background processing
*
* Character limits:
* - session-start: <= 1000 chars
* - per-prompt: <= 500 chars
*/
import type { SessionEndTask } from '../unified-context-builder.js';
import { SessionStateService, type SessionState } from './session-state-service.js';
// =============================================================================
// Constants
// =============================================================================
/** Maximum character count for session-start context */
const SESSION_START_LIMIT = 1000;
/** Maximum character count for per-prompt context */
const PER_PROMPT_LIMIT = 500;
// =============================================================================
// Types
// =============================================================================
/**
* Options for building context
*/
export interface BuildContextOptions {
/** Session ID for state tracking */
sessionId: string;
/** Project root path */
projectId?: string;
/** Whether this is the first prompt in the session */
isFirstPrompt?: boolean;
/** Character limit for the generated context */
charLimit?: number;
/** Current prompt text (for per-prompt context) */
prompt?: string;
}
/**
* Context generation result
*/
export interface ContextResult {
/** Generated context content */
content: string;
/** Type of context generated */
type: 'session-start' | 'context';
/** Whether this was the first prompt */
isFirstPrompt: boolean;
/** Updated session state */
state: SessionState;
/** Character count of generated content */
charCount: number;
}
/**
* Options for HookContextService
*/
export interface HookContextServiceOptions {
/** Project root path */
projectPath: string;
/** Storage type for session state */
storageType?: 'global' | 'session-scoped';
}
// =============================================================================
// HookContextService
// =============================================================================
/**
* Service for generating hook context
*
* This service wraps UnifiedContextBuilder and SessionStateService to provide
* a unified interface for context generation across CLI hooks and API routes.
*/
export class HookContextService {
private projectPath: string;
private sessionStateService: SessionStateService;
private unifiedContextBuilder: InstanceType<typeof import('../unified-context-builder.js').UnifiedContextBuilder> | null = null;
private clusteringService: InstanceType<typeof import('../session-clustering-service.js').SessionClusteringService> | null = null;
private initialized = false;
constructor(options: HookContextServiceOptions) {
this.projectPath = options.projectPath;
this.sessionStateService = new SessionStateService({
storageType: options.storageType,
projectPath: options.projectPath
});
}
/**
* Initialize lazy-loaded services
*/
private async initialize(): Promise<void> {
if (this.initialized) return;
try {
// Try to load UnifiedContextBuilder (requires embedder)
const { isUnifiedEmbedderAvailable } = await import('../unified-vector-index.js');
if (isUnifiedEmbedderAvailable()) {
const { UnifiedContextBuilder } = await import('../unified-context-builder.js');
this.unifiedContextBuilder = new UnifiedContextBuilder(this.projectPath);
}
} catch {
// UnifiedContextBuilder not available
}
try {
// Always load SessionClusteringService as fallback
const { SessionClusteringService } = await import('../session-clustering-service.js');
this.clusteringService = new SessionClusteringService(this.projectPath);
} catch {
// SessionClusteringService not available
}
this.initialized = true;
}
// ---------------------------------------------------------------------------
// Public: Context Generation
// ---------------------------------------------------------------------------
/**
* Build context for session-start hook
*
* @param options - Build context options
* @returns Context generation result
*/
async buildSessionStartContext(options: BuildContextOptions): Promise<ContextResult> {
await this.initialize();
const charLimit = options.charLimit ?? SESSION_START_LIMIT;
// Update session state
const { isFirstPrompt, state } = this.sessionStateService.incrementLoad(
options.sessionId,
options.prompt
);
let content = '';
// Try UnifiedContextBuilder first
if (this.unifiedContextBuilder) {
content = await this.unifiedContextBuilder.buildSessionStartContext();
} else if (this.clusteringService) {
// Fallback to SessionClusteringService
content = await this.clusteringService.getProgressiveIndex({
type: 'session-start',
sessionId: options.sessionId
});
}
// Truncate if needed
if (content.length > charLimit) {
content = content.substring(0, charLimit - 20) + '...';
}
return {
content,
type: 'session-start',
isFirstPrompt,
state,
charCount: content.length
};
}
/**
* Build context for per-prompt hook
*
* @param options - Build context options
* @returns Context generation result
*/
async buildPromptContext(options: BuildContextOptions): Promise<ContextResult> {
await this.initialize();
const charLimit = options.charLimit ?? PER_PROMPT_LIMIT;
// Update session state
const { isFirstPrompt, state } = this.sessionStateService.incrementLoad(
options.sessionId,
options.prompt
);
let content = '';
let contextType: 'session-start' | 'context' = 'context';
// First prompt uses session-start context
if (isFirstPrompt) {
contextType = 'session-start';
if (this.unifiedContextBuilder) {
content = await this.unifiedContextBuilder.buildSessionStartContext();
} else if (this.clusteringService) {
content = await this.clusteringService.getProgressiveIndex({
type: 'session-start',
sessionId: options.sessionId
});
}
} else if (options.prompt && options.prompt.trim().length > 0) {
// Subsequent prompts use per-prompt context
contextType = 'context';
if (this.unifiedContextBuilder) {
content = await this.unifiedContextBuilder.buildPromptContext(options.prompt);
} else if (this.clusteringService) {
content = await this.clusteringService.getProgressiveIndex({
type: 'context',
sessionId: options.sessionId,
prompt: options.prompt
});
}
}
// Truncate if needed
if (content.length > charLimit) {
content = content.substring(0, charLimit - 20) + '...';
}
return {
content,
type: contextType,
isFirstPrompt,
state,
charCount: content.length
};
}
// ---------------------------------------------------------------------------
// Public: Session End Tasks
// ---------------------------------------------------------------------------
/**
* Build session end tasks for async background processing
*
* @param sessionId - Session ID for context
* @returns Array of tasks to execute
*/
async buildSessionEndTasks(sessionId: string): Promise<SessionEndTask[]> {
await this.initialize();
if (this.unifiedContextBuilder) {
return this.unifiedContextBuilder.buildSessionEndTasks(sessionId);
}
// No tasks available without UnifiedContextBuilder
return [];
}
// ---------------------------------------------------------------------------
// Public: Session State Management
// ---------------------------------------------------------------------------
/**
* Get session state
*
* @param sessionId - Session ID
* @returns Session state or null if not found
*/
getSessionState(sessionId: string): SessionState | null {
return this.sessionStateService.load(sessionId);
}
/**
* Check if this is the first prompt for a session
*
* @param sessionId - Session ID
* @returns true if this is the first prompt
*/
isFirstPrompt(sessionId: string): boolean {
return this.sessionStateService.isFirstLoad(sessionId);
}
/**
* Get load count for a session
*
* @param sessionId - Session ID
* @returns Load count (0 if not found)
*/
getLoadCount(sessionId: string): number {
return this.sessionStateService.getLoadCount(sessionId);
}
/**
* Clear session state
*
* @param sessionId - Session ID
* @returns true if state was cleared
*/
clearSessionState(sessionId: string): boolean {
return this.sessionStateService.clear(sessionId);
}
// ---------------------------------------------------------------------------
// Public: Utility Methods
// ---------------------------------------------------------------------------
/**
* Check if UnifiedContextBuilder is available
*
* @returns true if embedder is available
*/
async isAdvancedContextAvailable(): Promise<boolean> {
await this.initialize();
return this.unifiedContextBuilder !== null;
}
/**
* Get the project path
*/
getProjectPath(): string {
return this.projectPath;
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a HookContextService instance
*
* @param projectPath - Project root path
* @param storageType - Storage type for session state
* @returns HookContextService instance
*/
export function createHookContextService(
projectPath: string,
storageType?: 'global' | 'session-scoped'
): HookContextService {
return new HookContextService({ projectPath, storageType });
}

View File

@@ -0,0 +1,75 @@
/**
* Core Services Exports
*
* Central export point for all CCW core services.
*/
// Session State Management
export {
SessionStateService,
loadSessionState,
saveSessionState,
clearSessionState,
updateSessionState,
incrementSessionLoad,
getSessionStatePath,
validateSessionId
} from './session-state-service.js';
export type {
SessionState,
SessionStateOptions,
SessionStorageType
} from './session-state-service.js';
// Hook Context Service
export { HookContextService } from './hook-context-service.js';
export type { BuildContextOptions, ContextResult } from './hook-context-service.js';
// Session End Service
export { SessionEndService } from './session-end-service.js';
export type { EndTask, TaskResult } from './session-end-service.js';
// Mode Registry Service
export {
ModeRegistryService,
MODE_CONFIGS,
EXCLUSIVE_MODES,
STALE_MARKER_THRESHOLD,
createModeRegistryService
} from './mode-registry-service.js';
export type {
ModeConfig,
ModeStatus,
ModeRegistryOptions,
CanStartResult,
ExecutionMode
} from './mode-registry-service.js';
// Checkpoint Service
export { CheckpointService, createCheckpointService } from './checkpoint-service.js';
export type {
CheckpointServiceOptions,
Checkpoint,
CheckpointMeta,
CheckpointTrigger,
WorkflowStateSnapshot,
ModeStateSnapshot,
MemoryContextSnapshot
} from './checkpoint-service.js';
// CLI Session Manager
export { CliSessionManager } from './cli-session-manager.js';
export type {
CliSession,
CreateCliSessionOptions,
ExecuteInCliSessionOptions,
CliSessionOutputEvent
} from './cli-session-manager.js';
// Flow Executor
export { FlowExecutor } from './flow-executor.js';
export type { ExecutionContext, NodeResult } from './flow-executor.js';
// CLI Launch Registry
export { getLaunchConfig } from './cli-launch-registry.js';
export type { CliLaunchConfig, CliTool, LaunchMode } from './cli-launch-registry.js';

View File

@@ -0,0 +1,730 @@
/**
* ModeRegistryService - Centralized Mode State Management
*
* Provides unified mode state detection and management for CCW.
* All modes store state in `.workflow/modes/` directory for consistency.
*
* Features:
* - Mode activation/deactivation tracking
* - Exclusive mode conflict detection
* - Stale marker cleanup (1 hour threshold)
* - File-based state persistence
*
* Based on oh-my-claudecode mode-registry pattern.
*/
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, readdirSync, statSync, rmSync } from 'fs';
import { join, dirname } from 'path';
// =============================================================================
// Types
// =============================================================================
/**
* Supported execution modes
*/
export type ExecutionMode =
| 'autopilot'
| 'ralph'
| 'ultrawork'
| 'swarm'
| 'pipeline'
| 'team'
| 'ultraqa';
/**
* Mode configuration
*/
export interface ModeConfig {
/** Display name for the mode */
name: string;
/** Primary state file path (relative to .workflow/modes/) */
stateFile: string;
/** Property to check in JSON state for active status */
activeProperty: string;
/** Whether this mode is mutually exclusive with other exclusive modes */
exclusive?: boolean;
/** Description of the mode */
description?: string;
}
/**
* Status of a mode
*/
export interface ModeStatus {
/** The mode identifier */
mode: ExecutionMode;
/** Whether the mode is currently active */
active: boolean;
/** Path to the state file */
stateFilePath: string;
/** Session ID if session-scoped */
sessionId?: string;
}
/**
* Result of checking if a mode can be started
*/
export interface CanStartResult {
/** Whether the mode can be started */
allowed: boolean;
/** The mode that is blocking (if not allowed) */
blockedBy?: ExecutionMode;
/** Human-readable message */
message?: string;
}
/**
* Options for mode registry operations
*/
export interface ModeRegistryOptions {
/** Project root path */
projectPath: string;
/** Enable logging */
enableLogging?: boolean;
}
// =============================================================================
// Constants
// =============================================================================
/**
* Stale marker threshold (1 hour)
* Markers older than this are auto-removed to prevent crashed sessions
* from blocking indefinitely.
*/
const STALE_MARKER_THRESHOLD = 60 * 60 * 1000; // 1 hour in milliseconds
/**
* Mode configuration registry
*
* Maps each mode to its state file location and detection method.
* All paths are relative to .workflow/modes/ directory.
*/
const MODE_CONFIGS: Record<ExecutionMode, ModeConfig> = {
autopilot: {
name: 'Autopilot',
stateFile: 'autopilot-state.json',
activeProperty: 'active',
exclusive: true,
description: 'Autonomous execution mode for multi-step tasks'
},
ralph: {
name: 'Ralph',
stateFile: 'ralph-state.json',
activeProperty: 'active',
exclusive: false,
description: 'Research and Analysis Learning Pattern Handler'
},
ultrawork: {
name: 'Ultrawork',
stateFile: 'ultrawork-state.json',
activeProperty: 'active',
exclusive: false,
description: 'Ultra-focused work mode for deep tasks'
},
swarm: {
name: 'Swarm',
stateFile: 'swarm-state.json',
activeProperty: 'active',
exclusive: true,
description: 'Multi-agent swarm execution mode'
},
pipeline: {
name: 'Pipeline',
stateFile: 'pipeline-state.json',
activeProperty: 'active',
exclusive: true,
description: 'Pipeline execution mode for sequential tasks'
},
team: {
name: 'Team',
stateFile: 'team-state.json',
activeProperty: 'active',
exclusive: false,
description: 'Team collaboration mode'
},
ultraqa: {
name: 'UltraQA',
stateFile: 'ultraqa-state.json',
activeProperty: 'active',
exclusive: false,
description: 'Ultra-focused QA mode'
}
};
/**
* Modes that are mutually exclusive (cannot run concurrently)
*/
const EXCLUSIVE_MODES: ExecutionMode[] = ['autopilot', 'swarm', 'pipeline'];
// Export for external use
export { MODE_CONFIGS, EXCLUSIVE_MODES, STALE_MARKER_THRESHOLD };
// =============================================================================
// ModeRegistryService
// =============================================================================
/**
* Service for managing mode state
*
* This service provides centralized mode state management using file-based
* persistence. It supports exclusive mode detection and stale marker cleanup.
*/
export class ModeRegistryService {
private projectPath: string;
private enableLogging: boolean;
private modesDir: string;
constructor(options: ModeRegistryOptions) {
this.projectPath = options.projectPath;
this.enableLogging = options.enableLogging ?? false;
this.modesDir = join(this.projectPath, '.workflow', 'modes');
}
// ---------------------------------------------------------------------------
// Public: Directory Management
// ---------------------------------------------------------------------------
/**
* Get the modes directory path
*/
getModesDir(): string {
return this.modesDir;
}
/**
* Ensure the modes directory exists
*/
ensureModesDir(): void {
if (!existsSync(this.modesDir)) {
mkdirSync(this.modesDir, { recursive: true });
}
}
// ---------------------------------------------------------------------------
// Public: Mode State Queries
// ---------------------------------------------------------------------------
/**
* Check if a specific mode is currently active
*
* @param mode - The mode to check
* @param sessionId - Optional session ID to check session-scoped state
* @returns true if the mode is active
*/
isModeActive(mode: ExecutionMode, sessionId?: string): boolean {
const config = MODE_CONFIGS[mode];
if (sessionId) {
// Check session-scoped path
const sessionStateFile = this.getSessionStatePath(mode, sessionId);
return this.isJsonModeActive(sessionStateFile, config, sessionId);
}
// Check legacy shared path
const stateFile = this.getStateFilePath(mode);
return this.isJsonModeActive(stateFile, config);
}
/**
* Check if a mode has state (file exists)
*
* @param mode - The mode to check
* @param sessionId - Optional session ID
* @returns true if state file exists
*/
hasModeState(mode: ExecutionMode, sessionId?: string): boolean {
const stateFile = sessionId
? this.getSessionStatePath(mode, sessionId)
: this.getStateFilePath(mode);
return existsSync(stateFile);
}
/**
* Get all active modes
*
* @param sessionId - Optional session ID to check session-scoped state
* @returns Array of active mode identifiers
*/
getActiveModes(sessionId?: string): ExecutionMode[] {
const modes: ExecutionMode[] = [];
for (const mode of Object.keys(MODE_CONFIGS) as ExecutionMode[]) {
if (this.isModeActive(mode, sessionId)) {
modes.push(mode);
}
}
return modes;
}
/**
* Check if any mode is currently active
*
* @param sessionId - Optional session ID
* @returns true if any mode is active
*/
isAnyModeActive(sessionId?: string): boolean {
return this.getActiveModes(sessionId).length > 0;
}
/**
* Get the currently active exclusive mode (if any)
*
* @returns The active exclusive mode or null
*/
getActiveExclusiveMode(): ExecutionMode | null {
for (const mode of EXCLUSIVE_MODES) {
if (this.isModeActiveInAnySession(mode)) {
return mode;
}
}
return null;
}
/**
* Get status of all modes
*
* @param sessionId - Optional session ID
* @returns Array of mode statuses
*/
getAllModeStatuses(sessionId?: string): ModeStatus[] {
return (Object.keys(MODE_CONFIGS) as ExecutionMode[]).map(mode => ({
mode,
active: this.isModeActive(mode, sessionId),
stateFilePath: sessionId
? this.getSessionStatePath(mode, sessionId)
: this.getStateFilePath(mode),
sessionId
}));
}
// ---------------------------------------------------------------------------
// Public: Mode Control
// ---------------------------------------------------------------------------
/**
* Check if a new mode can be started
*
* @param mode - The mode to start
* @param sessionId - Optional session ID
* @returns CanStartResult with allowed status and blocker info
*/
canStartMode(mode: ExecutionMode, sessionId?: string): CanStartResult {
const config = MODE_CONFIGS[mode];
// Check for mutually exclusive modes
if (EXCLUSIVE_MODES.includes(mode)) {
for (const exclusiveMode of EXCLUSIVE_MODES) {
if (exclusiveMode !== mode && this.isModeActiveInAnySession(exclusiveMode)) {
const exclusiveConfig = MODE_CONFIGS[exclusiveMode];
return {
allowed: false,
blockedBy: exclusiveMode,
message: `Cannot start ${config.name} while ${exclusiveConfig.name} is active. Cancel ${exclusiveConfig.name} first.`
};
}
}
}
// Check if already active in this session
if (sessionId && this.isModeActive(mode, sessionId)) {
return {
allowed: false,
blockedBy: mode,
message: `${config.name} is already active in this session.`
};
}
return { allowed: true };
}
/**
* Activate a mode
*
* @param mode - The mode to activate
* @param sessionId - Session ID
* @param context - Optional context to store with state
* @returns true if activation was successful
*/
activateMode(mode: ExecutionMode, sessionId: string, context?: Record<string, unknown>): boolean {
const config = MODE_CONFIGS[mode];
// Check if can start
const canStart = this.canStartMode(mode, sessionId);
if (!canStart.allowed) {
this.log(`Cannot activate ${config.name}: ${canStart.message}`);
return false;
}
try {
this.ensureModesDir();
const stateFile = this.getSessionStatePath(mode, sessionId);
const stateDir = dirname(stateFile);
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
const state = {
[config.activeProperty]: true,
session_id: sessionId,
activatedAt: new Date().toISOString(),
...context
};
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
this.log(`Activated ${config.name} for session ${sessionId}`);
return true;
} catch (error) {
this.log(`Failed to activate ${config.name}: ${(error as Error).message}`);
return false;
}
}
/**
* Deactivate a mode
*
* @param mode - The mode to deactivate
* @param sessionId - Session ID
* @returns true if deactivation was successful
*/
deactivateMode(mode: ExecutionMode, sessionId: string): boolean {
const config = MODE_CONFIGS[mode];
try {
const stateFile = this.getSessionStatePath(mode, sessionId);
if (!existsSync(stateFile)) {
return true; // Already inactive
}
unlinkSync(stateFile);
this.log(`Deactivated ${config.name} for session ${sessionId}`);
return true;
} catch (error) {
this.log(`Failed to deactivate ${config.name}: ${(error as Error).message}`);
return false;
}
}
/**
* Clear all state for a mode
*
* @param mode - The mode to clear
* @param sessionId - Optional session ID (if provided, only clears session state)
* @returns true if successful
*/
clearModeState(mode: ExecutionMode, sessionId?: string): boolean {
let success = true;
if (sessionId) {
// Clear session-scoped state only
const sessionStateFile = this.getSessionStatePath(mode, sessionId);
if (existsSync(sessionStateFile)) {
try {
unlinkSync(sessionStateFile);
} catch {
success = false;
}
}
return success;
}
// Clear all state for this mode
const stateFile = this.getStateFilePath(mode);
if (existsSync(stateFile)) {
try {
unlinkSync(stateFile);
} catch {
success = false;
}
}
// Also clear session-scoped states
try {
const sessionIds = this.listSessionIds();
for (const sid of sessionIds) {
const sessionFile = this.getSessionStatePath(mode, sid);
if (existsSync(sessionFile)) {
try {
unlinkSync(sessionFile);
} catch {
success = false;
}
}
}
} catch {
// Ignore errors scanning sessions
}
return success;
}
/**
* Clear all mode states (force clear)
*
* @returns true if all states were cleared
*/
clearAllModeStates(): boolean {
let success = true;
for (const mode of Object.keys(MODE_CONFIGS) as ExecutionMode[]) {
if (!this.clearModeState(mode)) {
success = false;
}
}
return success;
}
// ---------------------------------------------------------------------------
// Public: Session Management
// ---------------------------------------------------------------------------
/**
* Check if a mode is active in any session
*
* @param mode - The mode to check
* @returns true if the mode is active in any session
*/
isModeActiveInAnySession(mode: ExecutionMode): boolean {
// Check legacy path first
if (this.isModeActive(mode)) {
return true;
}
// Scan all session dirs
const sessionIds = this.listSessionIds();
for (const sid of sessionIds) {
if (this.isModeActive(mode, sid)) {
return true;
}
}
return false;
}
/**
* Get all session IDs that have a specific mode active
*
* @param mode - The mode to check
* @returns Array of session IDs with this mode active
*/
getActiveSessionsForMode(mode: ExecutionMode): string[] {
const sessionIds = this.listSessionIds();
return sessionIds.filter(sid => this.isModeActive(mode, sid));
}
/**
* List all session IDs that have mode state files
*
* @returns Array of session IDs
*/
listSessionIds(): string[] {
const sessionsDir = join(this.modesDir, 'sessions');
if (!existsSync(sessionsDir)) {
return [];
}
try {
return readdirSync(sessionsDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
.filter(name => this.isValidSessionId(name));
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Public: Stale State Cleanup
// ---------------------------------------------------------------------------
/**
* Clear stale session directories
*
* Removes session directories that have no recent activity.
*
* @param maxAgeMs - Maximum age in milliseconds (default: 24 hours)
* @returns Array of removed session IDs
*/
clearStaleSessionDirs(maxAgeMs: number = 24 * 60 * 60 * 1000): string[] {
const sessionsDir = join(this.modesDir, 'sessions');
if (!existsSync(sessionsDir)) {
return [];
}
const removed: string[] = [];
const sessionIds = this.listSessionIds();
for (const sid of sessionIds) {
const sessionDir = this.getSessionDir(sid);
try {
const files = readdirSync(sessionDir);
// Remove empty directories
if (files.length === 0) {
rmSync(sessionDir, { recursive: true, force: true });
removed.push(sid);
continue;
}
// Check modification time of any state file
let newest = 0;
for (const f of files) {
const stat = statSync(join(sessionDir, f));
if (stat.mtimeMs > newest) {
newest = stat.mtimeMs;
}
}
// Remove if stale
if (Date.now() - newest > maxAgeMs) {
rmSync(sessionDir, { recursive: true, force: true });
removed.push(sid);
}
} catch {
// Skip on error
}
}
return removed;
}
/**
* Clean up stale markers (older than threshold)
*
* @returns Array of cleaned up session IDs
*/
cleanupStaleMarkers(): string[] {
const cleaned: string[] = [];
const sessionIds = this.listSessionIds();
for (const sid of sessionIds) {
for (const mode of Object.keys(MODE_CONFIGS) as ExecutionMode[]) {
const stateFile = this.getSessionStatePath(mode, sid);
if (existsSync(stateFile)) {
try {
const content = readFileSync(stateFile, 'utf-8');
const state = JSON.parse(content);
if (state.activatedAt) {
const activatedAt = new Date(state.activatedAt).getTime();
const age = Date.now() - activatedAt;
if (age > STALE_MARKER_THRESHOLD) {
this.log(`Cleaning up stale ${mode} marker for session ${sid} (${Math.round(age / 60000)} min old)`);
unlinkSync(stateFile);
cleaned.push(sid);
}
}
} catch {
// Skip invalid files
}
}
}
}
return Array.from(new Set(cleaned)); // Remove duplicates
}
// ---------------------------------------------------------------------------
// Private: Utility Methods
// ---------------------------------------------------------------------------
/**
* Get the state file path for a mode (legacy shared path)
*/
private getStateFilePath(mode: ExecutionMode): string {
const config = MODE_CONFIGS[mode];
return join(this.modesDir, config.stateFile);
}
/**
* Get the session-scoped state file path
*/
private getSessionStatePath(mode: ExecutionMode, sessionId: string): string {
const config = MODE_CONFIGS[mode];
return join(this.modesDir, 'sessions', sessionId, config.stateFile);
}
/**
* Get the session directory path
*/
private getSessionDir(sessionId: string): string {
return join(this.modesDir, 'sessions', sessionId);
}
/**
* Check if a JSON-based mode is active
*/
private isJsonModeActive(
stateFile: string,
config: ModeConfig,
sessionId?: string
): boolean {
if (!existsSync(stateFile)) {
return false;
}
try {
const content = readFileSync(stateFile, 'utf-8');
const state = JSON.parse(content);
// Validate session identity if sessionId provided
if (sessionId && state.session_id && state.session_id !== sessionId) {
return false;
}
if (config.activeProperty) {
return state[config.activeProperty] === true;
}
return true;
} catch {
return false;
}
}
/**
* Validate session ID format
*/
private isValidSessionId(sessionId: string): boolean {
if (!sessionId || typeof sessionId !== 'string') {
return false;
}
// Allow alphanumeric, hyphens, and underscores only
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
return SAFE_SESSION_ID_PATTERN.test(sessionId);
}
/**
* Log a message if logging is enabled
*/
private log(message: string): void {
if (this.enableLogging) {
const timestamp = new Date().toISOString();
console.log(`[ModeRegistry ${timestamp}] ${message}`);
}
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a ModeRegistryService instance
*
* @param projectPath - Project root path
* @param enableLogging - Enable logging
* @returns ModeRegistryService instance
*/
export function createModeRegistryService(
projectPath: string,
enableLogging?: boolean
): ModeRegistryService {
return new ModeRegistryService({ projectPath, enableLogging });
}

View File

@@ -0,0 +1,408 @@
/**
* SessionEndService - Unified session end handling
*
* Provides centralized management for session-end tasks:
* - Task registration with priority
* - Async execution with error handling
* - Built-in tasks: incremental-embedding, clustering, heat-scores
*
* Design:
* - Best-effort execution (failures logged but don't block)
* - Priority-based ordering
* - Support for async background execution
*/
// =============================================================================
// Types
// =============================================================================
/**
* A task to be executed at session end
*/
export interface EndTask {
/** Unique task type identifier */
type: string;
/** Task priority (higher = executed first) */
priority: number;
/** Whether to run asynchronously in background */
async: boolean;
/** Task handler function */
handler: () => Promise<void>;
/** Optional description */
description?: string;
}
/**
* Result of a task execution
*/
export interface TaskResult {
/** Task type identifier */
type: string;
/** Whether the task succeeded */
success: boolean;
/** Execution duration in milliseconds */
duration: number;
/** Error message if failed */
error?: string;
}
/**
* Options for SessionEndService
*/
export interface SessionEndServiceOptions {
/** Project root path */
projectPath: string;
/** Whether to log task execution */
enableLogging?: boolean;
}
/**
* Summary of session end execution
*/
export interface SessionEndSummary {
/** Total tasks executed */
totalTasks: number;
/** Number of successful tasks */
successful: number;
/** Number of failed tasks */
failed: number;
/** Total execution time in milliseconds */
totalDuration: number;
/** Individual task results */
results: TaskResult[];
}
// =============================================================================
// Built-in Task Types
// =============================================================================
/** Task type for incremental vector embedding */
export const TASK_INCREMENTAL_EMBEDDING = 'incremental-embedding';
/** Task type for incremental clustering */
export const TASK_INCREMENTAL_CLUSTERING = 'incremental-clustering';
/** Task type for heat score updates */
export const TASK_HEAT_SCORE_UPDATE = 'heat-score-update';
// =============================================================================
// SessionEndService
// =============================================================================
/**
* Service for managing and executing session-end tasks
*
* This service provides a unified interface for registering and executing
* background tasks when a session ends. Tasks are executed best-effort
* with proper error handling and logging.
*/
export class SessionEndService {
private projectPath: string;
private enableLogging: boolean;
private tasks: Map<string, EndTask> = new Map();
constructor(options: SessionEndServiceOptions) {
this.projectPath = options.projectPath;
this.enableLogging = options.enableLogging ?? false;
}
// ---------------------------------------------------------------------------
// Public: Task Registration
// ---------------------------------------------------------------------------
/**
* Register a session-end task
*
* @param task - Task to register
* @returns true if task was registered (false if type already exists)
*/
registerEndTask(task: EndTask): boolean {
if (this.tasks.has(task.type)) {
this.log(`Task "${task.type}" already registered, skipping`);
return false;
}
this.tasks.set(task.type, task);
this.log(`Registered task "${task.type}" with priority ${task.priority}`);
return true;
}
/**
* Unregister a session-end task
*
* @param type - Task type to unregister
* @returns true if task was removed
*/
unregisterEndTask(type: string): boolean {
const removed = this.tasks.delete(type);
if (removed) {
this.log(`Unregistered task "${type}"`);
}
return removed;
}
/**
* Check if a task type is registered
*
* @param type - Task type to check
* @returns true if task is registered
*/
hasTask(type: string): boolean {
return this.tasks.has(type);
}
/**
* Get all registered task types
*
* @returns Array of task types
*/
getRegisteredTasks(): string[] {
return Array.from(this.tasks.keys());
}
// ---------------------------------------------------------------------------
// Public: Task Execution
// ---------------------------------------------------------------------------
/**
* Execute all registered session-end tasks
*
* Tasks are executed in priority order (highest first).
* Failures are logged but don't prevent other tasks from running.
*
* @param sessionId - Session ID for context
* @returns Summary of execution results
*/
async executeEndTasks(sessionId: string): Promise<SessionEndSummary> {
const startTime = Date.now();
const results: TaskResult[] = [];
// Sort tasks by priority (descending)
const sortedTasks = Array.from(this.tasks.values()).sort(
(a, b) => b.priority - a.priority
);
this.log(`Executing ${sortedTasks.length} session-end tasks for session ${sessionId}`);
// Execute tasks concurrently
const executionPromises = sortedTasks.map(async (task) => {
const taskStart = Date.now();
try {
this.log(`Starting task "${task.type}"...`);
await task.handler();
const duration = Date.now() - taskStart;
this.log(`Task "${task.type}" completed in ${duration}ms`);
return {
type: task.type,
success: true,
duration
} as TaskResult;
} catch (err) {
const duration = Date.now() - taskStart;
const errorMessage = (err as Error).message || 'Unknown error';
this.log(`Task "${task.type}" failed: ${errorMessage}`);
return {
type: task.type,
success: false,
duration,
error: errorMessage
} as TaskResult;
}
});
// Wait for all tasks to complete
const taskResults = await Promise.allSettled(executionPromises);
// Collect results
for (const result of taskResults) {
if (result.status === 'fulfilled') {
results.push(result.value);
} else {
// This shouldn't happen as we catch errors inside the task
results.push({
type: 'unknown',
success: false,
duration: 0,
error: result.reason?.message || 'Task promise rejected'
});
}
}
const totalDuration = Date.now() - startTime;
const successful = results.filter((r) => r.success).length;
const failed = results.length - successful;
this.log(
`Session-end tasks completed: ${successful}/${results.length} successful, ` +
`${totalDuration}ms total`
);
return {
totalTasks: results.length,
successful,
failed,
totalDuration,
results
};
}
/**
* Execute only async (background) tasks
*
* This is useful for fire-and-forget background processing.
*
* @param sessionId - Session ID for context
* @returns Promise that resolves immediately (tasks run in background)
*/
executeBackgroundTasks(sessionId: string): void {
const asyncTasks = Array.from(this.tasks.values())
.filter((t) => t.async)
.sort((a, b) => b.priority - a.priority);
if (asyncTasks.length === 0) {
return;
}
// Fire-and-forget
Promise.all(
asyncTasks.map(async (task) => {
try {
this.log(`Background task "${task.type}" starting...`);
await task.handler();
this.log(`Background task "${task.type}" completed`);
} catch (err) {
this.log(`Background task "${task.type}" failed: ${(err as Error).message}`);
}
})
).catch(() => {
// Ignore errors - background tasks are best-effort
});
}
// ---------------------------------------------------------------------------
// Public: Built-in Tasks
// ---------------------------------------------------------------------------
/**
* Register built-in session-end tasks
*
* This registers the standard tasks:
* - incremental-embedding (priority 100)
* - incremental-clustering (priority 50)
* - heat-score-update (priority 25)
*
* @param sessionId - Session ID for context
*/
async registerBuiltinTasks(sessionId: string): Promise<void> {
// Try to import and register embedding task
try {
const { isUnifiedEmbedderAvailable, UnifiedVectorIndex } = await import(
'../unified-vector-index.js'
);
const { getMemoryMdContent } = await import('../memory-consolidation-pipeline.js');
if (isUnifiedEmbedderAvailable()) {
this.registerEndTask({
type: TASK_INCREMENTAL_EMBEDDING,
priority: 100,
async: true,
description: 'Index new/updated content in vector store',
handler: async () => {
const vectorIndex = new UnifiedVectorIndex(this.projectPath);
const memoryContent = getMemoryMdContent(this.projectPath);
if (memoryContent) {
await vectorIndex.indexContent(memoryContent, {
source_id: 'MEMORY_MD',
source_type: 'core_memory',
category: 'core_memory'
});
}
}
});
}
} catch {
// Embedding dependencies not available
this.log('Embedding task not registered: dependencies not available');
}
// Try to import and register clustering task
try {
const { SessionClusteringService } = await import('../session-clustering-service.js');
this.registerEndTask({
type: TASK_INCREMENTAL_CLUSTERING,
priority: 50,
async: true,
description: 'Cluster unclustered sessions',
handler: async () => {
const clusteringService = new SessionClusteringService(this.projectPath);
await clusteringService.autocluster({ scope: 'unclustered' });
}
});
} catch {
this.log('Clustering task not registered: dependencies not available');
}
// Try to import and register heat score task
try {
const { getMemoryStore } = await import('../memory-store.js');
this.registerEndTask({
type: TASK_HEAT_SCORE_UPDATE,
priority: 25,
async: true,
description: 'Update entity heat scores',
handler: async () => {
const memoryStore = getMemoryStore(this.projectPath);
const hotEntities = memoryStore.getHotEntities(50);
for (const entity of hotEntities) {
if (entity.id != null) {
memoryStore.calculateHeatScore(entity.id);
}
}
}
});
} catch {
this.log('Heat score task not registered: dependencies not available');
}
}
// ---------------------------------------------------------------------------
// Private: Utility Methods
// ---------------------------------------------------------------------------
/**
* Log a message if logging is enabled
*/
private log(message: string): void {
if (this.enableLogging) {
console.log(`[SessionEndService] ${message}`);
}
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a SessionEndService instance with built-in tasks
*
* @param projectPath - Project root path
* @param sessionId - Session ID for context
* @param enableLogging - Whether to enable logging
* @returns SessionEndService instance with built-in tasks registered
*/
export async function createSessionEndService(
projectPath: string,
sessionId: string,
enableLogging = false
): Promise<SessionEndService> {
const service = new SessionEndService({ projectPath, enableLogging });
await service.registerBuiltinTasks(sessionId);
return service;
}

View File

@@ -0,0 +1,330 @@
/**
* SessionStateService - Unified session state management
*
* Provides centralized session state persistence across CLI hooks and API routes.
* Supports both legacy global path (~/.claude/.ccw-sessions/) and session-scoped
* paths (.workflow/sessions/{sessionId}/) for workflow integration.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
/**
* Session state interface
*/
export interface SessionState {
/** ISO timestamp of first session load */
firstLoad: string;
/** Number of times session has been loaded */
loadCount: number;
/** Last prompt text (optional) */
lastPrompt?: string;
/** Active mode for the session (optional) */
activeMode?: 'analysis' | 'write' | 'review' | 'auto';
}
/**
* Storage type for session state
*/
export type SessionStorageType = 'global' | 'session-scoped';
/**
* Options for session state operations
*/
export interface SessionStateOptions {
/** Storage type: 'global' uses ~/.claude/.ccw-sessions/, 'session-scoped' uses .workflow/sessions/{sessionId}/ */
storageType?: SessionStorageType;
/** Project root path (required for session-scoped storage) */
projectPath?: string;
}
/**
* Validates that a session ID is safe to use in file paths.
* Session IDs should be alphanumeric with optional hyphens and underscores.
* This prevents path traversal attacks (e.g., "../../../etc").
*
* @param sessionId - The session ID to validate
* @returns true if the session ID is safe, false otherwise
*/
export function validateSessionId(sessionId: string): boolean {
if (!sessionId || typeof sessionId !== 'string') {
return false;
}
// Allow alphanumeric, hyphens, and underscores only
// Must be 1-256 characters (reasonable length limit)
// Must not start with a dot (hidden files) or hyphen
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
return SAFE_SESSION_ID_PATTERN.test(sessionId);
}
/**
* Get the default global session state directory
* Uses ~/.claude/.ccw-sessions/ for reliable persistence across sessions
*/
function getGlobalStateDir(): string {
return join(homedir(), '.claude', '.ccw-sessions');
}
/**
* Get session state file path
*
* Supports two storage modes:
* - 'global': ~/.claude/.ccw-sessions/session-{sessionId}.json (default)
* - 'session-scoped': {projectPath}/.workflow/sessions/{sessionId}/state.json
*
* @param sessionId - The session ID
* @param options - Storage options
* @returns Full path to the session state file
*/
export function getSessionStatePath(sessionId: string, options?: SessionStateOptions): string {
if (!validateSessionId(sessionId)) {
throw new Error(`Invalid session ID: ${sessionId}`);
}
const storageType = options?.storageType ?? 'global';
if (storageType === 'session-scoped') {
if (!options?.projectPath) {
throw new Error('projectPath is required for session-scoped storage');
}
const stateDir = join(options.projectPath, '.workflow', 'sessions', sessionId);
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
return join(stateDir, 'state.json');
}
// Global storage (default)
const stateDir = getGlobalStateDir();
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
return join(stateDir, `session-${sessionId}.json`);
}
/**
* Load session state from file
*
* @param sessionId - The session ID
* @param options - Storage options
* @returns SessionState if exists and valid, null otherwise
*/
export function loadSessionState(sessionId: string, options?: SessionStateOptions): SessionState | null {
if (!validateSessionId(sessionId)) {
return null;
}
try {
const stateFile = getSessionStatePath(sessionId, options);
if (!existsSync(stateFile)) {
return null;
}
const content = readFileSync(stateFile, 'utf-8');
const parsed = JSON.parse(content) as SessionState;
// Validate required fields
if (typeof parsed.firstLoad !== 'string' || typeof parsed.loadCount !== 'number') {
return null;
}
return parsed;
} catch {
return null;
}
}
/**
* Save session state to file
*
* @param sessionId - The session ID
* @param state - The session state to save
* @param options - Storage options
*/
export function saveSessionState(sessionId: string, state: SessionState, options?: SessionStateOptions): void {
if (!validateSessionId(sessionId)) {
throw new Error(`Invalid session ID: ${sessionId}`);
}
const stateFile = getSessionStatePath(sessionId, options);
// Ensure parent directory exists
const stateDir = dirname(stateFile);
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
}
/**
* Clear session state (for session-end cleanup)
*
* @param sessionId - The session ID
* @param options - Storage options
* @returns true if state was cleared, false if it didn't exist
*/
export function clearSessionState(sessionId: string, options?: SessionStateOptions): boolean {
if (!validateSessionId(sessionId)) {
return false;
}
try {
const stateFile = getSessionStatePath(sessionId, options);
if (!existsSync(stateFile)) {
return false;
}
unlinkSync(stateFile);
// For session-scoped storage, also remove the session directory if empty
if (options?.storageType === 'session-scoped' && options.projectPath) {
const sessionDir = join(options.projectPath, '.workflow', 'sessions', sessionId);
try {
// Try to remove the directory (will fail if not empty)
rmSync(sessionDir, { recursive: false, force: true });
} catch {
// Directory not empty or other error - ignore
}
}
return true;
} catch {
return false;
}
}
/**
* Update session state with new values
*
* This is a convenience function that loads existing state, merges with updates,
* and saves the result.
*
* @param sessionId - The session ID
* @param updates - Partial state to merge
* @param options - Storage options
* @returns The updated state
*/
export function updateSessionState(
sessionId: string,
updates: Partial<SessionState>,
options?: SessionStateOptions
): SessionState {
const existing = loadSessionState(sessionId, options);
const newState: SessionState = existing
? { ...existing, ...updates }
: {
firstLoad: new Date().toISOString(),
loadCount: 1,
...updates
};
saveSessionState(sessionId, newState, options);
return newState;
}
/**
* Increment the load count for a session
*
* This is a convenience function for the common pattern of tracking
* how many times a session has been loaded.
*
* @param sessionId - The session ID
* @param prompt - Optional prompt to record as lastPrompt
* @param options - Storage options
* @returns Object with isFirstPrompt flag and updated state
*/
export function incrementSessionLoad(
sessionId: string,
prompt?: string,
options?: SessionStateOptions
): { isFirstPrompt: boolean; state: SessionState } {
const existing = loadSessionState(sessionId, options);
const isFirstPrompt = !existing;
const state: SessionState = isFirstPrompt
? {
firstLoad: new Date().toISOString(),
loadCount: 1,
lastPrompt: prompt
}
: {
...existing,
loadCount: existing.loadCount + 1,
...(prompt !== undefined && { lastPrompt: prompt })
};
saveSessionState(sessionId, state, options);
return { isFirstPrompt, state };
}
/**
* SessionStateService class for object-oriented usage
*/
export class SessionStateService {
private options?: SessionStateOptions;
constructor(options?: SessionStateOptions) {
this.options = options;
}
/**
* Get session state file path
*/
getStatePath(sessionId: string): string {
return getSessionStatePath(sessionId, this.options);
}
/**
* Load session state
*/
load(sessionId: string): SessionState | null {
return loadSessionState(sessionId, this.options);
}
/**
* Save session state
*/
save(sessionId: string, state: SessionState): void {
saveSessionState(sessionId, state, this.options);
}
/**
* Clear session state
*/
clear(sessionId: string): boolean {
return clearSessionState(sessionId, this.options);
}
/**
* Update session state
*/
update(sessionId: string, updates: Partial<SessionState>): SessionState {
return updateSessionState(sessionId, updates, this.options);
}
/**
* Increment load count
*/
incrementLoad(sessionId: string, prompt?: string): { isFirstPrompt: boolean; state: SessionState } {
return incrementSessionLoad(sessionId, prompt, this.options);
}
/**
* Check if session is first load
*/
isFirstLoad(sessionId: string): boolean {
return this.load(sessionId) === null;
}
/**
* Get load count for session
*/
getLoadCount(sessionId: string): number {
const state = this.load(sessionId);
return state?.loadCount ?? 0;
}
}

View File

@@ -5,11 +5,15 @@
"session-start": [
{
"name": "Progressive Disclosure",
"description": "Injects progressive disclosure index at session start",
"description": "Injects progressive disclosure index at session start with recovery detection",
"enabled": true,
"handler": "internal:context",
"timeout": 5000,
"failMode": "silent"
"failMode": "silent",
"notes": [
"Checks for recovery checkpoints and injects recovery message if found",
"Uses RecoveryHandler.checkRecovery() for session recovery"
]
}
],
"session-end": [
@@ -21,6 +25,61 @@
"timeout": 30000,
"async": true,
"failMode": "log"
},
{
"name": "Mode State Cleanup",
"description": "Deactivates all active modes for the session",
"enabled": true,
"command": "ccw hook session-end --stdin",
"timeout": 5000,
"failMode": "silent"
}
],
"Stop": [
{
"name": "Stop Handler",
"description": "Handles Stop hook events with Soft Enforcement - injects continuation messages for active workflows/modes",
"enabled": true,
"command": "ccw hook stop --stdin",
"timeout": 5000,
"failMode": "silent",
"notes": [
"Priority order: context-limit > user-abort > active-workflow > active-mode",
"ALWAYS returns continue: true (never blocks stops)",
"Injects continuation message instead of blocking",
"Deadlock prevention: context-limit stops are always allowed",
"Uses ModeRegistryService to check active modes"
]
}
],
"PreCompact": [
{
"name": "Checkpoint Creation",
"description": "Creates checkpoint before context compaction to preserve session state",
"enabled": true,
"command": "ccw hook pre-compact --stdin",
"timeout": 10000,
"failMode": "silent",
"notes": [
"Creates checkpoint with mode states, workflow state, and memory context",
"Uses mutex to prevent concurrent compaction for same directory",
"Returns systemMessage with checkpoint summary for context injection"
]
}
],
"UserPromptSubmit": [
{
"name": "Keyword Detection",
"description": "Detects mode keywords in prompts and activates corresponding modes",
"enabled": true,
"command": "ccw hook keyword --stdin",
"timeout": 5000,
"failMode": "silent",
"notes": [
"Supported keywords: autopilot, ralph, ultrawork, swarm, pipeline, team, ultrapilot, ultraqa",
"Maps keywords to execution modes using ModeRegistryService",
"Injects systemMessage on mode activation"
]
}
],
"file-modified": [
@@ -55,6 +114,10 @@
"handler": "Use 'internal:context' for built-in context generation, or 'command' for external commands",
"failMode": "Options: 'silent' (ignore errors), 'log' (log errors), 'fail' (abort on error)",
"variables": "Available: $SESSION_ID, $FILE_PATH, $PROJECT_PATH, $CLUSTER_ID",
"async": "Async hooks run in background and don't block the main flow"
"async": "Async hooks run in background and don't block the main flow",
"Stop hook": "The Stop hook uses Soft Enforcement - it never blocks but may inject continuation messages",
"PreCompact hook": "Creates checkpoint before compaction; uses mutex to prevent concurrent operations",
"UserPromptSubmit hook": "Detects mode keywords and activates corresponding execution modes",
"session-end hook": "Cleans up mode states using ModeRegistryService.deactivateMode()"
}
}