← Files The LegalQuants CompanionARCHIVED FILE

hooks/wiki_hook.py

7.27 KB · Oct 2, 2026 · 00:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Optional Codex lifecycle adapter for the provider-neutral /wiki engine.

This file is deliberately outside the packaged ``skills/wiki`` tree. ChatGPT
Work never needs or discovers it; a Codex lifecycle event is the sole capability
proof. The adapter is inert by default, has no per-matter state, and always
fails open.
"""

from __future__ import annotations

import sys

# Codex and Claude Code run hooks with whatever `python3` is on PATH. Everything
# below needs 3.12 (datetime.UTC, PEP 695 in sibling skills). A hook must fail
# open: on an older interpreter, say so once at session start and exit 0,
# never a traceback.
if sys.version_info < (3, 12):  # noqa: UP036 - the probe is the point
    if "session-start" in sys.argv:
        found = f"{sys.version_info[0]}.{sys.version_info[1]}"
        print(
            "CODEX for Legal: Wiki automation is off because its hooks need "
            f"Python 3.12 or newer on PATH (found {found})."
        )
    raise SystemExit(0)


import argparse
import json
import os
import sys
from datetime import UTC, datetime
from pathlib import Path

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
PACKAGED_SCRIPTS = PLUGIN_ROOT / "skills" / "wiki" / "scripts"
AUTHORED_SCRIPTS = PLUGIN_ROOT / "skills" / "core" / "wiki" / "scripts"
# Choose by the engine file, not the folder: a stale empty folder must not win.
SCRIPTS = (
    PACKAGED_SCRIPTS if (PACKAGED_SCRIPTS / "wiki.py").is_file() else AUTHORED_SCRIPTS
)
# The Companion ships this hook without the wiki skill. No engine, nothing to
# do: exit quietly rather than fail the hook on every session start and prompt.
if not (SCRIPTS / "wiki.py").is_file():
    raise SystemExit(0)
sys.path.insert(0, str(SCRIPTS))

import wiki  # noqa: E402, I001


AUTOMATION_NOTICE = (
    "Codex can optionally bring relevant Wiki notes into selected projects or "
    "all projects. Use $wiki and ask for automation settings if you want it."
)
RUNTIME_SCHEMA_VERSION = 1


def read_input() -> dict:
    try:
        value = json.load(sys.stdin)
    except (json.JSONDecodeError, OSError, UnicodeDecodeError):
        return {}
    return value if isinstance(value, dict) else {}


def _confirmed_automation_enabled(setting: str) -> bool:
    """Read only the confirmed Wiki automation setting, last value wins.

    This intentionally duplicates a few lines of the retrieval parser. The
    hook must decide *before* it accesses a prompt, so it cannot depend on a
    retrieval entrypoint that may already have read it.
    """
    playbook = wiki.user_playbook_path()
    if not playbook.is_file():
        return False
    confirmed = False
    value: str | None = None
    prefix = f"- [wiki] automatic {setting}:"
    try:
        lines = playbook.read_text(encoding="utf-8").splitlines()
    except (OSError, UnicodeDecodeError):
        return False
    for raw in lines:
        line = raw.strip()
        if line.startswith("## "):
            confirmed = line == "## Confirmed"
            continue
        if not confirmed or not line.casefold().startswith(prefix):
            continue
        candidate = line[len(prefix) :].strip().split(maxsplit=1)
        if candidate:
            value = candidate[0].casefold()
    return value == "on"


def _default_wiki() -> Path | None:
    """Resolve only the user-level default wiki; never create or guess one."""
    try:
        candidate = wiki.resolve_wiki(
            argparse.Namespace(wiki=None, wiki_name=None, ignore_current=True)
        )
    except (OSError, ValueError, wiki.WikiError):
        return None
    if not (
        candidate.is_dir()
        and (candidate / ".wiki").is_dir()
        and (candidate / "index.md").is_file()
    ):
        return None
    return candidate


def _runtime_path() -> Path:
    """Operational-only Codex capability/notice state, never legal content."""
    return wiki.user_registry_path().parent / "runtime.json"


def _load_runtime(path: Path) -> dict:
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        value = {}
    return value if isinstance(value, dict) else {}


def _mark_capability_and_take_notice() -> bool:
    """Record that a Codex hook ran and atomically consume the one-time notice."""
    path = _runtime_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    lock = path.with_name("runtime.lock")
    try:
        fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
    except OSError:
        return False
    try:
        os.close(fd)
        data = _load_runtime(path)
        lifecycle = data.get("codex_lifecycle")
        if not isinstance(lifecycle, dict):
            lifecycle = {}
        show_notice = not bool(lifecycle.get("automation_notice_shown"))
        lifecycle.update(
            {
                "last_seen_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
                "automation_notice_shown": True,
            }
        )
        data["schema_version"] = RUNTIME_SCHEMA_VERSION
        data["codex_lifecycle"] = lifecycle
        wiki.atomic_write_text(
            path, json.dumps(data, indent=1, ensure_ascii=False) + "\n"
        )
        return show_notice
    except (OSError, UnicodeDecodeError):
        # The notice is intentionally best-effort. A read-only home must not
        # make a lifecycle hook fail or repeatedly announce itself.
        return False
    finally:
        lock.unlink(missing_ok=True)


def session_start(payload: dict) -> int:
    # A successful hook invocation is the capability proof. Do not expose an
    # automation journey before the user has a real, registered wiki.
    del payload
    if _default_wiki() is None:
        return 0
    if _mark_capability_and_take_notice():
        print(AUTOMATION_NOTICE)
    return 0


def prompt_submit(payload: dict) -> int:
    target = _default_wiki()
    if target is None:
        return 0
    # Do not access the prompt when automatic retrieval is disabled. The host
    # may have supplied it in stdin, but this adapter neither uses nor logs it.
    if not _confirmed_automation_enabled(
        "retrieval"
    ) or not wiki.automation_scope_allows(payload.get("cwd")):
        return 0
    prompt = payload.get("prompt")
    if not isinstance(prompt, str) or not prompt:
        return 0
    args = argparse.Namespace(
        wiki=str(target),
        state=None,
        wiki_name=None,
        # The hook has already checked the confirmed automatic setting. This
        # value keeps the retrieval engine from coupling automatic activation
        # to the former generic `retrieval: on` preference.
        playbook=None,
        automatic=True,
        query=prompt,
        limit=3,
        deadline_ms=800,
        today=None,
        json=False,
        quiet=True,
    )
    try:
        import wiki_retrieval

        wiki_retrieval.cmd_retrieve(args)
    except (AttributeError, ImportError, OSError, ValueError, wiki.WikiError):
        return 0
    return 0


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(prog="wiki_hook.py")
    parser.add_argument("event", choices=("session-start", "prompt-submit"))
    args = parser.parse_args(argv[1:])
    payload = read_input()
    if args.event == "session-start":
        return session_start(payload)
    return prompt_submit(payload)


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))

SHA-256: 74de2036cb2aaa8fe976280f8acfdd37365c8cc588434bfdd6b78338dd8ec9a4