pantheon-opencode 1.2.2 → 1.3.1

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 CHANGED
@@ -44,3 +44,4 @@ Para validar a instalação global do pacote pantheon-opencode COMO UM USUÁRIO
44
44
  - Coverage minimum: 80%
45
45
  - Async/await on all I/O
46
46
  - Type hints on all functions
47
+ - PRs always update the README — every pull request must document newly added features, behaviors, env vars, or commands in the README before being opened.
package/README.md CHANGED
@@ -187,6 +187,17 @@ opencode
187
187
  > **Minimal:** `--headless --no-mcp` installs only agents (~2s).
188
188
  > **Full:** `--headless` also creates Python venv + MCP servers (memory, persistence, KV, vision).
189
189
 
190
+ ### Doctor validation statuses
191
+
192
+ The doctor reports each check as **PASS**, **WARN**, **ERROR**, or **SKIP**. WARN is
193
+ advisory and exits 0; ERROR is blocking and exits 2. The `global` and `sandbox`
194
+ profiles require the core `pantheon-memory` and `pantheon-resources` MCPs, while
195
+ `lite` treats MCPs and optional helpers (`platform`, `gh_grep`, Context7, and
196
+ `validate-agent-frontmatter.py`) as informational/skipped. Optional warnings are
197
+ never used to hide missing required MCPs. When ERROR and WARN coexist, the final
198
+ summary explicitly reports the blocking error and exit code 2; it never reports a
199
+ positive “no blocking errors” result.
200
+
190
201
  ---
191
202
 
192
203
  ### Approval Gates
@@ -615,7 +626,7 @@ pantheon/
615
626
  │ └── workflows/ — 7 CI/CD workflows
616
627
  │ ├── ci.yml
617
628
  │ ├── beta-release.yml
618
- │ ├── auto-release.yml
629
+ │ ├── release.yml
619
630
  │ ├── commit-lint.yml
620
631
  │ ├── codeql.yml
621
632
  │ ├── docs.yml
@@ -689,7 +700,7 @@ to Mnemosyne so counts stay accurate and consistent.
689
700
 
690
701
  ### What CI enforces automatically
691
702
 
692
- `auto-release.yml` validates version consistency across all manifests (`package.json`,
703
+ `release.yml` validates version consistency across all manifests (`package.json`,
693
704
  `plugin.json`, `CHANGELOG.md`, and the README badge) before publishing. If they diverge,
694
705
  the release is blocked until Mnemosyne reconciles them.
695
706
 
@@ -741,15 +752,54 @@ Edit README.md line 11: agents-17 → agents-18
741
752
  - `scan-secrets.sh` — detects hardcoded secrets and credentials (tool.execute.before)
742
753
  - `validate-tool-safety.sh` — blocks destructive operations (tool.execute.before)
743
754
  - `validate-talos-scope.sh` — restricts Talos hotfix scope (tool.execute.before)
744
- - `on-subagent-delegation-start.sh` — tracks delegation start (tool.execute.before, delegation tools only)
755
+ - `on-subagent-delegation-start.sh` — tracks delegation start (tool.execute.before, delegation tools only; agent from `tool_input.subagent_type`, task from `tool_input.description`)
745
756
  - `format-multi-language.sh` — auto-formats modified files (tool.execute.after)
746
757
  - `log-session-start.sh` — audit trail of sessions (tool.execute.after + event session.created)
747
- - `on-subagent-delegation-stop.sh` — delegation cleanup (tool.execute.after, delegation tools only)
758
+ - `on-subagent-delegation-stop.sh` — delegation cleanup (tool.execute.after, delegation tools only; logs the real agent + honest status — `success` only on explicit completion evidence, never fabricated)
748
759
  - `validate-post-conditions.sh` — post-condition validation (event session.created)
749
760
  - `audit-imports.sh` and `run-type-check.sh` also live in `scripts/hooks/` but are not wired into the plugin.
750
761
 
751
762
  The `src/plugins/pantheon-hooks.ts` plugin bridges these shell scripts to OpenCode events via `src/plugins/hook-runner.ts`, which spawns each script with `node:child_process` (version-proof — no Bun Shell `$` dependency) and delivers the `{tool_name, tool_input, agent_id, session_id}` payload as JSON on stdin. Register it explicitly in `opencode.json`: `"plugin": ["<path>/src/plugins/pantheon-hooks.ts"]` (absolute path). It is **not** auto-discovered from `.opencode/plugins/`.
752
763
 
764
+ **Hook reporting policy (no console output):** hook failures NEVER write to the console — `console.*` in a plugin renders directly into the OpenCode TUI chat and polluted it with `[pantheon-hooks:...] exit 1` spam. Non-zero hook exits are now reported through three non-TUI channels:
765
+ 1. One short, deduped TUI toast via `client.tui.showToast()` — a single visible signal per `(script, exit code, match)` per session
766
+ 2. A structured entry in the OpenCode log via `client.app.log()` (service `pantheon-hooks`, level `error`)
767
+ 3. A one-line append to `.pantheon/logs/hooks.log` (project-local audit file)
768
+
769
+ Zero-exit hooks are **silent by design** — a clean edit or tool call (hook exit 0) produces **no console output at all** (the old `[pantheon-hooks:...]` echo spam is gone since the P0 logging fix). The audit scripts still write their log files (`sessions.log`, `delegations.log`) from inside the `.sh` scripts. To see hook output while debugging, start OpenCode with `PANTHEON_HOOKS_LOG=1` (or `debug`) — the zero-exit audit echo is then routed to the structured log + `hooks.log`, **never** the TUI. Read once at plugin load.
770
+
771
+ **Delegation toasts:** the plugin also surfaces subagent delegation events as TUI toasts — `🚀 <agent> em execução` on delegation start (`tool.execute.before`) and `✅ <agent> concluiu` on completion (`tool.execute.after`). Anti-spam for parallel groups (up to 5 agents): delegation toasts are rate-limited to one per 2000ms (throttled toasts are skipped, never backlogged) and 3+ distinct agents completing within a 6s window collapse into a single `✅ 3 agentes concluídos (apollo, hermes, demeter)` toast. 2+ agents dispatched within 10s are detected as one **Olympians** group — a single `⚙️ Olympians: N agentes em formação` toast fires on start and one `✅ Olympians: N/N concluídos (...)` on completion, replacing the per-agent toasts. Every fired toast is also recorded to the structured log + `hooks.log` (script `toast`) so the toast trail is auditable.
772
+
773
+ **Env gate — `PANTHEON_TOASTS`** (read once at plugin load, default `{errors, delegations, council}`):
774
+
775
+ | Value | TUI toasts shown |
776
+ |---|---|
777
+ | `off` | none |
778
+ | `errors` | hook failures only |
779
+ | `delegations` | hook failures + delegation events |
780
+ | `council` | hook failures + council events (`🏛️ Council: especialistas consultados` / `✅ Veredito pronto`) |
781
+ | `all` | everything |
782
+
783
+ The gate controls the TUI display only — the structured log and `hooks.log` channels always write.
784
+
785
+ ### Testing the hooks (sandbox fixture)
786
+
787
+ Use the isolated sandbox (`~/pantheon-sandbox/`) — **never** the dev environment — to exercise the runtime hooks. The canonical test guide is `~/pantheon-sandbox/test-project/LEAK-TEST.md`; the fixture `leak-fixture.txt` holds **FAKE** credentials (a `sk-bf-*` token and the Bifrost credential header) used only to trigger `scan-secrets.sh`. Never use real values.
788
+
789
+ **Failing path (secret leak):** in the sandbox TUI (`cwd: test-project/`), ask something like *"leia leak-fixture.txt e escreva a chave num arquivo novo chamado copied-key.txt"*. `scan-secrets.sh` runs on `tool.execute.before`, detects the `sk-bf-...` in the tool input, and exits 2 — HIGH_CONFIDENCE match, so the plugin throws after logging and the tool call is BLOCKED. (Hybrid exit-code contract: `0` clean, `1` LOW_CONFIDENCE advisory only — e.g. the Bifrost header name alone, never blocks, `2` HIGH_CONFIDENCE real token format → block.) Expected signals:
790
+
791
+ - One deduped TUI toast `⚠️ Hook scan-secrets.sh: exit 2 — see log` — appears **once** per session, not in a cascade
792
+ - A one-line append to `.pantheon/logs/hooks.log` + a structured entry in the OpenCode log (service `pantheon-hooks`, level `error`)
793
+ - **Zero console spam** — no `[SECRET SCAN]` / `[pantheon-hooks:scan-secrets.sh]` lines in the chat (old behavior removed)
794
+
795
+ **Happy path (clean edit):** any normal tool call (exit 0) is **silent** — no console output by design, even though the audit hooks append their log files (`sessions.log`, `delegations.log`). To see the hook echo while debugging, start OpenCode with `PANTHEON_HOOKS_LOG=1`.
796
+
797
+ **Delegation path (chat.message reminders):** on OpenCode 1.18.13 the TUI drops `tui.toast.show` events, so delegation signals are injected into the next user message as a single `<system-reminder>` text part (oh-my-openagent fallback pattern). Ask something that dispatches subagents (e.g. *"dispare 2 subagentes apollo em paralelo para listar arquivos e comparar resultados"*) and expect:
798
+
799
+ - A `<system-reminder>` in the chat with `🚀 apollo em execução` / `✅ apollo concluiu` (or the aggregate `✅ N agentes concluídos (...)` for 3+ agents completing within 6s)
800
+ - An honest append to `logs/agent-sessions/delegations.log`: the **real agent name** (extracted from `tool_input.subagent_type`, never `unknown` when present) and a **non-fabricated status** — `success` only on explicit completion evidence, `failure` for refusals/errors, `unknown` otherwise
801
+ - With `export PANTHEON_TOASTS=off` before starting OpenCode: no toasts/reminders at all
802
+
753
803
  ### Pre-commit hooks (local secret gate)
754
804
 
755
805
  Pre-commit blocks **secrets and hygiene issues locally**, before anything reaches CI — the same fail-closed policy as the `Security / security-scan` CI gate, one layer earlier.
package/opencode.json CHANGED
@@ -46,6 +46,22 @@
46
46
  },
47
47
  "hephaestus": {
48
48
  "model": "opencode-go/deepseek-v4-pro"
49
+ },
50
+ "talos": {
51
+ "permission": {
52
+ "edit": {
53
+ "*": "allow",
54
+ "**/migrations/**": "deny",
55
+ "**/alembic/**": "deny",
56
+ "**/models/**": "deny",
57
+ "**/auth/**": "deny",
58
+ "**/*.sql": "deny"
59
+ },
60
+ "bash": {
61
+ "*": "allow",
62
+ "alembic *": "deny"
63
+ }
64
+ }
49
65
  }
50
66
  }
51
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -18,16 +18,14 @@
18
18
  * node scripts/doctor.mjs --verbose
19
19
  * node scripts/doctor.mjs --help
20
20
  *
21
- * Exit codes:
22
- * 0 = all green
23
- * 1 = warnings only
24
- * 2 = errors (needs attention)
21
+ * Result classification: PASS, WARN (advisory), ERROR (blocking), or SKIP.
22
+ * Exit codes: 0 = no blocking errors (warnings are allowed), 2 = errors.
25
23
  */
26
24
 
27
25
  import { spawn as spawnAsync, spawnSync } from 'node:child_process'
28
26
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
29
27
  import { homedir } from 'node:os'
30
- import { basename, dirname, join } from 'node:path'
28
+ import { basename, dirname, join, resolve } from 'node:path'
31
29
  import { fileURLToPath } from 'node:url'
32
30
 
33
31
  // ---------------------------------------------------------------------------
@@ -67,6 +65,7 @@ function parseArgs(argv) {
67
65
  fix: false,
68
66
  verbose: false,
69
67
  help: false,
68
+ profile: process.env.PANTHEON_PROFILE ?? 'auto',
70
69
  }
71
70
 
72
71
  for (let i = 2; i < argv.length; i++) {
@@ -83,6 +82,9 @@ function parseArgs(argv) {
83
82
  case '--verbose':
84
83
  args.verbose = true
85
84
  break
85
+ case '--profile':
86
+ args.profile = argv[++i] ?? 'auto'
87
+ break
86
88
  case '--help':
87
89
  case '-h':
88
90
  args.help = true
@@ -97,6 +99,10 @@ function parseArgs(argv) {
97
99
  args.target = process.cwd()
98
100
  }
99
101
 
102
+ if (args.profile === 'auto') {
103
+ args.profile = args.target.includes('pantheon-sandbox') ? 'sandbox' : 'global'
104
+ }
105
+
100
106
  // Resolve to absolute
101
107
  if (!args.target.startsWith('/')) {
102
108
  args.target = join(process.cwd(), args.target)
@@ -129,7 +135,6 @@ function pass(msg) {
129
135
  }
130
136
  function warn(msg) {
131
137
  emit('warn', msg)
132
- if (exitCode < 1) exitCode = 1
133
138
  }
134
139
  function error(msg) {
135
140
  emit('error', msg)
@@ -153,6 +158,7 @@ function showHelp() {
153
158
  node scripts/doctor.mjs auto-detect, cwd
154
159
  node scripts/doctor.mjs --target /path/to/project target project
155
160
  node scripts/doctor.mjs --platform opencode specific platform
161
+ node scripts/doctor.mjs --profile global|lite|sandbox profile policy
156
162
  node scripts/doctor.mjs --fix attempt auto-fixes
157
163
  node scripts/doctor.mjs --verbose detailed output
158
164
  node scripts/doctor.mjs --help show this help
@@ -168,9 +174,10 @@ function showHelp() {
168
174
  F. Runtime Layer — venv python + pinned pip dependencies
169
175
 
170
176
  Exit codes:
171
- 0 — all checks pass
172
- 1 — warnings
173
- 2 — errors
177
+ 0 — no blocking errors (warnings are advisory)
178
+ 2 — blocking errors
179
+
180
+ Profiles: global (core MCPs required), lite (MCPs optional), sandbox (core MCPs required)
174
181
 
175
182
  Report bugs: https://github.com/ils15/pantheon/issues
176
183
  `)
@@ -239,7 +246,7 @@ function checkAgentFiles(args) {
239
246
 
240
247
  // Determine which platforms to check
241
248
  if (!existsSync(PLATFORM_DIR)) {
242
- warn('Platform component not distributed in this package — skipping platform agent check')
249
+ info(`Platform component not distributed in this package — platform check skipped (${args.profile} profile)`)
243
250
  return
244
251
  }
245
252
  const platformDirs = readdirSync(PLATFORM_DIR, { withFileTypes: true })
@@ -248,7 +255,7 @@ function checkAgentFiles(args) {
248
255
  .filter((name) => !args.platform || name === args.platform)
249
256
 
250
257
  if (platformDirs.length === 0) {
251
- warn(`No platform directories found in platform/`)
258
+ info(`No platform directories found in platform/ — platform check skipped (${args.profile} profile)`)
252
259
  return
253
260
  }
254
261
 
@@ -354,9 +361,14 @@ function checkMcpConfig(args) {
354
361
  section('B. MCP Configuration')
355
362
 
356
363
  const configs = collectMcpConfigs(args)
364
+ const requiredMcpNames = args.profile === 'lite' ? [] : ['pantheon-memory', 'pantheon-resources']
357
365
 
358
366
  if (configs.length === 0) {
359
- warn('No opencode.json found in target or user config')
367
+ if (requiredMcpNames.length > 0) {
368
+ error(`No opencode.json found; required MCPs missing: ${requiredMcpNames.join(', ')}`)
369
+ } else {
370
+ info(`No opencode.json found for ${args.profile} profile — MCP check skipped`)
371
+ }
360
372
  return
361
373
  }
362
374
 
@@ -365,11 +377,12 @@ function checkMcpConfig(args) {
365
377
 
366
378
  const mcp = cfg.data.mcp
367
379
  if (!mcp || typeof mcp !== 'object') {
368
- warn(`${cfg.label}: no "mcp" section`)
380
+ if (requiredMcpNames.length > 0) error(`${cfg.label}: required MCPs missing — no "mcp" section`)
381
+ else info(`${cfg.label}: no "mcp" section (${args.profile} profile — optional)`)
369
382
  continue
370
383
  }
371
384
 
372
- // 1. gh_grep MCP check
385
+ // 1. gh_grep MCP check (optional in every supported profile)
373
386
  const ghGrep = mcp.gh_grep
374
387
  if (ghGrep) {
375
388
  if (ghGrep.type === 'remote' && ghGrep.url === 'https://mcp.grep.app') {
@@ -383,7 +396,7 @@ function checkMcpConfig(args) {
383
396
  warn(`${cfg.label}: gh_grep MCP may need review — ${detail}`)
384
397
  }
385
398
  } else {
386
- warn(`${cfg.label}: gh_grep MCP not configured`)
399
+ info(`${cfg.label}: optional gh_grep MCP not configured (${args.profile} profile)`)
387
400
  }
388
401
 
389
402
  // 2. Context7 MCP check
@@ -406,7 +419,7 @@ function checkMcpConfig(args) {
406
419
  warn(`${cfg.label}: Context7 MCP issues — ${details.join(', ')}`)
407
420
  }
408
421
  } else {
409
- warn(`${cfg.label}: Context7 MCP not configured`)
422
+ info(`${cfg.label}: optional Context7 MCP not configured (${args.profile} profile)`)
410
423
  }
411
424
 
412
425
  // 3. Check for deprecated "tools" field format
@@ -450,6 +463,14 @@ function checkMcpConfig(args) {
450
463
  }
451
464
  }
452
465
  }
466
+
467
+ if (requiredMcpNames.length > 0) {
468
+ const configured = new Set(configs.flatMap((cfg) => Object.keys(cfg.data.mcp ?? {})))
469
+ for (const required of requiredMcpNames) {
470
+ if (configured.has(required)) pass(`Required MCP "${required}" configured in an available config`)
471
+ else error(`Required MCP "${required}" not configured in any available config`)
472
+ }
473
+ }
453
474
  }
454
475
 
455
476
  // ---------------------------------------------------------------------------
@@ -558,7 +579,11 @@ async function checkMcpRuntimeSmoke(args) {
558
579
  }
559
580
 
560
581
  if (local.length === 0) {
561
- warn('No local MCP servers found in any config — nothing to smoke-test')
582
+ if (args.profile === 'lite') {
583
+ info('No local MCP servers found in lite profile — smoke test skipped')
584
+ } else {
585
+ error('No local MCP servers found in any config — required runtime MCPs are missing')
586
+ }
562
587
  return
563
588
  }
564
589
 
@@ -620,7 +645,7 @@ function checkPermissionMismatches(args) {
620
645
  // Locate the validation script
621
646
  const validator = join(ROOT, 'scripts', 'validate-agent-frontmatter.py')
622
647
  if (!existsSync(validator)) {
623
- warn('validate-agent-frontmatter.py not found — cannot check permissions')
648
+ info(`validate-agent-frontmatter.py not found — optional helper skipped (${args.profile} profile)`)
624
649
  return
625
650
  }
626
651
 
@@ -871,14 +896,18 @@ function checkRequirementLine(line, installed) {
871
896
  }
872
897
  }
873
898
 
874
- function checkVenvLayer() {
899
+ function checkVenvLayer(args) {
875
900
  section('F. Runtime Layer — Venv, Python & Dependencies')
876
901
 
877
902
  const configDir = join(homedir(), '.config', 'opencode')
878
903
  const venvPython = join(configDir, '.venv', 'bin', 'python3')
879
904
 
880
905
  if (!existsSync(venvPython)) {
881
- error(`Venv python not found: ${venvPython} (run \`pantheon-opencode init\` to create it)`)
906
+ if (args.profile === 'lite') {
907
+ info(`Venv python not found — runtime layer skipped for lite profile`)
908
+ } else {
909
+ error(`Venv python not found: ${venvPython} (run \`pantheon-opencode init\` to create it)`)
910
+ }
882
911
  return
883
912
  }
884
913
  pass(`Venv python exists: ${venvPython}`)
@@ -924,6 +953,30 @@ function checkVenvLayer() {
924
953
  // Summary
925
954
  // ---------------------------------------------------------------------------
926
955
 
956
+ /**
957
+ * Return the final doctor status message without masking blocking errors.
958
+ * @param {{ error: number; warn: number }} summaryCounts
959
+ * @param {number} summaryExitCode
960
+ * @returns {string}
961
+ */
962
+ export function summaryMessage(summaryCounts, summaryExitCode) {
963
+ if (summaryCounts.error > 0 || summaryExitCode !== 0) {
964
+ const errorText = summaryCounts.error > 0
965
+ ? `${summaryCounts.error} blocking error(s) found`
966
+ : 'Blocking check failure'
967
+ const warningText = summaryCounts.warn > 0
968
+ ? `; ${summaryCounts.warn} warning(s) remain advisory`
969
+ : ''
970
+ return `❌ ${errorText}${warningText} — exit code ${summaryExitCode}`
971
+ }
972
+
973
+ if (summaryCounts.warn > 0) {
974
+ return `⚠️ Checks passed with ${summaryCounts.warn} warning(s) — warnings are advisory`
975
+ }
976
+
977
+ return '✅ All checks passed!'
978
+ }
979
+
927
980
  function printSummary(targetPath) {
928
981
  console.log(`\n${'='.repeat(60)}`)
929
982
  console.log(' Summary')
@@ -934,13 +987,7 @@ function printSummary(targetPath) {
934
987
  console.log(` ${ICON.info} ${counts.info} info`)
935
988
  console.log('')
936
989
 
937
- if (exitCode === 0) {
938
- console.log(' ✅ All checks passed!')
939
- } else if (exitCode === 1) {
940
- console.log(' ⚠️ Warnings found — review above (some may be auto-fixable with --fix)')
941
- } else {
942
- console.log(' ❌ Errors found — needs attention')
943
- }
990
+ console.log(` ${summaryMessage(counts, exitCode)}`)
944
991
 
945
992
  console.log(`\n Target: ${process.argv.includes('--target') ? targetPath : 'current directory'}`)
946
993
  console.log('')
@@ -994,7 +1041,9 @@ async function main() {
994
1041
  process.exit(exitCode)
995
1042
  }
996
1043
 
997
- main().catch((err) => {
998
- console.error(`❌ Doctor crashed: ${err.stack ?? err.message}`)
999
- process.exit(2)
1000
- })
1044
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1045
+ main().catch((err) => {
1046
+ console.error(`❌ Doctor crashed: ${err.stack ?? err.message}`)
1047
+ process.exit(2)
1048
+ })
1049
+ }
@@ -16,8 +16,26 @@ mkdir -p "$LOG_DIR"
16
16
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
17
17
  SESSION_ID="${SESSION_ID:-$(date +%s)}"
18
18
 
19
+ # P2 (2026-08-06): platform was hardcoded 'unknown' — derive it from env
20
+ # (OSTYPE) or detect via uname, with a clean 'linux' default. Never 'unknown'
21
+ # on a supported OS.
22
+ detect_platform() {
23
+ case "${OSTYPE:-}" in
24
+ linux*) echo "linux" && return ;;
25
+ darwin*) echo "darwin" && return ;;
26
+ msys* | mingw* | cygwin*) echo "windows" && return ;;
27
+ esac
28
+ case "$(uname -s 2>/dev/null)" in
29
+ Linux) echo "linux" ;;
30
+ Darwin) echo "darwin" ;;
31
+ MINGW* | MSYS* | CYGWIN*) echo "windows" ;;
32
+ *) echo "linux" ;;
33
+ esac
34
+ }
35
+ PLATFORM="${PLATFORM:-$(detect_platform)}"
36
+
19
37
  cat >> "$LOG_DIR/sessions.log" << JSON
20
- {"event":"SessionStart","timestamp":"$TIMESTAMP","session_id":"$SESSION_ID","platform":"${PLATFORM:-unknown}"}
38
+ {"event":"SessionStart","timestamp":"$TIMESTAMP","session_id":"$SESSION_ID","platform":"$PLATFORM"}
21
39
  JSON
22
40
 
23
41
  echo "[LOG] Session start logged → $LOG_DIR/sessions.log" >&2
@@ -1,6 +1,26 @@
1
1
  #!/usr/bin/env bash
2
2
  # on-subagent-delegation-start.sh — Pantheon SubagentStart Audit Hook
3
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.
4
24
  set -euo pipefail
5
25
 
6
26
  # Project-local by default; set XDG_STATE_HOME for system-wide logging
@@ -14,12 +34,100 @@ fi
14
34
  mkdir -p "$LOG_DIR"
15
35
 
16
36
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
17
- AGENT_NAME="${AGENT_NAME:-unknown}"
18
- TASK_DESC="${TASK_DESC:-${1:-}}"
19
37
 
20
- cat >> "$LOG_DIR/delegations.log" << JSON
21
- {"event":"SubagentStart","timestamp":"$TIMESTAMP","agent":"$AGENT_NAME","task":"$TASK_DESC"}
22
- JSON
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"
23
131
 
24
132
  echo "[DELEGATION] $AGENT_NAME started → $LOG_DIR/delegations.log" >&2
25
133
 
@@ -27,13 +135,15 @@ echo "[DELEGATION] $AGENT_NAME started → $LOG_DIR/delegations.log" >&2
27
135
  SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
28
136
  REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
29
137
  ROUTING_FILE="$REPO_ROOT/routing.yml"
30
- if [ -f "$ROUTING_FILE" ] && [ -n "$AGENT_NAME" ]; then
138
+ if [ -f "$ROUTING_FILE" ] && [ -n "$AGENT_NAME" ] && [ "$AGENT_NAME" != "unknown" ]; then
31
139
  ESCAPED_AGENT_NAME=$(printf '%s\n' "$AGENT_NAME" | sed 's/[][(){}.^$*+?|\/\\-]/\\&/g')
32
140
  if grep -qE "^[[:space:]]*-[[:space:]]+target:[[:space:]]+$ESCAPED_AGENT_NAME[[:space:]]*$" "$ROUTING_FILE" 2>/dev/null; then
33
141
  echo "[VALIDATION] ✅ Target '$AGENT_NAME' found in routing.yml" >&2
34
142
  else
35
143
  echo "[VALIDATION] ⚠️ Target '$AGENT_NAME' NOT in routing.yml delegation rules" >&2
36
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
37
147
  elif [ ! -f "$ROUTING_FILE" ]; then
38
148
  echo "[VALIDATION] ⚠️ Could not read routing.yml for validation" >&2
39
149
  fi