pantheon-opencode 1.3.5 → 1.3.7

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 (71) hide show
  1. package/README.md +33 -1
  2. package/package.json +3 -3
  3. package/scripts/_pantheon_paths.py +0 -1
  4. package/scripts/build-agents-md.mjs +4 -2
  5. package/scripts/code_mode_server.py +307 -21
  6. package/scripts/doctor.mjs +26 -13
  7. package/scripts/hooks/format-multi-language.sh +6 -3
  8. package/scripts/hooks/log-session-start.sh +6 -0
  9. package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
  10. package/scripts/hooks/validate-post-conditions.sh +1 -1
  11. package/scripts/hooks/validate-tool-safety.sh +5 -2
  12. package/scripts/install/agents-md.mjs +3 -1
  13. package/scripts/install/cli-ui.mjs +24 -4
  14. package/scripts/install/interactive.mjs +18 -7
  15. package/scripts/install/migrate-config.mjs +13 -19
  16. package/scripts/install/migrate.mjs +8 -5
  17. package/scripts/install/model-picker.mjs +4 -1
  18. package/scripts/install/opencode.mjs +2 -0
  19. package/scripts/install/shared.mjs +6 -4
  20. package/scripts/install/state.mjs +9 -6
  21. package/scripts/mcp_persistence_server.py +23 -22
  22. package/scripts/mcp_resources_server.py +60 -1
  23. package/scripts/memory_mcp_server.py +80 -4
  24. package/scripts/postinstall.mjs +27 -25
  25. package/scripts/prune.mjs +1 -1
  26. package/scripts/redaction-gate.mjs +3 -1
  27. package/scripts/sync-tui.mjs +148 -0
  28. package/scripts/uninstall.mjs +0 -2
  29. package/scripts/validation-policy.mjs +6 -1
  30. package/scripts/version-check.mjs +9 -4
  31. package/src/agents/aphrodite.md +1 -1
  32. package/src/agents/demeter.md +1 -1
  33. package/src/agents/hephaestus.md +1 -1
  34. package/src/agents/hermes.md +1 -1
  35. package/src/agents/themis.md +4 -0
  36. package/src/mcp/code_mode_server.py +304 -18
  37. package/src/mcp/eval_store.py +233 -0
  38. package/src/mcp/mcp_persistence_server.py +23 -22
  39. package/src/mcp/mcp_resources_server.py +58 -0
  40. package/src/mcp/memory_mcp_server.py +80 -3
  41. package/src/pantheon/auto-wake.ts +2 -4
  42. package/src/pantheon/background-job-board.ts +17 -4
  43. package/src/pantheon/comment-checker.ts +48 -0
  44. package/src/pantheon/delegation-classifier.ts +307 -0
  45. package/src/pantheon/delegation-enforce.ts +21 -21
  46. package/src/pantheon/delegation-finalize.ts +44 -4
  47. package/src/pantheon/delegation-notify.ts +97 -0
  48. package/src/pantheon/delegation.ts +413 -23
  49. package/src/pantheon/dispatch-guard.ts +2 -0
  50. package/src/pantheon/file-persistence.ts +7 -3
  51. package/src/pantheon/permission-eval.ts +284 -0
  52. package/src/pantheon/permission-globs.ts +86 -0
  53. package/src/pantheon/presets.d.mts +41 -0
  54. package/src/pantheon/presets.mjs +134 -0
  55. package/src/pantheon/retry-policy.ts +212 -0
  56. package/src/pantheon/session-guard.ts +1 -3
  57. package/src/pantheon/step-cap.ts +110 -0
  58. package/src/pantheon/tui-version-check.ts +72 -0
  59. package/src/pantheon/vision.ts +7 -5
  60. package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
  61. package/src/plugin.ts +57 -3
  62. package/src/plugins/hook-runner.ts +1 -1
  63. package/src/plugins/pantheon-hooks.ts +1 -1
  64. package/src/plugins/tui/dist/tui.d.ts +1 -1
  65. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  66. package/src/plugins/tui/dist/tui.js +19 -14
  67. package/src/plugins/tui/dist/tui.tsx +52 -18
  68. package/src/plugins/tui/package.json +1 -1
  69. package/src/plugins/tui/src/index.tsx +106 -24
  70. package/src/plugins/tui/tsdown.config.ts +1 -1
  71. package/src/routing.yml +42 -0
@@ -24,10 +24,16 @@
24
24
  set -euo pipefail
25
25
 
26
26
  # Project-local by default; set XDG_STATE_HOME for system-wide logging
27
+ # Anchor to PANTHEON_HOME or PANTHEON_PROJECT when available to avoid
28
+ # CWD-dependent relative path ("logs/agent-sessions" wrote to cwd).
27
29
  if [ -n "${LOG_DIR:-}" ]; then
28
30
  LOG_DIR="$LOG_DIR"
29
31
  elif [ -n "${XDG_STATE_HOME:-}" ]; then
30
32
  LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
33
+ elif [ -n "${PANTHEON_HOME:-}" ]; then
34
+ LOG_DIR="$PANTHEON_HOME/logs/agent-sessions"
35
+ elif [ -n "${PANTHEON_PROJECT:-}" ]; then
36
+ LOG_DIR="$PANTHEON_PROJECT/logs/agent-sessions"
31
37
  else
32
38
  LOG_DIR="logs/agent-sessions"
33
39
  fi
@@ -133,20 +139,30 @@ echo "[DELEGATION] $AGENT_NAME started → $LOG_DIR/delegations.log" >&2
133
139
 
134
140
  # --- Dispatch-time validation: check target against routing.yml ---
135
141
  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
142
+ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." 2>/dev/null && pwd)"
143
+
144
+ # Find routing.yml: try repo root first, then PANTHEON_HOME, then PANTHEON_PROJECT.
145
+ # Silently skip if none found (deployed users may not have routing.yml locally).
146
+ ROUTING_FILE=""
147
+ if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/routing.yml" ]; then
148
+ ROUTING_FILE="$REPO_ROOT/routing.yml"
149
+ elif [ -n "${PANTHEON_HOME:-}" ] && [ -f "$PANTHEON_HOME/routing.yml" ]; then
150
+ ROUTING_FILE="$PANTHEON_HOME/routing.yml"
151
+ elif [ -n "${PANTHEON_PROJECT:-}" ] && [ -f "$PANTHEON_PROJECT/routing.yml" ]; then
152
+ ROUTING_FILE="$PANTHEON_PROJECT/routing.yml"
153
+ fi
154
+
155
+ if [ -n "$ROUTING_FILE" ] && [ -n "$AGENT_NAME" ] && [ "$AGENT_NAME" != "unknown" ]; then
139
156
  ESCAPED_AGENT_NAME=$(printf '%s\n' "$AGENT_NAME" | sed 's/[][(){}.^$*+?|\/\\-]/\\&/g')
140
157
  if grep -qE "^[[:space:]]*-[[:space:]]+target:[[:space:]]+$ESCAPED_AGENT_NAME[[:space:]]*$" "$ROUTING_FILE" 2>/dev/null; then
141
158
  echo "[VALIDATION] ✅ Target '$AGENT_NAME' found in routing.yml" >&2
142
159
  else
143
160
  echo "[VALIDATION] ⚠️ Target '$AGENT_NAME' NOT in routing.yml delegation rules" >&2
144
161
  fi
145
- elif [ -f "$ROUTING_FILE" ]; then
162
+ elif [ -n "$ROUTING_FILE" ]; then
146
163
  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
164
  fi
165
+ # else: no routing.yml found — silently skip (deployed users may not have it locally)
150
166
 
151
167
  # --- Cost estimation for delegation ---
152
168
  declare -A TIER_MAP
@@ -9,7 +9,7 @@ SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.st
9
9
 
10
10
  REVIEW_DIR=".pantheon/memory-bank/.tmp"
11
11
  if [ -d "$REVIEW_DIR" ]; then
12
- REVIEW_COUNT=$(ls "$REVIEW_DIR"/REVIEW-* 2>/dev/null | wc -l)
12
+ REVIEW_COUNT=$(find "$REVIEW_DIR" -maxdepth 1 -name 'REVIEW-*' -type f 2>/dev/null | wc -l)
13
13
  if [ "$REVIEW_COUNT" -eq 0 ]; then
14
14
  echo "[POST-CONDITION] ⚠️ No REVIEW artifact found in $REVIEW_DIR/" >&2
15
15
  echo "[POST-CONDITION] 💡 Remember: implementation agents MUST call @themis for review" >&2
@@ -18,8 +18,11 @@ BLOCKED_PATTERNS=(
18
18
  "DROP\s+DATABASE"
19
19
  "TRUNCATE\s+TABLE"
20
20
  "DELETE\s+FROM\s+.*;?\s*$"
21
- "rm\s+.*\s+--no-preserve-root"
22
- ":(){ :|:& };:"
21
+ # DEAD PATTERN REMOVED: `rm\s+.*\s+--no-preserve-root` — redundant with
22
+ # pattern #1 which already blocks `rm -rf /` regardless of flags.
23
+ # DEAD PATTERN REMOVED: `:(){ :|:& };:` — fork bomb syntax never appears
24
+ # in real tool_input.command payloads (the entire stdin would need to be
25
+ # exactly this 13-char token with zero surrounding JSON/context).
23
26
  ">\s*/dev/\(sd[a-z]\|hd[a-z]\|disk[0-9]*\)"
24
27
  "dd\s+if=.*of=/dev/"
25
28
  "mkfs\."
@@ -76,7 +76,9 @@ export function readCanonicalAgents() {
76
76
  if (!existsSync(AGENTS_DIR)) return []
77
77
 
78
78
  const files = readdirSync(AGENTS_DIR)
79
- .filter((f) => (f.endsWith('.agent.md') || f.endsWith('.md')) && f.toLowerCase() !== 'readme.md')
79
+ .filter(
80
+ (f) => (f.endsWith('.agent.md') || f.endsWith('.md')) && f.toLowerCase() !== 'readme.md',
81
+ )
80
82
  .sort()
81
83
 
82
84
  const agents = []
@@ -95,22 +95,42 @@ export function summaryTable(items) {
95
95
  const bot = `\u2514${'\u2500'.repeat(nameWidth + 2)}\u2534${'\u2500'.repeat(statusWidth + 2)}\u2518`
96
96
 
97
97
  console.log(tag(top))
98
- console.log(tag(`\u2502 ${'Componente'.padEnd(nameWidth)} \u2502 ${'Status'.padEnd(statusWidth)} \u2502`))
98
+ console.log(
99
+ tag(`\u2502 ${'Componente'.padEnd(nameWidth)} \u2502 ${'Status'.padEnd(statusWidth)} \u2502`),
100
+ )
99
101
  console.log(tag(sep))
100
102
  for (const item of items) {
101
- console.log(tag(`\u2502 ${item.name.padEnd(nameWidth)} \u2502 ${String(item.status).padEnd(statusWidth)} \u2502`))
103
+ console.log(
104
+ tag(
105
+ `\u2502 ${item.name.padEnd(nameWidth)} \u2502 ${String(item.status).padEnd(statusWidth)} \u2502`,
106
+ ),
107
+ )
102
108
  }
103
109
  console.log(tag(bot))
104
110
  }
105
111
 
106
112
  export function spinner(message) {
107
- const frames = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
113
+ const frames = [
114
+ '\u280b',
115
+ '\u2819',
116
+ '\u2839',
117
+ '\u2838',
118
+ '\u283c',
119
+ '\u2834',
120
+ '\u2826',
121
+ '\u2827',
122
+ '\u2807',
123
+ '\u280f',
124
+ ]
108
125
  let i = 0
109
126
  let running = true
110
127
 
111
128
  if (tty() && !_quiet) {
112
129
  const interval = setInterval(() => {
113
- if (!running) { clearInterval(interval); return }
130
+ if (!running) {
131
+ clearInterval(interval)
132
+ return
133
+ }
114
134
  process.stdout.write(`\r${frames[i % frames.length]} ${tag(message)}...`)
115
135
  i++
116
136
  }, 80)
@@ -31,7 +31,10 @@ function teardownStdin() {
31
31
  }
32
32
  }
33
33
 
34
- export async function promptMultiSelect(options, { title = '', selected = new Set(), pageSize = 6 } = {}) {
34
+ export async function promptMultiSelect(
35
+ options,
36
+ { title = '', selected = new Set(), pageSize = 6 } = {},
37
+ ) {
35
38
  if (options.length === 0) return new Set()
36
39
 
37
40
  const preSelected = selected instanceof Set ? selected : new Set(selected)
@@ -83,7 +86,11 @@ export async function promptMultiSelect(options, { title = '', selected = new Se
83
86
  const count = items.filter((i) => i.checked).length
84
87
  const total = items.length
85
88
  const noun = count === 1 ? 'selected' : 'selected'
86
- lines.push(colors.dim(`(${count}/${total} ${noun}) — ${colors.cyan('↑')}${colors.dim('/')}${colors.cyan('↓')} navigate · ${colors.cyan('Space')}${colors.dim(' toggle · ')}${colors.cyan('Enter')}${colors.dim(' confirm')}`))
89
+ lines.push(
90
+ colors.dim(
91
+ `(${count}/${total} ${noun}) — ${colors.cyan('↑')}${colors.dim('/')}${colors.cyan('↓')} navigate · ${colors.cyan('Space')}${colors.dim(' toggle · ')}${colors.cyan('Enter')}${colors.dim(' confirm')}`,
92
+ ),
93
+ )
87
94
 
88
95
  const lineCount = lines.length
89
96
  if (previousLineCount > 0) cursorUp(previousLineCount)
@@ -247,7 +254,11 @@ export async function promptList(options, { title = '' } = {}) {
247
254
 
248
255
  if (hasMoreBelow) lines.push(colors.dim(' ...'))
249
256
 
250
- lines.push(colors.dim(`(${colors.cyan('↑')}${colors.dim('/')}${colors.cyan('↓')} navigate · ${colors.cyan('Enter')}${colors.dim(' select')}`))
257
+ lines.push(
258
+ colors.dim(
259
+ `(${colors.cyan('↑')}${colors.dim('/')}${colors.cyan('↓')} navigate · ${colors.cyan('Enter')}${colors.dim(' select')}`,
260
+ ),
261
+ )
251
262
 
252
263
  const lineCount = lines.length
253
264
  if (previousLineCount > 0) cursorUp(previousLineCount)
@@ -307,9 +318,7 @@ export async function promptList(options, { title = '' } = {}) {
307
318
  }
308
319
 
309
320
  export async function runInteractiveInstall({ components = [], defaultComponents = [] } = {}) {
310
- const defaultSet = new Set(
311
- defaultComponents.length > 0 ? defaultComponents : components,
312
- )
321
+ const defaultSet = new Set(defaultComponents.length > 0 ? defaultComponents : components)
313
322
 
314
323
  const componentOptions = [
315
324
  {
@@ -362,7 +371,9 @@ export async function runInteractiveInstall({ components = [], defaultComponents
362
371
 
363
372
  if (selectedComponents.size === 0) {
364
373
  process.stdout.write(`${colors.yellow('No components selected — using defaults\n')}`)
365
- defaultSet.forEach((v) => selectedComponents.add(v))
374
+ defaultSet.forEach((v) => {
375
+ selectedComponents.add(v)
376
+ })
366
377
  }
367
378
 
368
379
  const targetOptions = [
@@ -16,20 +16,20 @@
16
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
18
  import {
19
- readState,
20
- writeState,
21
- createInitialState,
22
- addMigrationRecord,
23
- updateVersion,
24
- } from './state.mjs'
25
- import {
26
- readCheckpoint,
27
- writeCheckpoint,
28
19
  advanceCheckpoint,
20
+ listAppliedMigrations,
21
+ readCheckpoint,
29
22
  removeCheckpoint,
30
23
  resumePendingMigration,
31
- listAppliedMigrations,
24
+ writeCheckpoint,
32
25
  } from './migrate.mjs'
26
+ import {
27
+ addMigrationRecord,
28
+ createInitialState,
29
+ readState,
30
+ updateVersion,
31
+ writeState,
32
+ } from './state.mjs'
33
33
 
34
34
  // ---------------------------------------------------------------------------
35
35
  // Migration step definitions
@@ -290,16 +290,10 @@ function main() {
290
290
  addMigrationRecord(state, config.from, config.to, 'completed')
291
291
  removeCheckpoint(config.target)
292
292
  writeState(config.target, state)
293
- console.log(
294
- `\n \u2705 Migration complete: ${config.from} \u2192 ${config.to}`,
295
- )
296
- console.log(
297
- ` State written to: ${join(config.target, '.pantheon', 'install-state.json')}`,
298
- )
293
+ console.log(`\n \u2705 Migration complete: ${config.from} \u2192 ${config.to}`)
294
+ console.log(` State written to: ${join(config.target, '.pantheon', 'install-state.json')}`)
299
295
  } else {
300
- console.log(
301
- `\n \u2705 Migration would complete (dry-run): ${config.from} \u2192 ${config.to}`,
302
- )
296
+ console.log(`\n \u2705 Migration would complete (dry-run): ${config.from} \u2192 ${config.to}`)
303
297
  }
304
298
  }
305
299
 
@@ -11,7 +11,7 @@
11
11
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
12
  import { dirname, join } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
- import { readState, writeState, createInitialState } from './state.mjs'
14
+ import { createInitialState, readState, writeState } from './state.mjs'
15
15
 
16
16
  const __dirname = dirname(fileURLToPath(import.meta.url))
17
17
  const ROOT = join(__dirname, '..', '..')
@@ -243,9 +243,7 @@ export function rollbackMigration(target, version) {
243
243
  }
244
244
 
245
245
  const migrations = state.applied_migrations || []
246
- const targetMigration = migrations.find(
247
- (m) => m.to === version && m.status === 'completed',
248
- )
246
+ const targetMigration = migrations.find((m) => m.to === version && m.status === 'completed')
249
247
  if (!targetMigration) {
250
248
  console.log(` \u26A0\uFE0F No completed migration found for version ${version}`)
251
249
  return { success: false, reason: 'Migration not found' }
@@ -341,7 +339,12 @@ function main() {
341
339
  console.log(` Previous version: ${history.previousVersion || 'N/A'}`)
342
340
  console.log(` Applied migrations: ${history.migrations.length}`)
343
341
  for (const m of history.migrations) {
344
- const icon = m.status === 'completed' ? '\u2705' : m.status === 'rolled_back' ? '\u{1F504}' : '\u26A0\uFE0F'
342
+ const icon =
343
+ m.status === 'completed'
344
+ ? '\u2705'
345
+ : m.status === 'rolled_back'
346
+ ? '\u{1F504}'
347
+ : '\u26A0\uFE0F'
345
348
  console.log(` ${icon} ${m.from} \u2192 ${m.to} [${m.status}] (${m.applied_at})`)
346
349
  }
347
350
  return
@@ -55,7 +55,10 @@ export function writeActivePreset(
55
55
  copyFileSync(filePath, backupPath)
56
56
  backupMade = true
57
57
  }
58
- writeFileSync(`${filePath}.tmp-${process.pid}`, `${JSON.stringify(buildActivePresetFile(presetName, source), null, 2)}\n`)
58
+ writeFileSync(
59
+ `${filePath}.tmp-${process.pid}`,
60
+ `${JSON.stringify(buildActivePresetFile(presetName, source), null, 2)}\n`,
61
+ )
59
62
  renameSync(`${filePath}.tmp-${process.pid}`, filePath)
60
63
  return { written: true, path: filePath, backupPath: backupMade ? backupPath : null }
61
64
  }
@@ -452,6 +452,7 @@ export async function installOpenCode(
452
452
  '_pantheon_paths.py',
453
453
  'mcp_persistence_server.py',
454
454
  'pantheon_vision_server.py',
455
+ 'eval_store.py',
455
456
  ]
456
457
  const srcScriptsDir = join(ROOT, 'scripts')
457
458
  // Canonical MCP server sources live in src/mcp/. Map them explicitly so the
@@ -466,6 +467,7 @@ export async function installOpenCode(
466
467
  '_pantheon_paths.py': join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
467
468
  'mcp_persistence_server.py': join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
468
469
  'pantheon_vision_server.py': join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
470
+ 'eval_store.py': join(ROOT, 'src', 'mcp', 'eval_store.py'),
469
471
  }
470
472
  const dstScriptsDir = join(runtimeTarget, 'scripts')
471
473
  if (!dryRun) mkdirSync(dstScriptsDir, { recursive: true })
@@ -15,10 +15,10 @@ import {
15
15
  import { basename, dirname, join } from 'node:path'
16
16
  import { fileURLToPath } from 'node:url'
17
17
 
18
- const REQUIRED_NODE_MAJOR = 18;
18
+ const REQUIRED_NODE_MAJOR = 18
19
19
  if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
20
- console.error(`❌ Node.js >= ${REQUIRED_NODE_MAJOR} required (current: ${process.versions.node})`);
21
- process.exit(1);
20
+ console.error(`❌ Node.js >= ${REQUIRED_NODE_MAJOR} required (current: ${process.versions.node})`)
21
+ process.exit(1)
22
22
  }
23
23
 
24
24
  import yaml from 'js-yaml'
@@ -33,7 +33,9 @@ export const PLATFORM_DIR = join(ROOT, 'platform')
33
33
  export function getAgentNames() {
34
34
  if (!existsSync(AGENTS_DIR)) return []
35
35
  return readdirSync(AGENTS_DIR)
36
- .filter((f) => (f.endsWith('.agent.md') || f.endsWith('.md')) && f.toLowerCase() !== 'readme.md')
36
+ .filter(
37
+ (f) => (f.endsWith('.agent.md') || f.endsWith('.md')) && f.toLowerCase() !== 'readme.md',
38
+ )
37
39
  .map((f) => f.replace(/\.(agent\.)?md$/, ''))
38
40
  .sort()
39
41
  }
@@ -95,12 +95,15 @@ export function upgradeStateV1toV2(state) {
95
95
  updated_at: state.updated_at || now,
96
96
  migration_checkpoint: null,
97
97
  applied_migrations: Array.isArray(state.applied_migrations) ? state.applied_migrations : [],
98
- components: state.components && typeof state.components === 'object' ? state.components : {
99
- agents: { version: 1, status: 'installed' },
100
- skills: { version: 1, status: 'installed' },
101
- plugins: { version: 1, status: 'unknown' },
102
- runtime: { version: 1, status: 'unknown' },
103
- },
98
+ components:
99
+ state.components && typeof state.components === 'object'
100
+ ? state.components
101
+ : {
102
+ agents: { version: 1, status: 'installed' },
103
+ skills: { version: 1, status: 'installed' },
104
+ plugins: { version: 1, status: 'unknown' },
105
+ runtime: { version: 1, status: 'unknown' },
106
+ },
104
107
  }
105
108
  }
106
109
 
@@ -178,14 +178,15 @@ _global_root = (
178
178
  _global_db = _init_db(_global_root / "global.db")
179
179
 
180
180
  _project_db_instance = None
181
+ _project_db_path: Path | None = None
181
182
  if args.project_db:
182
- _project_db_instance = _init_db(Path(args.project_db))
183
+ _project_db_path = Path(args.project_db)
184
+ _project_db_instance = _init_db(_project_db_path)
183
185
  else:
184
186
  _proj = pantheon_project()
185
187
  if _proj:
186
- _project_db_instance = _init_db(
187
- _proj / ".pantheon" / "persistence" / "project.db"
188
- )
188
+ _project_db_path = _proj / ".pantheon" / "persistence" / "project.db"
189
+ _project_db_instance = _init_db(_project_db_path)
189
190
  _project_db = _project_db_instance
190
191
 
191
192
 
@@ -262,7 +263,7 @@ async def kv_get(
262
263
  row = conn.execute(
263
264
  "SELECT value FROM kv_store "
264
265
  "WHERE namespace = ? AND key = ? "
265
- "AND (expires_at IS NULL OR expires_at > datetime('now')) "
266
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
266
267
  "AND deleted_at IS NULL",
267
268
  (namespace, key),
268
269
  ).fetchone()
@@ -289,13 +290,13 @@ async def kv_stats(
289
290
  total = conn.execute("SELECT COUNT(*) FROM kv_store WHERE deleted_at IS NULL").fetchone()[0]
290
291
  expired = conn.execute(
291
292
  "SELECT COUNT(*) FROM kv_store "
292
- "WHERE expires_at IS NOT NULL AND expires_at < datetime('now') "
293
+ "WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now') "
293
294
  "AND deleted_at IS NULL"
294
295
  ).fetchone()[0]
295
296
 
296
297
  ns_rows = conn.execute(
297
298
  "SELECT namespace, COUNT(*) as cnt, "
298
- "SUM(CASE WHEN expires_at IS NOT NULL AND expires_at < datetime('now') THEN 1 ELSE 0 END) as expired_count "
299
+ "SUM(CASE WHEN expires_at IS NOT NULL AND datetime(expires_at) < datetime('now') THEN 1 ELSE 0 END) as expired_count "
299
300
  "FROM kv_store WHERE deleted_at IS NULL GROUP BY namespace ORDER BY cnt DESC"
300
301
  ).fetchall()
301
302
 
@@ -372,7 +373,7 @@ async def kv_list(
372
373
  rows = conn.execute(
373
374
  "SELECT key, value, created_at, expires_at FROM kv_store "
374
375
  "WHERE namespace = ? AND key LIKE ? "
375
- "AND (expires_at IS NULL OR expires_at > datetime('now')) "
376
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
376
377
  "AND deleted_at IS NULL "
377
378
  "ORDER BY key LIMIT ?",
378
379
  (namespace, f"{prefix}%", limit),
@@ -426,7 +427,7 @@ async def kv_search(
426
427
  "JOIN kv_store ON kv_store_fts.rowid = kv_store.id "
427
428
  "WHERE kv_store_fts MATCH ? "
428
429
  "AND kv_store.deleted_at IS NULL "
429
- "AND (kv_store.expires_at IS NULL OR kv_store.expires_at > datetime('now'))"
430
+ "AND (kv_store.expires_at IS NULL OR datetime(kv_store.expires_at) > datetime('now'))"
430
431
  )
431
432
  params: list[str | int] = [fts_query]
432
433
 
@@ -474,7 +475,7 @@ async def purge_expired(
474
475
  expired = conn.execute(
475
476
  "SELECT key FROM kv_store "
476
477
  "WHERE expires_at IS NOT NULL "
477
- "AND expires_at < datetime('now') "
478
+ "AND datetime(expires_at) < datetime('now') "
478
479
  "AND deleted_at IS NULL",
479
480
  ).fetchall()
480
481
  expired_keys = [r[0] for r in expired]
@@ -490,7 +491,7 @@ async def purge_expired(
490
491
  conn.execute(
491
492
  "UPDATE kv_store SET deleted_at = datetime('now') "
492
493
  "WHERE expires_at IS NOT NULL "
493
- "AND expires_at < datetime('now') "
494
+ "AND datetime(expires_at) < datetime('now') "
494
495
  "AND deleted_at IS NULL",
495
496
  )
496
497
  conn.commit()
@@ -516,7 +517,7 @@ def _opportunistic_auto_purge(conn: sqlite3.Connection, namespace: str, threshol
516
517
  conn.execute(
517
518
  "UPDATE kv_store SET deleted_at = datetime('now') "
518
519
  "WHERE namespace = ? AND expires_at IS NOT NULL "
519
- "AND expires_at < datetime('now') AND deleted_at IS NULL",
520
+ "AND datetime(expires_at) < datetime('now') AND deleted_at IS NULL",
520
521
  (namespace,),
521
522
  )
522
523
  conn.commit()
@@ -659,7 +660,7 @@ async def context_get(
659
660
  row = conn.execute(
660
661
  "SELECT value FROM kv_store "
661
662
  "WHERE namespace = ? AND key = ? "
662
- "AND (expires_at IS NULL OR expires_at > datetime('now')) "
663
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
663
664
  "AND deleted_at IS NULL",
664
665
  (ns, key),
665
666
  ).fetchone()
@@ -691,7 +692,7 @@ async def context_list(
691
692
  rows = conn.execute(
692
693
  "SELECT key, created_at, expires_at FROM kv_store "
693
694
  "WHERE namespace = ? "
694
- "AND (expires_at IS NULL OR expires_at > datetime('now')) "
695
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
695
696
  "AND deleted_at IS NULL "
696
697
  "ORDER BY created_at DESC LIMIT 50",
697
698
  (ns,),
@@ -737,7 +738,7 @@ async def context_stats(
737
738
 
738
739
  expired = conn.execute(
739
740
  "SELECT COUNT(*) FROM kv_store "
740
- "WHERE namespace = ? AND expires_at < datetime('now') "
741
+ "WHERE namespace = ? AND datetime(expires_at) < datetime('now') "
741
742
  "AND deleted_at IS NULL",
742
743
  (ns,),
743
744
  ).fetchone()[0]
@@ -773,15 +774,15 @@ async def context_stats(
773
774
  }
774
775
 
775
776
  def _resolve_db_path(scope: str) -> Path | None:
776
- """Resolve the database file path for a given scope."""
777
+ """Resolve the database file path for a given scope.
778
+
779
+ Returns the actual path used at init (honoring an explicit
780
+ ``--project-db``), so deletelog writes and db_size stats land next to
781
+ the real database file.
782
+ """
777
783
  if scope == "global":
778
784
  return _global_root / "global.db"
779
- if _project_db_instance:
780
- # Find the project root from the project DB path
781
- proj_root = pantheon_project()
782
- if proj_root:
783
- return proj_root / ".pantheon" / "persistence" / "project.db"
784
- return None
785
+ return _project_db_path
785
786
 
786
787
 
787
788
  # ── Main Entrypoint ─────────────────────────────────────────────────────────────
@@ -1,4 +1,3 @@
1
- # Auto-generated: resolved symlink from ../src/mcp/mcp_resources_server.py
2
1
  #!/usr/bin/env python3
3
2
  """Pantheon MCP Resources Server.
4
3
 
@@ -19,6 +18,7 @@ from typing import Any
19
18
 
20
19
  import yaml
21
20
  from _pantheon_paths import pantheon_home, pantheon_project
21
+ from eval_store import get_latest_eval, list_evals
22
22
  from mcp.server.fastmcp import FastMCP
23
23
 
24
24
  # ── Path Resolution ──────────────────────────────────────────────────────────
@@ -112,6 +112,8 @@ async def list_skills() -> str:
112
112
  async def get_routing() -> str:
113
113
  """Return the full content of routing.yml."""
114
114
  routing_file = _PANTHEON_HOME / "routing.yml"
115
+ if not routing_file.exists() and _PANTHEON_PROJECT:
116
+ routing_file = _PANTHEON_PROJECT / "src" / "routing.yml"
115
117
  if not routing_file.exists():
116
118
  return "routing.yml not found."
117
119
  return routing_file.read_text(encoding="utf-8")
@@ -200,6 +202,63 @@ async def get_memory_bank(path: str) -> str:
200
202
  return resolved.read_text(encoding="utf-8")
201
203
 
202
204
 
205
+ # ── Plugin Eval Resources ─────────────────────────────────────────────────────
206
+
207
+
208
+ def _valid_plugin_name(name: str) -> bool:
209
+ """Validate a plugin name for the eval resource (no traversal)."""
210
+ return bool(name) and "/" not in name and "\\" not in name and ".." not in name
211
+
212
+
213
+ @mcp.resource(
214
+ "pantheon://eval",
215
+ description="List of evaluated plugins/skills with their latest "
216
+ "certification score (from the plugin_eval memory namespace)",
217
+ )
218
+ async def list_plugin_evals() -> str:
219
+ """Return a markdown list of evaluated plugins with latest scores."""
220
+ entries = list_evals()
221
+ if not entries:
222
+ return "No plugin evals recorded yet."
223
+
224
+ lines = ["# Plugin Eval Certification", ""]
225
+ for entry in entries:
226
+ name = entry.get("name", "?")
227
+ score = entry.get("metadata", {}).get("score", "?")
228
+ created = entry.get("created_at", "")
229
+ lines.append(f"- **{name}** — score {score} (evaluated {created})")
230
+ return "\n".join(lines)
231
+
232
+
233
+ @mcp.resource(
234
+ "pantheon://eval/{plugin}",
235
+ description="Latest certification report for a plugin/skill "
236
+ "(from the plugin_eval memory namespace)",
237
+ )
238
+ async def get_plugin_eval(plugin: str) -> str:
239
+ """Return the latest eval certification report for a plugin.
240
+
241
+ Security: plugin names containing path separators or traversal
242
+ sequences are rejected before any lookup.
243
+ """
244
+ if not _valid_plugin_name(plugin):
245
+ return "Invalid plugin name: blocked."
246
+
247
+ entry = get_latest_eval(plugin)
248
+ if entry is None:
249
+ return f"No eval found for plugin '{plugin}'."
250
+
251
+ score = entry.get("metadata", {}).get("score", "?")
252
+ created = entry.get("created_at", "")
253
+ value = entry.get("value", "{}")
254
+ return (
255
+ f"# Eval Report: {plugin}\n\n"
256
+ f"**Score:** {score}\n"
257
+ f"**Evaluated:** {created}\n\n"
258
+ f"```json\n{value}\n```"
259
+ )
260
+
261
+
203
262
  # ── Main Entrypoint ───────────────────────────────────────────────────────────
204
263
 
205
264
  if __name__ == "__main__":