#!/usr/bin/env bun /** * SessionStart Hook for OpenSpec Workflow * * This hook automatically loads context from the active OpenSpec change when * a new Claude Code session starts or resumes. It reads .openspec/active.json * to find the current change, then loads the proposal, design, and tasks to * restore context seamlessly. * * @see {@link https://github.com/jbabin91/super-claude} for documentation * * Runtime: Bun (native TypeScript support) * Execution: Triggered on session start/resume * Performance Target: <100ms */ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; /** * Active change tracker schema */ type ActiveChange = { change: string; // Change ID (folder name) started: string; // ISO 8601 timestamp lastCheckpoint: string; // ISO 8601 timestamp }; /** * Hook input from Claude Code (via stdin) */ type HookInput = { cwd: string; // Current working directory [key: string]: unknown; // Other potential fields }; /** * Parse hook input from stdin. * * @returns Parsed HookInput object * @throws Error if stdin is invalid */ async function parseStdin(): Promise { const stdin = await Bun.stdin.text(); if (!stdin || stdin.trim() === '') { throw new Error('No input received from stdin'); } try { const input = JSON.parse(stdin) as HookInput; if (!input.cwd || typeof input.cwd !== 'string') { throw new Error('Invalid input: missing or invalid cwd field'); } return input; } catch (error) { if (error instanceof SyntaxError) { throw new Error('Invalid JSON from stdin: ' + error.message); } throw error; } } /** * Load active change from openspec/active.json * * @param cwd Current working directory * @returns ActiveChange object or null if not found */ function loadActiveChange(cwd: string): ActiveChange | null { const activePath = path.resolve(cwd, 'openspec/active.json'); if (!existsSync(activePath)) { return null; } try { const content = readFileSync(activePath, 'utf8'); const active = JSON.parse(content) as ActiveChange; // Validate required fields if (!active.change || typeof active.change !== 'string') { console.warn('[WARNING] Invalid active.json: missing change field'); return null; } return active; } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; console.warn('[WARNING] Failed to load active.json: ' + msg); return null; } } /** * Load file content safely with fallback * * @param path File path * @param fallback Fallback message if file not found * @returns File content or fallback */ function loadFileContent(path: string, fallback: string): string { if (!existsSync(path)) { return fallback; } try { return readFileSync(path, 'utf8'); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; console.warn('[WARNING] Failed to read ' + path + ': ' + msg); return fallback; } } /** * Count completed and total tasks from tasks.md * * @param tasksContent Content of tasks.md * @returns Object with total and completed counts */ function countTasks(tasksContent: string): { total: number; completed: number; } { const lines = tasksContent.split('\n'); let total = 0; let completed = 0; for (const line of lines) { if (line.trim().startsWith('- [x]') || line.trim().startsWith('- [X]')) { completed++; total++; } else if (line.trim().startsWith('- [ ]')) { total++; } } return { total, completed }; } /** * Format time elapsed since timestamp * * @param timestamp ISO 8601 timestamp * @returns Human-readable time elapsed */ function formatTimeElapsed(timestamp: string): string { const now = new Date(); const then = new Date(timestamp); const diffMs = now.getTime() - then.getTime(); const minutes = Math.floor(diffMs / 1000 / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return days + (days === 1 ? ' day ago' : ' days ago'); if (hours > 0) return hours + (hours === 1 ? ' hour ago' : ' hours ago'); if (minutes > 0) return minutes + (minutes === 1 ? ' minute ago' : ' minutes ago'); return 'just now'; } /** * Get last N lines from content * * @param content File content * @param n Number of lines * @returns Last N lines */ function getLastLines(content: string, n: number): string { const lines = content.trim().split('\n'); const lastN = lines.slice(-n); return lastN.join('\n'); } /** * Format context output for Claude * * @param active Active change data * @param cwd Current working directory * @returns Formatted context string */ function formatContext(active: ActiveChange, cwd: string): string { const changeDir = path.resolve(cwd, 'openspec/changes', active.change); // Check if change directory exists if (!existsSync(changeDir)) { return ( '⚠️ Active change "' + active.change + '" not found.\n' + 'It may have been archived. Use /openspec:work to select a new change.' ); } // Load context files const proposalPath = path.join(changeDir, 'proposal.md'); const designPath = path.join(changeDir, 'design.md'); const tasksPath = path.join(changeDir, 'tasks.md'); const proposal = loadFileContent( proposalPath, '(No proposal.md - create one with /openspec:proposal)', ); const design = loadFileContent( designPath, '(No design.md - create one to track your approach)', ); const tasks = loadFileContent(tasksPath, '(No tasks.md - no tasks defined)'); // Count task progress const taskCounts = countTasks(tasks); const percentage = taskCounts.total > 0 ? Math.round((taskCounts.completed / taskCounts.total) * 100) : 0; // Format timestamps const startedAgo = formatTimeElapsed(active.started); const checkpointAgo = active.lastCheckpoint ? formatTimeElapsed(active.lastCheckpoint) : 'never'; // Build output const recentContext = getLastLines(design, 15); const lines = [ '='.repeat(70), '📋 RESUMING OPENSPEC WORK', '='.repeat(70), '', 'Active Change: ' + active.change, 'Started: ' + startedAgo, 'Last Checkpoint: ' + checkpointAgo, '', 'Progress:', ' Tasks: ' + taskCounts.completed + '/' + taskCounts.total + ' (' + percentage + '%)', '', '-'.repeat(70), 'PROPOSAL (WHY)', '-'.repeat(70), proposal, '', '-'.repeat(70), 'DESIGN (HOW - Living Doc)', '-'.repeat(70), design, '', '-'.repeat(70), 'RECENT CHECKPOINT NOTES', '-'.repeat(70), recentContext, '', '-'.repeat(70), 'TASKS (WHAT)', '-'.repeat(70), tasks, '', '='.repeat(70), 'COMMANDS:', ' /openspec:status - Check progress', ' /openspec:checkpoint - Save progress', ' /openspec:work - Switch changes', ' /openspec:done - Complete and archive', '='.repeat(70), ]; return lines.join('\n'); } /** * Main hook execution * * Workflow: * 1. Parse stdin * 2. Load active change * 3. If active, load context files * 4. Format and output context * 5. Exit cleanly */ async function main(): Promise { const startTime = Date.now(); try { // 1. Parse input const input = await parseStdin(); // 2. Load active change const active = loadActiveChange(input.cwd); // If no active change, exit silently (no output) if (!active) { process.exit(0); } // 3. Format and output context const context = formatContext(active, input.cwd); console.log(context); // Performance monitoring const duration = Date.now() - startTime; if (duration > 100) { console.warn('[WARNING] Slow SessionStart hook: ' + duration + 'ms'); } process.exit(0); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; console.error('[ERROR] SessionStart hook error: ' + msg); // Don't fail the session start - just exit silently process.exit(0); } } // Execute await main();