claude-skills/engineering/agent-memory/hooks/session_end.py
Claude 9f2c50e285
fix(agent-memory): address automated review; harden the session-id fallback
Five of six findings were real. Verified each against the code rather than
taking them at face value; one was wrong and is noted below.

1. validate_examples.py carried a stale header from the spec-only phase --
   "PARKED AS AN ASSET (deliberately not .py yet)... ON IMPLEMENTATION: rename
   to skills/agent-memory/scripts/validate_examples.py". The file is already at
   exactly that path. Rewritten to describe what it now is, keeping the
   substantive part: why it exists, the seven check families, why it compares
   the doc's algorithm by source text rather than exec()-ing a code fence, and
   that nothing runs it automatically.

2. Missing shebang -- added. The same finding also claimed mode 100644; that
   half is wrong, `git ls-files -s` shows 100755 for all five scripts.

3. hooks.json's description still opened "CONTRACT ONLY -- the referenced
   scripts are not yet implemented", true of none of them now. Trimmed to keep
   only the UserPromptSubmit provisionality (9.5 is genuinely still open) and
   to record the measured latency alongside it.

4. The session-id fallback was the finding worth the most. `session_id` is the
   right key -- engineering/security-guidance's shipped hook reads the same one
   -- but the fallback was the CONSTANT "unknown-session", and sessions dedupe
   by value. Had the key ever been absent, every session would collapse onto
   one id, len(set(sessions)) would plateau at 1, and every claim would cap at
   L1 forever with no error anywhere. Now falls back to the transcript's own
   basename, which IS the session id. Verified end-to-end with session_id
   omitted from the payload: the atom records the real session UUID.

5. Dead `now` parameter on _eligible_l1 -- removed.

6. marketplace.json metadata still said 104 agents / 120 slash commands,
   pre-existing drift on a line this branch already edits. Trued up to 110/130.

Re-verified after: 69 checks 0 failures, SKILL.md 6/6 PASS, both blocking gates
still fire by name, check_paths 620 files clean, counters and plugin-json pass,
all 5 scripts --help, all 3 hooks parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EM5xmJ7AmTMg31rq68BCym
2026-08-24 18:45:52 +00:00

91 lines
3.5 KiB
Python

#!/usr/bin/env python3
"""SessionEnd -- capture L0 -> L1, detect contradictions, stage promotions.
DESIGN.md 5.3. Runs `async: true` so it can never delay session teardown.
The pipeline, in order, because the order is the safety property:
1. extract rule-based, high-precision markers only (9.2 option (a))
2. REDACT every atom, before anything is written (6 rule 1)
3. merge increment observations, extend sessions, raise confidence to
the max -- never lower (4.1.3)
4. detect 4.2.1's two rules; mark the OLDER atom contested
5. write atomic os.replace under a 5s-bounded lock (5.4); on
contention the atoms are DROPPED and the loss is logged to
.memory/errors.log -- not stderr, which for an async hook
goes nowhere a human reads
6. promote L1->L2->L3 on recurrence, staged to .memory/staged/
Step 6 NEVER writes CLAUDE.md. Adoption is a separate, explicit, human step
(`/cs:memory adopt`), which backs both CLAUDE.md files up first.
A missing .memory/atoms.jsonl is the normal first-run state, not an error.
Disable with AGENT_MEMORY_SESSIONEND=0.
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"skills", "agent-memory", "scripts"))
def main():
if os.environ.get("AGENT_MEMORY_SESSIONEND") == "0":
return 0
try:
raw = "" if sys.stdin.isatty() else sys.stdin.read()
payload = json.loads(raw) if raw.strip() else {}
except Exception:
return 0
transcript = payload.get("transcript_path")
if not transcript or not os.path.exists(transcript):
return 0
try:
import memory_core as core
import memory_extract as extract
import memory_promote as promote
cwd = payload.get("cwd") or os.getcwd()
project = os.path.basename(os.path.abspath(cwd))
# A CONSTANT fallback here would be a silent, permanent bug. Sessions
# are deduped by value (5.3), so if `session_id` were ever absent,
# every session would collapse onto one id, `len(set(sessions))` would
# plateau at 1, and EVERY claim would be capped at L1 forever with no
# error anywhere -- the gates need 2-3 distinct sessions. So fall back
# to the transcript's own basename, which IS the session: Claude Code
# names each transcript for its session id.
session = payload.get("session_id") or os.path.splitext(
os.path.basename(transcript))[0] or "unknown-session"
store = core.AtomStore(os.path.join(cwd, ".memory"))
new_atoms = extract.extract(transcript, project, session)
if not new_atoms:
return 0
atoms, _n_new, _n_merged = extract.merge_into_store(store, new_atoms)
core.mark_contradictions(atoms)
if not store.write(atoms):
# 5.4 -- the one place data disappears. It is logged inside write().
return 0
l2, _blocked = promote.promote_l1_to_l2(atoms)
l3, notes = promote.promote_l2_to_l3(atoms + l2)
warnings = promote.apply_caps(atoms + l2 + l3)
if l2 or l3:
promote.stage(store, l2, l3, notes, warnings)
except Exception:
# Never blocks teardown. A lost capture costs one re-observation; L1 is
# the recoverable tier by construction (5.4).
return 0
return 0
if __name__ == "__main__":
sys.exit(main())