mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
- Add hook quick templates component with configurable templates - Refactor NativeSessionPanel to use new SessionTimeline component - Add OpenCode session parser for parsing OpenCode CLI sessions - Enhance API with session-related endpoints - Add locale translations for hooks and native session features - Update hook commands and routes for better hook management
982 lines
31 KiB
TypeScript
982 lines
31 KiB
TypeScript
/**
|
|
* Hook Command - CLI endpoint for Claude Code hooks
|
|
* Provides simplified interface for hook operations, replacing complex bash/curl commands
|
|
*/
|
|
|
|
import chalk from 'chalk';
|
|
import { existsSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
interface HookOptions {
|
|
stdin?: boolean;
|
|
sessionId?: string;
|
|
prompt?: string;
|
|
type?: 'session-start' | 'context' | 'session-end' | 'stop' | 'pre-compact';
|
|
path?: string;
|
|
limit?: string;
|
|
}
|
|
|
|
interface HookData {
|
|
session_id?: string;
|
|
prompt?: string;
|
|
cwd?: string;
|
|
tool_input?: Record<string, unknown>;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Read JSON data from stdin (for Claude Code hooks)
|
|
*/
|
|
async function readStdin(): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
let data = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('readable', () => {
|
|
let chunk;
|
|
while ((chunk = process.stdin.read()) !== null) {
|
|
data += chunk;
|
|
}
|
|
});
|
|
process.stdin.on('end', () => {
|
|
resolve(data);
|
|
});
|
|
// Handle case where stdin is empty or not piped
|
|
if (process.stdin.isTTY) {
|
|
resolve('');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get project path from hook data or current working directory
|
|
*/
|
|
function getProjectPath(hookCwd?: string): string {
|
|
return hookCwd || process.cwd();
|
|
}
|
|
|
|
/**
|
|
* Session context action - provides progressive context loading
|
|
*
|
|
* Uses HookContextService for unified context generation:
|
|
* - session-start: MEMORY.md summary + clusters + hot entities + patterns
|
|
* - per-prompt: vector search across all memory categories
|
|
*
|
|
* Falls back to SessionClusteringService.getProgressiveIndex() when
|
|
* the embedder is unavailable, preserving backward compatibility.
|
|
*/
|
|
async function sessionContextAction(options: HookOptions): Promise<void> {
|
|
let { stdin, sessionId, prompt } = options;
|
|
let hookCwd: string | undefined;
|
|
|
|
// If --stdin flag is set, read from stdin (Claude Code hook format)
|
|
if (stdin) {
|
|
try {
|
|
const stdinData = await readStdin();
|
|
if (stdinData) {
|
|
const hookData = JSON.parse(stdinData) as HookData;
|
|
sessionId = hookData.session_id || sessionId;
|
|
hookCwd = hookData.cwd;
|
|
prompt = hookData.prompt || prompt;
|
|
}
|
|
} catch {
|
|
// Silently continue if stdin parsing fails
|
|
}
|
|
}
|
|
|
|
if (!sessionId) {
|
|
if (!stdin) {
|
|
console.error(chalk.red('Error: --session-id is required'));
|
|
console.error(chalk.gray('Usage: ccw hook session-context --session-id <id>'));
|
|
console.error(chalk.gray(' ccw hook session-context --stdin'));
|
|
}
|
|
process.exit(stdin ? 0 : 1);
|
|
}
|
|
|
|
try {
|
|
const projectPath = getProjectPath(hookCwd);
|
|
|
|
// Check for recovery on session-start
|
|
const isFirstPrompt = !prompt || prompt.trim() === '';
|
|
let recoveryMessage = '';
|
|
|
|
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}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// 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);
|
|
}
|
|
|
|
// Interactive mode: show detailed output
|
|
console.log(chalk.green('Session Context'));
|
|
console.log(chalk.gray('─'.repeat(40)));
|
|
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:'), 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 {
|
|
console.log(chalk.gray('(No context generated)'));
|
|
}
|
|
} catch (error) {
|
|
if (stdin) {
|
|
// Silent failure for hooks
|
|
process.exit(0);
|
|
}
|
|
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Session end action - triggers async background tasks for memory maintenance.
|
|
*
|
|
* 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.
|
|
*/
|
|
async function sessionEndAction(options: HookOptions): Promise<void> {
|
|
let { stdin, sessionId } = 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;
|
|
}
|
|
} catch {
|
|
// Silently continue if stdin parsing fails
|
|
}
|
|
}
|
|
|
|
if (!sessionId) {
|
|
if (!stdin) {
|
|
console.error(chalk.red('Error: --session-id is required'));
|
|
}
|
|
process.exit(stdin ? 0 : 1);
|
|
}
|
|
|
|
try {
|
|
const projectPath = getProjectPath(hookCwd);
|
|
|
|
// 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.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);
|
|
}
|
|
|
|
if (!stdin) {
|
|
console.log(chalk.green(`Session End: executing ${registeredTasks.length} background tasks...`));
|
|
}
|
|
|
|
// Execute all tasks
|
|
const summary = await sessionEndService.executeEndTasks(sessionId);
|
|
|
|
if (!stdin) {
|
|
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);
|
|
} catch (error) {
|
|
if (stdin) {
|
|
process.exit(0);
|
|
}
|
|
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async function parseStatusAction(options: HookOptions): Promise<void> {
|
|
const { path: filePath } = options;
|
|
|
|
if (!filePath) {
|
|
console.error(chalk.red('Error: --path is required'));
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
// Check if this is a CCW status.json file
|
|
if (!filePath.includes('status.json') ||
|
|
!filePath.match(/\.(ccw|ccw-coordinator|ccw-debug)[/\\]/)) {
|
|
console.log(chalk.gray('(Not a CCW status file)'));
|
|
process.exit(0);
|
|
}
|
|
|
|
// Read and parse status.json
|
|
if (!existsSync(filePath)) {
|
|
console.log(chalk.gray('(Status file not found)'));
|
|
process.exit(0);
|
|
}
|
|
|
|
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}`;
|
|
|
|
console.log(message);
|
|
process.exit(0);
|
|
} catch (error) {
|
|
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async function notifyAction(options: HookOptions): Promise<void> {
|
|
const { stdin } = options;
|
|
let hookData: HookData = {};
|
|
|
|
if (stdin) {
|
|
try {
|
|
const stdinData = await readStdin();
|
|
if (stdinData) {
|
|
hookData = JSON.parse(stdinData) as HookData;
|
|
}
|
|
} catch {
|
|
// Silently continue if stdin parsing fails
|
|
}
|
|
}
|
|
|
|
try {
|
|
const { notifyRefreshRequired } = await import('../tools/notifier.js');
|
|
await notifyRefreshRequired();
|
|
|
|
if (!stdin) {
|
|
console.log(chalk.green('Notification sent to dashboard'));
|
|
}
|
|
process.exit(0);
|
|
} catch (error) {
|
|
if (stdin) {
|
|
process.exit(0);
|
|
}
|
|
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Project state action - reads project-tech.json and project-guidelines.json
|
|
* and outputs a concise summary for session context injection.
|
|
*
|
|
* Used as SessionStart hook: stdout → injected as system message.
|
|
*/
|
|
async function projectStateAction(options: HookOptions): Promise<void> {
|
|
let { stdin, path: projectPath } = options;
|
|
const limit = Math.min(parseInt(options.limit || '5', 10), 20);
|
|
|
|
if (stdin) {
|
|
try {
|
|
const stdinData = await readStdin();
|
|
if (stdinData) {
|
|
const hookData = JSON.parse(stdinData) as HookData;
|
|
projectPath = hookData.cwd || projectPath;
|
|
}
|
|
} catch {
|
|
// Silently continue if stdin parsing fails
|
|
}
|
|
}
|
|
|
|
projectPath = projectPath || process.cwd();
|
|
|
|
const result: {
|
|
tech: { recent: Array<{ title: string; category: string; date: string }> };
|
|
guidelines: { constraints: string[]; recent_learnings: Array<{ insight: string; date: string }> };
|
|
} = {
|
|
tech: { recent: [] },
|
|
guidelines: { constraints: [], recent_learnings: [] }
|
|
};
|
|
|
|
// Read project-tech.json
|
|
const techPath = join(projectPath, '.workflow', 'project-tech.json');
|
|
if (existsSync(techPath)) {
|
|
try {
|
|
const tech = JSON.parse(readFileSync(techPath, 'utf8'));
|
|
const allEntries: Array<{ title: string; category: string; date: string }> = [];
|
|
if (tech.development_index) {
|
|
for (const [cat, entries] of Object.entries(tech.development_index)) {
|
|
if (Array.isArray(entries)) {
|
|
for (const e of entries as Array<{ title?: string; date?: string }>) {
|
|
allEntries.push({ title: e.title || '', category: cat, date: e.date || '' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
allEntries.sort((a, b) => b.date.localeCompare(a.date));
|
|
result.tech.recent = allEntries.slice(0, limit);
|
|
} catch { /* ignore parse errors */ }
|
|
}
|
|
|
|
// Read project-guidelines.json
|
|
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
|
|
if (existsSync(guidelinesPath)) {
|
|
try {
|
|
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
|
|
// constraints is Record<string, array> - flatten all categories
|
|
const allConstraints: string[] = [];
|
|
if (gl.constraints && typeof gl.constraints === 'object') {
|
|
for (const entries of Object.values(gl.constraints)) {
|
|
if (Array.isArray(entries)) {
|
|
for (const c of entries) {
|
|
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result.guidelines.constraints = allConstraints.slice(0, limit);
|
|
|
|
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
|
|
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
|
|
result.guidelines.recent_learnings = learnings.slice(0, limit).map(
|
|
(l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' })
|
|
);
|
|
} catch { /* ignore parse errors */ }
|
|
}
|
|
|
|
if (stdin) {
|
|
// Format as <project-state> tag for system message injection
|
|
const techStr = result.tech.recent.map(e => `${e.title} (${e.category})`).join(', ');
|
|
const constraintStr = result.guidelines.constraints.join('; ');
|
|
const learningStr = result.guidelines.recent_learnings.map(e => e.insight).join('; ');
|
|
|
|
const parts: string[] = ['<project-state>'];
|
|
if (techStr) parts.push(`Recent: ${techStr}`);
|
|
if (constraintStr) parts.push(`Constraints: ${constraintStr}`);
|
|
if (learningStr) parts.push(`Learnings: ${learningStr}`);
|
|
parts.push('</project-state>');
|
|
|
|
process.stdout.write(parts.join('\n'));
|
|
process.exit(0);
|
|
}
|
|
|
|
// Interactive mode: show detailed output
|
|
console.log(chalk.green('Project State Summary'));
|
|
console.log(chalk.gray('─'.repeat(40)));
|
|
console.log(chalk.cyan('Project:'), projectPath);
|
|
console.log(chalk.cyan('Limit:'), limit);
|
|
console.log();
|
|
|
|
if (result.tech.recent.length > 0) {
|
|
console.log(chalk.yellow('Recent Development:'));
|
|
for (const e of result.tech.recent) {
|
|
console.log(` ${chalk.gray(e.date)} ${e.title} ${chalk.cyan(`(${e.category})`)}`);
|
|
}
|
|
} else {
|
|
console.log(chalk.gray('(No development index entries)'));
|
|
}
|
|
|
|
console.log();
|
|
|
|
if (result.guidelines.constraints.length > 0) {
|
|
console.log(chalk.yellow('Constraints:'));
|
|
for (const c of result.guidelines.constraints) {
|
|
console.log(` - ${c}`);
|
|
}
|
|
} else {
|
|
console.log(chalk.gray('(No constraints)'));
|
|
}
|
|
|
|
if (result.guidelines.recent_learnings.length > 0) {
|
|
console.log(chalk.yellow('Recent Learnings:'));
|
|
for (const l of result.guidelines.recent_learnings) {
|
|
console.log(` ${chalk.gray(l.date)} ${l.insight}`);
|
|
}
|
|
} else {
|
|
console.log(chalk.gray('(No learnings)'));
|
|
}
|
|
|
|
// Also output JSON for piping
|
|
console.log();
|
|
console.log(chalk.gray('JSON:'));
|
|
console.log(JSON.stringify(result, null, 2));
|
|
}
|
|
|
|
/**
|
|
* Show help for hook command
|
|
*/
|
|
function showHelp(): void {
|
|
console.log(`
|
|
${chalk.bold('ccw hook')} - CLI endpoint for Claude Code hooks
|
|
|
|
${chalk.bold('USAGE')}
|
|
ccw hook <subcommand> [options]
|
|
|
|
${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
|
|
project-state Output project guidelines and recent dev history summary
|
|
|
|
${chalk.bold('OPTIONS')}
|
|
--stdin Read input from stdin (for Claude Code hooks)
|
|
--path File or project path (for parse-status, project-state)
|
|
--limit Max entries to return (for project-state, default: 5)
|
|
--session-id Session ID (alternative to stdin)
|
|
--prompt Current prompt text (alternative to stdin)
|
|
|
|
${chalk.bold('EXAMPLES')}
|
|
${chalk.gray('# Parse CCW status file:')}
|
|
ccw hook parse-status --path .workflow/.ccw/ccw-123/status.json
|
|
|
|
${chalk.gray('# Use in Claude Code hook (settings.json):')}
|
|
ccw hook session-context --stdin
|
|
|
|
${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.gray('# Project state summary (interactive):')}
|
|
ccw hook project-state --path /my/project
|
|
|
|
${chalk.gray('# Project state summary (hook, reads cwd from stdin):')}
|
|
ccw hook project-state --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": {
|
|
"PostToolUse": [{
|
|
"trigger": "PostToolUse",
|
|
"matcher": "Write",
|
|
"command": "bash",
|
|
"args": ["-c", "INPUT=$(cat); FILE_PATH=$(echo \\"$INPUT\\" | jq -r \\".tool_input.file_path // empty\\"); [ -n \\"$FILE_PATH\\" ] && ccw hook parse-status --path \\"$FILE_PATH\\""]
|
|
}]
|
|
}
|
|
}
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Main hook command handler
|
|
*/
|
|
export async function hookCommand(
|
|
subcommand: string,
|
|
args: string | string[],
|
|
options: HookOptions
|
|
): Promise<void> {
|
|
switch (subcommand) {
|
|
case 'parse-status':
|
|
await parseStatusAction(options);
|
|
break;
|
|
case 'session-context':
|
|
case 'context':
|
|
await sessionContextAction(options);
|
|
break;
|
|
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;
|
|
case 'project-state':
|
|
await projectStateAction(options);
|
|
break;
|
|
case 'help':
|
|
case undefined:
|
|
showHelp();
|
|
break;
|
|
default:
|
|
console.error(chalk.red(`Unknown subcommand: ${subcommand}`));
|
|
console.error(chalk.gray('Run "ccw hook help" for usage information'));
|
|
process.exit(1);
|
|
}
|
|
}
|