#!/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 ``/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/.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// (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()