#!/usr/bin/env python3 """echo_session.py — the session-end bundle: one call ends a session correctly. [2.1.0] Ending a substantive session used to take four-plus separate invocations (session-log PUT, Agent Log append, heartbeat PUT, optional scope set, optional reflect apply) — a cost per session, and a partial failure left broken orientation state (log written but heartbeat stale). This verb does all of it in one call, under ONE advisory-lock acquisition, in a fixed order with the heartbeat written LAST as the commit marker: a failure part-way leaves the previous pointer intact, never a dangling one. Bundle shape (JSON object): { "slug": "echo-mcp-spec", # required, kebab-case "log_body": "", # required "agent_log_line": "- 2026-07-28: ...", # optional; derived when omitted "scope": "new scope text", # optional -> scope set "reflect": [ { ...PROPOSAL_SCHEMA... } ] # optional -> routed via capture } Dry-run by default (previews the whole plan, reflect included); --apply writes. Times: the filename HHMM comes from $ECHO_NOW (HHMM) else the local clock; the date from ECHO_TODAY via echo.today(). Offline: every step rides the offline queue, so a session end during an outage queues durably instead of failing. CLI: echo.py session-end [--apply] """ from __future__ import annotations import datetime as dt import json import os import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import echo # noqa: E402 SESSION_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9-]+\.md$") SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") def _hhmm() -> str: v = os.environ.get("ECHO_NOW", "").strip() if v: if not re.match(r"^\d{4}$", v): raise echo.EchoError(f"session-end: $ECHO_NOW must be HHMM, got '{v}'", 2) return v return dt.datetime.now().strftime("%H%M") def validate(bundle: dict) -> tuple[str, str]: """Return (session_path, agent_log_line) or raise EchoError(2). Validation runs BEFORE any write — a bad bundle writes nothing at all.""" if not isinstance(bundle, dict): raise echo.EchoError("session-end: bundle must be a JSON object", 2) slug = str(bundle.get("slug", "")).strip() if not SLUG_RE.match(slug): raise echo.EchoError(f"session-end: slug must be kebab-case, got '{slug}'", 2) if not str(bundle.get("log_body", "")).strip(): raise echo.EchoError("session-end: log_body is required (the full session-log markdown)", 2) reflect = bundle.get("reflect") if reflect is not None and not isinstance(reflect, list): raise echo.EchoError("session-end: reflect must be a JSON array of proposals", 2) fname = f"{echo.today()}-{_hhmm()}-{slug}.md" if not SESSION_RE.match(fname): raise echo.EchoError(f"session-end: derived filename '{fname}' violates the " "canonical YYYY-MM-DD-HHMM-.md form", 2) path = f"_agent/sessions/{fname}" line = str(bundle.get("agent_log_line") or "").strip() \ or f"- {echo.today()}: session logged [[{path[:-3]}]]" return path, line def session_end_op(bundle: dict, apply: bool = False) -> dict: """Core session-end (Phase 0): validate -> plan -> (apply). Returns an envelope (plan rows + per-step results); never prints to stdout — helper chatter from the underlying verbs routes to stderr. Raises echo.EchoError(2) on a bad bundle, BEFORE any write.""" import contextlib import echo_output import echo_reflect path, line = validate(bundle) scope = str(bundle.get("scope") or "").strip() proposals = bundle.get("reflect") or [] valid, errors = echo_reflect.validate(proposals) rows: list[dict] = [] if valid: echo_reflect.classify(valid) rows = [{"action": p.get("_action"), "kind": p.get("kind"), "title": p["title"], "path": p.get("_path")} for p in valid] data = {"path": path, "agent_log_line": line, "scope": scope or None, "reflect_rows": rows, "reflect_errors": errors, "dry_run": not apply, "steps": {}} if not apply: return echo_output.envelope("session-end", data) import echo_concurrency import echo_ops steps: dict[str, str] = {} with echo_concurrency.vault_lock(), contextlib.redirect_stdout(sys.stderr): # 1. The session log itself. A hard failure here aborts the whole bundle — # nothing after it (including the heartbeat) runs, so orientation state # can never point at a log that was never written. echo.cmd_put(path, echo.temp_file(str(bundle["log_body"]).encode("utf-8"))) steps["session_log"] = "ok" # 2. Agent-log line (best-effort by contract; queues itself when offline). echo_ops.ensure_daily_log(line) steps["agent_log"] = "ok" # 3. Reflect proposals through the normal capture path (gate-aware). if valid: gated = 0 for p in valid: if p.get("_action") == "error": continue env = echo_ops.capture_op( p.get("kind"), p["title"], body_text=p.get("body") or "", aliases=p.get("aliases") or [], sources=p.get("sources") or [], tags=p.get("tags") or [], date=p.get("date"), domain=p.get("domain", "business"), inbox=bool(p.get("inbox")) or not p.get("kind")) gated += 1 if env.get("action") == "duplicate-gate" else 0 steps["reflect"] = f"{len(valid) - gated}/{len(valid)} applied" \ + (f", {gated} gated (re-propose with --merge-into)" if gated else "") else: steps["reflect"] = "skipped (none)" # 4. Scope switch (optional). if scope: echo.cmd_scope("set", scope) steps["scope"] = "ok" else: steps["scope"] = "unchanged" # 5. Heartbeat LAST — the commit marker for the whole bundle. echo.cmd_put("_agent/heartbeat/last-session.md", echo.temp_file(f"{path} @ {echo.now_iso()}\n".encode("utf-8"))) steps["heartbeat"] = "ok" data["steps"] = steps return echo_output.envelope("session-end", data) def session_end(bundle: dict, apply: bool = False) -> int: """CLI wrapper: same plan/summary text as before; envelope logic in session_end_op.""" env = session_end_op(bundle, apply=apply) for e in env["reflect_errors"]: print(f"skip: {e}", file=sys.stderr) print(f"session-end plan ({'APPLY' if apply else 'dry-run'}):") print(f" 1. session log -> {env['path']}") print(f" 2. agent-log line: {env['agent_log_line']}") rows = env["reflect_rows"] if rows: print(f" 3. reflect: {len(rows)} proposal(s)") print("\n".join(f" {r['action'] or '?':7} | {('-' if r['action'] == 'inbox' else r['kind'] or '?'):9} " f"| {r['title']} -> {r['path'] or '?'}" for r in rows)) else: print(" 3. reflect: (none)") print(f" 4. scope set: {env['scope']!r}" if env["scope"] else " 4. scope: (unchanged)") print(f" 5. heartbeat -> {env['path']} @ (written LAST — the commit marker)") if env["dry_run"]: print("\nsession-end: dry-run — re-run with --apply to write.") return 0 print("\nsession-end: done — " + "; ".join(f"{k}: {v}" for k, v in env["steps"].items())) return 0