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.
Files changed (37) hide show
  1. package/AGENTS.md +10 -0
  2. package/README.md +53 -15
  3. package/bin/pantheon-init.mjs +14 -0
  4. package/opencode.json +17 -1
  5. package/package.json +13 -4
  6. package/scripts/changelog-extract.mjs +112 -0
  7. package/scripts/doctor.mjs +303 -13
  8. package/scripts/hooks/audit-imports.sh +43 -0
  9. package/scripts/hooks/format-multi-language.sh +96 -0
  10. package/scripts/hooks/log-session-start.sh +42 -0
  11. package/scripts/hooks/on-subagent-delegation-start.sh +201 -0
  12. package/scripts/hooks/on-subagent-delegation-stop.sh +419 -0
  13. package/scripts/hooks/run-type-check.sh +21 -0
  14. package/scripts/hooks/scan-secrets.sh +97 -0
  15. package/scripts/hooks/validate-post-conditions.sh +21 -0
  16. package/scripts/hooks/validate-talos-scope.sh +50 -0
  17. package/scripts/hooks/validate-tool-safety.sh +46 -0
  18. package/scripts/install/opencode.mjs +58 -29
  19. package/scripts/install/plugin.mjs +4 -2
  20. package/scripts/install-mcp.mjs +104 -56
  21. package/scripts/mcp_persistence_server.py +1 -0
  22. package/scripts/prune.mjs +359 -0
  23. package/scripts/version-check.mjs +146 -0
  24. package/scripts/versioning.mjs +13 -14
  25. package/src/agents/zeus.md +33 -5
  26. package/src/mcp/requirements-mcp.txt +8 -5
  27. package/src/mcp/requirements-vision.txt +3 -3
  28. package/src/plugins/hook-runner.ts +186 -0
  29. package/src/plugins/pantheon-hooks.ts +1503 -0
  30. package/src/plugins/tui/dist/tui.d.ts +8 -0
  31. package/src/plugins/tui/dist/tui.d.ts.map +1 -0
  32. package/src/plugins/tui/dist/tui.js +1198 -0
  33. package/src/plugins/tui/dist/tui.js.map +1 -0
  34. package/src/plugins/tui/dist/tui.tsx +1204 -0
  35. package/src/plugins/tui/package.json +2 -2
  36. package/src/plugins/tui/src/index.tsx +16 -1
  37. 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