pantheon-opencode 1.2.0 → 1.2.2

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 (63) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +112 -15
  3. package/bin/pantheon-init.mjs +93 -0
  4. package/commands/pantheon-vision.md +67 -0
  5. package/opencode.json +14 -10
  6. package/package.json +20 -4
  7. package/scripts/_pantheon_paths.py +0 -2
  8. package/scripts/changelog-extract.mjs +112 -0
  9. package/scripts/doctor.mjs +307 -15
  10. package/scripts/hooks/audit-imports.sh +43 -0
  11. package/scripts/hooks/format-multi-language.sh +96 -0
  12. package/scripts/hooks/log-session-start.sh +24 -0
  13. package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
  14. package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
  15. package/scripts/hooks/run-type-check.sh +21 -0
  16. package/scripts/hooks/scan-secrets.sh +55 -0
  17. package/scripts/hooks/validate-post-conditions.sh +21 -0
  18. package/scripts/hooks/validate-talos-scope.sh +43 -0
  19. package/scripts/hooks/validate-tool-safety.sh +46 -0
  20. package/scripts/install/health-check.mjs +101 -1
  21. package/scripts/install/model-picker.mjs +118 -0
  22. package/scripts/install/opencode.mjs +155 -43
  23. package/scripts/install/plugin.mjs +4 -2
  24. package/scripts/install/venv.mjs +25 -2
  25. package/scripts/install-mcp.mjs +170 -37
  26. package/scripts/mcp_persistence_server.py +2 -1
  27. package/scripts/prune.mjs +359 -0
  28. package/scripts/secret-scan.mjs +88 -0
  29. package/scripts/uninstall.mjs +3 -3
  30. package/scripts/validate-routing.mjs +15 -0
  31. package/scripts/version-check.mjs +146 -0
  32. package/scripts/versioning.mjs +13 -14
  33. package/src/agents/hermes.md +2 -2
  34. package/src/agents/mnemosyne.md +1 -1
  35. package/src/agents/zeus.md +1 -1
  36. package/src/mcp/_pantheon_paths.py +0 -2
  37. package/src/mcp/install-mcp.mjs +6 -913
  38. package/src/mcp/mcp_persistence_server.py +2 -1
  39. package/src/mcp/pantheon_vision_server.py +495 -0
  40. package/src/mcp/requirements-mcp.txt +8 -5
  41. package/src/mcp/requirements-vision.txt +3 -0
  42. package/src/pantheon/auto-wake.ts +91 -0
  43. package/src/pantheon/background-job-board.ts +568 -0
  44. package/src/pantheon/file-persistence.ts +95 -0
  45. package/src/pantheon/presets.d.mts +108 -0
  46. package/src/pantheon/presets.mjs +554 -0
  47. package/src/pantheon/vision.ts +1437 -0
  48. package/src/plugin.ts +69 -25
  49. package/src/plugins/hook-runner.ts +186 -0
  50. package/src/plugins/pantheon-hooks.ts +153 -0
  51. package/src/plugins/tui/dist/tui.d.ts +1 -1
  52. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  53. package/src/plugins/tui/dist/tui.js +812 -223
  54. package/src/plugins/tui/dist/tui.js.map +1 -1
  55. package/src/plugins/tui/dist/tui.tsx +881 -121
  56. package/src/plugins/tui/package.json +3 -3
  57. package/src/plugins/tui/src/index.tsx +881 -121
  58. package/src/routing.yml +148 -0
  59. package/src/skills/context-compression/SKILL.md +1 -1
  60. package/src/skills/loop-engineering/SKILL.md +1 -1
  61. package/src/skills/orchestration-workflow/SKILL.md +11 -11
  62. package/scripts/scrub_secrets.py +0 -281
  63. package/src/mcp/requirements-mcp-core.txt +0 -5
@@ -24,9 +24,10 @@
24
24
  * 2 = errors (needs attention)
25
25
  */
26
26
 
27
- import { spawnSync } from 'node:child_process'
27
+ import { spawn as spawnAsync, spawnSync } from 'node:child_process'
28
28
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
29
- import { dirname, join } from 'node:path'
29
+ import { homedir } from 'node:os'
30
+ import { basename, dirname, join } from 'node:path'
30
31
  import { fileURLToPath } from 'node:url'
31
32
 
32
33
  // ---------------------------------------------------------------------------
@@ -44,8 +45,6 @@ const _ALL_PLATFORMS = ['opencode']
44
45
  const PLATFORM_LABELS = {
45
46
  opencode: 'OpenCode',
46
47
  }
47
- opencode: 'OpenCode',
48
- }
49
48
 
50
49
  // ---------------------------------------------------------------------------
51
50
  // State
@@ -160,10 +159,13 @@ function showHelp() {
160
159
 
161
160
  Checks:
162
161
  A. Agent Files — canonical vs platform sync
163
- B. MCP Configuration — opencode.json MCP settings
162
+ B. MCP Configuration — opencode.json MCP settings (Config layer)
163
+ B.5 MCP Spawn Paths — local MCP exe/args resolve to existing files
164
+ B.6 MCP Runtime Smoke — spawn each local MCP + JSON-RPC initialize handshake
164
165
  C. Permission Checks — frontmatter validation
165
166
  D. Sync Status — npm run sync freshness
166
167
  E. Git Status — uncommitted changes
168
+ F. Runtime Layer — venv python + pinned pip dependencies
167
169
 
168
170
  Exit codes:
169
171
  0 — all checks pass
@@ -236,6 +238,10 @@ function checkAgentFiles(args) {
236
238
  pass(`${canonical.length} canonical agents found in agents/`)
237
239
 
238
240
  // Determine which platforms to check
241
+ if (!existsSync(PLATFORM_DIR)) {
242
+ warn('Platform component not distributed in this package — skipping platform agent check')
243
+ return
244
+ }
239
245
  const platformDirs = readdirSync(PLATFORM_DIR, { withFileTypes: true })
240
246
  .filter((d) => d.isDirectory() && d.name !== '_template' && d.name !== 'plans')
241
247
  .map((d) => d.name)
@@ -312,12 +318,12 @@ function checkAgentFiles(args) {
312
318
  // Check B: MCP Configuration
313
319
  // ---------------------------------------------------------------------------
314
320
 
315
- function checkMcpConfig(args) {
316
- section('B. MCP Configuration')
317
-
318
- // Determine which opencode.json to check:
319
- // 1. Target project's opencode.json
320
- // 2. Pantheon ROOT opencode.json
321
+ /**
322
+ * Collect every reachable opencode.json (project → repository → user config)
323
+ * with its parsed content, for the config, spawn-path and runtime layers.
324
+ * @returns {{label:string, path:string, data:object}[]}
325
+ */
326
+ function collectMcpConfigs(args) {
321
327
  const candidates = [
322
328
  { label: 'project root', path: join(args.target, 'opencode.json') },
323
329
  { label: 'repository root', path: join(ROOT, 'opencode.json') },
@@ -334,7 +340,6 @@ function checkMcpConfig(args) {
334
340
  candidates.push({ label: 'user config', path: homeConfig })
335
341
  }
336
342
 
337
- /** @type {{label:string, data:object}[]} */
338
343
  const configs = []
339
344
  for (const c of candidates) {
340
345
  const data = readJson(c.path)
@@ -342,6 +347,13 @@ function checkMcpConfig(args) {
342
347
  configs.push({ label: c.label, data, path: c.path })
343
348
  }
344
349
  }
350
+ return configs
351
+ }
352
+
353
+ function checkMcpConfig(args) {
354
+ section('B. MCP Configuration')
355
+
356
+ const configs = collectMcpConfigs(args)
345
357
 
346
358
  if (configs.length === 0) {
347
359
  warn('No opencode.json found in target or user config')
@@ -410,6 +422,155 @@ function checkMcpConfig(args) {
410
422
  if (args.verbose) {
411
423
  checkAgentMcpReferences(cfg)
412
424
  }
425
+
426
+ // 5. Hermetic spawn check — every local MCP command must resolve to an
427
+ // existing executable and existing absolute script paths. Catches the
428
+ // ENOENT-on-spawn failure class (dead hardcoded path, ambiguous relative
429
+ // path) before opencode tries to spawn the server.
430
+ for (const [name, mcpEntry] of Object.entries(mcp)) {
431
+ if (!mcpEntry || mcpEntry.type !== 'local') continue
432
+ const cmd = mcpEntry.command
433
+ if (!Array.isArray(cmd) || cmd.length === 0) {
434
+ warn(`${cfg.label}: MCP "${name}" has no command array`)
435
+ continue
436
+ }
437
+ const exe = cmd[0]
438
+ if (!exe.includes('/')) {
439
+ // PATH-resolved executable — cannot verify statically; surface for manual check
440
+ info(`${cfg.label}: MCP "${name}" uses PATH executable "${exe}" (verify with \`which ${exe}\`)`)
441
+ continue
442
+ }
443
+ if (!existsSync(exe)) {
444
+ error(`${cfg.label}: MCP "${name}" executable does not exist: ${exe}`)
445
+ continue
446
+ }
447
+ const deadArgs = cmd.slice(1).filter((a) => a.startsWith('/') && !existsSync(a))
448
+ if (deadArgs.length > 0) {
449
+ error(`${cfg.label}: MCP "${name}" has missing script path(s): ${deadArgs.join(', ')}`)
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Check B.6: MCP Runtime Smoke (JSON-RPC initialize handshake)
457
+ // ---------------------------------------------------------------------------
458
+ // The static spawn-path check (B.5) proves the files exist; this layer proves
459
+ // the server actually STARTS and answers the JSON-RPC initialize handshake.
460
+ // Catches ENOENT-on-spawn, Python import errors and runtime crashes that the
461
+ // static check cannot see — with a hard timeout so a hung server fails the
462
+ // check instead of blocking the doctor run.
463
+
464
+ /**
465
+ * Spawn one local MCP server, send a JSON-RPC `initialize` request and wait
466
+ * for a well-formed response.
467
+ * @param {string[]} command - argv of the MCP server (exe + args)
468
+ * @param {string} cwd - working directory (relative script args resolve here)
469
+ * @param {number} timeoutMs - hard timeout for the handshake
470
+ * @returns {Promise<{ok:boolean, serverName?:string, reason?:string}>}
471
+ */
472
+ function mcpInitializeSmoke(command, cwd, timeoutMs) {
473
+ const payload = JSON.stringify({
474
+ jsonrpc: '2.0',
475
+ id: 1,
476
+ method: 'initialize',
477
+ params: {
478
+ protocolVersion: '2024-11-05',
479
+ capabilities: {},
480
+ clientInfo: { name: 'pantheon-doctor', version: '0.0.0' },
481
+ },
482
+ })
483
+ return new Promise((resolve) => {
484
+ let proc
485
+ try {
486
+ proc = spawnAsync(command[0], command.slice(1), { cwd, stdio: ['pipe', 'pipe', 'pipe'] })
487
+ } catch (err) {
488
+ resolve({ ok: false, reason: `spawn threw: ${err.message}` })
489
+ return
490
+ }
491
+ let stdout = ''
492
+ let stderr = ''
493
+ let done = false
494
+ const finish = (result) => {
495
+ if (done) return
496
+ done = true
497
+ clearTimeout(timer)
498
+ try {
499
+ proc.kill('SIGKILL')
500
+ } catch {
501
+ /* already gone */
502
+ }
503
+ resolve(result)
504
+ }
505
+ const timer = setTimeout(
506
+ () => finish({ ok: false, reason: `timeout after ${timeoutMs}ms (no initialize response)` }),
507
+ timeoutMs,
508
+ )
509
+ proc.stdout.on('data', (chunk) => {
510
+ stdout += chunk.toString()
511
+ const line = stdout.split('\n').find((l) => l.includes('"id":1'))
512
+ if (!line) return
513
+ try {
514
+ const msg = JSON.parse(line.trim())
515
+ if (msg.id === 1 && msg.result) {
516
+ const info = msg.result.serverInfo
517
+ const label = info?.name ? `${info.name}${info.version ? ` ${info.version}` : ''}` : undefined
518
+ finish({ ok: true, serverName: label })
519
+ return
520
+ }
521
+ if (msg.error) {
522
+ finish({ ok: false, reason: `initialize error: ${msg.error.code ?? ''} ${msg.error.message ?? ''}` })
523
+ }
524
+ } catch {
525
+ /* not JSON yet — keep buffering */
526
+ }
527
+ })
528
+ proc.stderr.on('data', (chunk) => {
529
+ stderr += chunk.toString()
530
+ if (stderr.length > 4096) stderr = stderr.slice(-4096)
531
+ })
532
+ proc.on('error', (err) =>
533
+ finish({ ok: false, reason: `spawn failed: ${err.code ?? err.message}` }),
534
+ )
535
+ proc.on('exit', (code, signal) => {
536
+ if (done) return
537
+ const tail = stderr.trim().split('\n').filter(Boolean).slice(-3).join(' | ')
538
+ finish({
539
+ ok: false,
540
+ reason: `process exited early (code=${code} signal=${signal})${tail ? ` — ${tail}` : ''}`,
541
+ })
542
+ })
543
+ proc.stdin.write(`${payload}\n`)
544
+ proc.stdin.end()
545
+ })
546
+ }
547
+
548
+ async function checkMcpRuntimeSmoke(args) {
549
+ section('B.6 MCP Runtime Smoke')
550
+
551
+ const local = []
552
+ for (const cfg of collectMcpConfigs(args)) {
553
+ for (const [name, entry] of Object.entries(cfg.data.mcp ?? {})) {
554
+ if (!entry || entry.type !== 'local') continue
555
+ if (!Array.isArray(entry.command) || entry.command.length === 0) continue
556
+ local.push({ name, command: entry.command, cwd: dirname(cfg.path), source: cfg.label })
557
+ }
558
+ }
559
+
560
+ if (local.length === 0) {
561
+ warn('No local MCP servers found in any config — nothing to smoke-test')
562
+ return
563
+ }
564
+
565
+ info(`Smoke-testing ${local.length} local MCP server(s) via JSON-RPC initialize`)
566
+
567
+ for (const { name, command, cwd, source } of local) {
568
+ const result = await mcpInitializeSmoke(command, cwd, 10000)
569
+ if (result.ok) {
570
+ pass(`MCP "${name}" (${source}): initialize handshake OK${result.serverName ? ` — ${result.serverName}` : ''}`)
571
+ } else {
572
+ error(`MCP "${name}" (${source}): ${result.reason}`)
573
+ }
413
574
  }
414
575
  }
415
576
 
@@ -633,6 +794,132 @@ function checkGitStatus(args) {
633
794
  }
634
795
  }
635
796
 
797
+ // ---------------------------------------------------------------------------
798
+ // Check F: Runtime Layer — Venv, Python & pinned dependencies
799
+ // ---------------------------------------------------------------------------
800
+ // The MCP servers run on the venv Python under ~/.config/opencode/.venv
801
+ // (same resolution as scripts/install-mcp.mjs). This layer verifies the venv
802
+ // exists, the interpreter responds, and the installed packages satisfy the
803
+ // EXACT pins in src/mcp/requirements-mcp.txt and requirements-vision.txt
804
+ // (issue #18 / #21).
805
+
806
+ /** Parse `pip freeze` output into a normalized name → version map. */
807
+ function parsePipFreeze(text) {
808
+ const map = {}
809
+ for (const line of text.split('\n')) {
810
+ const m = line.trim().match(/^([A-Za-z0-9_.-]+)==(\S+)/)
811
+ if (!m) continue
812
+ map[m[1].toLowerCase().replace(/-/g, '_')] = m[2]
813
+ }
814
+ return map
815
+ }
816
+
817
+ /** Split a version string into comparable numeric segments. */
818
+ function parseVersion(v) {
819
+ const m = String(v).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?/)
820
+ if (!m) return []
821
+ return [m[1], m[2] ?? '0', m[3] ?? '0', m[4] ?? '0'].map(Number)
822
+ }
823
+
824
+ /** Compare two versions numerically. Returns <0, 0 or >0. */
825
+ function compareVersions(a, b) {
826
+ const pa = parseVersion(a)
827
+ const pb = parseVersion(b)
828
+ for (let i = 0; i < 4; i++) {
829
+ const da = pa[i] ?? 0
830
+ const db = pb[i] ?? 0
831
+ if (da !== db) return da - db
832
+ }
833
+ return 0
834
+ }
835
+
836
+ /**
837
+ * Check one requirement line (name + operator + version) against installed.
838
+ * Emits pass/error through the doctor counters.
839
+ */
840
+ function checkRequirementLine(line, installed) {
841
+ const m = line.match(/^\s*([A-Za-z0-9_.-]+)\s*(==|>=|<=|>|<|~=|!=)\s*([^\s#]+)/)
842
+ if (!m) return
843
+ const name = m[1].toLowerCase().replace(/-/g, '_')
844
+ const op = m[2]
845
+ const want = m[3]
846
+ const have = installed[name]
847
+ if (have === undefined) {
848
+ error(`Dependency "${m[1]}" not installed (required ${op}${want})`)
849
+ return
850
+ }
851
+ const cmp = compareVersions(have, want)
852
+ let ok = false
853
+ switch (op) {
854
+ case '==': ok = cmp === 0; break
855
+ case '>=': ok = cmp >= 0; break
856
+ case '<=': ok = cmp <= 0; break
857
+ case '>': ok = cmp > 0; break
858
+ case '<': ok = cmp < 0; break
859
+ case '!=': ok = cmp !== 0; break
860
+ case '~=': {
861
+ const spec = parseVersion(want)
862
+ const got = parseVersion(have)
863
+ ok = got[0] === spec[0] && got[1] === spec[1] && cmp >= 0
864
+ break
865
+ }
866
+ }
867
+ if (ok) {
868
+ pass(`Dependency ${m[1]}==${have} satisfies ${m[1]}${op}${want}`)
869
+ } else {
870
+ error(`Dependency ${m[1]} version mismatch: installed ${have}, required ${op}${want}`)
871
+ }
872
+ }
873
+
874
+ function checkVenvLayer() {
875
+ section('F. Runtime Layer — Venv, Python & Dependencies')
876
+
877
+ const configDir = join(homedir(), '.config', 'opencode')
878
+ const venvPython = join(configDir, '.venv', 'bin', 'python3')
879
+
880
+ if (!existsSync(venvPython)) {
881
+ error(`Venv python not found: ${venvPython} (run \`pantheon-opencode init\` to create it)`)
882
+ return
883
+ }
884
+ pass(`Venv python exists: ${venvPython}`)
885
+
886
+ const probe = spawn(venvPython, ['-c', 'import sys; print(sys.version.split()[0])'])
887
+ if (probe.status !== 0) {
888
+ error(`Venv python does not respond (exit ${probe.status}): ${probe.stderr}`)
889
+ return
890
+ }
891
+ pass(`Venv python responds (Python ${probe.stdout})`)
892
+
893
+ const freeze = spawn(venvPython, ['-m', 'pip', 'freeze'])
894
+ if (freeze.status !== 0) {
895
+ warn(`pip freeze failed (exit ${freeze.status}) — cannot verify pinned versions`)
896
+ return
897
+ }
898
+ const installed = parsePipFreeze(freeze.stdout)
899
+
900
+ const reqFiles = [
901
+ join(ROOT, 'src', 'mcp', 'requirements-mcp.txt'),
902
+ join(ROOT, 'src', 'mcp', 'requirements-vision.txt'),
903
+ ]
904
+ for (const reqFile of reqFiles) {
905
+ if (!existsSync(reqFile)) {
906
+ warn(`requirements file not found: ${reqFile}`)
907
+ continue
908
+ }
909
+ const base = basename(reqFile)
910
+ info(`Checking ${base} pins against installed packages`)
911
+ const lines = readFileSync(reqFile, 'utf8').split('\n')
912
+ let checked = 0
913
+ for (const line of lines) {
914
+ const trimmed = line.trim()
915
+ if (!trimmed || trimmed.startsWith('#')) continue
916
+ checked++
917
+ checkRequirementLine(trimmed, installed)
918
+ }
919
+ if (checked === 0) warn(`${base}: no requirement entries found`)
920
+ }
921
+ }
922
+
636
923
  // ---------------------------------------------------------------------------
637
924
  // Summary
638
925
  // ---------------------------------------------------------------------------
@@ -663,7 +950,7 @@ function printSummary(targetPath) {
663
950
  // Main
664
951
  // ---------------------------------------------------------------------------
665
952
 
666
- function main() {
953
+ async function main() {
667
954
  const args = parseArgs(process.argv)
668
955
 
669
956
  if (args.help) {
@@ -693,9 +980,11 @@ function main() {
693
980
  join(args.target, 'scripts', 'doctor.mjs') === join(ROOT, 'scripts', 'doctor.mjs') ||
694
981
  existsSync(join(args.target, 'scripts', 'doctor.mjs')) === false
695
982
 
696
- // Run checks
983
+ // Run checks (layer order: Config → Venv → spawn paths → runtime smoke)
697
984
  checkAgentFiles(args)
698
985
  checkMcpConfig(args)
986
+ checkVenvLayer(args)
987
+ await checkMcpRuntimeSmoke(args)
699
988
  checkPermissionMismatches(args)
700
989
  checkSyncStatus(args)
701
990
  checkGitStatus(args)
@@ -705,4 +994,7 @@ function main() {
705
994
  process.exit(exitCode)
706
995
  }
707
996
 
708
- main()
997
+ main().catch((err) => {
998
+ console.error(`❌ Doctor crashed: ${err.stack ?? err.message}`)
999
+ process.exit(2)
1000
+ })
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # audit-imports.sh — Pantheon PostToolUse Import Audit Hook
3
+ # Blocks wildcard imports and flags suspicious patterns.
4
+ set -euo pipefail
5
+
6
+ FILE_PATH="${FILE_PATH:-${1:-}}"
7
+
8
+ if [[ -z "$FILE_PATH" ]] || [[ ! -f "$FILE_PATH" ]]; then
9
+ # Scan all Python/JS files in recent changes if no file specified
10
+ FILES=$(git diff --name-only HEAD 2>/dev/null | grep -E '\.(py|js|ts|jsx|tsx)$' || true)
11
+ if [[ -z "$FILES" ]]; then
12
+ exit 0
13
+ fi
14
+ else
15
+ FILES="$FILE_PATH"
16
+ fi
17
+
18
+ FOUND=0
19
+ for f in $FILES; do
20
+ [[ -f "$f" ]] || continue
21
+ EXT="${f##*.}"
22
+ case "$EXT" in
23
+ py)
24
+ if grep -qE '^from\s+\S+\s+import\s+\*' "$f"; then
25
+ echo "[IMPORT AUDIT] Wildcard import found in $f" >&2
26
+ FOUND=1
27
+ fi
28
+ ;;
29
+ js|jsx|ts|tsx)
30
+ # Flag require() without destructuring as suspicious
31
+ if grep -qE 'require\([^)]+\)' "$f"; then
32
+ echo "[IMPORT AUDIT] CommonJS require() found in $f — consider ES modules" >&2
33
+ fi
34
+ ;;
35
+ esac
36
+ done
37
+
38
+ if [[ $FOUND -eq 1 ]]; then
39
+ echo "[IMPORT AUDIT] Fix wildcard imports before committing." >&2
40
+ exit 1
41
+ fi
42
+
43
+ exit 0
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # format-multi-language.sh — Pantheon PostToolUse Formatting Hook
3
+ # Runs appropriate formatter on the modified file.
4
+ # Supports both Claude Code stdin protocol and legacy env-var input.
5
+ set -euo pipefail
6
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
7
+ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
8
+
9
+ # ── Claude Code stdin protocol ──────────────────────────────────────────
10
+ # Parse JSON from stdin when piped (PostToolUse hook format).
11
+ # Extracts file_path from Edit/Write or file paths from Bash commands.
12
+ if [ -p /dev/stdin ]; then
13
+ STDIN_JSON=$(cat)
14
+ TOOL_NAME=$(echo "$STDIN_JSON" | python3 -c "
15
+ import sys, json
16
+ d = json.load(sys.stdin)
17
+ print(d.get('tool_name', ''))
18
+ " 2>/dev/null || echo "")
19
+
20
+ case "$TOOL_NAME" in
21
+ Edit|Write)
22
+ JSON_PATH=$(echo "$STDIN_JSON" | python3 -c "
23
+ import sys, json
24
+ d = json.load(sys.stdin)
25
+ print(d.get('tool_input', {}).get('file_path', ''))
26
+ " 2>/dev/null || echo "")
27
+ if [ -n "$JSON_PATH" ]; then
28
+ FILE_PATH="$JSON_PATH"
29
+ fi
30
+ ;;
31
+ Bash)
32
+ COMMAND=$(echo "$STDIN_JSON" | python3 -c "
33
+ import sys, json
34
+ d = json.load(sys.stdin)
35
+ print(d.get('tool_input', {}).get('command', ''))
36
+ " 2>/dev/null || echo "")
37
+ if [ -n "$COMMAND" ]; then
38
+ # Extract first file-like path from bash command (supports relative and quoted paths)
39
+ CANDIDATE=$(python3 -c "
40
+ import re, shlex, sys
41
+ command = sys.argv[1]
42
+ try:
43
+ tokens = shlex.split(command)
44
+ except ValueError:
45
+ tokens = command.split()
46
+ path_re = re.compile(r'^([^\\n]+\\.[A-Za-z0-9]+)$')
47
+ for token in tokens:
48
+ if token.startswith('-'):
49
+ continue
50
+ if path_re.match(token):
51
+ print(token)
52
+ break
53
+ " "$COMMAND" 2>/dev/null || echo "")
54
+ if [ -n "$CANDIDATE" ]; then
55
+ FILE_PATH="$CANDIDATE"
56
+ fi
57
+ fi
58
+ ;;
59
+ esac
60
+ fi
61
+
62
+ FILE_PATH="${FILE_PATH:-${1:-}}"
63
+ if [[ -n "$FILE_PATH" ]] && [[ "$FILE_PATH" != /* ]]; then
64
+ if [[ -f "$REPO_ROOT/$FILE_PATH" ]]; then
65
+ FILE_PATH="$REPO_ROOT/$FILE_PATH"
66
+ fi
67
+ fi
68
+
69
+ if [[ -z "$FILE_PATH" ]] || [[ ! -f "$FILE_PATH" ]]; then
70
+ exit 0
71
+ fi
72
+
73
+ EXT="${FILE_PATH##*.}"
74
+ EXIT_CODE=0
75
+
76
+ case "$EXT" in
77
+ py)
78
+ if command -v ruff >/dev/null 2>&1; then
79
+ ruff format "$FILE_PATH" || true
80
+ elif command -v black >/dev/null 2>&1; then
81
+ black -q "$FILE_PATH" || true
82
+ fi
83
+ ;;
84
+ js|jsx|ts|tsx|json|jsonc|md|yaml|yml|css|html)
85
+ if command -v prettier >/dev/null 2>&1; then
86
+ prettier --write "$FILE_PATH" || true
87
+ fi
88
+ ;;
89
+ sh|bash)
90
+ if command -v shfmt >/dev/null 2>&1; then
91
+ shfmt -w "$FILE_PATH" || true
92
+ fi
93
+ ;;
94
+ esac
95
+
96
+ exit 0
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # log-session-start.sh — Pantheon SessionStart Logging Hook
3
+ # Logs session start with structured JSON.
4
+ set -euo pipefail
5
+
6
+ # Project-local by default; set XDG_STATE_HOME for system-wide logging
7
+ if [ -n "${LOG_DIR:-}" ]; then
8
+ LOG_DIR="$LOG_DIR"
9
+ elif [ -n "${XDG_STATE_HOME:-}" ]; then
10
+ LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
11
+ else
12
+ LOG_DIR="logs/agent-sessions"
13
+ fi
14
+ mkdir -p "$LOG_DIR"
15
+
16
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
17
+ SESSION_ID="${SESSION_ID:-$(date +%s)}"
18
+
19
+ cat >> "$LOG_DIR/sessions.log" << JSON
20
+ {"event":"SessionStart","timestamp":"$TIMESTAMP","session_id":"$SESSION_ID","platform":"${PLATFORM:-unknown}"}
21
+ JSON
22
+
23
+ echo "[LOG] Session start logged → $LOG_DIR/sessions.log" >&2
24
+ exit 0
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ # on-subagent-delegation-start.sh — Pantheon SubagentStart Audit Hook
3
+ # Logs when Zeus delegates to a subagent.
4
+ set -euo pipefail
5
+
6
+ # Project-local by default; set XDG_STATE_HOME for system-wide logging
7
+ if [ -n "${LOG_DIR:-}" ]; then
8
+ LOG_DIR="$LOG_DIR"
9
+ elif [ -n "${XDG_STATE_HOME:-}" ]; then
10
+ LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
11
+ else
12
+ LOG_DIR="logs/agent-sessions"
13
+ fi
14
+ mkdir -p "$LOG_DIR"
15
+
16
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
17
+ AGENT_NAME="${AGENT_NAME:-unknown}"
18
+ TASK_DESC="${TASK_DESC:-${1:-}}"
19
+
20
+ cat >> "$LOG_DIR/delegations.log" << JSON
21
+ {"event":"SubagentStart","timestamp":"$TIMESTAMP","agent":"$AGENT_NAME","task":"$TASK_DESC"}
22
+ JSON
23
+
24
+ echo "[DELEGATION] $AGENT_NAME started → $LOG_DIR/delegations.log" >&2
25
+
26
+ # --- Dispatch-time validation: check target against routing.yml ---
27
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
28
+ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
29
+ ROUTING_FILE="$REPO_ROOT/routing.yml"
30
+ if [ -f "$ROUTING_FILE" ] && [ -n "$AGENT_NAME" ]; then
31
+ ESCAPED_AGENT_NAME=$(printf '%s\n' "$AGENT_NAME" | sed 's/[][(){}.^$*+?|\/\\-]/\\&/g')
32
+ if grep -qE "^[[:space:]]*-[[:space:]]+target:[[:space:]]+$ESCAPED_AGENT_NAME[[:space:]]*$" "$ROUTING_FILE" 2>/dev/null; then
33
+ echo "[VALIDATION] ✅ Target '$AGENT_NAME' found in routing.yml" >&2
34
+ else
35
+ echo "[VALIDATION] ⚠️ Target '$AGENT_NAME' NOT in routing.yml delegation rules" >&2
36
+ fi
37
+ elif [ ! -f "$ROUTING_FILE" ]; then
38
+ echo "[VALIDATION] ⚠️ Could not read routing.yml for validation" >&2
39
+ fi
40
+
41
+ # --- Cost estimation for delegation ---
42
+ declare -A TIER_MAP
43
+ TIER_MAP[athena]="premium"
44
+ TIER_MAP[themis]="premium"
45
+ TIER_MAP[hermes]="default"
46
+ TIER_MAP[aphrodite]="default"
47
+ TIER_MAP[demeter]="default"
48
+ TIER_MAP[prometheus]="default"
49
+ TIER_MAP[hephaestus]="default"
50
+ TIER_MAP[gaia]="default"
51
+ TIER_MAP[zeus]="default"
52
+ TIER_MAP[apollo]="fast"
53
+ TIER_MAP[nyx]="fast"
54
+ TIER_MAP[iris]="fast"
55
+ TIER_MAP[mnemosyne]="fast"
56
+ TIER_MAP[talos]="fast"
57
+
58
+ TIER="${TIER_MAP[$AGENT_NAME]:-default}"
59
+
60
+ case "$TIER" in
61
+ premium)
62
+ COST_MIN=0.50
63
+ COST_MAX=2.00
64
+ COST_EST=1.25
65
+ ;;
66
+ fast)
67
+ COST_MIN=0.02
68
+ COST_MAX=0.10
69
+ COST_EST=0.06
70
+ ;;
71
+ *)
72
+ COST_MIN=0.10
73
+ COST_MAX=0.50
74
+ COST_EST=0.30
75
+ ;;
76
+ esac
77
+
78
+ # Format cost with 2 decimal places
79
+ COST_EST_FMT=$(printf "%.2f" "$COST_EST")
80
+
81
+ if [ "$TIER" = "premium" ]; then
82
+ echo "[COST_WARN] High-cost delegation to $AGENT_NAME — estimated \$$COST_EST_FMT" >&2
83
+ fi
84
+
85
+ if (( $(echo "$COST_EST > 5.00" | bc -l 2>/dev/null) )); then
86
+ echo "[COST_ALERT] Expensive delegation to $AGENT_NAME — consider if subtask is possible" >&2
87
+ fi
88
+
89
+ echo "[COST] Delegation to $AGENT_NAME — tier: $TIER — estimated: \$$COST_EST_FMT" >&2
90
+
91
+ exit 0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # on-subagent-delegation-stop.sh — Pantheon SubagentStop Audit Hook
3
+ # Logs completion or failure of subagent tasks.
4
+ set -euo pipefail
5
+
6
+ # Project-local by default; set XDG_STATE_HOME for system-wide logging
7
+ if [ -n "${LOG_DIR:-}" ]; then
8
+ LOG_DIR="$LOG_DIR"
9
+ elif [ -n "${XDG_STATE_HOME:-}" ]; then
10
+ LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
11
+ else
12
+ LOG_DIR="logs/agent-sessions"
13
+ fi
14
+ mkdir -p "$LOG_DIR"
15
+
16
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
17
+ AGENT_NAME="${AGENT_NAME:-unknown}"
18
+ STATUS="${STATUS:-success}"
19
+ REASON="${REASON:-${1:-}}"
20
+
21
+ LOG_FILE="$LOG_DIR/delegations.log"
22
+ if [[ "$STATUS" == "failure" ]]; then
23
+ LOG_FILE="$LOG_DIR/delegation-failures.log"
24
+ fi
25
+
26
+ cat >> "$LOG_FILE" << JSON
27
+ {"event":"SubagentStop","timestamp":"$TIMESTAMP","agent":"$AGENT_NAME","status":"$STATUS","reason":"$REASON"}
28
+ JSON
29
+
30
+ echo "[DELEGATION] $AGENT_NAME stopped ($STATUS) → $LOG_FILE" >&2
31
+
32
+ # --- Post-condition validation: remind about Themis review ---
33
+ if echo "hermes aphrodite demeter prometheus" | grep -wq "$AGENT_NAME" 2>/dev/null; then
34
+ echo "[POST-CONDITION] 🔔 Reminder: '$AGENT_NAME' should call Themis for review after implementation" >&2
35
+ fi
36
+
37
+ exit 0