pantheon-opencode 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -0
- package/README.md +53 -15
- package/bin/pantheon-init.mjs +14 -0
- package/opencode.json +17 -1
- package/package.json +13 -4
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +303 -13
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +42 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +201 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +419 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +97 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +50 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/opencode.mjs +58 -29
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install-mcp.mjs +104 -56
- package/scripts/mcp_persistence_server.py +1 -0
- package/scripts/prune.mjs +359 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/zeus.md +33 -5
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -3
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +1503 -0
- package/src/plugins/tui/dist/tui.d.ts +8 -0
- package/src/plugins/tui/dist/tui.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui.js +1198 -0
- package/src/plugins/tui/dist/tui.js.map +1 -0
- package/src/plugins/tui/dist/tui.tsx +1204 -0
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +16 -1
- package/src/mcp/requirements-mcp-core.txt +0 -5
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# on-subagent-delegation-start.sh — Pantheon SubagentStart Audit Hook
|
|
3
|
+
# Logs when Zeus delegates to a subagent.
|
|
4
|
+
#
|
|
5
|
+
# Honest telemetry (Council P1-1, 2026-08-06; P0-2, 2026-08-06 2nd session):
|
|
6
|
+
# the previous version read AGENT_NAME from env (always '' — opencode
|
|
7
|
+
# tool.execute hooks carry no agent_id) and TASK_DESC from payload.tool_name
|
|
8
|
+
# (always 'task' — useless). This version reads the Claude Code protocol
|
|
9
|
+
# payload {tool_name, tool_input, agent_id, session_id, delegation_id} from
|
|
10
|
+
# stdin and derives:
|
|
11
|
+
# agent = tool_input.subagent_type (task tool) -> payload agent_id (the
|
|
12
|
+
# plugin's BEFORE-time agent — never after-time result junk) ->
|
|
13
|
+
# tool_name -> env AGENT_NAME -> 'unknown'. Never 'unknown' when
|
|
14
|
+
# subagent_type is present.
|
|
15
|
+
# task = tool_input.description (the real delegation task) -> env
|
|
16
|
+
# TASK_DESC -> $1 -> tool name. Falls back to the tool name when
|
|
17
|
+
# no description is supplied.
|
|
18
|
+
# delegation_id = payload.delegation_id — generated by the plugin in
|
|
19
|
+
# tool.execute.before (crypto.randomUUID, P0-2). The stop hook
|
|
20
|
+
# receives the SAME id (via the callID-keyed before-record) so
|
|
21
|
+
# delegations.log joins SubagentStart <-> SubagentStop on it.
|
|
22
|
+
# All fields pass through a primitive sanitizer — a merged result blob can
|
|
23
|
+
# never contaminate agent/session_id/delegation_id.
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
# Project-local by default; set XDG_STATE_HOME for system-wide logging
|
|
27
|
+
if [ -n "${LOG_DIR:-}" ]; then
|
|
28
|
+
LOG_DIR="$LOG_DIR"
|
|
29
|
+
elif [ -n "${XDG_STATE_HOME:-}" ]; then
|
|
30
|
+
LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
|
|
31
|
+
else
|
|
32
|
+
LOG_DIR="logs/agent-sessions"
|
|
33
|
+
fi
|
|
34
|
+
mkdir -p "$LOG_DIR"
|
|
35
|
+
|
|
36
|
+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
37
|
+
|
|
38
|
+
# Preserve the pre-derivation env values (derived fields overwrite them below).
|
|
39
|
+
ENV_AGENT_NAME="${AGENT_NAME:-}"
|
|
40
|
+
ENV_TASK_DESC="${TASK_DESC:-}"
|
|
41
|
+
ARG_TASK_DESC="${1:-}"
|
|
42
|
+
|
|
43
|
+
# The Claude Code protocol payload arrives on stdin (hook-runner always pipes
|
|
44
|
+
# it, even empty). Buffer it in a temp file — prompts can exceed argv limits.
|
|
45
|
+
TMP_IN=$(mktemp "${TMPDIR:-/tmp}/subagent-start.XXXXXX")
|
|
46
|
+
TMP_PY=$(mktemp "${TMPDIR:-/tmp}/subagent-start.XXXXXX.py")
|
|
47
|
+
trap 'rm -f "$TMP_IN" "$TMP_PY"' EXIT
|
|
48
|
+
cat > "$TMP_IN"
|
|
49
|
+
|
|
50
|
+
# Derivation logic: agent + task + session_id + delegation_id from the
|
|
51
|
+
# payload. (Kept in a temp file — heredocs inside $(...) are not portable.)
|
|
52
|
+
cat > "$TMP_PY" <<'PY'
|
|
53
|
+
import sys
|
|
54
|
+
import json
|
|
55
|
+
|
|
56
|
+
def prim(v):
|
|
57
|
+
"""P0-1 sanitizer: primitives -> str; junk (dicts/lists from a merged
|
|
58
|
+
result blob) -> '' so log fields are never contaminated."""
|
|
59
|
+
if isinstance(v, str):
|
|
60
|
+
return v
|
|
61
|
+
if isinstance(v, (int, float, bool)):
|
|
62
|
+
return str(v)
|
|
63
|
+
return ''
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(sys.argv[1], encoding='utf-8') as f:
|
|
67
|
+
payload = json.load(f)
|
|
68
|
+
except Exception:
|
|
69
|
+
payload = {}
|
|
70
|
+
|
|
71
|
+
env_agent = sys.argv[2] if len(sys.argv) > 2 else ''
|
|
72
|
+
tool_name = payload.get('tool_name') or ''
|
|
73
|
+
ti = payload.get('tool_input')
|
|
74
|
+
if not isinstance(ti, dict):
|
|
75
|
+
ti = {}
|
|
76
|
+
session_id = prim(payload.get('session_id'))
|
|
77
|
+
delegation_id = prim(payload.get('delegation_id'))
|
|
78
|
+
|
|
79
|
+
# Agent (P0-2): subagent_type -> env AGENT_NAME (plugin agent_id from the
|
|
80
|
+
# before-time record) -> tool_name -> 'unknown'
|
|
81
|
+
agent = ti.get('subagent_type') or ti.get('subagentType') or env_agent or tool_name or ''
|
|
82
|
+
# Task: description (real delegation task) -> env TASK_DESC -> $1 -> agent
|
|
83
|
+
task = ti.get('description') or ''
|
|
84
|
+
if isinstance(task, str):
|
|
85
|
+
task = task.strip()
|
|
86
|
+
|
|
87
|
+
print('%s\t%s\t%s\t%s' % (prim(agent), prim(task), session_id, delegation_id))
|
|
88
|
+
PY
|
|
89
|
+
|
|
90
|
+
DERIVED=$(python3 "$TMP_PY" "$TMP_IN" "$ENV_AGENT_NAME" 2>/dev/null || printf 'unknown\t\t\t')
|
|
91
|
+
|
|
92
|
+
# cut is positional (read collapses consecutive tabs on empty fields).
|
|
93
|
+
AGENT_NAME=$(printf '%s' "$DERIVED" | cut -f1)
|
|
94
|
+
TASK_DESC=$(printf '%s' "$DERIVED" | cut -f2)
|
|
95
|
+
SESSION_ID=$(printf '%s' "$DERIVED" | cut -f3)
|
|
96
|
+
DELEGATION_ID=$(printf '%s' "$DERIVED" | cut -f4)
|
|
97
|
+
|
|
98
|
+
# Agent fallback chain (subagent_type -> env -> tool_name -> 'unknown').
|
|
99
|
+
if [ -z "$AGENT_NAME" ]; then
|
|
100
|
+
AGENT_NAME="$ENV_AGENT_NAME"
|
|
101
|
+
fi
|
|
102
|
+
if [ -z "$AGENT_NAME" ]; then
|
|
103
|
+
AGENT_NAME="unknown"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# Task fallback (stdin description -> env TASK_DESC -> $1 -> tool name).
|
|
107
|
+
if [ -z "$TASK_DESC" ]; then
|
|
108
|
+
TASK_DESC="${ENV_TASK_DESC:-$ARG_TASK_DESC}"
|
|
109
|
+
fi
|
|
110
|
+
if [ -z "$TASK_DESC" ]; then
|
|
111
|
+
TASK_DESC="$AGENT_NAME"
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# Emit a schema-compatible line: {event, timestamp, agent, task, session_id,
|
|
115
|
+
# delegation_id} — P0-2: delegation_id is the join key with SubagentStop.
|
|
116
|
+
# Built with json.dumps so arbitrary description text cannot break the JSON.
|
|
117
|
+
LOG_LINE=$(python3 -c '
|
|
118
|
+
import json
|
|
119
|
+
import sys
|
|
120
|
+
print(json.dumps({
|
|
121
|
+
"event": "SubagentStart",
|
|
122
|
+
"timestamp": sys.argv[1],
|
|
123
|
+
"agent": sys.argv[2],
|
|
124
|
+
"task": sys.argv[3],
|
|
125
|
+
"session_id": sys.argv[4],
|
|
126
|
+
"delegation_id": sys.argv[5],
|
|
127
|
+
}, ensure_ascii=False))
|
|
128
|
+
' "$TIMESTAMP" "$AGENT_NAME" "$TASK_DESC" "$SESSION_ID" "$DELEGATION_ID")
|
|
129
|
+
|
|
130
|
+
echo "$LOG_LINE" >> "$LOG_DIR/delegations.log"
|
|
131
|
+
|
|
132
|
+
echo "[DELEGATION] $AGENT_NAME started → $LOG_DIR/delegations.log" >&2
|
|
133
|
+
|
|
134
|
+
# --- Dispatch-time validation: check target against routing.yml ---
|
|
135
|
+
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
136
|
+
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
|
|
137
|
+
ROUTING_FILE="$REPO_ROOT/routing.yml"
|
|
138
|
+
if [ -f "$ROUTING_FILE" ] && [ -n "$AGENT_NAME" ] && [ "$AGENT_NAME" != "unknown" ]; then
|
|
139
|
+
ESCAPED_AGENT_NAME=$(printf '%s\n' "$AGENT_NAME" | sed 's/[][(){}.^$*+?|\/\\-]/\\&/g')
|
|
140
|
+
if grep -qE "^[[:space:]]*-[[:space:]]+target:[[:space:]]+$ESCAPED_AGENT_NAME[[:space:]]*$" "$ROUTING_FILE" 2>/dev/null; then
|
|
141
|
+
echo "[VALIDATION] ✅ Target '$AGENT_NAME' found in routing.yml" >&2
|
|
142
|
+
else
|
|
143
|
+
echo "[VALIDATION] ⚠️ Target '$AGENT_NAME' NOT in routing.yml delegation rules" >&2
|
|
144
|
+
fi
|
|
145
|
+
elif [ -f "$ROUTING_FILE" ]; then
|
|
146
|
+
echo "[VALIDATION] ⚠️ Could not identify the delegated agent (agent='$AGENT_NAME') — skipping routing.yml validation" >&2
|
|
147
|
+
elif [ ! -f "$ROUTING_FILE" ]; then
|
|
148
|
+
echo "[VALIDATION] ⚠️ Could not read routing.yml for validation" >&2
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
# --- Cost estimation for delegation ---
|
|
152
|
+
declare -A TIER_MAP
|
|
153
|
+
TIER_MAP[athena]="premium"
|
|
154
|
+
TIER_MAP[themis]="premium"
|
|
155
|
+
TIER_MAP[hermes]="default"
|
|
156
|
+
TIER_MAP[aphrodite]="default"
|
|
157
|
+
TIER_MAP[demeter]="default"
|
|
158
|
+
TIER_MAP[prometheus]="default"
|
|
159
|
+
TIER_MAP[hephaestus]="default"
|
|
160
|
+
TIER_MAP[gaia]="default"
|
|
161
|
+
TIER_MAP[zeus]="default"
|
|
162
|
+
TIER_MAP[apollo]="fast"
|
|
163
|
+
TIER_MAP[nyx]="fast"
|
|
164
|
+
TIER_MAP[iris]="fast"
|
|
165
|
+
TIER_MAP[mnemosyne]="fast"
|
|
166
|
+
TIER_MAP[talos]="fast"
|
|
167
|
+
|
|
168
|
+
TIER="${TIER_MAP[$AGENT_NAME]:-default}"
|
|
169
|
+
|
|
170
|
+
case "$TIER" in
|
|
171
|
+
premium)
|
|
172
|
+
COST_MIN=0.50
|
|
173
|
+
COST_MAX=2.00
|
|
174
|
+
COST_EST=1.25
|
|
175
|
+
;;
|
|
176
|
+
fast)
|
|
177
|
+
COST_MIN=0.02
|
|
178
|
+
COST_MAX=0.10
|
|
179
|
+
COST_EST=0.06
|
|
180
|
+
;;
|
|
181
|
+
*)
|
|
182
|
+
COST_MIN=0.10
|
|
183
|
+
COST_MAX=0.50
|
|
184
|
+
COST_EST=0.30
|
|
185
|
+
;;
|
|
186
|
+
esac
|
|
187
|
+
|
|
188
|
+
# Format cost with 2 decimal places
|
|
189
|
+
COST_EST_FMT=$(printf "%.2f" "$COST_EST")
|
|
190
|
+
|
|
191
|
+
if [ "$TIER" = "premium" ]; then
|
|
192
|
+
echo "[COST_WARN] High-cost delegation to $AGENT_NAME — estimated \$$COST_EST_FMT" >&2
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
if (( $(echo "$COST_EST > 5.00" | bc -l 2>/dev/null) )); then
|
|
196
|
+
echo "[COST_ALERT] Expensive delegation to $AGENT_NAME — consider if subtask is possible" >&2
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
echo "[COST] Delegation to $AGENT_NAME — tier: $TIER — estimated: \$$COST_EST_FMT" >&2
|
|
200
|
+
|
|
201
|
+
exit 0
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# on-subagent-delegation-stop.sh — Pantheon SubagentStop Audit Hook
|
|
3
|
+
# Logs completion, failure, or BACKGROUND DISPATCH of subagent tasks.
|
|
4
|
+
#
|
|
5
|
+
# Honest telemetry (Council P1-1, 2026-08-06; P0-1/P0-2, 2026-08-06 2nd
|
|
6
|
+
# session): the previous version read AGENT_NAME from env (always '' — opencode
|
|
7
|
+
# tool.execute hooks carry no agent_id) and defaulted STATUS to 'success',
|
|
8
|
+
# fabricating success even for refusals/failures. This version reads the
|
|
9
|
+
# Claude Code protocol payload {tool_name, tool_input, agent_id, session_id,
|
|
10
|
+
# delegation_id, task_id, duration_ms} from stdin and derives:
|
|
11
|
+
#
|
|
12
|
+
# agent = tool_input.subagent_type (task tool) -> payload agent_id (the
|
|
13
|
+
# plugin's BEFORE-time agent from its callID-keyed record — the
|
|
14
|
+
# after-hook args may carry the MERGED background result
|
|
15
|
+
# {task_id, state} and are NEVER used to derive the agent) ->
|
|
16
|
+
# tool_name -> env AGENT_NAME -> 'unknown'.
|
|
17
|
+
# status = 1. env STATUS override (hook-runner never sets it — empty here)
|
|
18
|
+
# 2. payload.status — plugin-derived from the REAL tool result.
|
|
19
|
+
# P0-1: 'dispatched' is now a first-class status — the task
|
|
20
|
+
# tool in BACKGROUND mode returns IMMEDIATELY with
|
|
21
|
+
# {task_id, state:'running'}, so tool.execute.after observes
|
|
22
|
+
# DISPATCH, not completion. Background dispatch is recorded
|
|
23
|
+
# as 'dispatched', NEVER 'success'.
|
|
24
|
+
# 3. explicit status-ish field inside tool_output, including a
|
|
25
|
+
# state of running/queued/pending/scheduled -> 'dispatched'
|
|
26
|
+
# 4. background-dispatch signature (task_id present in the
|
|
27
|
+
# result, or state running/queued) -> 'dispatched'
|
|
28
|
+
# 5. explicit status-ish field in tool_input (as before)
|
|
29
|
+
# 6. strong failure markers in the tool_output text -> 'failure'
|
|
30
|
+
# 7. non-empty tool_output.output -> 'success' (real completion
|
|
31
|
+
# evidence — the result reached the plugin)
|
|
32
|
+
# 8. strong failure markers in the tool_input text -> 'failure'
|
|
33
|
+
# 9. else 'unknown' — NEVER fabricated 'success'
|
|
34
|
+
# delegation_id = payload.delegation_id — the SAME id the start hook
|
|
35
|
+
# logged (P0-2), so delegations.log joins Start<->Stop on it.
|
|
36
|
+
# task_id = payload.task_id — the plugin extracts it into its OWN
|
|
37
|
+
# field; never concatenated into agent/status.
|
|
38
|
+
# duration_ms = payload.duration_ms — plugin-computed from the
|
|
39
|
+
# before-record start timestamp.
|
|
40
|
+
# All log fields pass through a primitive sanitizer — a merged result blob can
|
|
41
|
+
# never contaminate agent/status/session_id/task_id.
|
|
42
|
+
#
|
|
43
|
+
# LIMITATION (P0-1, documented): on opencode 1.18.13 real completion of
|
|
44
|
+
# BACKGROUND tasks is NOT observable via tool.execute.after (no second
|
|
45
|
+
# after-event fires). The stop record for a background launch is 'dispatched'
|
|
46
|
+
# by design — 'success' would be fabricated telemetry.
|
|
47
|
+
set -euo pipefail
|
|
48
|
+
|
|
49
|
+
# Project-local by default; set XDG_STATE_HOME for system-wide logging
|
|
50
|
+
if [ -n "${LOG_DIR:-}" ]; then
|
|
51
|
+
LOG_DIR="$LOG_DIR"
|
|
52
|
+
elif [ -n "${XDG_STATE_HOME:-}" ]; then
|
|
53
|
+
LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
|
|
54
|
+
else
|
|
55
|
+
LOG_DIR="logs/agent-sessions"
|
|
56
|
+
fi
|
|
57
|
+
mkdir -p "$LOG_DIR"
|
|
58
|
+
|
|
59
|
+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
60
|
+
|
|
61
|
+
# Preserve the pre-derivation env values (derived fields overwrite them below).
|
|
62
|
+
ENV_AGENT_NAME="${AGENT_NAME:-}"
|
|
63
|
+
ENV_STATUS="${STATUS:-}"
|
|
64
|
+
ENV_REASON="${REASON:-}"
|
|
65
|
+
ARG_REASON="${1:-}"
|
|
66
|
+
|
|
67
|
+
# The Claude Code protocol payload arrives on stdin (hook-runner always pipes
|
|
68
|
+
# it, even empty). Buffer it in a temp file — prompts can exceed argv limits.
|
|
69
|
+
TMP_IN=$(mktemp "${TMPDIR:-/tmp}/subagent-stop.XXXXXX")
|
|
70
|
+
TMP_PY=$(mktemp "${TMPDIR:-/tmp}/subagent-stop.XXXXXX.py")
|
|
71
|
+
TMP_OUT=$(mktemp "${TMPDIR:-/tmp}/subagent-stop.XXXXXX.out")
|
|
72
|
+
trap 'rm -f "$TMP_IN" "$TMP_PY" "$TMP_OUT"' EXIT
|
|
73
|
+
cat > "$TMP_IN"
|
|
74
|
+
|
|
75
|
+
# Derivation logic: agent + status + reason + correlation fields from the
|
|
76
|
+
# payload. (Kept in a temp file — heredocs inside $(...) are not portable.)
|
|
77
|
+
cat > "$TMP_PY" <<'PY'
|
|
78
|
+
import sys
|
|
79
|
+
import json
|
|
80
|
+
import re
|
|
81
|
+
|
|
82
|
+
def prim(v):
|
|
83
|
+
"""P0-1 sanitizer: primitives -> str; junk (dicts/lists from a merged
|
|
84
|
+
result blob) -> '' so log fields are never contaminated."""
|
|
85
|
+
if isinstance(v, str):
|
|
86
|
+
return v
|
|
87
|
+
if isinstance(v, (int, float, bool)):
|
|
88
|
+
return str(v)
|
|
89
|
+
return ''
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
with open(sys.argv[1], encoding='utf-8') as f:
|
|
93
|
+
payload = json.load(f)
|
|
94
|
+
except Exception:
|
|
95
|
+
payload = {}
|
|
96
|
+
|
|
97
|
+
env_status = sys.argv[2] if len(sys.argv) > 2 else ''
|
|
98
|
+
env_agent = sys.argv[3] if len(sys.argv) > 3 else ''
|
|
99
|
+
tool_name = payload.get('tool_name') or ''
|
|
100
|
+
ti = payload.get('tool_input')
|
|
101
|
+
if not isinstance(ti, dict):
|
|
102
|
+
ti = {}
|
|
103
|
+
session_id = prim(payload.get('session_id'))
|
|
104
|
+
delegation_id = prim(payload.get('delegation_id'))
|
|
105
|
+
task_id = prim(payload.get('task_id'))
|
|
106
|
+
duration_ms = prim(payload.get('duration_ms'))
|
|
107
|
+
|
|
108
|
+
# P1-1 follow-up: the plugin now forwards the REAL tool result —
|
|
109
|
+
# payload.tool_output = the tool.execute.after callback output
|
|
110
|
+
# ({title, output, metadata}) and payload.status = the plugin-derived status
|
|
111
|
+
# from it. Prefer these over the args-only heuristics below.
|
|
112
|
+
plugin_status = payload.get('status') or ''
|
|
113
|
+
result = payload.get('tool_output')
|
|
114
|
+
if not isinstance(result, dict):
|
|
115
|
+
result = None
|
|
116
|
+
result_text = json.dumps(result, ensure_ascii=False).lower() if result else ''
|
|
117
|
+
|
|
118
|
+
# Agent (P0-2): subagent_type -> env AGENT_NAME (plugin agent_id resolved from
|
|
119
|
+
# the tool.execute.before record — after-time args may hold the MERGED
|
|
120
|
+
# background result {task_id, state} with no subagent_type) -> tool_name -> ''
|
|
121
|
+
agent = ti.get('subagent_type') or ti.get('subagentType') or env_agent or tool_name or ''
|
|
122
|
+
|
|
123
|
+
# Status: env override -> plugin status -> result field -> background dispatch
|
|
124
|
+
# -> tool_input field -> result markers/output -> tool_input markers
|
|
125
|
+
# -> unknown. NEVER fabricated success.
|
|
126
|
+
status = env_status
|
|
127
|
+
reason = ''
|
|
128
|
+
source = ''
|
|
129
|
+
STATUS_KEYS = (
|
|
130
|
+
'status', 'state', 'result', 'success', 'ok',
|
|
131
|
+
'error', 'failed', 'error_message', 'exception',
|
|
132
|
+
)
|
|
133
|
+
SUCCESS_WORDS = ('success', 'successful', 'completed', 'complete',
|
|
134
|
+
'done', 'finished', 'ok', 'true', '1')
|
|
135
|
+
FAILURE_WORDS = ('failure', 'failed', 'error', 'refused', 'denied',
|
|
136
|
+
'exception', 'false', '0', 'cancelled', 'canceled', 'aborted')
|
|
137
|
+
BACKGROUND_STATES = ('running', 'queued', 'pending', 'scheduled', 'dispatched')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def classify(key, val):
|
|
141
|
+
"""Return ('success' | 'failure' | '', reason) for a status-ish value."""
|
|
142
|
+
if val in (None, '', False):
|
|
143
|
+
return '', ''
|
|
144
|
+
if isinstance(val, bool):
|
|
145
|
+
return ('success' if val else 'failure'), ''
|
|
146
|
+
if isinstance(val, (str, int, float)):
|
|
147
|
+
low = str(val).strip().lower()
|
|
148
|
+
if low in SUCCESS_WORDS:
|
|
149
|
+
return 'success', ''
|
|
150
|
+
if low in FAILURE_WORDS:
|
|
151
|
+
return 'failure', ''
|
|
152
|
+
return '', "status-like field '%s' present but unrecognized: %s" % (key, str(val)[:80])
|
|
153
|
+
return '', ''
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def status_field_in(scope, prefix):
|
|
157
|
+
"""First explicit status-ish field inside a dict scope, or ('', '')."""
|
|
158
|
+
for key in STATUS_KEYS:
|
|
159
|
+
if key in scope and scope[key] not in (None, '', False):
|
|
160
|
+
s, r = classify(key, scope[key])
|
|
161
|
+
if s:
|
|
162
|
+
return s, prefix + key
|
|
163
|
+
return '', ''
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def result_task_id():
|
|
167
|
+
"""P0-1/P0-5: task_id from metadata, JSON-as-string output, or the TEXT
|
|
168
|
+
form `<task id="ses_..." state="running">` (the REAL opencode 1.18.x
|
|
169
|
+
background-dispatch format) — always its own field."""
|
|
170
|
+
if not result:
|
|
171
|
+
return ''
|
|
172
|
+
meta = result.get('metadata')
|
|
173
|
+
if isinstance(meta, dict):
|
|
174
|
+
for k in ('task_id', 'taskId'):
|
|
175
|
+
v = meta.get(k)
|
|
176
|
+
if v:
|
|
177
|
+
return prim(v)
|
|
178
|
+
text = '%s %s' % (result.get('title') or '', result.get('output') or '')
|
|
179
|
+
m = re.search(r'"task_id"\s*:\s*"?([^",\s}]+)"?', text)
|
|
180
|
+
if m:
|
|
181
|
+
return m.group(1)
|
|
182
|
+
m = re.search(r'\btask\s+id="(ses_[^"]+)"', text, re.I)
|
|
183
|
+
if m:
|
|
184
|
+
return m.group(1)
|
|
185
|
+
return ''
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def result_state():
|
|
189
|
+
"""P0-1/P0-5: state from metadata, JSON-as-string output, or the TEXT
|
|
190
|
+
form `state="running"` (lowercased)."""
|
|
191
|
+
if not result:
|
|
192
|
+
return ''
|
|
193
|
+
meta = result.get('metadata')
|
|
194
|
+
if isinstance(meta, dict) and meta.get('state'):
|
|
195
|
+
return str(meta['state']).strip().lower()
|
|
196
|
+
text = '%s %s' % (result.get('title') or '', result.get('output') or '')
|
|
197
|
+
m = re.search(r'"state"\s*:\s*"?([^",\s}]+)"?', text)
|
|
198
|
+
if m:
|
|
199
|
+
return m.group(1).strip().lower()
|
|
200
|
+
m = re.search(r'\bstate="(running|queued|pending|scheduled|dispatched)"', text, re.I)
|
|
201
|
+
if m:
|
|
202
|
+
return m.group(1).strip().lower()
|
|
203
|
+
return ''
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# 2. Plugin-derived status (from the real tool result) — trusted signal.
|
|
207
|
+
# P0-1: 'dispatched' (background launch) is a first-class status.
|
|
208
|
+
if not status and plugin_status:
|
|
209
|
+
low = plugin_status.strip().lower()
|
|
210
|
+
if low in ('success', 'failure', 'dispatched'):
|
|
211
|
+
status = low
|
|
212
|
+
source = 'plugin'
|
|
213
|
+
reason = 'plugin-derived status from tool.execute.after result'
|
|
214
|
+
else:
|
|
215
|
+
reason = "plugin status unrecognized: %s" % plugin_status[:80]
|
|
216
|
+
|
|
217
|
+
# 3. Explicit status field inside the tool result (flat, then metadata).
|
|
218
|
+
# P0-1: a state of running/queued/pending marks a background dispatch.
|
|
219
|
+
if not status and result:
|
|
220
|
+
meta = result.get('metadata')
|
|
221
|
+
if isinstance(meta, dict):
|
|
222
|
+
for k in ('state', 'status'):
|
|
223
|
+
v = meta.get(k)
|
|
224
|
+
if v is not None and v is not False:
|
|
225
|
+
if str(v).strip().lower() in BACKGROUND_STATES:
|
|
226
|
+
status = 'dispatched'
|
|
227
|
+
source = 'result.metadata.' + k
|
|
228
|
+
reason = 'background dispatch state in tool result'
|
|
229
|
+
break
|
|
230
|
+
if not status:
|
|
231
|
+
status, source = status_field_in(result, 'result.')
|
|
232
|
+
if not status and isinstance(meta, dict):
|
|
233
|
+
status, source = status_field_in(meta, 'result.metadata.')
|
|
234
|
+
if status:
|
|
235
|
+
reason = "explicit field '%s' in tool result indicates %s" % (source, status)
|
|
236
|
+
|
|
237
|
+
# 4. P0-1: background-dispatch signature — task_id present in the result
|
|
238
|
+
# (metadata or JSON-as-string output) without a terminal status above.
|
|
239
|
+
if not status and result:
|
|
240
|
+
tid = result_task_id()
|
|
241
|
+
state = result_state()
|
|
242
|
+
if tid or state in BACKGROUND_STATES:
|
|
243
|
+
status = 'dispatched'
|
|
244
|
+
source = 'result'
|
|
245
|
+
reason = 'background dispatch: task_id=%s state=%s (completion not observable)' % (
|
|
246
|
+
tid or '-', state or '-')
|
|
247
|
+
|
|
248
|
+
# 5. Explicit status field in tool_input (as before).
|
|
249
|
+
if not status:
|
|
250
|
+
for key in STATUS_KEYS:
|
|
251
|
+
if key in ti and ti[key] not in (None, '', False):
|
|
252
|
+
val = ti[key]
|
|
253
|
+
status, reason = classify(key, val)
|
|
254
|
+
source = key
|
|
255
|
+
if status:
|
|
256
|
+
break
|
|
257
|
+
reason = reason or ''
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
# 6. Strong failure markers in the tool result text.
|
|
261
|
+
if not status and result:
|
|
262
|
+
markers = ('"refused"', '"denied"', '"traceback"', '"exception"',
|
|
263
|
+
'failed to', 'unable to', 'timed out', 'error:')
|
|
264
|
+
if any(m in result_text for m in markers):
|
|
265
|
+
status = 'failure'
|
|
266
|
+
source = 'result'
|
|
267
|
+
reason = 'failure markers found in tool result'
|
|
268
|
+
|
|
269
|
+
# 7. Non-empty result output = real completion evidence.
|
|
270
|
+
if not status and result:
|
|
271
|
+
out = result.get('output')
|
|
272
|
+
if isinstance(out, str) and out.strip():
|
|
273
|
+
status = 'success'
|
|
274
|
+
source = 'result'
|
|
275
|
+
reason = 'tool result output present (real completion evidence)'
|
|
276
|
+
|
|
277
|
+
# 8. Strong failure markers in tool_input (as before).
|
|
278
|
+
if not status:
|
|
279
|
+
text = json.dumps(ti, ensure_ascii=False).lower()
|
|
280
|
+
markers = ('"refused"', '"denied"', '"traceback"', '"exception"',
|
|
281
|
+
'failed to', 'unable to', 'timed out', 'error:')
|
|
282
|
+
if any(m in text for m in markers):
|
|
283
|
+
status = 'failure'
|
|
284
|
+
source = 'tool_input'
|
|
285
|
+
reason = 'failure markers found in tool_input'
|
|
286
|
+
|
|
287
|
+
# 9. Unknown — never fabricated success.
|
|
288
|
+
if not status:
|
|
289
|
+
status = 'unknown'
|
|
290
|
+
keys = ','.join(sorted(ti.keys())) if ti else '(empty)'
|
|
291
|
+
reason = ('no result/status in payload; tool_input keys: %s'
|
|
292
|
+
'; tool_output present: %s') % (keys, 'yes' if result else 'no')
|
|
293
|
+
if not reason and status in ('success', 'failure', 'dispatched'):
|
|
294
|
+
reason = "explicit field '%s' indicates %s" % (source, status) if source else 'env STATUS override'
|
|
295
|
+
|
|
296
|
+
# P0-5: task_id fallback — the TEXT form `<task id="ses_...">` is the REAL
|
|
297
|
+
# background-dispatch format on opencode 1.18.x; derive it standalone so the
|
|
298
|
+
# script emits the id in its own field even without the plugin's extraction.
|
|
299
|
+
# A payload-supplied task_id (plugin-derived) always wins.
|
|
300
|
+
if not task_id and result:
|
|
301
|
+
task_id = result_task_id()
|
|
302
|
+
|
|
303
|
+
# Result snippet for telemetry (kept short — prompts can be huge). This is the
|
|
304
|
+
# free-text blob in its OWN field — it never contaminates agent/status/etc.
|
|
305
|
+
result_snippet = ''
|
|
306
|
+
if result:
|
|
307
|
+
try:
|
|
308
|
+
raw = result.get('output') or result.get('title') or json.dumps(result, ensure_ascii=False)
|
|
309
|
+
result_snippet = str(raw)[:200]
|
|
310
|
+
except Exception:
|
|
311
|
+
result_snippet = ''
|
|
312
|
+
|
|
313
|
+
# P0-5: JSON round-trip replaces the 8-field TSV (a real newline in
|
|
314
|
+
# result_snippet used to shift fields 5-8 into garbage with cut -fN). ONE
|
|
315
|
+
# json.dumps line — result_snippet and reason are additionally newline/tab-
|
|
316
|
+
# collapsed + 200-char truncated so NO delimiter-breaking character can
|
|
317
|
+
# corrupt the handoff or the log line (defense in depth on top of the
|
|
318
|
+
# json.dumps escaping).
|
|
319
|
+
|
|
320
|
+
def clean(v):
|
|
321
|
+
"""Collapse \n\t\r to single spaces; truncate to 200 chars."""
|
|
322
|
+
return re.sub(r'[\n\t\r]+', ' ', str(v))[:200]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
print(json.dumps({
|
|
326
|
+
'agent': prim(agent),
|
|
327
|
+
'status': prim(status),
|
|
328
|
+
'session_id': session_id,
|
|
329
|
+
'result_snippet': clean(result_snippet),
|
|
330
|
+
'delegation_id': delegation_id,
|
|
331
|
+
'task_id': task_id,
|
|
332
|
+
'duration_ms': duration_ms,
|
|
333
|
+
'reason': clean(reason),
|
|
334
|
+
}, ensure_ascii=False))
|
|
335
|
+
PY
|
|
336
|
+
|
|
337
|
+
DERIVED=$(python3 "$TMP_PY" "$TMP_IN" "$ENV_STATUS" "$ENV_AGENT_NAME" 2>/dev/null || printf '{"agent":"unknown","status":"unknown"}')
|
|
338
|
+
|
|
339
|
+
# P0-5: JSON round-trip into the bash variables — NO cut/TSV anywhere. The
|
|
340
|
+
# derivation script emits ONE json.dumps line (snippet/reason already
|
|
341
|
+
# newline-collapsed); a python3 one-liner parses it back into NUL-delimited
|
|
342
|
+
# values; bash reads each field with `IFS= read -r -d ''` so every field
|
|
343
|
+
# arrives intact — empty fields survive (no tab-collapsing), spaces/unicode
|
|
344
|
+
# are not split, and no delimiter-breaking character can shift fields. On
|
|
345
|
+
# python failure the fallback JSON above keeps agent/status honest.
|
|
346
|
+
printf '%s' "$DERIVED" | python3 -c '
|
|
347
|
+
import json
|
|
348
|
+
import sys
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
d = json.load(sys.stdin)
|
|
352
|
+
except Exception:
|
|
353
|
+
d = {}
|
|
354
|
+
for name in ("agent", "status", "session_id", "result_snippet",
|
|
355
|
+
"delegation_id", "task_id", "duration_ms", "reason"):
|
|
356
|
+
sys.stdout.write(str(d.get(name, "")))
|
|
357
|
+
sys.stdout.write("\0")
|
|
358
|
+
' > "$TMP_OUT" || true
|
|
359
|
+
{
|
|
360
|
+
IFS= read -r -d '' AGENT_NAME
|
|
361
|
+
IFS= read -r -d '' STATUS
|
|
362
|
+
IFS= read -r -d '' SESSION_ID
|
|
363
|
+
IFS= read -r -d '' RESULT_SNIPPET
|
|
364
|
+
IFS= read -r -d '' DELEGATION_ID
|
|
365
|
+
IFS= read -r -d '' TASK_ID
|
|
366
|
+
IFS= read -r -d '' DURATION_MS
|
|
367
|
+
IFS= read -r -d '' REASON
|
|
368
|
+
} < "$TMP_OUT" || true
|
|
369
|
+
|
|
370
|
+
# Agent fallback chain (subagent_type -> env -> tool_name -> 'unknown').
|
|
371
|
+
if [ -z "$AGENT_NAME" ]; then
|
|
372
|
+
AGENT_NAME="$ENV_AGENT_NAME"
|
|
373
|
+
fi
|
|
374
|
+
if [ -z "$AGENT_NAME" ]; then
|
|
375
|
+
AGENT_NAME="unknown"
|
|
376
|
+
fi
|
|
377
|
+
|
|
378
|
+
# Legacy positional/env reason fallback (only when the derivation found none).
|
|
379
|
+
if [ -z "$REASON" ]; then
|
|
380
|
+
REASON="${ENV_REASON:-$ARG_REASON}"
|
|
381
|
+
fi
|
|
382
|
+
|
|
383
|
+
# Emit a schema-compatible line: {event, timestamp, agent, status, reason,
|
|
384
|
+
# session_id, result, delegation_id, task_id, duration_ms} — P0-2: the
|
|
385
|
+
# delegation_id joins this record to the matching SubagentStart; task_id and
|
|
386
|
+
# duration_ms ride in their OWN fields. Built with json.dumps so arbitrary
|
|
387
|
+
# reason/result text cannot break the JSON.
|
|
388
|
+
LOG_LINE=$(python3 -c '
|
|
389
|
+
import json
|
|
390
|
+
import sys
|
|
391
|
+
print(json.dumps({
|
|
392
|
+
"event": "SubagentStop",
|
|
393
|
+
"timestamp": sys.argv[1],
|
|
394
|
+
"agent": sys.argv[2],
|
|
395
|
+
"status": sys.argv[3],
|
|
396
|
+
"reason": sys.argv[4],
|
|
397
|
+
"session_id": sys.argv[5],
|
|
398
|
+
"result": sys.argv[6],
|
|
399
|
+
"delegation_id": sys.argv[7],
|
|
400
|
+
"task_id": sys.argv[8],
|
|
401
|
+
"duration_ms": sys.argv[9] or None,
|
|
402
|
+
}, ensure_ascii=False))
|
|
403
|
+
' "$TIMESTAMP" "$AGENT_NAME" "$STATUS" "$REASON" "$SESSION_ID" "$RESULT_SNIPPET" "$DELEGATION_ID" "$TASK_ID" "$DURATION_MS")
|
|
404
|
+
|
|
405
|
+
LOG_FILE="$LOG_DIR/delegations.log"
|
|
406
|
+
if [ "$STATUS" = "failure" ]; then
|
|
407
|
+
LOG_FILE="$LOG_DIR/delegation-failures.log"
|
|
408
|
+
fi
|
|
409
|
+
|
|
410
|
+
echo "$LOG_LINE" >> "$LOG_FILE"
|
|
411
|
+
|
|
412
|
+
echo "[DELEGATION] $AGENT_NAME stopped ($STATUS) delegation=$DELEGATION_ID → $LOG_FILE" >&2
|
|
413
|
+
|
|
414
|
+
# --- Post-condition validation: remind about Themis review ---
|
|
415
|
+
if echo "hermes aphrodite demeter prometheus" | grep -wq "$AGENT_NAME" 2>/dev/null; then
|
|
416
|
+
echo "[POST-CONDITION] 🔔 Reminder: '$AGENT_NAME' should call Themis for review after implementation" >&2
|
|
417
|
+
fi
|
|
418
|
+
|
|
419
|
+
exit 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# run-type-check.sh — Pantheon PostToolUse Type Check Hook
|
|
3
|
+
# Runs mypy (Python) or tsc (TypeScript) if config exists.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Only run if relevant config files exist
|
|
7
|
+
if [[ -f "pyproject.toml" ]] || [[ -f "setup.cfg" ]] || [[ -f ".mypy.ini" ]]; then
|
|
8
|
+
if command -v mypy >/dev/null 2>&1; then
|
|
9
|
+
echo "[TYPE CHECK] Running mypy..." >&2
|
|
10
|
+
mypy . --ignore-missing-imports || true
|
|
11
|
+
fi
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
if [[ -f "tsconfig.json" ]]; then
|
|
15
|
+
if command -v tsc >/dev/null 2>&1; then
|
|
16
|
+
echo "[TYPE CHECK] Running tsc --noEmit..." >&2
|
|
17
|
+
tsc --noEmit || true
|
|
18
|
+
fi
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
exit 0
|