fix: address self-review findings

This commit is contained in:
Matt Van Horn
2026-08-24 22:26:22 -03:00
committed by Alex Schmitt
parent d8409a4b08
commit 40c8235d48
4 changed files with 211 additions and 23 deletions
+32 -7
View File
@@ -30,8 +30,9 @@ This command is the counterpart to `/save-session`.
If no argument provided:
1. Check `~/.claude/session-data/`
2. Pick the most recently modified `*-session.tmp` file
3. If the folder does not exist or has no matching files, tell the user:
2. Read the matching `*-session.tmp` candidates and apply the candidate ranking below
3. Load the highest-ranked candidate
4. If the folder does not exist or has no eligible matching files, tell the user:
```
No session files found in ~/.claude/session-data/
Run /save-session at the end of a session to create one.
@@ -42,11 +43,30 @@ If an argument is provided:
- If it looks like a date (`YYYY-MM-DD`), search `~/.claude/session-data/` first, then the legacy
`~/.claude/sessions/`, for files matching `YYYY-MM-DD-session.tmp` (legacy format) or
`YYYY-MM-DD-<shortid>-session.tmp` (current format)
and load the most recently modified variant for that date
- If it looks like a file path, read that file directly
`YYYY-MM-DD-<shortid>-session.tmp` (current format), apply the candidate ranking below across
all matches, and load the highest-ranked candidate for that date
- If it looks like a file path, read exactly that file directly. Do not apply candidate ranking or
substitute a different file, even if the requested file is empty or another file is newer
- If not found, report clearly and stop
#### Candidate ranking for implicit and date-based lookup
Rank only automatically discovered candidates. Never use this ranking for an explicit file path.
1. Reject files that are unreadable, empty, whitespace-only, or contain only headings, metadata,
separators, and placeholder values such as `[Session context goes here]`, `- [ ]`, a lone `-`,
or `[relevant files]`.
2. Reject generated summaries with only one task and no populated files-modified, tools-used,
completed, in-progress, notes, or context-to-load content. This structural rule filters
one-message summarizer echoes without depending on any particular prompt text.
3. Keep candidates with substantive populated content: completed work, in-progress work, concrete
next-session notes, concrete context paths, multiple tasks, modified files, or tools used.
4. Among eligible substantive candidates, prefer the newest modification time.
5. If modification times are equal, prefer more populated sections, then more non-placeholder
content, then larger byte size, then the lexicographically smaller resolved path. Count populated
sections and content only after removing headings, metadata, separators, and placeholder text.
These final tie-breaks make selection deterministic.
### Step 2: Read the entire session file
Read the complete file. Do not summarize yet.
@@ -96,7 +116,9 @@ If no next step is defined — ask the user where to start, and optionally sugge
## Edge Cases
**Multiple sessions for the same date** (`2024-01-15-session.tmp`, `2024-01-15-abc123de-session.tmp`):
Load the most recently modified matching file for that date, regardless of whether it uses the legacy no-id format or the current short-id format.
Apply the candidate ranking across every matching legacy and current-format file. A substantive
session must win over a newer placeholder or one-message summarizer echo; modification time decides
between eligible candidates.
**Session file references files that no longer exist:**
Note this during the briefing — "WARNING: `path/to/file.ts` referenced in session but not found on disk."
@@ -108,7 +130,10 @@ Note the gap — "WARNING: This session is from N days ago (threshold: 7 days).
Read it and follow the same briefing process — the format is the same regardless of source.
**Session file is empty or malformed:**
Report: "Session file found but appears empty or unreadable. You may need to create a new one with /save-session."
For implicit or date-based discovery, reject it and continue ranking the remaining candidates. If no
eligible candidate remains, report: "Session files were found but appear empty or unreadable. You may
need to create a new one with /save-session." For an explicit path, report that the requested file is
empty or unreadable without loading a substitute.
---
+23 -12
View File
@@ -94,6 +94,10 @@ function extractSessionSummary(transcriptPath) {
};
}
function isLowSubstanceTranscript(summary) {
return summary.totalMessages === 1 && summary.toolsUsed.length === 0 && summary.filesModified.length === 0;
}
// Read hook input from stdin (Claude Code provides transcript_path via stdin JSON)
const MAX_STDIN = 1024 * 1024;
let stdinData = '';
@@ -181,6 +185,24 @@ async function main() {
}
}
// Classify known transcripts before resolving session metadata or touching the
// session directory. Missing, unreadable, or unparseable transcript data keeps
// the established fallback behavior because it cannot be classified reliably.
let summary = null;
let transcriptExists = false;
if (transcriptPath) {
transcriptExists = fs.existsSync(transcriptPath);
if (transcriptExists) {
summary = extractSessionSummary(transcriptPath);
if (summary && isLowSubstanceTranscript(summary)) {
log('[SessionEnd] Skipped one-message session without tool or file activity');
return;
}
} else {
log(`[SessionEnd] Transcript not found: ${transcriptPath}`);
}
}
const sessionsDir = getSessionsDir();
const today = getDateString();
// Derive shortId from transcript_path UUID when available, using the SAME
@@ -211,21 +233,10 @@ async function main() {
const currentTime = getTimeString();
// Try to extract summary from transcript
let summary = null;
if (transcriptPath) {
if (fs.existsSync(transcriptPath)) {
summary = extractSessionSummary(transcriptPath);
} else {
log(`[SessionEnd] Transcript not found: ${transcriptPath}`);
}
}
// Decide whether to call LLM for a richer summary.
// Triggers: context remaining < 20%, or every 50 user messages as a baseline.
let llmSummary = null;
if (transcriptPath && summary && fs.existsSync(transcriptPath)) {
if (transcriptPath && summary && transcriptExists) {
const contextPct = getContextRemainingPct(transcriptPath);
const isContextLow = contextPct !== null && contextPct < getContextThreshold();
const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10);
+12 -3
View File
@@ -4888,7 +4888,11 @@ async function runTests() {
const testDir = createTestDir();
const transcriptPath = path.join(testDir, 'transcript.jsonl');
// Only user messages — no tool_use entries at all
const lines = ['{"type":"user","content":"How does authentication work?"}', '{"type":"assistant","message":{"content":[{"type":"text","text":"It uses JWT"}]}}'];
const lines = [
'{"type":"user","content":"How does authentication work?"}',
'{"type":"assistant","message":{"content":[{"type":"text","text":"It uses JWT"}]}}',
'{"type":"user","content":"Explain the token refresh path too"}'
];
fs.writeFileSync(transcriptPath, lines.join('\n'));
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
@@ -5262,8 +5266,11 @@ async function runTests() {
await asyncTest('handles stdin exceeding MAX_STDIN (1MB) gracefully', async () => {
const testDir = createTestDir();
const transcriptPath = path.join(testDir, 'transcript.jsonl');
// Create a minimal valid transcript so env var fallback works
fs.writeFileSync(transcriptPath, JSON.stringify({ type: 'user', content: 'Overflow test' }) + '\n');
// Create a substantive valid transcript so env var fallback works
fs.writeFileSync(
transcriptPath,
[JSON.stringify({ type: 'user', content: 'Overflow test' }), JSON.stringify({ type: 'user', content: 'Verify fallback behavior' })].join('\n') + '\n'
);
// Create stdin > 1MB: truncated JSON will be invalid → falls back to env var
const oversizedPayload = '{"transcript_path":"' + 'x'.repeat(1048600) + '"}';
@@ -5880,6 +5887,8 @@ async function runTests() {
const lines = [
// Normal user message (string content) — should be included
'{"type":"user","content":"Real user message"}',
// A second valid message keeps this fixture eligible for persistence
'{"type":"user","content":"Follow-up user message"}',
// User message with numeric content — exercises the else: '' branch
'{"type":"user","content":42}',
// User message with boolean content — also hits the else branch
+144 -1
View File
@@ -37,6 +37,20 @@ function countOccurrences(haystack, needle) {
return n;
}
function runHook(home, transcript, env = {}) {
return spawnSync('node', [script], {
encoding: 'utf8',
input: transcript ? JSON.stringify({ transcript_path: transcript }) : '',
env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_SESSION_ID: '', ...env },
timeout: 10000,
});
}
function sessionFileFor(home, uuid) {
const shortId = sanitizeSessionId(uuid.slice(-8).toLowerCase());
return path.join(home, '.claude', 'session-data', `${getDateString()}-${shortId}-session.tmp`);
}
function runTests() {
console.log('\n=== Testing session-end.js ===\n');
@@ -73,7 +87,10 @@ function runTests() {
const transcript = path.join(home, `${uuid}.jsonl`);
fs.writeFileSync(
transcript,
JSON.stringify({ type: 'user', message: { role: 'user', content: userText } }) + '\n'
[
JSON.stringify({ type: 'user', message: { role: 'user', content: userText } }),
JSON.stringify({ type: 'tool_use', tool_name: 'Edit', tool_input: { file_path: '/src/release.js' } }),
].join('\n') + '\n'
);
const res = spawnSync('node', [script], {
@@ -95,6 +112,132 @@ function runTests() {
}
}) ? passed++ : failed++);
(test('writes a session for a multi-message transcript', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
try {
const uuid = '11111111-2222-4333-8444-555555555555';
const transcript = path.join(home, `${uuid}.jsonl`);
fs.writeFileSync(
transcript,
[
JSON.stringify({ type: 'user', content: 'Investigate the failing hook' }),
JSON.stringify({ type: 'user', content: 'Add regression coverage' }),
].join('\n') + '\n'
);
const res = runHook(home, transcript);
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
const sessionFile = sessionFileFor(home, uuid);
const out = fs.readFileSync(sessionFile, 'utf8');
assert.ok(out.includes(START), 'Should include the generated summary start marker');
assert.ok(out.includes(END), 'Should include the generated summary end marker');
assert.ok(out.includes('**Last Updated:**'), 'Should include session metadata');
assert.ok(out.includes('Add regression coverage'), 'Should include the latest user task');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}) ? passed++ : failed++);
(test('writes a session for one user message with tool activity', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
try {
const uuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
const transcript = path.join(home, `${uuid}.jsonl`);
fs.writeFileSync(
transcript,
[
JSON.stringify({ type: 'user', content: 'Fix the configuration' }),
JSON.stringify({ type: 'tool_use', tool_name: 'Edit', tool_input: { file_path: '/src/config.js' } }),
].join('\n') + '\n'
);
const res = runHook(home, transcript);
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
assert.ok(fs.existsSync(sessionFileFor(home, uuid)), 'Tool activity should make the session eligible');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}) ? passed++ : failed++);
(test('skips a one-message prompt with no tool activity', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
try {
const uuid = '12345678-1234-4234-8234-123456789abc';
const transcript = path.join(home, `${uuid}.jsonl`);
fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Print the current version' }) + '\n');
const res = runHook(home, transcript);
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
assert.ok(!fs.existsSync(sessionFileFor(home, uuid)), 'One-shot prompt should not create a session file');
assert.ok(!fs.existsSync(path.join(home, '.claude', 'session-data')), 'Rejected transcript should not create the sessions directory');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}) ? passed++ : failed++);
(test('skips a one-message summarizer-style transcript without prompt matching', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
try {
const uuid = 'fedcba98-7654-4321-8765-fedcba987654';
const transcript = path.join(home, `${uuid}.jsonl`);
fs.writeFileSync(
transcript,
[
JSON.stringify({ type: 'user', message: { role: 'user', content: 'Summarize the supplied conversation as concise markdown.' } }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: '## Summary\nThe hook behavior was reviewed.' } }),
].join('\n') + '\n'
);
const res = runHook(home, transcript);
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
assert.ok(!fs.existsSync(sessionFileFor(home, uuid)), 'Summarizer subprocess should not create a session file');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}) ? passed++ : failed++);
(test('does not rewrite an existing session for a rejected transcript', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
try {
const uuid = '99999999-8888-4777-8666-555555555555';
const transcript = path.join(home, `${uuid}.jsonl`);
const sessionFile = sessionFileFor(home, uuid);
const original = '# Session: preserved\n**Last Updated:** 09:00\n\n---\n\nUser-authored context\n';
const originalTime = new Date('2026-01-02T03:04:05.000Z');
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
fs.writeFileSync(sessionFile, original);
fs.utimesSync(sessionFile, originalTime, originalTime);
fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Answer this one question' }) + '\n');
const res = runHook(home, transcript);
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
assert.strictEqual(fs.readFileSync(sessionFile, 'utf8'), original, 'Rejected transcript should not change existing content');
assert.strictEqual(fs.statSync(sessionFile).mtimeMs, originalTime.getTime(), 'Rejected transcript should not advance mtime');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}) ? passed++ : failed++);
(test('keeps fallback behavior when transcript metadata is malformed', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
try {
const res = spawnSync('node', [script], {
encoding: 'utf8',
input: '{not-json',
env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_SESSION_ID: 'fallback-session-12345678', CLAUDE_TRANSCRIPT_PATH: '' },
timeout: 10000,
});
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
const sessionsDir = path.join(home, '.claude', 'session-data');
assert.strictEqual(fs.readdirSync(sessionsDir).filter(name => name.endsWith('-session.tmp')).length, 1, 'Fallback should still create the placeholder session');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}) ? passed++ : failed++);
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}