Files
gh-jbabin91-super-claude-pl…/hooks/session-start.ts
2025-11-29 18:50:12 +08:00

327 lines
8.0 KiB
TypeScript
Executable File

#!/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<HookInput> {
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<void> {
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();