218 lines
8.8 KiB
Python
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()
|