← Files BodhiKitARCHIVED FILE

scripts/bodhi-stop-hook-core.py

10.5 KB · Oct 3, 2026 · 06:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Stop hook: schema safety net for BodhiKit tracking files.

Runs `bodhi-state verify` on any learning project THIS session could have
touched — under the working directory or under the per-repo
`.bodhikit/config.json` projectRoot — whose tracking files changed recently.
Global `~/.bodhikit/config.json` searchPaths are deliberately not walked: a
project studied in another terminal must not block an unrelated session's
stop with a repair or a revision sheet it has no context to write.
If a file is structurally broken, or THIS SESSION studied a project (its
transcript shows bodhi-state `touch-state` — the closing bookkeeping every
study skill performs) and the project has no revision sheet for today (the
learner's take-home; see skills/reflect/references/revision-sheet.md), the
hook blocks the stop once and tells the model what to write or fix.

The Stop event fires at the end of EVERY assistant turn, not at "session
end", and other sessions may have studied the same projects earlier today.
So the sheet requirement is scoped by the session transcript: a project this
session never wrote to is never this session's to summarise (1.18.1 — the
first release blocked a /continue menu turn with two sheets from other
sessions).

Fail-open by design: any unexpected error exits 0 (never trap the user in a
loop), `stop_hook_active` short-circuits re-entry, and the project search is
bounded by depth, by a wall-clock budget, and by a prune list so a large home
directory cannot push the hook past its timeout.
"""

import json
import os
import re
import subprocess
import sys
import time

MAX_PROJECTS = 8
MAX_DEPTH = 4
RECENT_SECONDS = 6 * 3600          # only verify projects touched this session-ish
SEARCH_BUDGET_SECONDS = 8.0        # hooks.json gives the hook 30 s in total
PRUNE = {"node_modules", "vendor", "target", "dist", "build", "Library",
         "Applications", "Music", "Movies", "Pictures", "go", "Caches"}


def find_projects(roots, budget=SEARCH_BUDGET_SECONDS):
    """Dirs containing .bodhi/state.json under any root: shallow, pruned,
    time-boxed. Order is stable (roots in order, then os.walk order)."""
    found, seen = [], set()
    deadline = time.monotonic() + budget
    for root in roots:
        root = os.path.abspath(os.path.expanduser(root))
        if not os.path.isdir(root):
            continue
        for dirpath, dirnames, _ in os.walk(root):
            if time.monotonic() > deadline or len(found) >= MAX_PROJECTS:
                return found
            depth = dirpath[len(root):].count(os.sep)
            if depth >= MAX_DEPTH:
                dirnames[:] = []
                continue
            dirnames[:] = [d for d in dirnames
                           if (not d.startswith(".") or d == ".bodhi")
                           and d not in PRUNE]
            if os.path.exists(os.path.join(dirpath, ".bodhi", "state.json")):
                if dirpath not in seen:
                    seen.add(dirpath)
                    found.append(dirpath)
                dirnames[:] = []
    return found


def configured_roots(cwd):
    """cwd plus the per-repo projectRoot (walking up 3 parents), per the
    state-ops KB discovery procedure. Session-scoped by design (see module
    docstring) — never the global searchPaths."""
    roots = [cwd]
    d = os.path.abspath(cwd)
    for _ in range(4):
        cfg = os.path.join(d, ".bodhikit", "config.json")
        if os.path.exists(cfg):
            try:
                with open(cfg, encoding="utf-8") as f:
                    pr = json.load(f).get("projectRoot")
                if isinstance(pr, str) and pr:
                    roots.append(pr if os.path.isabs(pr) else os.path.join(d, pr))
            except (OSError, ValueError):
                pass
            break
        parent = os.path.dirname(d)
        if parent == d:
            break
        d = parent
    return roots


def recently_touched(project):
    bdir = os.path.join(project, ".bodhi")
    now = time.time()
    try:
        for name in os.listdir(bdir):
            p = os.path.join(bdir, name)
            if os.path.isfile(p) and now - os.path.getmtime(p) < RECENT_SECONDS:
                return True
    except OSError:
        pass
    return False


def session_writes(transcript_path):
    """Which projects THIS session closed, from its transcript: every Bash
    tool_use whose command runs bodhi-state with --project (either the
    `--project <path>` or the `--project=<path>` form argparse accepts).
    Returns {abs_project_path: {"touch": bool}} — touch-state is the
    closing bookkeeping every study skill performs. Unreadable or absent
    transcript -> {} (fail-open: never block on another session's behalf)."""
    out = {}
    if not transcript_path or not os.path.exists(transcript_path):
        return out
    arg_re = re.compile(r"""--project(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))""")
    try:
        with open(transcript_path, encoding="utf-8", errors="replace") as f:
            for line in f:
                if "bodhi-state" not in line or "tool_use" not in line:
                    continue
                try:
                    rec = json.loads(line)
                except json.JSONDecodeError:
                    continue
                cwd = rec.get("cwd") or ""
                for block in rec.get("message", {}).get("content", []) or []:
                    if not isinstance(block, dict) or block.get("type") != "tool_use":
                        continue
                    cmd = str((block.get("input") or {}).get("command", ""))
                    if "bodhi-state" not in cmd:
                        continue
                    for m in arg_re.finditer(cmd):
                        raw = next(g for g in m.groups() if g)
                        path = os.path.abspath(os.path.join(cwd, os.path.expanduser(raw)))
                        entry = out.setdefault(path, {"touch": False})
                        if re.search(r"\btouch-state\b", cmd[m.end():]):
                            entry["touch"] = True
    except OSError:
        return {}
    return out


def failure_reasons(stdout, stderr):
    """Turn a non-zero `verify` into human-readable lines. `verify` prints
    {"ok": false, "errors": [...]}; a `die` prints {"ok": false, "error": ...};
    anything else (a traceback) falls back to the last stderr line so the
    block reason is never blank."""
    try:
        payload = json.loads(stdout)
    except (json.JSONDecodeError, TypeError):
        payload = None
    if isinstance(payload, dict):
        errors = payload.get("errors")
        if isinstance(errors, list) and errors:
            return [str(e) for e in errors]
        if payload.get("error"):
            return [str(payload["error"])]
    tail = [ln for ln in (stderr or "").strip().splitlines() if ln.strip()]
    if tail:
        return [tail[-1][:200]]
    head = (stdout or "").strip()
    return [head[:200] if head else "verify failed without output"]


def main():
    try:
        payload = json.load(sys.stdin)
    except (json.JSONDecodeError, OSError):
        return
    if payload.get("stop_hook_active"):
        return  # already blocked once this turn; never loop
    cwd = payload.get("cwd") or os.getcwd()
    script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bodhi-state")
    if not os.path.exists(script):
        return
    wrote = session_writes(payload.get("transcript_path"))

    failures, missing_sheets = [], []
    for project in find_projects(configured_roots(cwd)):
        if not recently_touched(project):
            continue
        try:
            r = subprocess.run(
                [sys.executable, script, "--project", project, "verify"],
                capture_output=True, text=True, timeout=10)
        except (subprocess.SubprocessError, OSError):
            continue
        if r.returncode != 0:
            failures.append((project, failure_reasons(r.stdout, r.stderr)))
            continue
        # A session that studied something ends with a revision sheet — the
        # learner's take-home. Only projects THIS session closed (its
        # transcript shows touch-state for them) are this session's to
        # summarise; revision-brief confirms a study event and whether
        # today's sheet exists.
        mine = wrote.get(os.path.abspath(project), {})
        if not mine.get("touch"):
            continue
        try:
            b = subprocess.run(
                [sys.executable, script, "--project", project, "revision-brief"],
                capture_output=True, text=True, timeout=10)
            brief = json.loads(b.stdout) if b.returncode == 0 else {}
        except (subprocess.SubprocessError, OSError, ValueError):
            brief = {}
        if brief.get("sessionToday") and not brief.get("existing"):
            missing_sheets.append((project, brief.get("suggestedFile", "revision/<today>.md"),
                                   [c.get("name") for c in brief.get("concepts", [])][:6]))

    reasons = []
    if failures:
        lines = [f"{project}: " + "; ".join(errors[:5]) for project, errors in failures]
        reasons.append(
            "BodhiKit tracking files failed schema verification after this "
            "session's writes:\n" + "\n".join(lines) +
            "\nRepair them before stopping — prefer re-running the write "
            "through scripts/bodhi-state (record-review / record-session / "
            "touch-state) rather than hand-editing JSON. Structural drift: "
            "`bodhi-state normalize`. A file at v2: "
            "`bodhi-state migrate-spaced-review`.")
    if missing_sheets:
        lines = [f"{project}: write {path}  (studied today: {', '.join(n for n in names if n)})"
                 for project, path, names in missing_sheets]
        reasons.append(
            "This session studied a project and has not written today's revision sheet:\n"
            + "\n".join(lines) +
            "\nWrite it now from this session, following "
            "skills/reflect/references/revision-sheet.md in the BodhiKit plugin "
            "(run `bodhi-state --project <project> revision-brief` for the "
            "concepts, results and next-review dates; outcome clauses, the worked "
            "example, where the learner slipped, two self-test prompts with "
            "answers, next reviews, free links only from .bodhi/resources.md or "
            "official docs). Then stop.")
    if reasons:
        print(json.dumps({"decision": "block", "reason": "\n\n".join(reasons)}))


if __name__ == "__main__":
    try:
        main()
    except Exception:  # fail-open: a broken hook must never block the user
        pass
    sys.exit(0)

SHA-256: 84b2626cfa6f0ad5a8f91477dbfb3718b1554be21275590ce84eae47f1bd615d