mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
Add comprehensive tests for keyword detection, session state management, and user abort detection
- Implement tests for KeywordDetector including keyword detection, sanitization, and priority handling. - Add tests for SessionStateService covering session validation, loading, saving, and state updates. - Create tests for UserAbortDetector to validate user abort detection logic and pattern matching.
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user