117 lines
3.7 KiB
Python
117 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
|
"""session_start.py — aggregated deterministic status pass for the cc-os
|
|
plugin family. Runs every registered check in-process (checks.REGISTRY) and
|
|
emits at most one warning banner plus any near-zero-token notes.
|
|
|
|
ok -> silent
|
|
note -> additionalContext line (never snoozed or suppressed)
|
|
warn -> one line in the single systemMessage banner; snoozed once per day
|
|
and permanently suppressible via .cc-os/suppress-<check>
|
|
|
|
Outside a git project only environment-scoped checks run and no state is
|
|
written. Always exits 0 — the hook must never block a session.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import signal
|
|
import sys
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from checks import DEFAULT_VAULT_PATH, REGISTRY, CheckResult, Ctx # noqa: E402
|
|
from state import StateDir, find_project_root, read_config # noqa: E402
|
|
|
|
CHECK_BUDGET_SECONDS = 2
|
|
BANNER_HEADER = (
|
|
"[os-status] Session preconditions need attention (run /os-status:fix to"
|
|
" address all of these):"
|
|
)
|
|
|
|
|
|
def run_isolated(check, ctx) -> CheckResult:
|
|
"""Failure isolation: an exception or a blown time budget becomes a
|
|
generic warn naming the check; the remaining checks keep running."""
|
|
|
|
def timed_out(signum, frame):
|
|
raise TimeoutError
|
|
|
|
alarmed = hasattr(signal, "SIGALRM")
|
|
if alarmed:
|
|
previous = signal.signal(signal.SIGALRM, timed_out)
|
|
signal.alarm(CHECK_BUDGET_SECONDS)
|
|
try:
|
|
result = check.fn(ctx)
|
|
if not isinstance(result, CheckResult):
|
|
raise TypeError("check returned a non-CheckResult")
|
|
return result
|
|
except Exception:
|
|
return CheckResult("warn", f"status check '{check.name}' failed to run")
|
|
finally:
|
|
if alarmed:
|
|
signal.alarm(0)
|
|
signal.signal(signal.SIGALRM, previous)
|
|
|
|
|
|
def run(ctx: Ctx, state, today: date, out) -> None:
|
|
notes, warns = [], []
|
|
for check in REGISTRY:
|
|
if check.project_scoped and ctx.project_root is None:
|
|
continue
|
|
result = run_isolated(check, ctx)
|
|
if result.status == "note":
|
|
notes.append(result.message)
|
|
elif result.status == "warn":
|
|
if state and (
|
|
state.suppressed(check.name) or state.snoozed(check.name, today)
|
|
):
|
|
continue
|
|
warns.append((check.name, result.message))
|
|
|
|
payload = {}
|
|
if warns:
|
|
lines = [BANNER_HEADER]
|
|
lines.extend(f" - {message}" for _, message in warns)
|
|
if state:
|
|
names = ", ".join(name for name, _ in warns)
|
|
lines.append(
|
|
f" (silence one permanently: touch .cc-os/suppress-<name>; warned: {names})"
|
|
)
|
|
for name, _ in warns:
|
|
state.stamp(name, today)
|
|
payload["systemMessage"] = "\n".join(lines)
|
|
if notes:
|
|
payload["hookSpecificOutput"] = {
|
|
"hookEventName": "SessionStart",
|
|
"additionalContext": "\n".join(notes),
|
|
}
|
|
if payload:
|
|
out.write(json.dumps(payload))
|
|
|
|
|
|
def main() -> int:
|
|
try:
|
|
root = find_project_root(Path.cwd())
|
|
config = read_config(root) if root else {}
|
|
ctx = Ctx(
|
|
project_root=root,
|
|
settings_path=Path.home() / ".claude" / "settings.json",
|
|
vault_path=Path(
|
|
os.path.expanduser(config.get("vault_path", DEFAULT_VAULT_PATH))
|
|
),
|
|
config=config,
|
|
environ=dict(os.environ),
|
|
)
|
|
state = StateDir(root) if root else None
|
|
run(ctx, state, date.today(), sys.stdout)
|
|
except Exception:
|
|
pass # never block a session
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|