be3fd36ebf
1. capture-update keeps the whole body (was truncating to first line) 2. frontmatter completeness: kind-default status + kind-seeded tags at capture, fm create-or-replace, incomplete-frontmatter lint, sweep backfill (one data source: KIND_STATUS/KIND_REQUIRED_FM) 3. pre-write duplicate gate (exit 76, --merge-into/--force) 4. recall corpus + ranking: sessions/journal indexed (down-weighted), BM25 x freshness x status fusion, recall --json, index schema 2 5. session hooks: SessionStart auto-load, Stop reflection nudge 6. one-tap triage verb with processing-log audit; --json on read verbs +22 mock end-to-end tests; all suites green. Docs current (README, CHANGELOG, SKILL.md, commands, references, MAINTENANCE, eval README). Drops tracked .DS_Store (gitignored); retires README-gretchen.md (moved to gitignored dist/); adds the field report that drove item 2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
119 lines
4.2 KiB
Python
119 lines
4.2 KiB
Python
#!/usr/bin/env python3
|
|
"""echo_hook_stop.py — Stop hook: one-shot session-end reflection nudge.
|
|
|
|
Reflection (/echo-reflect) only captures memory if someone remembers to run it.
|
|
This hook watches the first Stop of a *substantive* session and, when nothing has
|
|
been reflected or session-logged yet, blocks once with a reminder so the model
|
|
runs the reflect flow (which still previews and asks the operator before writing
|
|
— the safety gate lives in reflect, not here).
|
|
|
|
Guards (a hook must NEVER trap a session in a loop or nag):
|
|
* `stop_hook_active` -> exit immediately (loop guard);
|
|
* fires at most ONCE per session (marker file under ECHO_STATE_DIR/hook-state);
|
|
* skips quiet sessions (< ECHO_REFLECT_MIN_TURNS user turns, default 5);
|
|
* skips when the transcript shows reflection / a session log already happened;
|
|
* skips when ECHO isn't configured on this machine;
|
|
* always exits 0.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
MIN_TURNS = int(os.environ.get("ECHO_REFLECT_MIN_TURNS", "5"))
|
|
|
|
REASON = (
|
|
"[echo-memory] Session-end reflection has not run yet. If this session produced "
|
|
"durable facts, decisions, commitments, or artifacts worth remembering: run the "
|
|
"/echo-reflect flow (extract -> preview -> apply only with the operator's confirm) "
|
|
"and write the session log + heartbeat per the echo-memory skill. If nothing "
|
|
"durable emerged, simply finish — do not invent memories. "
|
|
"(This reminder fires once per session.)"
|
|
)
|
|
|
|
|
|
def state_marker(session_id: str) -> Path:
|
|
base = Path(os.environ.get("ECHO_STATE_DIR") or (Path.home() / ".echo-memory"))
|
|
return base / "hook-state" / f"{session_id}.reflect-nudged"
|
|
|
|
|
|
def count_user_turns(raw: str) -> int:
|
|
turns = 0
|
|
for line in raw.splitlines():
|
|
try:
|
|
rec = json.loads(line)
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
if rec.get("type") != "user":
|
|
continue
|
|
content = (rec.get("message") or {}).get("content")
|
|
if isinstance(content, str) and content.strip():
|
|
turns += 1
|
|
elif isinstance(content, list) and any(
|
|
isinstance(b, dict) and b.get("type") == "text" for b in content):
|
|
turns += 1
|
|
return turns
|
|
|
|
|
|
def already_reflected(raw: str) -> bool:
|
|
"""Transcript evidence that reflection or session logging already happened."""
|
|
return ("/echo-reflect" in raw
|
|
or re.search(r'echo\.py"?\s+reflect\b', raw) is not None
|
|
or "put _agent/sessions/" in raw
|
|
or "put _agent/heartbeat/last-session.md" in raw)
|
|
|
|
|
|
def main() -> int:
|
|
try:
|
|
payload = json.load(sys.stdin)
|
|
except Exception: # noqa: BLE001
|
|
return 0
|
|
if payload.get("stop_hook_active"):
|
|
return 0
|
|
session_id = str(payload.get("session_id") or "unknown")
|
|
marker = state_marker(session_id)
|
|
if marker.exists():
|
|
return 0
|
|
|
|
try:
|
|
import echo_config
|
|
if not echo_config.is_configured(echo_config.load()):
|
|
return 0 # nothing to reflect into — stay silent
|
|
except Exception: # noqa: BLE001
|
|
return 0
|
|
|
|
raw = ""
|
|
tp = payload.get("transcript_path")
|
|
if tp:
|
|
try:
|
|
raw = Path(tp).read_text(encoding="utf-8", errors="replace")
|
|
except Exception: # noqa: BLE001
|
|
raw = ""
|
|
|
|
if already_reflected(raw):
|
|
try: # done for this session — never fire
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
marker.write_text("reflected\n", encoding="utf-8")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return 0
|
|
if count_user_turns(raw) < MIN_TURNS:
|
|
return 0 # quiet so far; a later stop may qualify (no marker written)
|
|
|
|
try:
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
marker.write_text("nudged\n", encoding="utf-8")
|
|
except Exception: # noqa: BLE001
|
|
pass # if the marker can't be written, still nudge; stop_hook_active guards loops
|
|
print(json.dumps({"decision": "block", "reason": REASON}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|