This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/sea-haven-claude-plugin/hooks/user_prompt_submit.py
Claude 2aa72d73f4
feat(ws0+ws2+ws4): plugin scaffold, Slack /new-task, auto-delegate hook
WS0 — sea-haven-claude-plugin/ scaffold:
  - CLAUDE.md: Sea Haven engineering context (pipeline overview, rules,
    /new-task + /delegate usage, available phases)
  - settings.template.json: UserPromptSubmit hook wiring template
  - hooks/user_prompt_submit.py: standalone script (WS4)

WS2 — SlackListener /new-task intake:
  - Add NewTaskCallback type alias (Callable[[str, str], str])
  - Add new_task_callback param to SlackListener.__init__
  - _is_new_task_command() helper for slash_commands+/new-task detection
  - _handle_new_task_command() method: authorized-only, calls callback,
    exception-safe (listen loop stays alive on callback errors)
  - handle_event() routes /new-task BEFORE the answer path (post-AUTHZ-01)

WS4 — UserPromptSubmit auto-delegate hook:
  - /delegate <text> and DELEGATE: <text> prefixes trigger delegation
  - Calls POST /tasks on the agent-team HTTP API (WS1)
  - Blocks the Claude Code prompt; shows thread_id + next-steps message
  - Graceful degradation: missing token, HTTP error, network error all
    produce a block with a human-readable reason
  - run() is a pure function for testability (no stdin/stdout in tests)

Tests: 22 new tests in test_ws0_ws2_ws4_plugin_slack_hook.py.
Full suite: 1066 passed. ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYp761G9HojkmLqLZrASVi
2026-06-23 01:39:12 +00:00

160 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""UserPromptSubmit hook: auto-delegate prefixed prompts to the agent-team API.
Claude Code calls this script via the UserPromptSubmit hook whenever the user
submits a prompt. If the prompt starts with ``/delegate`` (or ``DELEGATE:``),
the hook forwards the task description to the agent-team HTTP API
(``POST /tasks``) and blocks the prompt — the task is now running in the
pipeline; no need to also answer it locally.
Prompts that don't match the delegate prefix are passed through unchanged
(exit 0 with no output).
Configuration (env vars):
AGENT_TEAM_API_URL Base URL of the agent-team HTTP API
(default: http://127.0.0.1:8765)
AGENT_TEAM_API_TOKEN Bearer token from ``run-team.py serve``
(required when the API has token auth enabled)
Claude Code hook contract:
* stdin: JSON object with at least a ``prompt`` key.
* stdout: JSON object or empty.
- Empty / exit 0 → pass through (Claude answers normally).
- ``{"action": "block", "reason": "..."}`` → block the prompt; Claude
shows the reason to the user instead of answering.
* exit 0 = allowed (or delegated+blocked); non-zero = block with error.
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any
_DEFAULT_API_URL = "http://127.0.0.1:8765"
_DELEGATE_PREFIXES = ("/delegate ", "DELEGATE: ")
def _should_delegate(prompt: str) -> tuple[bool, str]:
"""Return (True, task_text) when the prompt is a delegate command, else (False, '').
Matches ``/delegate <text>``, ``/delegate`` (bare — no text → usage hint),
and ``DELEGATE: <text>``.
"""
stripped = prompt.strip()
for prefix in _DELEGATE_PREFIXES:
if stripped.startswith(prefix):
return True, stripped[len(prefix) :].strip()
# Bare /delegate with no trailing space or text.
if stripped == "/delegate":
return True, ""
return False, ""
def _call_api(task: str, *, api_url: str, token: str) -> dict[str, Any]:
"""POST /tasks and return the parsed response dict.
Raises urllib.error.URLError / urllib.error.HTTPError on network / HTTP
errors; the caller turns these into a block reason so Claude shows the error
to the user rather than silently passing through.
"""
payload = json.dumps({"task": task, "transport": "claude_code"}).encode("utf-8")
headers: dict[str, str] = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(
f"{api_url.rstrip('/')}/tasks",
data=payload,
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
return json.loads(resp.read().decode("utf-8"))
def run(prompt: str, *, api_url: str = _DEFAULT_API_URL, token: str = "") -> dict[str, Any]:
"""Core hook logic — pure function, fully testable without stdin/stdout.
Returns the hook output dict:
- ``{}`` to pass through (no delegation);
- ``{"action": "block", "reason": "..."}`` to block and show a message.
"""
should, task_text = _should_delegate(prompt)
if not should:
return {}
if not task_text:
return {
"action": "block",
"reason": (
"Usage: /delegate <task description>\n"
"Example: /delegate Add OAuth2 to the admin portal"
),
}
if not token:
env_token = os.environ.get("AGENT_TEAM_API_TOKEN", "")
if not env_token:
return {
"action": "block",
"reason": (
"AGENT_TEAM_API_TOKEN is not set. "
"Start the agent-team server with `run-team.py serve` "
"and export the token it prints."
),
}
token = env_token
try:
result = _call_api(task_text, api_url=api_url, token=token)
except urllib.error.HTTPError as exc:
return {
"action": "block",
"reason": f"agent-team API error {exc.code}: {exc.reason}. Is the server running?",
}
except urllib.error.URLError as exc:
return {
"action": "block",
"reason": (
f"Cannot reach agent-team API at {api_url}: {exc.reason}. "
"Is `run-team.py serve` running?"
),
}
except Exception as exc: # noqa: BLE001
return {
"action": "block",
"reason": f"Unexpected error delegating task: {exc}",
}
thread_id = result.get("thread_id", "(unknown)")
return {
"action": "block",
"reason": (
f"Task delegated to agent-team pipeline.\n"
f"thread_id: {thread_id}\n"
f"The coordinator will send a clarifying question via Slack."
),
}
def main() -> int:
try:
data = json.loads(sys.stdin.read())
except (json.JSONDecodeError, OSError):
return 0 # pass through on bad input
prompt = data.get("prompt", "") if isinstance(data, dict) else ""
api_url = os.environ.get("AGENT_TEAM_API_URL", _DEFAULT_API_URL)
token = os.environ.get("AGENT_TEAM_API_TOKEN", "")
output = run(prompt, api_url=api_url, token=token)
if output:
print(json.dumps(output))
return 0
if __name__ == "__main__":
sys.exit(main())