2026-01-23 15:08:07 +08:00
#!/usr/bin/env node
/**
2026-03-04 14:48:06 -08:00
* Stop Hook (Session End) - Persist learnings during active sessions
2026-01-23 15:08:07 +08:00
*
* Cross-platform (Windows, macOS, Linux)
*
2026-03-04 14:48:06 -08:00
* Runs on Stop events (after each response). Extracts a meaningful summary
* from the session transcript (via stdin JSON transcript_path) and updates a
* session file for cross-session continuity.
2026-01-23 15:08:07 +08:00
*/
const path = require ( 'path' );
const fs = require ( 'fs' );
const {
getSessionsDir ,
getDateString ,
getTimeString ,
2026-01-25 18:21:27 -08:00
getSessionIdShort ,
2026-03-12 08:50:24 -07:00
getProjectName ,
2026-01-23 15:08:07 +08:00
ensureDir ,
2026-02-11 23:56:41 -08:00
readFile ,
2026-01-23 15:08:07 +08:00
writeFile ,
2026-03-12 08:50:24 -07:00
runCommand ,
2026-03-20 01:38:11 -07:00
stripAnsi ,
2026-01-23 15:08:07 +08:00
log
} = require ( '../lib/utils' );
2026-03-04 14:48:06 -08:00
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->' ;
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->' ;
2026-03-12 08:50:24 -07:00
const SESSION_SEPARATOR = '\n---\n' ;
2026-03-04 14:48:06 -08:00
2026-02-11 23:56:41 -08:00
/**
* Extract a meaningful summary from the session transcript.
* Reads the JSONL transcript and pulls out key information:
* - User messages (tasks requested)
* - Tools used
* - Files modified
*/
function extractSessionSummary ( transcriptPath ) {
const content = readFile ( transcriptPath );
if ( ! content ) return null ;
const lines = content . split ( '\n' ). filter ( Boolean );
const userMessages = [];
const toolsUsed = new Set ();
const filesModified = new Set ();
2026-02-12 07:06:53 -08:00
let parseErrors = 0 ;
2026-02-11 23:56:41 -08:00
for ( const line of lines ) {
try {
const entry = JSON . parse ( line );
// Collect user messages (first 200 chars each)
2026-02-13 18:04:27 +09:00
if ( entry . type === 'user' || entry . role === 'user' || entry . message ? . role === 'user' ) {
// Support both direct content and nested message.content (Claude Code JSONL format)
const rawContent = entry . message ? . content ?? entry . content ;
const text = typeof rawContent === 'string'
? rawContent
: Array . isArray ( rawContent )
? rawContent . map ( c => ( c && c . text ) || '' ). join ( ' ' )
2026-02-11 23:56:41 -08:00
: '' ;
2026-03-20 01:38:11 -07:00
const cleaned = stripAnsi ( text ). trim ();
if ( cleaned ) {
userMessages . push ( cleaned . slice ( 0 , 200 ));
2026-02-11 23:56:41 -08:00
}
}
2026-02-13 18:04:27 +09:00
// Collect tool names and modified files (direct tool_use entries)
2026-02-11 23:56:41 -08:00
if ( entry . type === 'tool_use' || entry . tool_name ) {
const toolName = entry . tool_name || entry . name || '' ;
if ( toolName ) toolsUsed . add ( toolName );
const filePath = entry . tool_input ? . file_path || entry . input ? . file_path || '' ;
if ( filePath && ( toolName === 'Edit' || toolName === 'Write' )) {
filesModified . add ( filePath );
}
}
2026-02-13 18:04:27 +09:00
// Extract tool uses from assistant message content blocks (Claude Code JSONL format)
if ( entry . type === 'assistant' && Array . isArray ( entry . message ? . content )) {
for ( const block of entry . message . content ) {
if ( block . type === 'tool_use' ) {
const toolName = block . name || '' ;
if ( toolName ) toolsUsed . add ( toolName );
const filePath = block . input ? . file_path || '' ;
if ( filePath && ( toolName === 'Edit' || toolName === 'Write' )) {
filesModified . add ( filePath );
}
}
}
}
2026-02-11 23:56:41 -08:00
} catch {
2026-02-12 07:06:53 -08:00
parseErrors ++ ;
2026-02-11 23:56:41 -08:00
}
}
2026-02-12 07:06:53 -08:00
if ( parseErrors > 0 ) {
log ( `[SessionEnd] Skipped ${ parseErrors } / ${ lines . length } unparseable transcript lines` );
}
2026-02-11 23:56:41 -08:00
if ( userMessages . length === 0 ) return null ;
return {
userMessages : userMessages . slice ( - 10 ), // Last 10 user messages
toolsUsed : Array . from ( toolsUsed ). slice ( 0 , 20 ),
filesModified : Array . from ( filesModified ). slice ( 0 , 30 ),
totalMessages : userMessages . length
};
}
2026-02-12 15:33:55 -08:00
// Read hook input from stdin (Claude Code provides transcript_path via stdin JSON)
const MAX_STDIN = 1024 * 1024 ;
let stdinData = '' ;
2026-02-12 16:08:49 -08:00
process . stdin . setEncoding ( 'utf8' );
2026-02-12 15:33:55 -08:00
process . stdin . on ( 'data' , chunk => {
if ( stdinData . length < MAX_STDIN ) {
2026-02-18 07:40:12 +00:00
const remaining = MAX_STDIN - stdinData . length ;
stdinData += chunk . substring ( 0 , remaining );
2026-02-12 15:33:55 -08:00
}
});
process . stdin . on ( 'end' , () => {
runMain ();
});
function runMain () {
main (). catch ( err => {
console . error ( '[SessionEnd] Error:' , err . message );
process . exit ( 0 );
});
}
2026-03-12 08:50:24 -07:00
function getSessionMetadata () {
const branchResult = runCommand ( 'git rev-parse --abbrev-ref HEAD' );
return {
project : getProjectName () || 'unknown' ,
branch : branchResult . success ? branchResult . output : 'unknown' ,
worktree : process . cwd ()
};
}
function extractHeaderField ( header , label ) {
const match = header . match ( new RegExp ( `\\*\\* ${ escapeRegExp ( label ) } :\\*\\*\\s*(.+)$` , 'm' ));
return match ? match [ 1 ]. trim () : null ;
}
function buildSessionHeader ( today , currentTime , metadata , existingContent = '' ) {
const headingMatch = existingContent . match ( /^#\s+.+$/m );
const heading = headingMatch ? headingMatch [ 0 ] : `# Session: ${ today } ` ;
const date = extractHeaderField ( existingContent , 'Date' ) || today ;
const started = extractHeaderField ( existingContent , 'Started' ) || currentTime ;
return [
heading ,
`**Date:** ${ date } ` ,
`**Started:** ${ started } ` ,
`**Last Updated:** ${ currentTime } ` ,
`**Project:** ${ metadata . project } ` ,
`**Branch:** ${ metadata . branch } ` ,
`**Worktree:** ${ metadata . worktree } ` ,
''
]. join ( '\n' );
}
function mergeSessionHeader ( content , today , currentTime , metadata ) {
const separatorIndex = content . indexOf ( SESSION_SEPARATOR );
if ( separatorIndex === - 1 ) {
return null ;
}
const existingHeader = content . slice ( 0 , separatorIndex );
const body = content . slice ( separatorIndex + SESSION_SEPARATOR . length );
const nextHeader = buildSessionHeader ( today , currentTime , metadata , existingHeader );
return ` ${ nextHeader }${ SESSION_SEPARATOR }${ body } ` ;
}
2026-01-23 15:08:07 +08:00
async function main () {
2026-02-12 15:33:55 -08:00
// Parse stdin JSON to get transcript_path
let transcriptPath = null ;
try {
const input = JSON . parse ( stdinData );
transcriptPath = input . transcript_path ;
} catch {
// Fallback: try env var for backwards compatibility
transcriptPath = process . env . CLAUDE_TRANSCRIPT_PATH ;
}
2026-01-23 15:08:07 +08:00
const sessionsDir = getSessionsDir ();
const today = getDateString ();
2026-01-25 18:21:27 -08:00
const shortId = getSessionIdShort ();
const sessionFile = path . join ( sessionsDir , ` ${ today } - ${ shortId } -session.tmp` );
2026-03-12 08:50:24 -07:00
const sessionMetadata = getSessionMetadata ();
2026-01-23 15:08:07 +08:00
ensureDir ( sessionsDir );
const currentTime = getTimeString ();
2026-02-11 23:56:41 -08:00
// Try to extract summary from transcript
let summary = null ;
2026-02-12 13:40:14 -08:00
if ( transcriptPath ) {
if ( fs . existsSync ( transcriptPath )) {
summary = extractSessionSummary ( transcriptPath );
} else {
log ( `[SessionEnd] Transcript not found: ${ transcriptPath } ` );
}
2026-02-11 23:56:41 -08:00
}
2026-01-23 15:08:07 +08:00
if ( fs . existsSync ( sessionFile )) {
2026-03-12 08:50:24 -07:00
const existing = readFile ( sessionFile );
let updatedContent = existing ;
if ( existing ) {
const merged = mergeSessionHeader ( existing , today , currentTime , sessionMetadata );
if ( merged ) {
updatedContent = merged ;
} else {
log ( `[SessionEnd] Failed to normalize header in ${ sessionFile } ` );
}
2026-02-12 13:40:14 -08:00
}
2026-01-23 15:08:07 +08:00
2026-03-04 14:48:06 -08:00
// If we have a new summary, update only the generated summary block.
// This keeps repeated Stop invocations idempotent and preserves
// user-authored sections in the same session file.
2026-03-12 08:50:24 -07:00
if ( summary && updatedContent ) {
const summaryBlock = buildSummaryBlock ( summary );
2026-03-04 14:48:06 -08:00
2026-03-12 08:50:24 -07:00
if ( updatedContent . includes ( SUMMARY_START_MARKER ) && updatedContent . includes ( SUMMARY_END_MARKER )) {
updatedContent = updatedContent . replace (
new RegExp ( ` ${ escapeRegExp ( SUMMARY_START_MARKER ) } [\\s\\S]*? ${ escapeRegExp ( SUMMARY_END_MARKER ) } ` ),
summaryBlock
);
} else {
// Migration path for files created before summary markers existed.
updatedContent = updatedContent . replace (
/## (?:Session Summary|Current State)[\s\S]*?$/ ,
` ${ summaryBlock } \n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`
);
2026-02-11 23:56:41 -08:00
}
2026-01-23 15:08:07 +08:00
}
2026-02-11 23:56:41 -08:00
2026-03-12 08:50:24 -07:00
if ( updatedContent ) {
writeFile ( sessionFile , updatedContent );
}
2026-02-11 23:56:41 -08:00
log ( `[SessionEnd] Updated session file: ${ sessionFile } ` );
2026-01-23 15:08:07 +08:00
} else {
2026-02-11 23:56:41 -08:00
// Create new session file
const summarySection = summary
2026-03-04 14:48:06 -08:00
? ` ${ buildSummaryBlock ( summary ) } \n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``
2026-02-11 23:56:41 -08:00
: `## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`` ;
2026-03-12 08:50:24 -07:00
const template = ` ${ buildSessionHeader ( today , currentTime , sessionMetadata ) }${ SESSION_SEPARATOR }${ summarySection }
2026-01-23 15:08:07 +08:00
` ;
writeFile ( sessionFile , template );
log ( `[SessionEnd] Created session file: ${ sessionFile } ` );
}
process . exit ( 0 );
}
2026-02-11 23:56:41 -08:00
function buildSummarySection ( summary ) {
let section = '## Session Summary\n\n' ;
2026-02-13 04:28:50 -08:00
// Tasks (from user messages — collapse newlines and escape backticks to prevent markdown breaks)
2026-02-11 23:56:41 -08:00
section += '### Tasks\n' ;
for ( const msg of summary . userMessages ) {
2026-02-13 04:28:50 -08:00
section += `- ${ msg . replace ( /\n/g , ' ' ). replace ( /`/g , '\\`' ) } \n` ;
2026-02-11 23:56:41 -08:00
}
section += '\n' ;
// Files modified
if ( summary . filesModified . length > 0 ) {
section += '### Files Modified\n' ;
for ( const f of summary . filesModified ) {
section += `- ${ f } \n` ;
}
section += '\n' ;
}
// Tools used
if ( summary . toolsUsed . length > 0 ) {
section += `### Tools Used\n ${ summary . toolsUsed . join ( ', ' ) } \n\n` ;
}
section += `### Stats\n- Total user messages: ${ summary . totalMessages } \n` ;
return section ;
}
2026-03-04 14:48:06 -08:00
function buildSummaryBlock ( summary ) {
return ` ${ SUMMARY_START_MARKER } \n ${ buildSummarySection ( summary ). trim () } \n ${ SUMMARY_END_MARKER } ` ;
}
function escapeRegExp ( value ) {
return String ( value ). replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' );
}