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.
- package/AGENTS.md +9 -0
- package/README.md +112 -15
- package/bin/pantheon-init.mjs +93 -0
- package/commands/pantheon-vision.md +67 -0
- package/opencode.json +14 -10
- package/package.json +20 -4
- package/scripts/_pantheon_paths.py +0 -2
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +307 -15
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +24 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +55 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +43 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/health-check.mjs +101 -1
- package/scripts/install/model-picker.mjs +118 -0
- package/scripts/install/opencode.mjs +155 -43
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install/venv.mjs +25 -2
- package/scripts/install-mcp.mjs +170 -37
- package/scripts/mcp_persistence_server.py +2 -1
- package/scripts/prune.mjs +359 -0
- package/scripts/secret-scan.mjs +88 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/validate-routing.mjs +15 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/hermes.md +2 -2
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +1 -1
- package/src/mcp/_pantheon_paths.py +0 -2
- package/src/mcp/install-mcp.mjs +6 -913
- package/src/mcp/mcp_persistence_server.py +2 -1
- package/src/mcp/pantheon_vision_server.py +495 -0
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -0
- package/src/pantheon/auto-wake.ts +91 -0
- package/src/pantheon/background-job-board.ts +568 -0
- package/src/pantheon/file-persistence.ts +95 -0
- package/src/pantheon/presets.d.mts +108 -0
- package/src/pantheon/presets.mjs +554 -0
- package/src/pantheon/vision.ts +1437 -0
- package/src/plugin.ts +69 -25
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +812 -223
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +881 -121
- package/src/plugins/tui/package.json +3 -3
- package/src/plugins/tui/src/index.tsx +881 -121
- package/src/routing.yml +148 -0
- package/src/skills/context-compression/SKILL.md +1 -1
- package/src/skills/loop-engineering/SKILL.md +1 -1
- package/src/skills/orchestration-workflow/SKILL.md +11 -11
- package/scripts/scrub_secrets.py +0 -281
- package/src/mcp/requirements-mcp-core.txt +0 -5
package/scripts/doctor.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|