Files
company-haness/.claude/hooks/subagent_register.py
T

218 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""SubagentStart hook: register the spawning subagent so SubagentStop can bind it
to *its* report (SHARED CONTRACT C4).
Append-only registry at ``<state_dir>/subagent-registry.jsonl`` — one JSON object
per line. ``stop_validate.py`` looks up an agent by ``agent_id`` to decide whether a
missing report is a violation (fail-closed) or the agent is exempt (allow).
Record (C4, minimal):
{agent_id, agent_type, workflow_id?, role?, expected_report_dir?, started_at}
plus a computed ``report_producing`` flag (whether stop_validate should require a
report from this agent). ``agent_id`` is the only required field.
SAFETY: this hook is best-effort metadata, NOT a gate. Malformed JSON, missing
fields, or an unresolved/unwritable workspace are logged to stderr and the hook
still exits 0 — it must never crash or block a session on registration failure.
"""
import json
import os
import sys
from datetime import datetime, timezone
import yaml
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(os.path.dirname(HERE))
import _workspace as W # noqa: E402
# Agent types that never produce an Org OS report (read-only / helper / built-in).
# An agent whose type is here (and that carries no workflow/role context) is
# recorded as report_producing=false so stop_validate does not over-block it.
HELPER_AGENT_TYPES = {
"explore", "plan", "general-purpose", "claude", "claude-code-guide",
"statusline-setup", "code-simplifier", "output-style-setup",
}
def agent_card_identity(agent_type):
"""Return the executable card's concrete role and collaboration kind.
Native SubagentStart events usually omit workflow/role/prompt. The selected
concrete agent card is therefore the strongest identity that the hook actually
receives; using it closes cross-role report substitution without inventing a
launcher-only field that Claude Code does not send.
"""
name = str(agent_type or "").strip().lower()
if not name:
return None, None
path = os.path.join(ROOT, ".claude", "agents", name + ".md")
try:
text = open(path, encoding="utf-8").read()
if not text.startswith("---\n"):
return None, None
frontmatter = yaml.safe_load(text.split("---\n", 2)[1]) or {}
return frontmatter.get("role-id"), frontmatter.get("collaboration-role")
except Exception:
return None, None
def _first(payload, *keys):
for k in keys:
v = payload.get(k)
if v not in (None, ""):
return v
return None
def is_report_producing(agent_type, workflow_id, role, expected_report_dir, explicit):
"""Decide whether this agent is expected to emit a report.
Priority: explicit payload flag > carries Org OS work context
(workflow/role/expected dir) > known helper type = no > has a generated
agent card (.claude/agents/<type>.md) = yes > default no (don't over-block).
"""
if isinstance(explicit, bool):
return explicit
if workflow_id or role or expected_report_dir:
return True
at = str(agent_type or "").strip().lower()
if not at or at in HELPER_AGENT_TYPES:
return False
# Org OS family/role workers have a generated agent card; helpers/built-ins do not.
if os.path.exists(os.path.join(ROOT, ".claude", "agents", at + ".md")):
return True
return False
def main():
try:
raw = sys.stdin.read()
except Exception as e: # pragma: no cover - stdin should always be readable
sys.stderr.write(f"[subagent_register] stdin read failed: {e}\n")
sys.exit(0)
raw = (raw or "").strip()
if not raw:
sys.stderr.write("[subagent_register] empty stdin; nothing to register.\n")
sys.exit(0)
try:
payload = json.loads(raw)
if not isinstance(payload, dict):
raise ValueError("payload is not a JSON object")
except (json.JSONDecodeError, ValueError) as e:
# Malformed input must not crash the session — log and move on.
sys.stderr.write(f"[subagent_register] malformed JSON, skipping: {e}\n")
sys.exit(0)
agent_id = _first(payload, "agent_id", "agentId", "subagent_id")
if not agent_id:
sys.stderr.write("[subagent_register] missing agent_id; cannot register.\n")
sys.exit(0)
agent_type = _first(payload, "agent_type", "agentType", "subagent_type")
workflow_id = (
_first(payload, "workflow_id", "workflow")
or os.environ.get("ORGOS_WORKFLOW_ID")
or os.environ.get("ORGOS_WORKFLOW")
)
role = _first(payload, "role", "role_id", "role-id")
card_role, card_collaboration = agent_card_identity(agent_type)
if not role and card_role:
role = card_role
expected_report_dir = _first(payload, "expected_report_dir", "expected-report-dir")
explicit_rp = payload.get("report_producing")
if not isinstance(explicit_rp, bool):
explicit_rp = payload.get("produces_report")
# Default the expected report dir from the workflow when not supplied — reports
# live under completion-records/<workflow>/ (C2).
if not expected_report_dir and workflow_id:
try:
expected_report_dir = os.path.join(W.records_dir(), str(workflow_id))
except Exception:
expected_report_dir = None
rp = is_report_producing(agent_type, workflow_id, role, expected_report_dir, explicit_rp)
if card_collaboration == "resolver-metadata" and not isinstance(explicit_rp, bool):
rp = False
# P0-2 binding(best-effort): the spawn was gated on a context-package by guard_tools;
# record the package path+hash from the spawn prompt so a later audit can bind the
# agent to the exact package it was certified against. Missing prompt -> omitted.
pkg_path = pkg_sha = None
prompt = _first(payload, "prompt", "task_prompt", "input")
if isinstance(prompt, str) and prompt:
import re
mp = re.search(r"context-package(?:-path)?:\s*([^\s`'\"]+)", prompt, re.I)
ms = re.search(r"context-package-sha256:\s*([0-9a-fA-F]{64})", prompt, re.I)
if mp:
pkg_path = mp.group(1).strip().strip("`'\"")
if ms:
pkg_sha = ms.group(1).strip().lower()
if not (pkg_path and pkg_sha):
try:
import spawn_bindings
claimed = spawn_bindings.claim_pending(
str(agent_type or ""),
str(agent_id),
session_id=str(_first(payload, "session_id", "sessionId") or "") or None,
)
if claimed:
pkg_path = claimed.get("context-package")
pkg_sha = claimed.get("context-package-sha256")
except Exception:
pass
record = {
"agent_id": agent_id,
"agent_type": agent_type,
"workflow_id": workflow_id,
"role": role,
"expected_report_dir": expected_report_dir,
"report_producing": rp,
"context_package": pkg_path,
"context_package_sha256": pkg_sha,
"started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
# Keep the C4 record minimal: omit fields we could not resolve.
record = {k: v for k, v in record.items() if v is not None}
# Resolve the state dir. workspace-unset is a CONFIG failure that must fail-closed
# for a report-producing Org OS worker (finding P0-1): a worker that cannot be
# registered would later stop with no identity binding, so we refuse the spawn
# rather than silently proceed. A helper agent (report_producing=false) still
# exits 0 — read-only helpers must not be blocked by an unset workspace.
try:
sdir = W.state_dir()
except W.WorkspaceNotSetError as e:
if rp:
sys.stderr.write(
f"[subagent_register] BLOCK: report-producing agent '{agent_id}' "
f"(type={agent_type}) but workspace unset — cannot register, refusing "
f"spawn (exit 2). ORGOS_WORKSPACE 를 설정하세요. {e}\n")
sys.exit(2)
sys.stderr.write(
f"[subagent_register] workspace unset; helper '{agent_id}' not registered "
f"(non-blocking): {e}\n")
sys.exit(0)
try:
os.makedirs(sdir, exist_ok=True)
path = os.path.join(sdir, "subagent-registry.jsonl")
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as e:
# Registry unwritable for a NON-config reason (disk/permission) — do NOT crash
# the session on an infra hiccup; that is not the fail-open hole under review.
sys.stderr.write(f"[subagent_register] could not write registry: {e}\n")
sys.exit(0)
print(f"registered {agent_id} (type={agent_type}) report_producing={rp}")
sys.exit(0)
if __name__ == "__main__":
main()