Version: v7.1.1 (verified against 03a969dd96dc7ff701d92dbc8a8b8d6db4728834)
The defect
Claude Code supplies session_id and transcript_path on stdin to every Stop hook — both identify the transcript of the session that just fired. MemoryReviewFire reads neither. The string session_id, transcript_path, and any read of stdin appear zero times in the file.
It then spawns the reviewer without --input:
|
function spawnReviewer(turnsReviewed: number): { spawned: boolean; reason: string } { |
|
if (!existsSync(REVIEWER_PATH)) { |
|
return { spawned: false, reason: "reviewer-not-found" }; |
|
} |
|
try { |
|
const env = { ...process.env }; |
|
delete env.ANTHROPIC_API_KEY; |
|
delete env.ANTHROPIC_AUTH_TOKEN; |
|
delete env.CLAUDECODE; |
|
const proc = spawn("bun", [REVIEWER_PATH, "review", "--turns", String(turnsReviewed)], { |
function spawnReviewer(turnsReviewed: number): { spawned: boolean; reason: string } {
...
const proc = spawn("bun", [REVIEWER_PATH, "review", "--turns", String(turnsReviewed)], {
--input is a supported flag — the reviewer documents it at line 27. It just isn't passed. So the reviewer falls back to a guess:
|
const transcript = opts.input ?? findMostRecentTranscript(); |
const transcript = opts.input ?? findMostRecentTranscript();
And findMostRecentTranscript() walks every project dir and picks the globally newest .jsonl by mtime:
|
function findMostRecentTranscript(): string | null { |
|
if (!existsSync(HARNESS_PROJECTS_DIR)) return null; |
|
|
|
let newest: { path: string; mtime: number } | null = null; |
|
try { |
|
const projects = readdirSync(HARNESS_PROJECTS_DIR); |
|
for (const project of projects) { |
|
const projectDir = pathJoin(HARNESS_PROJECTS_DIR, project); |
|
let stat: ReturnType<typeof statSync> | null = null; |
|
try { stat = statSync(projectDir); } catch { continue; } |
|
if (!stat) continue; |
|
if (!stat.isDirectory()) continue; |
|
|
|
const files = readdirSync(projectDir); |
|
for (const file of files) { |
|
if (!file.endsWith(".jsonl")) continue; |
|
const full = pathJoin(projectDir, file); |
|
try { |
|
const s = statSync(full); |
|
if (!newest || s.mtimeMs > newest.mtime) { |
|
newest = { path: full, mtime: s.mtimeMs }; |
|
} |
|
} catch { /* skip */ } |
|
} |
|
} |
|
} catch { return null; } |
|
|
|
return newest?.path ?? null; |
|
} |
The hook is handed the answer and throws it away to guess instead — and nothing records which transcript any given review actually consumed, so the guess is unauditable after the fact.
Repro (deterministic, ~15 seconds, no concurrency)
The obvious objection is that the newest-mtime file is usually the right one — a Stop hook fires right after the firing session wrote its own turn, so the heuristic is wrong in principle but mostly right in practice. That's true, and it's why this is easy to dismiss. It's also not a defence, because the selection depends on nothing but mtime:
# 1. What would the reviewer read right now?
# (findMostRecentTranscript, verbatim from MemoryReviewer.ts:98-126)
# -> ~/.claude/projects/<your-cwd-slug>/<this-session>.jsonl # correct, by luck
# 2. Plant any .jsonl in an unrelated project dir with a newer mtime:
mkdir -p ~/.claude/projects/-tmp-decoy-repro
echo '{"type":"user","message":{"role":"user","content":"DECOY"}}' \
> ~/.claude/projects/-tmp-decoy-repro/decoy-session.jsonl
touch -d '+1 hour' ~/.claude/projects/-tmp-decoy-repro/decoy-session.jsonl
# 3. Ask again:
# -> ~/.claude/projects/-tmp-decoy-repro/decoy-session.jsonl
I ran exactly this. Before planting the decoy the selector returned the firing session's own transcript; after, it returned decoy-session.jsonl — a file belonging to no session at all. The reviewer will ingest whatever file happens to carry the newest mtime, and its extracted content is what gets written to principal memory.
No concurrency is needed to demonstrate this. Concurrency is simply the case where it happens without anyone planting a file: with two sessions active, whichever wrote last owns the mtime, and the other session's Stop hook reviews it. But the defect stands on its own — the hook does not identify the transcript it is reviewing, and does not log which one it read.
Fix
Read the stdin payload and pin the transcript:
function readHookInput(): { session_id?: string; transcript_path?: string } {
try {
const raw = readFileSync(0, "utf8");
if (!raw.trim()) return {};
return JSON.parse(raw);
} catch { return {}; }
}
// transcript_path is authoritative; session_id resolves across project dirs
// (~/.claude/projects/<cwd-slug>/<session_id>.jsonl — the slug varies by cwd).
function resolveTranscript(hook): string | null {
if (hook.transcript_path && existsSync(hook.transcript_path)) return hook.transcript_path;
if (hook.session_id) return findTranscriptBySessionId(hook.session_id);
return null;
}
…then pass --input <transcript> to the reviewer.
That leaves one policy question, which is yours to make: when the transcript cannot be identified, the hook can either fall through to the existing mtime guess, or decline to spawn. I've implemented the latter locally, on the view that reviewing nothing beats reviewing an unidentified transcript — but the mtime fallback is the status quo and keeping it is a defensible call.
Either way, I'd also log the resolved transcript path per fire, so which-transcript-was-read stops being unanswerable.
I have this working locally and will open a PR if you want it.
Version: v7.1.1 (verified against
03a969dd96dc7ff701d92dbc8a8b8d6db4728834)The defect
Claude Code supplies
session_idandtranscript_pathon stdin to every Stop hook — both identify the transcript of the session that just fired.MemoryReviewFirereads neither. The stringsession_id,transcript_path, and any read of stdin appear zero times in the file.It then spawns the reviewer without
--input:LifeOS/LifeOS/install/hooks/MemoryReviewFire.hook.ts
Lines 110 to 119 in 03a969d
--inputis a supported flag — the reviewer documents it at line 27. It just isn't passed. So the reviewer falls back to a guess:LifeOS/LifeOS/install/LIFEOS/TOOLS/MemoryReviewer.ts
Line 541 in 03a969d
And
findMostRecentTranscript()walks every project dir and picks the globally newest.jsonlby mtime:LifeOS/LifeOS/install/LIFEOS/TOOLS/MemoryReviewer.ts
Lines 98 to 126 in 03a969d
The hook is handed the answer and throws it away to guess instead — and nothing records which transcript any given review actually consumed, so the guess is unauditable after the fact.
Repro (deterministic, ~15 seconds, no concurrency)
The obvious objection is that the newest-mtime file is usually the right one — a Stop hook fires right after the firing session wrote its own turn, so the heuristic is wrong in principle but mostly right in practice. That's true, and it's why this is easy to dismiss. It's also not a defence, because the selection depends on nothing but mtime:
I ran exactly this. Before planting the decoy the selector returned the firing session's own transcript; after, it returned
decoy-session.jsonl— a file belonging to no session at all. The reviewer will ingest whatever file happens to carry the newest mtime, and its extracted content is what gets written to principal memory.No concurrency is needed to demonstrate this. Concurrency is simply the case where it happens without anyone planting a file: with two sessions active, whichever wrote last owns the mtime, and the other session's Stop hook reviews it. But the defect stands on its own — the hook does not identify the transcript it is reviewing, and does not log which one it read.
Fix
Read the stdin payload and pin the transcript:
…then pass
--input <transcript>to the reviewer.That leaves one policy question, which is yours to make: when the transcript cannot be identified, the hook can either fall through to the existing mtime guess, or decline to spawn. I've implemented the latter locally, on the view that reviewing nothing beats reviewing an unidentified transcript — but the mtime fallback is the status quo and keeping it is a defensible call.
Either way, I'd also log the resolved transcript path per fire, so which-transcript-was-read stops being unanswerable.
I have this working locally and will open a PR if you want it.