mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-14 02:10:07 +08:00
fix: scope SessionStart context injection
This commit is contained in:
parent
67a8b914ee
commit
03108bea62
@ -33,6 +33,8 @@ const MAX_INJECTED_LEARNED_SKILLS = 6;
|
|||||||
const MAX_LEARNED_SKILL_SUMMARY_CHARS = 220;
|
const MAX_LEARNED_SKILL_SUMMARY_CHARS = 220;
|
||||||
const DEFAULT_SESSION_START_CONTEXT_MAX_CHARS = 8000;
|
const DEFAULT_SESSION_START_CONTEXT_MAX_CHARS = 8000;
|
||||||
const DEFAULT_SESSION_RETENTION_DAYS = 30;
|
const DEFAULT_SESSION_RETENTION_DAYS = 30;
|
||||||
|
const SESSION_START_MODE_INVALID = 'invalid';
|
||||||
|
const SESSION_START_MODE_SKIP = 'skip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a filesystem path to its canonical (real) form.
|
* Resolve a filesystem path to its canonical (real) form.
|
||||||
@ -101,6 +103,33 @@ function getSessionStartMaxContextChars() {
|
|||||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_SESSION_START_CONTEXT_MAX_CHARS;
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_SESSION_START_CONTEXT_MAX_CHARS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionStartMode(rawInput) {
|
||||||
|
const input = String(rawInput || '');
|
||||||
|
if (!input.trim()) return null;
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(input);
|
||||||
|
} catch {
|
||||||
|
log(`[SessionStart] Invalid stdin payload; skipping previous session summary injection. Length: ${input.length}`);
|
||||||
|
return SESSION_START_MODE_INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedModes = new Set(['startup', 'resume', 'clear', 'compact']);
|
||||||
|
const hookName = typeof payload.hookName === 'string' ? payload.hookName.trim() : '';
|
||||||
|
if (hookName.startsWith('SessionStart:')) {
|
||||||
|
const mode = hookName.slice('SessionStart:'.length).trim().toLowerCase();
|
||||||
|
return supportedModes.has(mode) ? mode : SESSION_START_MODE_SKIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.hook_event_name === 'SessionStart') {
|
||||||
|
const mode = typeof payload.source === 'string' ? payload.source.trim().toLowerCase() : '';
|
||||||
|
return supportedModes.has(mode) ? mode : SESSION_START_MODE_SKIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SESSION_START_MODE_SKIP;
|
||||||
|
}
|
||||||
|
|
||||||
function limitSessionStartContext(additionalContext, maxChars = getSessionStartMaxContextChars()) {
|
function limitSessionStartContext(additionalContext, maxChars = getSessionStartMaxContextChars()) {
|
||||||
const context = String(additionalContext || '');
|
const context = String(additionalContext || '');
|
||||||
|
|
||||||
@ -168,8 +197,8 @@ function pruneExpiredSessions(searchDirs, retentionDays) {
|
|||||||
*
|
*
|
||||||
* Priority (highest to lowest):
|
* Priority (highest to lowest):
|
||||||
* 1. Exact worktree (cwd) match — most recent
|
* 1. Exact worktree (cwd) match — most recent
|
||||||
* 2. Same project name match — most recent
|
* 2. Same project name match for legacy sessions without Worktree metadata
|
||||||
* 3. Fallback to overall most recent (original behavior)
|
* 3. No injection when sessions belong to a different worktree/project
|
||||||
*
|
*
|
||||||
* Sessions are already sorted newest-first, so the first match in each
|
* Sessions are already sorted newest-first, so the first match in each
|
||||||
* category wins.
|
* category wins.
|
||||||
@ -189,18 +218,12 @@ function selectMatchingSession(sessions, cwd, currentProject) {
|
|||||||
|
|
||||||
let projectMatch = null;
|
let projectMatch = null;
|
||||||
let projectMatchContent = null;
|
let projectMatchContent = null;
|
||||||
let fallbackSession = null;
|
let readableSessions = 0;
|
||||||
let fallbackContent = null;
|
|
||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const content = readFile(session.path);
|
const content = readFile(session.path);
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
readableSessions++;
|
||||||
// Cache first readable session+content pair for fallback
|
|
||||||
if (!fallbackSession) {
|
|
||||||
fallbackSession = session;
|
|
||||||
fallbackContent = content;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract **Worktree:** field
|
// Extract **Worktree:** field
|
||||||
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m);
|
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m);
|
||||||
@ -212,8 +235,9 @@ function selectMatchingSession(sessions, cwd, currentProject) {
|
|||||||
return { session, content, matchReason: 'worktree' };
|
return { session, content, matchReason: 'worktree' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project name match — keep searching for a worktree match
|
// Project name match is only safe for legacy session files written before
|
||||||
if (!projectMatch && currentProject) {
|
// Worktree metadata existed. A different explicit Worktree is not a match.
|
||||||
|
if (!projectMatch && currentProject && !sessionWorktree) {
|
||||||
const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
|
const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
|
||||||
const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : '';
|
const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : '';
|
||||||
if (sessionProject && sessionProject === currentProject) {
|
if (sessionProject && sessionProject === currentProject) {
|
||||||
@ -227,12 +251,9 @@ function selectMatchingSession(sessions, cwd, currentProject) {
|
|||||||
return { session: projectMatch, content: projectMatchContent, matchReason: 'project' };
|
return { session: projectMatch, content: projectMatchContent, matchReason: 'project' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: most recent readable session (original behavior)
|
log(readableSessions > 0
|
||||||
if (fallbackSession) {
|
? '[SessionStart] No worktree/project session match found'
|
||||||
return { session: fallbackSession, content: fallbackContent, matchReason: 'recency-fallback' };
|
: '[SessionStart] All session files were unreadable');
|
||||||
}
|
|
||||||
|
|
||||||
log('[SessionStart] All session files were unreadable');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -498,6 +519,7 @@ async function main() {
|
|||||||
const maxContextChars = getSessionStartMaxContextChars();
|
const maxContextChars = getSessionStartMaxContextChars();
|
||||||
const explicitContextDisabled = isSessionStartContextDisabled();
|
const explicitContextDisabled = isSessionStartContextDisabled();
|
||||||
const shouldInjectContext = !explicitContextDisabled && maxContextChars !== 0;
|
const shouldInjectContext = !explicitContextDisabled && maxContextChars !== 0;
|
||||||
|
const sessionStartMode = getSessionStartMode(fs.readFileSync(0, 'utf8'));
|
||||||
|
|
||||||
// Ensure directories exist
|
// Ensure directories exist
|
||||||
ensureDir(sessionsDir);
|
ensureDir(sessionsDir);
|
||||||
@ -532,50 +554,59 @@ async function main() {
|
|||||||
additionalContextParts.push(instinctSummary);
|
additionalContextParts.push(instinctSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for recent session files (last 7 days)
|
if (sessionStartMode && sessionStartMode !== 'startup') {
|
||||||
const recentSessions = dedupeRecentSessions(sessionSearchDirs);
|
const reason = sessionStartMode === SESSION_START_MODE_INVALID
|
||||||
|
? 'invalid stdin payload'
|
||||||
|
: sessionStartMode === SESSION_START_MODE_SKIP
|
||||||
|
? 'unrecognized SessionStart payload'
|
||||||
|
: `non-startup SessionStart mode: ${sessionStartMode}`;
|
||||||
|
log(`[SessionStart] Skipping previous session summary injection for ${reason}`);
|
||||||
|
} else {
|
||||||
|
// Check for recent session files (last 7 days)
|
||||||
|
const recentSessions = dedupeRecentSessions(sessionSearchDirs);
|
||||||
|
|
||||||
if (recentSessions.length > 0) {
|
if (recentSessions.length > 0) {
|
||||||
log(`[SessionStart] Found ${recentSessions.length} recent session(s)`);
|
log(`[SessionStart] Found ${recentSessions.length} recent session(s)`);
|
||||||
|
|
||||||
// Prefer a session that matches the current working directory or project.
|
// Prefer a session that matches the current working directory or project.
|
||||||
// Session files contain **Project:** and **Worktree:** header fields written
|
// Session files contain **Project:** and **Worktree:** header fields written
|
||||||
// by session-end.js, so we can match against them.
|
// by session-end.js, so we can match against them.
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const currentProject = getProjectName() || '';
|
const currentProject = getProjectName() || '';
|
||||||
|
|
||||||
const result = selectMatchingSession(recentSessions, cwd, currentProject);
|
const result = selectMatchingSession(recentSessions, cwd, currentProject);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
log(`[SessionStart] Selected: ${result.session.path} (match: ${result.matchReason})`);
|
log(`[SessionStart] Selected: ${result.session.path} (match: ${result.matchReason})`);
|
||||||
|
|
||||||
// Use the already-read content from selectMatchingSession (no duplicate I/O)
|
// Use the already-read content from selectMatchingSession (no duplicate I/O)
|
||||||
const content = stripAnsi(result.content);
|
const content = stripAnsi(result.content);
|
||||||
if (content && !content.includes('[Session context goes here]')) {
|
if (content && !content.includes('[Session context goes here]')) {
|
||||||
// STALE-REPLAY GUARD: wrap the summary in a historical-only marker so
|
// STALE-REPLAY GUARD: wrap the summary in a historical-only marker so
|
||||||
// the model does not re-execute stale skill invocations / ARGUMENTS
|
// the model does not re-execute stale skill invocations / ARGUMENTS
|
||||||
// from a prior compaction boundary. Observed in practice: after
|
// from a prior compaction boundary. Observed in practice: after
|
||||||
// compaction resume the model would re-run /fw-task-new (or any
|
// compaction resume the model would re-run /fw-task-new (or any
|
||||||
// ARGUMENTS-bearing slash skill) with the last ARGUMENTS it saw,
|
// ARGUMENTS-bearing slash skill) with the last ARGUMENTS it saw,
|
||||||
// duplicating issues/branches/Notion tasks. Tracking upstream at
|
// duplicating issues/branches/Notion tasks. Tracking upstream at
|
||||||
// https://github.com/affaan-m/everything-claude-code/issues/1534
|
// https://github.com/affaan-m/everything-claude-code/issues/1534
|
||||||
const guarded = [
|
const guarded = [
|
||||||
'HISTORICAL REFERENCE ONLY — NOT LIVE INSTRUCTIONS.',
|
'HISTORICAL REFERENCE ONLY — NOT LIVE INSTRUCTIONS.',
|
||||||
'The block below is a frozen summary of a PRIOR conversation that',
|
'The block below is a frozen summary of a PRIOR conversation that',
|
||||||
'ended at compaction. Any task descriptions, skill invocations, or',
|
'ended at compaction. Any task descriptions, skill invocations, or',
|
||||||
'ARGUMENTS= payloads inside it are STALE-BY-DEFAULT and MUST NOT be',
|
'ARGUMENTS= payloads inside it are STALE-BY-DEFAULT and MUST NOT be',
|
||||||
're-executed without an explicit, current user request in this',
|
're-executed without an explicit, current user request in this',
|
||||||
'session. Verify against git/working-tree state before any action —',
|
'session. Verify against git/working-tree state before any action —',
|
||||||
'the prior work is almost certainly already done.',
|
'the prior work is almost certainly already done.',
|
||||||
'',
|
'',
|
||||||
'--- BEGIN PRIOR-SESSION SUMMARY ---',
|
'--- BEGIN PRIOR-SESSION SUMMARY ---',
|
||||||
content,
|
content,
|
||||||
'--- END PRIOR-SESSION SUMMARY ---',
|
'--- END PRIOR-SESSION SUMMARY ---',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
additionalContextParts.push(guarded);
|
additionalContextParts.push(guarded);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('[SessionStart] No matching session found');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log('[SessionStart] No matching session found');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,6 +98,30 @@ function getSessionStartAdditionalContext(stdout) {
|
|||||||
return payload.hookSpecificOutput.additionalContext;
|
return payload.hookSpecificOutput.additionalContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RESUME_SESSION_SENTINEL = 'RESUME_CONTEXT_SHOULD_NOT_BE_INJECTED';
|
||||||
|
const INVALID_STDIN_SESSION_SENTINEL = 'INVALID_STDIN_CONTEXT_SHOULD_NOT_BE_INJECTED';
|
||||||
|
const INVALID_STDIN_LOG_SENTINEL = 'SENSITIVE_STDIN_SHOULD_NOT_BE_LOGGED';
|
||||||
|
const CROSS_PROJECT_SESSION_SENTINEL = 'CROSS_PROJECT_CONTEXT_SHOULD_NOT_BE_INJECTED';
|
||||||
|
const CROSS_WORKTREE_PROJECT_SENTINEL = 'CROSS_WORKTREE_PROJECT_CONTEXT_SHOULD_NOT_BE_INJECTED';
|
||||||
|
const CLI_RESUME_SESSION_SENTINEL = 'CLI_RESUME_CONTEXT_SHOULD_NOT_BE_INJECTED';
|
||||||
|
const CLI_CLEAR_SESSION_SENTINEL = 'CLI_CLEAR_CONTEXT_SHOULD_NOT_BE_INJECTED';
|
||||||
|
const DESKTOP_CLEAR_SESSION_SENTINEL = 'DESKTOP_CLEAR_CONTEXT_SHOULD_NOT_BE_INJECTED';
|
||||||
|
const PROJECT_ONLY_SESSION_SENTINEL = 'PROJECT_ONLY_CONTEXT_SHOULD_BE_INJECTED';
|
||||||
|
|
||||||
|
function buildSessionStartFixture(content, options = {}) {
|
||||||
|
const title = options.title ?? '# Session';
|
||||||
|
const project = options.project ?? path.basename(process.cwd());
|
||||||
|
const worktree = options.worktree ?? process.cwd();
|
||||||
|
|
||||||
|
const lines = [title, `**Project:** ${project}`];
|
||||||
|
if (worktree) {
|
||||||
|
lines.push(`**Worktree:** ${worktree}`);
|
||||||
|
}
|
||||||
|
lines.push('', content, '');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// Test helper
|
// Test helper
|
||||||
function test(name, fn) {
|
function test(name, fn) {
|
||||||
try {
|
try {
|
||||||
@ -413,7 +437,10 @@ async function runTests() {
|
|||||||
|
|
||||||
// Create a real session file
|
// Create a real session file
|
||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-efgh5678-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-efgh5678-session.tmp');
|
||||||
fs.writeFileSync(sessionFile, '# Real Session\n\nI worked on authentication refactor.\n');
|
fs.writeFileSync(
|
||||||
|
sessionFile,
|
||||||
|
buildSessionStartFixture('I worked on authentication refactor.', { title: '# Real Session' })
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -455,7 +482,10 @@ async function runTests() {
|
|||||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-large000-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-large000-session.tmp');
|
||||||
fs.writeFileSync(sessionFile, `# Large Session\n\nSTART_MARKER\n${'A'.repeat(20000)}\nEND_MARKER\n`);
|
fs.writeFileSync(
|
||||||
|
sessionFile,
|
||||||
|
buildSessionStartFixture(`START_MARKER\n${'A'.repeat(20000)}\nEND_MARKER`, { title: '# Large Session' })
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -484,7 +514,10 @@ async function runTests() {
|
|||||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-max0000-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-max0000-session.tmp');
|
||||||
fs.writeFileSync(sessionFile, `# Sized Session\n\n${'B'.repeat(1200)}\n`);
|
fs.writeFileSync(
|
||||||
|
sessionFile,
|
||||||
|
buildSessionStartFixture('B'.repeat(1200), { title: '# Sized Session' })
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -548,8 +581,14 @@ async function runTests() {
|
|||||||
fs.mkdirSync(legacyDir, { recursive: true });
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
fs.writeFileSync(canonicalFile, '# Canonical Session\n\nUse the canonical session-data copy.\n');
|
fs.writeFileSync(
|
||||||
fs.writeFileSync(legacyFile, '# Legacy Session\n\nDo not prefer the legacy duplicate.\n');
|
canonicalFile,
|
||||||
|
buildSessionStartFixture('Use the canonical session-data copy.', { title: '# Canonical Session' })
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
legacyFile,
|
||||||
|
buildSessionStartFixture('Do not prefer the legacy duplicate.', { title: '# Legacy Session' })
|
||||||
|
);
|
||||||
fs.utimesSync(canonicalFile, canonicalTime, canonicalTime);
|
fs.utimesSync(canonicalFile, canonicalTime, canonicalTime);
|
||||||
fs.utimesSync(legacyFile, legacyTime, legacyTime);
|
fs.utimesSync(legacyFile, legacyTime, legacyTime);
|
||||||
|
|
||||||
@ -580,7 +619,10 @@ async function runTests() {
|
|||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-winansi00-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-winansi00-session.tmp');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
sessionFile,
|
sessionFile,
|
||||||
'\x1b[H\x1b[2J\x1b[3J# Real Session\n\nI worked on \x1b[1;36mWindows terminal handling\x1b[0m.\x1b[K\n'
|
buildSessionStartFixture(
|
||||||
|
'I worked on \x1b[1;36mWindows terminal handling\x1b[0m.\x1b[K',
|
||||||
|
{ title: '\x1b[H\x1b[2J\x1b[3J# Real Session' }
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -604,6 +646,215 @@ async function runTests() {
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('skips prior session summary on Desktop SessionStart resume', async () => {
|
||||||
|
const isoHome = path.join(os.tmpdir(), `ecc-resume-start-${Date.now()}`);
|
||||||
|
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
|
const sessionFile = path.join(sessionsDir, '2026-02-11-resume00-session.tmp');
|
||||||
|
fs.writeFileSync(sessionFile, buildSessionStartFixture(RESUME_SESSION_SENTINEL));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runScript(
|
||||||
|
path.join(scriptsDir, 'session-start.js'),
|
||||||
|
JSON.stringify({ hookName: 'SessionStart:resume' }),
|
||||||
|
{ HOME: isoHome, USERPROFILE: isoHome }
|
||||||
|
);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
|
assert.ok(!additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should not inject a previous summary on resume');
|
||||||
|
assert.ok(!additionalContext.includes(RESUME_SESSION_SENTINEL), 'Should not inject resume session content');
|
||||||
|
assert.ok(result.stderr.includes('non-startup SessionStart mode: resume'), `Should log skip reason, stderr: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('skips prior session summary on CLI SessionStart resume', async () => {
|
||||||
|
const isoHome = path.join(os.tmpdir(), `ecc-cli-resume-start-${Date.now()}`);
|
||||||
|
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
|
const sessionFile = path.join(sessionsDir, '2026-02-11-clires00-session.tmp');
|
||||||
|
fs.writeFileSync(sessionFile, buildSessionStartFixture(CLI_RESUME_SESSION_SENTINEL));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runScript(
|
||||||
|
path.join(scriptsDir, 'session-start.js'),
|
||||||
|
JSON.stringify({ hook_event_name: 'SessionStart', source: 'resume' }),
|
||||||
|
{ HOME: isoHome, USERPROFILE: isoHome }
|
||||||
|
);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
|
assert.ok(!additionalContext.includes(CLI_RESUME_SESSION_SENTINEL), 'Should not inject CLI resume session content');
|
||||||
|
assert.ok(result.stderr.includes('non-startup SessionStart mode: resume'), `Should log skip reason, stderr: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('skips prior session summary on clear SessionStart payloads', async () => {
|
||||||
|
const isoHome = path.join(os.tmpdir(), `ecc-clear-start-${Date.now()}`);
|
||||||
|
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
|
const desktopFile = path.join(sessionsDir, '2026-02-11-deskclear-session.tmp');
|
||||||
|
fs.writeFileSync(desktopFile, buildSessionStartFixture(`${DESKTOP_CLEAR_SESSION_SENTINEL}\n${CLI_CLEAR_SESSION_SENTINEL}`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const desktopResult = await runScript(
|
||||||
|
path.join(scriptsDir, 'session-start.js'),
|
||||||
|
JSON.stringify({ hookName: 'SessionStart:clear' }),
|
||||||
|
{ HOME: isoHome, USERPROFILE: isoHome }
|
||||||
|
);
|
||||||
|
assert.strictEqual(desktopResult.code, 0);
|
||||||
|
const desktopContext = getSessionStartAdditionalContext(desktopResult.stdout);
|
||||||
|
assert.ok(!desktopContext.includes(DESKTOP_CLEAR_SESSION_SENTINEL), 'Should not inject Desktop clear session content');
|
||||||
|
|
||||||
|
const cliResult = await runScript(
|
||||||
|
path.join(scriptsDir, 'session-start.js'),
|
||||||
|
JSON.stringify({ hook_event_name: 'SessionStart', source: 'clear' }),
|
||||||
|
{ HOME: isoHome, USERPROFILE: isoHome }
|
||||||
|
);
|
||||||
|
assert.strictEqual(cliResult.code, 0);
|
||||||
|
const cliContext = getSessionStartAdditionalContext(cliResult.stdout);
|
||||||
|
assert.ok(!cliContext.includes(CLI_CLEAR_SESSION_SENTINEL), 'Should not inject CLI clear session content');
|
||||||
|
assert.ok(cliResult.stderr.includes('non-startup SessionStart mode: clear'), `Should log clear skip reason, stderr: ${cliResult.stderr}`);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('does not log malformed SessionStart stdin content while skipping prior summary', async () => {
|
||||||
|
const isoHome = path.join(os.tmpdir(), `ecc-invalid-start-${Date.now()}`);
|
||||||
|
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
|
const sessionFile = path.join(sessionsDir, '2026-02-11-invalid-session.tmp');
|
||||||
|
fs.writeFileSync(sessionFile, buildSessionStartFixture(INVALID_STDIN_SESSION_SENTINEL));
|
||||||
|
const malformedPayload = `{"hookName":"SessionStart:resume","secret":"${INVALID_STDIN_LOG_SENTINEL}"`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), malformedPayload, {
|
||||||
|
HOME: isoHome,
|
||||||
|
USERPROFILE: isoHome
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
|
assert.ok(!additionalContext.includes(INVALID_STDIN_SESSION_SENTINEL), 'Should not inject session content after malformed stdin');
|
||||||
|
assert.ok(result.stderr.includes('Invalid stdin payload'), `Should log invalid stdin payload, stderr: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes(`Length: ${malformedPayload.length}`), `Should log payload length only, stderr: ${result.stderr}`);
|
||||||
|
assert.ok(!result.stderr.includes(INVALID_STDIN_LOG_SENTINEL), 'Should not leak raw malformed stdin content');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('does not fall back to unrelated recent session content', async () => {
|
||||||
|
const isoHome = path.join(os.tmpdir(), `ecc-cross-project-start-${Date.now()}`);
|
||||||
|
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
|
const sessionFile = path.join(sessionsDir, '2026-02-11-crossproj-session.tmp');
|
||||||
|
fs.writeFileSync(sessionFile, buildSessionStartFixture(CROSS_PROJECT_SESSION_SENTINEL, {
|
||||||
|
project: 'different-project',
|
||||||
|
worktree: path.join(os.tmpdir(), 'different-project')
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
|
HOME: isoHome,
|
||||||
|
USERPROFILE: isoHome
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
|
assert.ok(!additionalContext.includes(CROSS_PROJECT_SESSION_SENTINEL), 'Should not inject unrelated newest session content');
|
||||||
|
assert.ok(result.stderr.includes('No worktree/project session match found'), `Should log no-match reason, stderr: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('does not inject same-project sessions from a different explicit worktree', async () => {
|
||||||
|
const isoHome = path.join(os.tmpdir(), `ecc-cross-worktree-start-${Date.now()}`);
|
||||||
|
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
|
const sessionFile = path.join(sessionsDir, '2026-02-11-crosswt-session.tmp');
|
||||||
|
fs.writeFileSync(sessionFile, buildSessionStartFixture(CROSS_WORKTREE_PROJECT_SENTINEL, {
|
||||||
|
worktree: path.join(os.tmpdir(), 'same-project-different-worktree')
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
|
HOME: isoHome,
|
||||||
|
USERPROFILE: isoHome
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
|
assert.ok(!additionalContext.includes(CROSS_WORKTREE_PROJECT_SENTINEL), 'Should not inject same-project content from another worktree');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('allows project fallback only for legacy sessions without worktree metadata', async () => {
|
||||||
|
const isoHome = path.join(os.tmpdir(), `ecc-project-only-start-${Date.now()}`);
|
||||||
|
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
|
const sessionFile = path.join(sessionsDir, '2026-02-11-projectonly-session.tmp');
|
||||||
|
fs.writeFileSync(sessionFile, buildSessionStartFixture(PROJECT_ONLY_SESSION_SENTINEL, { worktree: '' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
|
HOME: isoHome,
|
||||||
|
USERPROFILE: isoHome
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
|
assert.ok(additionalContext.includes(PROJECT_ONLY_SESSION_SENTINEL), 'Should still inject legacy same-project sessions');
|
||||||
|
assert.ok(result.stderr.includes('(match: project)'), `Should report project fallback, stderr: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
await asyncTest('reports learned skills count', async () => {
|
await asyncTest('reports learned skills count', async () => {
|
||||||
const isoHome = path.join(os.tmpdir(), `ecc-skills-start-${Date.now()}`);
|
const isoHome = path.join(os.tmpdir(), `ecc-skills-start-${Date.now()}`);
|
||||||
@ -4659,13 +4910,19 @@ async function runTests() {
|
|||||||
|
|
||||||
// Create session file 6.9 days old (should be INCLUDED by maxAge:7)
|
// Create session file 6.9 days old (should be INCLUDED by maxAge:7)
|
||||||
const recentFile = path.join(sessionsDir, '2026-02-06-recent69-session.tmp');
|
const recentFile = path.join(sessionsDir, '2026-02-06-recent69-session.tmp');
|
||||||
fs.writeFileSync(recentFile, '# Recent Session\n\nRECENT CONTENT HERE');
|
fs.writeFileSync(
|
||||||
|
recentFile,
|
||||||
|
buildSessionStartFixture('RECENT CONTENT HERE', { title: '# Recent Session' })
|
||||||
|
);
|
||||||
const sixPointNineDaysAgo = new Date(Date.now() - 6.9 * 24 * 60 * 60 * 1000);
|
const sixPointNineDaysAgo = new Date(Date.now() - 6.9 * 24 * 60 * 60 * 1000);
|
||||||
fs.utimesSync(recentFile, sixPointNineDaysAgo, sixPointNineDaysAgo);
|
fs.utimesSync(recentFile, sixPointNineDaysAgo, sixPointNineDaysAgo);
|
||||||
|
|
||||||
// Create session file 8 days old (should be EXCLUDED by maxAge:7)
|
// Create session file 8 days old (should be EXCLUDED by maxAge:7)
|
||||||
const oldFile = path.join(sessionsDir, '2026-02-05-old8day-session.tmp');
|
const oldFile = path.join(sessionsDir, '2026-02-05-old8day-session.tmp');
|
||||||
fs.writeFileSync(oldFile, '# Old Session\n\nOLD CONTENT SHOULD NOT APPEAR');
|
fs.writeFileSync(
|
||||||
|
oldFile,
|
||||||
|
buildSessionStartFixture('OLD CONTENT SHOULD NOT APPEAR', { title: '# Old Session' })
|
||||||
|
);
|
||||||
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
||||||
fs.utimesSync(oldFile, eightDaysAgo, eightDaysAgo);
|
fs.utimesSync(oldFile, eightDaysAgo, eightDaysAgo);
|
||||||
|
|
||||||
@ -4736,12 +4993,18 @@ async function runTests() {
|
|||||||
|
|
||||||
// Create older session (2 days ago)
|
// Create older session (2 days ago)
|
||||||
const olderSession = path.join(sessionsDir, '2026-02-11-olderabc-session.tmp');
|
const olderSession = path.join(sessionsDir, '2026-02-11-olderabc-session.tmp');
|
||||||
fs.writeFileSync(olderSession, '# Older Session\n\nOLDER_CONTEXT_MARKER');
|
fs.writeFileSync(
|
||||||
|
olderSession,
|
||||||
|
buildSessionStartFixture('OLDER_CONTEXT_MARKER', { title: '# Older Session' })
|
||||||
|
);
|
||||||
fs.utimesSync(olderSession, new Date(now - 2 * 86400000), new Date(now - 2 * 86400000));
|
fs.utimesSync(olderSession, new Date(now - 2 * 86400000), new Date(now - 2 * 86400000));
|
||||||
|
|
||||||
// Create newer session (1 day ago)
|
// Create newer session (1 day ago)
|
||||||
const newerSession = path.join(sessionsDir, '2026-02-12-newerdef-session.tmp');
|
const newerSession = path.join(sessionsDir, '2026-02-12-newerdef-session.tmp');
|
||||||
fs.writeFileSync(newerSession, '# Newer Session\n\nNEWER_CONTEXT_MARKER');
|
fs.writeFileSync(
|
||||||
|
newerSession,
|
||||||
|
buildSessionStartFixture('NEWER_CONTEXT_MARKER', { title: '# Newer Session' })
|
||||||
|
);
|
||||||
fs.utimesSync(newerSession, new Date(now - 1 * 86400000), new Date(now - 1 * 86400000));
|
fs.utimesSync(newerSession, new Date(now - 1 * 86400000), new Date(now - 1 * 86400000));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user