claude-skills/productivity/handoff/hooks/session_end.py
Claude 1d066e0dec
feat(productivity/handoff): v1.1 — SessionEnd hook, self-check script, --refresh flag
Ships the three improvements judged most impactful in v1.1 design review:

1. SessionEnd hook (hooks/session_end.py)
   Pairs with SessionStart. When a session ends with no handoff in the
   last 30 minutes, prints a one-line reminder. Cannot prompt
   interactively or block session end — surfaces text via stdout.
   Disable per-session with HANDOFF_SESSIONEND=0. hooks.json updated to
   wire both SessionStart and SessionEnd.

2. handoff_self_check.py — fidelity script (~300 LOC, stdlib-only)
   Operationalizes handoff_prompt.md. Six checks:
   - All 5 sections present
   - Goal is non-empty and non-placeholder
   - State-of-play bullets reference at least one artifact (commit hash,
     PR/issue number, file path, URL)
   - Open decisions are present (or explicit "- None.") when git is dirty
     or has recent commits
   - Skills to use: 3-5 entries, hard cap enforced
   - Artifacts contain paths/URLs only, no inline content
   Severity: high/medium/low. Strict mode exits 1 only on HIGH findings.
   --sample fixture has 3 planted issues (2 high + 1 medium) and exits 1.
   Canonical example_handoff.md passes clean (exit 0).
   /cs:handoff command updated to run self-check between scaffold-fill
   and redaction linter.

3. --refresh flag on handoff_template_generator.py
   Reuses the most recent handoff in the configured save location
   instead of creating a new file. Falls through to create-if-missing
   when no existing handoff is found. Keeps the save location
   uncluttered when work continues past the original handoff time;
   ensures the SessionStart hook always loads the up-to-date version.

Version bump: 2.7.4 -> 2.7.5. Marketplace description and keywords
updated. README v1.1 section added. SKILL.md gains "Refreshing an
Existing Handoff" and "SessionEnd Reminder" subsections.

Verified:
- All 9 Python files compile clean
- self-check --sample correctly fails (3 findings, exit 1)
- self-check passes clean against assets/example_handoff.md (exit 0)
- --refresh finds the latest /tmp/handoff-*.md and prints its path
- SessionEnd hook prints the reminder when no recent handoff exists
- check_plugin_json.py + marketplace.json + hooks.json all parse
- Plugin audit re-run: structure 84.2 -> 86.0, quality 62.2 -> 63.0,
  security PASS (0 critical, 0 high)
- Codex + Gemini sync re-ran clean

https://claude.ai/code/session_01KLhHBAfEDXdQMeRe6G8sRa
2026-05-23 01:22:07 +00:00

115 lines
3.1 KiB
Python

#!/usr/bin/env python3
"""SessionEnd hook for the handoff skill.
When a session ends, checks whether a recent handoff exists in the
configured save location. If none does (or the most recent is older than
the staleness threshold), prints a one-line reminder so the user
remembers to write one before context is lost.
Cannot block session end and cannot prompt interactively — the reminder
is printed to stdout, which Claude Code surfaces in the session log.
Disable per-session with HANDOFF_SESSIONEND=0.
Stdlib-only.
"""
import datetime as dt
import os
import sys
import tempfile
from pathlib import Path
SKILL_SCRIPTS = (
Path(__file__).resolve().parent.parent / "skills" / "handoff" / "scripts"
)
sys.path.insert(0, str(SKILL_SCRIPTS))
try:
import config_loader # type: ignore
except ImportError:
sys.exit(0)
HANDOFF_TOKENS = ("handoff-", "handoff_", "handoff.md")
STALE_AFTER_MINUTES = 30 # If no handoff in this window, remind.
def _disabled() -> bool:
return os.environ.get("HANDOFF_SESSIONEND", "1") == "0"
def _candidate_dirs(config: dict) -> list[Path]:
save = config.get("save_location", {})
mode = save.get("mode", "temp")
raw_path = save.get("path")
dirs: list[Path] = []
if mode == "temp":
dirs.append(Path(tempfile.gettempdir()))
elif raw_path:
dirs.append(Path(raw_path))
proj = Path.cwd() / ".handoff"
if proj.exists() and proj not in dirs:
dirs.append(proj)
return [d for d in dirs if d.exists()]
def _looks_like_handoff(p: Path) -> bool:
name = p.name.lower()
return name.endswith(".md") and any(t in name for t in HANDOFF_TOKENS)
def _latest_mtime(config: dict) -> dt.datetime | None:
latest: float | None = None
for d in _candidate_dirs(config):
try:
for entry in d.iterdir():
if not entry.is_file() or not _looks_like_handoff(entry):
continue
try:
mtime = entry.stat().st_mtime
except OSError:
continue
if latest is None or mtime > latest:
latest = mtime
except OSError:
continue
if latest is None:
return None
return dt.datetime.utcfromtimestamp(latest)
def _remind() -> None:
print("=" * 60)
print("Session ending — no recent handoff detected.")
print()
print("Want to capture state for next time? In a future session, run:")
print(" /cs:handoff \"<what the next session should do>\"")
print()
print("Disable this reminder: HANDOFF_SESSIONEND=0")
print("=" * 60)
def main() -> int:
if _disabled():
return 0
try:
config = config_loader.load_config()
except Exception:
return 0
try:
latest = _latest_mtime(config)
except Exception:
return 0
now = dt.datetime.utcnow()
stale_cutoff = now - dt.timedelta(minutes=STALE_AFTER_MINUTES)
if latest is None or latest < stale_cutoff:
try:
_remind()
except Exception:
return 0
return 0
if __name__ == "__main__":
sys.exit(main())