Skip to content
Agent Memory
Hook

Hooks

What andon runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
andon
1675 agents3 commands3 hooks

Where it lives

  • hooks/auto_orient.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """
    auto_orient.py — UserPromptSubmit hook that loads your MEMORY.md index into context
    at the start of a session, so the agent starts oriented instead of amnesiac.
    
    This is the other half of the anti-amnesia loop: the wrap protocol SAVES state at
    session end; orient LOADS it at the start. Two hooks, one loop.
    
    USAGE
      1. Copy to your agent config dir (e.g. ~/.claude/hooks/).
      2. Register as a UserPromptSubmit hook in settings.json — MERGE into any existing array:
           { "hooks": { "UserPromptSubmit": [{ "matcher": "*",
               "hooks": [{ "type": "command",
                 "command": "python3 /ABSOLUTE/PATH/TO/.claude/hooks/auto_orient.py" }] }] } }
      3. It looks for `memory/MEMORY.md` (then `MEMORY.md`) in the project; override with
         AUTO_ORIENT_MEMORY_PATH=/abs/path/to/MEMORY.md
      4. It injects the index ONCE per session (not every turn — that would waste context).
         Turn it off with AUTO_ORIENT=off.
      5. It caps the index text at 8000 bytes (a one-line header and a truncation note are
         added on top); override with AUTO_ORIENT_MAX_BYTES=N. A value that is not a
         positive whole number falls back to 8000.
    
    Fail-safe by design: any error, a missing file, or an already-oriented session →
    injects nothing and never blocks the prompt. (Contract: UserPromptSubmit context
    injection = {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
    "additionalContext": "..."}}, exit 0.)
    """
    from __future__ import annotations
    
    import hashlib
    import json
    import os
    import sys
    import tempfile
    from pathlib import Path
    
    DEFAULT_MAX_BYTES = 8000
    
    
    def _max_bytes():
        # Parsed at call time, not import time: an int() at module level ran outside the
        # fail-safe try below, so AUTO_ORIENT_MAX_BYTES=abc crashed the hook (exit 1).
        try:
            n = int(os.environ.get("AUTO_ORIENT_MAX_BYTES", ""))
        except ValueError:
            return DEFAULT_MAX_BYTES
        return n if n > 0 else DEFAULT_MAX_BYTES  # 0 / negative would keep nothing
    
    
    def _emit_context(text):
        sys.stdout.write(
            json.dumps(
                {
                    "hookSpecificOutput": {
                        "hookEventName": "UserPromptSubmit",
                        "additionalContext": text,
                    }
                }
            )
            + "\n"
        )
        sys.exit(0)
    
    
    def _noop():
        sys.exit(0)
    
    
    def _find_memory(cwd):
        override = os.environ.get("AUTO_ORIENT_MEMORY_PATH")
        candidates = []
        if override:
            candidates.append(Path(override).expanduser())
        if cwd:
            candidates += [Path(cwd) / "memory" / "MEMORY.md", Path(cwd) / "MEMORY.md"]
        for c in candidates:
            try:
                if c.is_file():
                    return c
            except OSError:
                continue
        return None
    
    
    def main():
        if os.environ.get("AUTO_ORIENT", "on").lower() == "off":
            _noop()
    
        try:
            data = json.loads(sys.stdin.read() or "{}")
        except json.JSONDecodeError:
            _noop()
    
        cwd = data.get("cwd") or os.getcwd()
        session_id = data.get("session_id") or ""
    
        mem = _find_memory(cwd)
        if not mem:
            _noop()
    
        # Read FIRST — a failed read must NOT mark the session oriented (else it never retries).
        try:
            text = mem.read_text(errors="ignore")
        except OSError:
            _noop()
        if not text.strip():
            _noop()
    
        # Once-per-session guard, only after a successful read. Don't re-inject every prompt.
        if session_id:
            key = hashlib.sha1(f"{session_id}:{mem}".encode()).hexdigest()[:16]
            marker = Path(tempfile.gettempdir()) / f"auto_orient_{key}"
            try:
                if marker.exists():
                    _noop()
                marker.write_text("1")
            except OSError:
                pass  # can't mark → still inject once now (safe, just not deduped)
    
        # Over the byte cap: keep whole lines (never cut mid-character). Only a first line
        # that is over the cap by itself gets cut mid-line, on a character boundary.
        max_bytes = _max_bytes()
        if len(text.encode()) > max_bytes:
            kept, total = [], 0
            for ln in text.splitlines(keepends=True):
                b = len(ln.encode())
                if total + b > max_bytes:
                    break
                kept.append(ln)
                total += b
            if not kept:
                # The first line alone is over the cap: cut it at the cap on a character
                # boundary (drop a split multi-byte tail) rather than inject only the notice.
                kept = [text.encode()[:max_bytes].decode("utf-8", errors="ignore")]
            text = "".join(kept).rstrip() + "\n\n…[truncated — open the file for the rest]"
    
        # Show a path relative to the project when possible (disambiguates root vs memory/).
        try:
            display_path = mem.relative_to(Path(cwd))
        except ValueError:
            display_path = mem.name
        _emit_context(f"[Project memory — loaded from {display_path} at session start]\n\n{text}")
    
    
    if __name__ == "__main__":
        try:
            main()
        except Exception:
            # Fail-safe: never block a prompt because the hook errored.
            _noop()
    
  • hooks/claim_check_hook.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """
    claim_check_hook.py — Stop-hook enforcer skeleton.
    
    Blocks turn-end if the assistant claims "done / shipped / verified / complete"
    without a recent verification log entry that refers to that specific claim and
    was written in the same session. Make the agent's done-claim mean something.
    
    USAGE
      1. Copy hooks/claim_check_hook.py + hooks/log_claim.py into your agent config dir
         (e.g. ~/.claude/hooks/).
      2. Register as a Stop hook in settings.json — MERGE into any existing "Stop" array,
         don't overwrite it:
           { "hooks": { "Stop": [{ "matcher": "*",
               "hooks": [{ "type": "command",
                 "command": "CLAIM_CHECK_ENFORCE_MODE=warn python3 /abs/path/to/claim_check_hook.py" }] }] } }
      3. Tell your agent (in CLAUDE.md) that after it verifies a done-claim it must log it:
           python3 /abs/path/to/log_claim.py "what I claimed" "how I verified it"
         log_claim.py creates ~/.claude/state/claim_checks/log.jsonl on first run.
      4. Test it fired:
           echo '{"transcript_path":"/dev/null"}' | python3 claim_check_hook.py
           → should print {"continue": true, ...}
      5. Modes (default = warn; promote to block once you trust it):
           CLAIM_CHECK_ENFORCE_MODE=warn    # surface a warning, don't block  (DEFAULT)
           CLAIM_CHECK_ENFORCE_MODE=block   # block turn-end until a fresh log entry exists
           CLAIM_CHECK_ENFORCE_MODE=off     # silent pass-through
      6. Evidence source (opt-in, v0.2; independent of the mode above):
           CLAIM_CHECK_EVIDENCE=log     # the agent's own log_claim.py line           (DEFAULT)
           CLAIM_CHECK_EVIDENCE=tool    # a harness-recorded PASSING verification run in the
                                        # transcript, this session, inside the window
           CLAIM_CHECK_EVIDENCE=both    # both of the above
         A non-blank unrecognized value is treated as "both" (strictest), not as "log"; a
         blank value means "log". In tool/both mode an internal error fails CLOSED.
         CLAIM_CHECK_VERIFY_COMMANDS="pytest,make test" overrides the allowlist.
    
    NOTE — this is a STOP hook; Stop hooks block with {"decision": "block"}.
    A PreToolUse hook uses a DIFFERENT schema ({"hookSpecificOutput": {"permissionDecision": "deny"}}).
    The two are NOT interchangeable — using the wrong one is a silent no-op.
    
    KNOWN LIMITS (binding is a forcing function, not a cryptographic guarantee):
      - Binding is by content-token overlap: it establishes TOPICAL correspondence, not proof.
        An agent that names the subject in its verification without doing the work can pass
        (legible on the record), and an incidental shared word — a file extension ("py"), an
        adverb ("successfully"), a number ("200") — can bind unrelated claim/evidence.
      - A truly subjectless closure ("Done." with no other content in the turn) is unbindable
        and degrades to a same-session freshness floor (still stronger than global freshness).
      - Two claims in the SAME sentence share that sentence's subject tokens (claims in
        DIFFERENT sentences are bound separately).
      - log_claim.py records the session from CLAUDE_CODE_SESSION_ID (set by Claude Code). Under
        a harness that does not set it, entries are session-less and cannot clear a session-bound
        claim — the verification must run in the same session that makes the claim.
      - CLAIM_CHECK_EVIDENCE=tool reads the transcript, which the agent's own Bash can still
        append to: forging a passing run is possible — deliberate, and the call that writes it
        is recorded, though the forging code can hide inside a script that call runs. A
        recorded run is not bound to the claim's subject (any passing pytest covers "the
        migration is complete"), it says nothing about whether the tests are meaningful or
        selected (-k, -run NOMATCH), and it can predate later edits. The allowlist credits a
        command LINE (the harness-recorded input.command), not the program that ran: a fake
        `pytest` on PATH, ./pytest, a shell function or (global) alias named pytest passes.
        Only the Bash tool's record shape is verified; Monitor, MCP tools, PowerShell and
        subagent (sidechain) runs are not credited. A block holds one turn (stop_hook_active).
      - "**DONE** Want me to deploy?" (a bold marker followed by a question on the same line) is
        exempted as a question — genuinely ambiguous; terminator-ended closures ("Done. Anything
        else?") are treated as completed assertions and DO gate. Plain bullet closers
        ("- Fixed the auth bug.") are a known detector-recall gap, tracked with issue #3.
    
    CUSTOMIZE
      - COMPLETION_VERBS — closure verbs that trigger the check (kept tight to avoid false-fires).
      - OPT_IN_VERBS — looser verbs; enable only if your domain needs them.
      - CLOSURE_NOUNS — subjects for the bare "Task complete!" / "Migration done." form.
      - CLAIM_CHECK_FRESH_MIN — "fresh" window (15 is forgiving; don't go below ~5).
      - OVERLAP_MIN_TOKENS — content tokens a log entry must share with the claim (default 1).
      - CLAIM_CHECKS_LOG_PATH env var — override the log location.
    """
    from __future__ import annotations
    
    import json
    import os
    import re
    import sys
    from bisect import bisect_left, bisect_right
    from datetime import datetime, timezone
    from pathlib import Path
    
    # ---------- CONFIG (edit these) ----------
    CLAIM_CHECKS_LOG = Path(
        os.environ.get(
            "CLAIM_CHECKS_LOG_PATH",
            str(Path.home() / ".claude" / "state" / "claim_checks" / "log.jsonl"),
        )
    ).expanduser()
    CLAIM_CHECK_FRESH_MIN = 15      # minutes — log entries newer than this count as fresh
    STALE_THRESHOLD_S = 30          # transcript-flush race guard
    DEFAULT_MODE = "warn"           # warn | block | off  (start warn; promote to block once tuned)
    OVERLAP_MIN_TOKENS = 1          # a fresh log entry must share >=N content tokens with the claim
    
    # Unambiguous closure verbs — kept tight so everyday prose ("the data is current",
    # "are we done here?") does NOT false-fire.
    COMPLETION_VERBS = (
        "ready", "shipped", "complete", "completed", "
  • hooks/log_claim.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """
    log_claim.py — write the verification entry that claim_check_hook.py looks for.
    
    Run this right AFTER you actually verify a done-claim:
    
        python3 log_claim.py "what you claimed" "how you verified it"
    
    It creates ~/.claude/state/claim_checks/log.jsonl on first run, so the very first
    claim doesn't get blocked by a missing file/dir. Override the location with the
    CLAIM_CHECKS_LOG_PATH env var (must match the hook's).
    
    Each entry also stamps:
      - session_id — from CLAUDE_CODE_SESSION_ID (the current agent session), so one
        session's log cannot alibi another session's claim on a shared $HOME.
      - claim_fingerprint — content tokens of the claim (subject nouns, closure verbs
        stopworded), so the hook can require a fresh entry that plausibly refers to the
        specific claim being made rather than accepting any recent entry. See issue #2.
    """
    from __future__ import annotations
    
    import json
    import os
    import re
    import sys
    from datetime import datetime, timezone
    from pathlib import Path
    
    CLAIM_CHECKS_LOG = Path(
        os.environ.get(
            "CLAIM_CHECKS_LOG_PATH",
            str(Path.home() / ".claude" / "state" / "claim_checks" / "log.jsonl"),
        )
    ).expanduser()
    
    # ---- claim fingerprinting (MUST stay byte-identical to claim_check_hook.py) ----
    # Closure verbs are stopworded: they appear in every done-claim, so binding on them
    # would be vacuous ("done" matches "done"). Only SUBJECT tokens (migration, endpoint,
    # paginator) carry the fingerprint. Cross-file consistency is pinned by a test.
    _STOPWORDS = frozenset({
        "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
        "has", "have", "had", "this", "that", "these", "those", "and", "or", "but",
        "to", "of", "in", "on", "at", "it", "its", "for", "with", "now", "all",
        "not", "from", "into", "out", "up", "down", "so", "as", "by", "just", "then",
        "also", "ive", "weve", "our", "your", "my", "me", "we", "i",
        # closure verbs — must NOT bind
        "done", "complete", "completed", "completing", "ready", "verified", "verify",
        "fixed", "fix", "resolved", "resolve", "shipped", "ship", "live", "set",
        "wired", "implemented", "implement", "applied", "apply", "patched", "synced",
        "finished", "finish", "production", "working",
        # generic dev nouns — too common to bind a specific claim on their own
        "test", "tests", "code", "file", "files", "thing", "things", "stuff",
        "work", "item", "items", "run", "ran",
    })
    
    
    def _content_tokens(text):
        toks = re.findall(r"[a-z0-9]+", (text or "").lower())
        return {t for t in toks if len(t) >= 2 and t not in _STOPWORDS}
    
    
    def _fingerprint(*texts):
        # Fingerprint the claim AND the verification: an honest verification usually
        # names the artifact ("route", "alembic", "stripe"), so a claim worded
        # differently from the verification still binds. See issue #2 discussion.
        return " ".join(sorted(_content_tokens(" ".join(t for t in texts if t))))
    
    
    def main():
        claim = sys.argv[1] if len(sys.argv) > 1 else ""
        verification = sys.argv[2] if len(sys.argv) > 2 else ""
        if not claim or not verification:
            print(
                'usage: python3 log_claim.py "<what you claim>" "<how you verified it>"',
                file=sys.stderr,
            )
            sys.exit(2)
    
        CLAIM_CHECKS_LOG.parent.mkdir(parents=True, exist_ok=True)
        entry = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "claim": claim,
            "verification": verification,
            "session_id": os.environ.get("CLAUDE_CODE_SESSION_ID", ""),
            "claim_fingerprint": _fingerprint(claim, verification),
        }
        with CLAIM_CHECKS_LOG.open("a", encoding="utf-8") as f:
            f.write(json.dumps(entry) + "\n")
        print(f"logged → {CLAIM_CHECKS_LOG}")
    
    
    if __name__ == "__main__":
        main()
    

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withandon

Stop fixing the same AI mistake twice. A Lean quality system for AI-assisted work — operators turn recurring defects into countermeasures. Built by a manufacturing operator, for people who actually ship.

Get the whole plugin