pantheon-opencode 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/AGENTS.md +37 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1013 -0
  4. package/bin/pantheon-init.mjs +183 -0
  5. package/commands/pantheon-audit.md +25 -0
  6. package/commands/pantheon-bg.md +10 -0
  7. package/commands/pantheon-consolidate.md +11 -0
  8. package/commands/pantheon-deepwork.md +128 -0
  9. package/commands/pantheon-doc.md +10 -0
  10. package/commands/pantheon-focus.md +9 -0
  11. package/commands/pantheon-forget.md +58 -0
  12. package/commands/pantheon-hash.md +11 -0
  13. package/commands/pantheon-optimize.md +79 -0
  14. package/commands/pantheon-remember.md +44 -0
  15. package/commands/pantheon-search.md +48 -0
  16. package/commands/pantheon-status.md +71 -0
  17. package/commands/pantheon-todo.md +11 -0
  18. package/commands/pantheon.md +49 -0
  19. package/docs/AGENT-MCP.md +194 -0
  20. package/docs/ARCHITECTURE.md +384 -0
  21. package/docs/BRANCH-PROTECTION.md +142 -0
  22. package/docs/INDEX.md +81 -0
  23. package/docs/INSTALLATION.md +217 -0
  24. package/docs/MCP.md +238 -0
  25. package/docs/MEMORY.md +471 -0
  26. package/docs/MIGRATION-MEMORY-BANK.md +139 -0
  27. package/docs/PLATFORMS.md +5 -0
  28. package/docs/QUICKSTART.md +49 -0
  29. package/docs/README.md +18 -0
  30. package/docs/RELEASING.md +256 -0
  31. package/docs/SETUP.md +5 -0
  32. package/docs/UPGRADING.md +41 -0
  33. package/docs/mcp-recommendations.md +439 -0
  34. package/docs/mcp-tools.md +156 -0
  35. package/docs/mcp-user-guide.md +204 -0
  36. package/docs/persistence-mcp.md +111 -0
  37. package/package.json +72 -0
  38. package/pantheon.schema.json +158 -0
  39. package/scripts/__init__.py +0 -0
  40. package/scripts/_pantheon_paths.py +68 -0
  41. package/scripts/check-version-consistency.sh +23 -0
  42. package/scripts/code_mode_server.py +202 -0
  43. package/scripts/doctor.mjs +763 -0
  44. package/scripts/generate-prompts.sh +222 -0
  45. package/scripts/generate-routing-docs.mjs +104 -0
  46. package/scripts/hash_verify.py +192 -0
  47. package/scripts/init-pantheon-mcp.sh +118 -0
  48. package/scripts/install/agents-md.mjs +214 -0
  49. package/scripts/install/health-check.mjs +196 -0
  50. package/scripts/install/migrate.mjs +209 -0
  51. package/scripts/install/opencode.mjs +645 -0
  52. package/scripts/install/shared.mjs +655 -0
  53. package/scripts/install/venv.mjs +116 -0
  54. package/scripts/install-mcp.mjs +885 -0
  55. package/scripts/install.mjs +26 -0
  56. package/scripts/manifest.mjs +622 -0
  57. package/scripts/mcp_persistence_server.py +459 -0
  58. package/scripts/mcp_resources_server.py +206 -0
  59. package/scripts/memory_cache.py +78 -0
  60. package/scripts/memory_mcp_server.py +605 -0
  61. package/scripts/paths.py +64 -0
  62. package/scripts/prune_context.py +72 -0
  63. package/scripts/release-bundle.mjs +109 -0
  64. package/scripts/scrub-secrets.py +282 -0
  65. package/scripts/scrub_secrets.py +281 -0
  66. package/scripts/test-context-compression.sh +166 -0
  67. package/scripts/themis_heuristic_scan.py +287 -0
  68. package/scripts/todo_enforcer.py +242 -0
  69. package/scripts/uninstall.mjs +1057 -0
  70. package/scripts/validate-routing.mjs +160 -0
  71. package/scripts/validate_agent_frontmatter.py +226 -0
  72. package/scripts/versioning.mjs +254 -0
  73. package/skills-lock.json +16 -0
  74. package/src/agents/aphrodite.md +162 -0
  75. package/src/agents/apollo.md +109 -0
  76. package/src/agents/athena.md +226 -0
  77. package/src/agents/demeter.md +146 -0
  78. package/src/agents/gaia.md +82 -0
  79. package/src/agents/hephaestus.md +105 -0
  80. package/src/agents/hermes.md +302 -0
  81. package/src/agents/iris.md +99 -0
  82. package/src/agents/mnemosyne.md +226 -0
  83. package/src/agents/nyx.md +87 -0
  84. package/src/agents/prometheus.md +199 -0
  85. package/src/agents/talos.md +93 -0
  86. package/src/agents/themis.md +187 -0
  87. package/src/agents/zeus.md +209 -0
  88. package/src/instructions/agent-return-format.instructions.md +26 -0
  89. package/src/instructions/backend-standards.instructions.md +45 -0
  90. package/src/instructions/documentation-standards.instructions.md +53 -0
  91. package/src/instructions/frontend-standards.instructions.md +46 -0
  92. package/src/instructions/memory-protocol.instructions.md +67 -0
  93. package/src/instructions/yagni.instructions.md +21 -0
  94. package/src/instructions/zeus-anti-stall.instructions.md +72 -0
  95. package/src/instructions/zeus-communication-rules.instructions.md +15 -0
  96. package/src/instructions/zeus-council-synthesis.instructions.md +105 -0
  97. package/src/instructions/zeus-timeout-retry.instructions.md +127 -0
  98. package/src/mcp/_pantheon_paths.py +67 -0
  99. package/src/mcp/code_mode_server.py +202 -0
  100. package/src/mcp/init-pantheon-mcp.sh +118 -0
  101. package/src/mcp/install-mcp.mjs +885 -0
  102. package/src/mcp/mcp_persistence_server.py +458 -0
  103. package/src/mcp/mcp_resources_server.py +205 -0
  104. package/src/mcp/memory_mcp_server.py +604 -0
  105. package/src/mcp/requirements-mcp-core.txt +5 -0
  106. package/src/mcp/requirements-mcp.txt +5 -0
  107. package/src/plugin.ts +19 -0
  108. package/src/plugins/tui/dist/tui.js +143 -0
  109. package/src/plugins/tui/dist/tui.tsx +144 -0
  110. package/src/plugins/tui/package.json +32 -0
  111. package/src/plugins/tui/src/index.tsx +144 -0
  112. package/src/plugins/tui/tsdown.config.ts +22 -0
  113. package/src/routing.yml +499 -0
  114. package/src/skills/README.md +230 -0
  115. package/src/skills/agent-coordination/SKILL.md +95 -0
  116. package/src/skills/artifact-management/SKILL.md +118 -0
  117. package/src/skills/auto-continue/SKILL.md +280 -0
  118. package/src/skills/code-review-checklist/SKILL.md +139 -0
  119. package/src/skills/context-compression/SKILL.md +861 -0
  120. package/src/skills/git-workflow-and-versioning/SKILL.md +32 -0
  121. package/src/skills/incremental-implementation/SKILL.md +27 -0
  122. package/src/skills/memory-bank/SKILL.md +165 -0
  123. package/src/skills/orchestration-workflow/SKILL.md +311 -0
  124. package/src/skills/security-hardening/SKILL.md +36 -0
  125. package/src/skills/session-goal/SKILL.md +138 -0
  126. package/src/skills/spec-driven-development/SKILL.md +23 -0
  127. package/src/skills/tdd-with-agents/SKILL.md +170 -0
  128. package/src/skills/visual-review-pipeline/SKILL.md +200 -0
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env python3
2
+ """Pantheon Persistence MCP Server.
3
+
4
+ Key-Value store with FTS5 full-text search, TTL-based expiration,
5
+ and namespace isolation. Uses SQLite with zero external dependencies.
6
+
7
+ Usage:
8
+ python scripts/mcp_persistence_server.py
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import re
15
+ import sqlite3
16
+ import sys
17
+ from datetime import UTC, datetime, timedelta
18
+ from pathlib import Path
19
+
20
+ from _pantheon_paths import pantheon_home, pantheon_project
21
+ from mcp.server.fastmcp import FastMCP
22
+
23
+ # ── Schema ──────────────────────────────────────────────────────────────────────
24
+
25
+ CREATE_SQL: str = """
26
+ CREATE TABLE IF NOT EXISTS kv_store (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ namespace TEXT NOT NULL,
29
+ key TEXT NOT NULL,
30
+ value TEXT NOT NULL,
31
+ expires_at TEXT, -- ISO 8601, NULL = forever
32
+ deleted_at TEXT, -- NULL = active, set on TTL purge
33
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
34
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
35
+ UNIQUE(namespace, key)
36
+ );
37
+
38
+ CREATE VIRTUAL TABLE IF NOT EXISTS kv_store_fts USING fts5(
39
+ namespace, key, value,
40
+ content='kv_store', content_rowid='id',
41
+ tokenize='porter unicode61'
42
+ );
43
+
44
+ CREATE TRIGGER IF NOT EXISTS kv_store_ai AFTER INSERT ON kv_store BEGIN
45
+ INSERT INTO kv_store_fts(rowid, namespace, key, value)
46
+ VALUES (new.id, new.namespace, new.key, new.value);
47
+ END;
48
+
49
+ CREATE TRIGGER IF NOT EXISTS kv_store_ad AFTER DELETE ON kv_store BEGIN
50
+ INSERT INTO kv_store_fts(kv_store_fts, rowid, namespace, key, value)
51
+ VALUES('delete', old.id, old.namespace, old.key, old.value);
52
+ END;
53
+
54
+ CREATE TRIGGER IF NOT EXISTS kv_store_au AFTER UPDATE ON kv_store BEGIN
55
+ INSERT INTO kv_store_fts(kv_store_fts, rowid, namespace, key, value)
56
+ VALUES('delete', old.id, old.namespace, old.key, old.value);
57
+ INSERT INTO kv_store_fts(rowid, namespace, key, value)
58
+ VALUES (new.id, new.namespace, new.key, new.value);
59
+ END;
60
+ """
61
+
62
+ # ── FastMCP App ────────────────────────────────────────────────────────────────
63
+
64
+ mcp = FastMCP(
65
+ "pantheon-persistence",
66
+ instructions="Key-Value store with FTS5 full-text search, "
67
+ "TTL-based expiration, and namespace isolation.",
68
+ )
69
+
70
+ # ── Database Initialization ─────────────────────────────────────────────────────
71
+
72
+ _global_db: sqlite3.Connection | None = None
73
+ _project_db: sqlite3.Connection | None = None
74
+ _DELETELOG_MAX_BYTES: int = 1_048_576 # 1 MB
75
+ _DELETELOG_KEEP: int = 3
76
+
77
+
78
+ def _init_db(db_path: Path) -> sqlite3.Connection:
79
+ """Initialize a SQLite database with WAL mode and schema."""
80
+ db_path.parent.mkdir(parents=True, exist_ok=True)
81
+ conn = sqlite3.connect(str(db_path), check_same_thread=False)
82
+ conn.execute("PRAGMA journal_mode=WAL")
83
+ conn.execute("PRAGMA busy_timeout=5000")
84
+ conn.execute("PRAGMA foreign_keys=ON")
85
+ # ── FTS5 Availability Check ──────────────────────────────────────────
86
+ # Check BEFORE schema creation. kv_search uses FTS5, which isn't on all systems.
87
+ row = conn.execute("PRAGMA compile_options").fetchall()
88
+ if not any("ENABLE_FTS5" in r[0] for r in row):
89
+ print(
90
+ "WARNING: SQLite FTS5 not available. kv_search will fail.", file=sys.stderr
91
+ )
92
+ import re as _re # noqa: PLC0415
93
+
94
+ _no_fts = _re.sub(
95
+ r"CREATE VIRTUAL TABLE IF NOT EXISTS kv_store_fts.*?;",
96
+ "",
97
+ CREATE_SQL,
98
+ flags=_re.DOTALL,
99
+ )
100
+ conn.executescript(_no_fts)
101
+ else:
102
+ conn.executescript(CREATE_SQL)
103
+ conn.commit()
104
+
105
+ return conn
106
+
107
+
108
+ def _db(scope: str) -> sqlite3.Connection:
109
+ """Resolve the connection for the given scope."""
110
+ if scope == "global":
111
+ if _global_db is None:
112
+ raise RuntimeError("Global database not initialized")
113
+ return _global_db
114
+ if scope == "project":
115
+ if _project_db is None:
116
+ raise ValueError(
117
+ "Project database not available. "
118
+ "Set PANTHEON_PROJECT or use scope='global'"
119
+ )
120
+ return _project_db
121
+ raise ValueError(f"Unknown scope: {scope!r}. Expected 'global' or 'project'.")
122
+
123
+
124
+ # ── Deletelog ────────────────────────────────────────────────────────────────────
125
+
126
+
127
+ def _rotate_deletelog(log_path: Path) -> None:
128
+ """Rotate deletelog at 1MB, keep last 3 rotated files."""
129
+ if not log_path.exists() or log_path.stat().st_size < _DELETELOG_MAX_BYTES:
130
+ return
131
+
132
+ # Shift existing rotated files: .3 → remove, .2 → .3, .1 → .2
133
+ for i in range(_DELETELOG_KEEP, 0, -1):
134
+ older = log_path.with_suffix(f".deletelog.{i}")
135
+ if older.exists():
136
+ if i == _DELETELOG_KEEP:
137
+ older.unlink()
138
+ else:
139
+ older.rename(log_path.with_suffix(f".deletelog.{i + 1}"))
140
+
141
+ # Rename current log to .1
142
+ log_path.rename(log_path.with_suffix(".deletelog.1"))
143
+
144
+
145
+ def _write_deletelog(db_path: Path, count: int, keys: list[str]) -> None:
146
+ """Append a TTL purge entry to the deletelog."""
147
+ log_path = db_path.with_name(db_path.name + ".deletelog")
148
+ _rotate_deletelog(log_path)
149
+
150
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
151
+ keys_str = ",".join(keys)
152
+ with open(log_path, "a", encoding="utf-8") as f:
153
+ f.write(f"[{timestamp}] PURGED={count} KEYS=[{keys_str}]\n")
154
+
155
+
156
+ # ── Resolve scope and init databases ────────────────────────────────────────────
157
+
158
+ parser = argparse.ArgumentParser()
159
+ parser.add_argument("--global-db", default=None)
160
+ parser.add_argument("--project-db", default=None)
161
+ args = parser.parse_args()
162
+
163
+ _global_root = (
164
+ Path(args.global_db) if args.global_db else pantheon_home() / "persistence"
165
+ )
166
+ _global_db = _init_db(_global_root / "global.db")
167
+
168
+ _project_db_instance = None
169
+ if args.project_db:
170
+ _project_db_instance = _init_db(Path(args.project_db))
171
+ else:
172
+ _proj = pantheon_project()
173
+ if _proj:
174
+ _project_db_instance = _init_db(
175
+ _proj / ".pantheon" / "persistence" / "project.db"
176
+ )
177
+ _project_db = _project_db_instance
178
+
179
+
180
+ # ── Tools ───────────────────────────────────────────────────────────────────────
181
+
182
+
183
+ @mcp.tool(
184
+ name="kv_store",
185
+ description="Store a key-value pair in a namespace with optional TTL (seconds). "
186
+ "INSERT OR REPLACE on duplicate (namespace, key).",
187
+ )
188
+ async def kv_store(
189
+ namespace: str,
190
+ key: str,
191
+ value: str,
192
+ ttl: int | None = None,
193
+ scope: str = "project",
194
+ ) -> dict:
195
+ """Store a value under namespace+key with optional TTL.
196
+
197
+ Args:
198
+ namespace: Logical grouping for keys.
199
+ key: Unique key within the namespace.
200
+ value: String value to store.
201
+ ttl: Time-to-live in seconds (None = forever).
202
+ scope: 'project' (default) or 'global'.
203
+
204
+ Returns:
205
+ Status dict with namespace and key.
206
+ """
207
+ conn = _db(scope)
208
+ expires_at: str | None = None
209
+ if ttl is not None:
210
+ expires_at = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat()
211
+
212
+ conn.execute(
213
+ "INSERT INTO kv_store (namespace, key, value, expires_at, "
214
+ "created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))"
215
+ "ON CONFLICT(namespace, key) DO UPDATE SET "
216
+ " value = excluded.value, "
217
+ " expires_at = excluded.expires_at, "
218
+ " updated_at = datetime('now')",
219
+ (namespace, key, value, expires_at),
220
+ )
221
+ conn.commit()
222
+ return {"status": "stored", "namespace": namespace, "key": key}
223
+
224
+
225
+ @mcp.tool(
226
+ name="kv_get",
227
+ description="Retrieve a value by namespace and key. "
228
+ "Returns None if not found or expired.",
229
+ )
230
+ async def kv_get(
231
+ namespace: str,
232
+ key: str,
233
+ scope: str = "project",
234
+ ) -> str | None:
235
+ """Get a value by namespace and key.
236
+
237
+ Args:
238
+ namespace: Logical grouping for keys.
239
+ key: Unique key within the namespace.
240
+ scope: 'project' (default) or 'global'.
241
+
242
+ Returns:
243
+ Value string or None if not found/expired.
244
+ """
245
+ conn = _db(scope)
246
+ row = conn.execute(
247
+ "SELECT value FROM kv_store "
248
+ "WHERE namespace = ? AND key = ? "
249
+ "AND (expires_at IS NULL OR expires_at > datetime('now')) "
250
+ "AND deleted_at IS NULL",
251
+ (namespace, key),
252
+ ).fetchone()
253
+ return str(row[0]) if row else None
254
+
255
+
256
+ @mcp.tool(
257
+ name="kv_delete",
258
+ description="Delete a key-value pair by namespace and key. "
259
+ "FTS trigger cascades automatically.",
260
+ )
261
+ async def kv_delete(
262
+ namespace: str,
263
+ key: str,
264
+ scope: str = "project",
265
+ ) -> dict:
266
+ """Delete a key-value pair.
267
+
268
+ Args:
269
+ namespace: Logical grouping for keys.
270
+ key: Unique key within the namespace.
271
+ scope: 'project' (default) or 'global'.
272
+
273
+ Returns:
274
+ Status dict indicating deletion outcome.
275
+ """
276
+ conn = _db(scope)
277
+ cursor = conn.execute(
278
+ "DELETE FROM kv_store WHERE namespace = ? AND key = ?",
279
+ (namespace, key),
280
+ )
281
+ conn.commit()
282
+ if cursor.rowcount > 0:
283
+ return {"status": "deleted"}
284
+ return {"status": "not_found"}
285
+
286
+
287
+ @mcp.tool(
288
+ name="kv_list",
289
+ description="List keys in a namespace with optional prefix filter. "
290
+ "Returns up to 'limit' entries (default 100).",
291
+ )
292
+ async def kv_list(
293
+ namespace: str,
294
+ prefix: str = "",
295
+ scope: str = "project",
296
+ limit: int = 100,
297
+ ) -> list[dict]:
298
+ """List keys in a namespace, filtered by prefix.
299
+
300
+ Args:
301
+ namespace: Logical grouping for keys.
302
+ prefix: Optional key prefix filter.
303
+ scope: 'project' (default) or 'global'.
304
+ limit: Maximum number of results (default 100).
305
+
306
+ Returns:
307
+ List of dicts with key, value, created_at, expires_at.
308
+ """
309
+ conn = _db(scope)
310
+ rows = conn.execute(
311
+ "SELECT key, value, created_at, expires_at FROM kv_store "
312
+ "WHERE namespace = ? AND key LIKE ? "
313
+ "AND (expires_at IS NULL OR expires_at > datetime('now')) "
314
+ "AND deleted_at IS NULL "
315
+ "ORDER BY key LIMIT ?",
316
+ (namespace, f"{prefix}%", limit),
317
+ ).fetchall()
318
+ return [
319
+ {
320
+ "key": r[0],
321
+ "value": r[1],
322
+ "created_at": r[2],
323
+ "expires_at": r[3],
324
+ }
325
+ for r in rows
326
+ ]
327
+
328
+
329
+ @mcp.tool(
330
+ name="kv_search",
331
+ description="Full-text search across keys and values using FTS5. "
332
+ "Optionally filter by namespace.",
333
+ )
334
+ async def kv_search(
335
+ query: str,
336
+ namespace: str | None = None,
337
+ scope: str = "project",
338
+ limit: int = 20,
339
+ ) -> list[dict]:
340
+ """Full-text search across keys and values.
341
+
342
+ Args:
343
+ query: Free-text search terms (max 10 terms quoted).
344
+ namespace: Optional namespace filter.
345
+ scope: 'project' (default) or 'global'.
346
+ limit: Maximum number of results (default 20).
347
+
348
+ Returns:
349
+ List of dicts with namespace, key, value, created_at, score.
350
+ """
351
+ conn = _db(scope)
352
+
353
+ # Sanitize: extract up to 10 word tokens, wrap each in quotes
354
+ terms = re.findall(r"\w+", query)[:10]
355
+ if not terms:
356
+ return []
357
+ fts_query = " OR ".join(f'"{t}"' for t in terms)
358
+
359
+ sql: str = (
360
+ "SELECT kv_store.namespace, kv_store.key, kv_store.value, kv_store.created_at, "
361
+ "BM25(kv_store_fts) AS score "
362
+ "FROM kv_store_fts "
363
+ "JOIN kv_store ON kv_store_fts.rowid = kv_store.id "
364
+ "WHERE kv_store_fts MATCH ? "
365
+ "AND kv_store.deleted_at IS NULL "
366
+ "AND (kv_store.expires_at IS NULL OR kv_store.expires_at > datetime('now'))"
367
+ )
368
+ params: list[str | int] = [fts_query]
369
+
370
+ if namespace is not None:
371
+ sql += " AND kv_store.namespace = ?"
372
+ params.append(namespace)
373
+
374
+ sql += " ORDER BY score LIMIT ?"
375
+ params.append(limit)
376
+
377
+ rows = conn.execute(sql, params).fetchall()
378
+ return [
379
+ {
380
+ "namespace": r[0],
381
+ "key": r[1],
382
+ "value": r[2],
383
+ "created_at": r[3],
384
+ "score": round(float(r[4]), 4),
385
+ }
386
+ for r in rows
387
+ ]
388
+
389
+
390
+ @mcp.tool(
391
+ name="purge_expired",
392
+ description="Purge expired TTL entries. Optionally dry-run to see count "
393
+ "without deleting. Logs purged keys to deletelog.",
394
+ )
395
+ async def purge_expired(
396
+ scope: str = "project",
397
+ dry_run: bool = False,
398
+ ) -> dict:
399
+ """Purge expired TTL entries from the database.
400
+
401
+ Args:
402
+ scope: 'project' (default) or 'global'.
403
+ dry_run: If True, only report count without purging.
404
+
405
+ Returns:
406
+ Dict with purged count and dry_run flag.
407
+ """
408
+ conn = _db(scope)
409
+
410
+ # Find expired entries first
411
+ expired = conn.execute(
412
+ "SELECT key FROM kv_store "
413
+ "WHERE expires_at IS NOT NULL "
414
+ "AND expires_at < datetime('now') "
415
+ "AND deleted_at IS NULL",
416
+ ).fetchall()
417
+ expired_keys = [r[0] for r in expired]
418
+ count = len(expired_keys)
419
+
420
+ if dry_run:
421
+ return {"purged": count, "dry_run": True}
422
+
423
+ if count == 0:
424
+ return {"purged": 0, "dry_run": False}
425
+
426
+ # Mark as deleted (soft delete)
427
+ conn.execute(
428
+ "UPDATE kv_store SET deleted_at = datetime('now') "
429
+ "WHERE expires_at IS NOT NULL "
430
+ "AND expires_at < datetime('now') "
431
+ "AND deleted_at IS NULL",
432
+ )
433
+ conn.commit()
434
+
435
+ # Write deletelog
436
+ db_path = _resolve_db_path(scope)
437
+ if db_path:
438
+ _write_deletelog(db_path, count, expired_keys)
439
+
440
+ return {"purged": count, "dry_run": False}
441
+
442
+
443
+ def _resolve_db_path(scope: str) -> Path | None:
444
+ """Resolve the database file path for a given scope."""
445
+ if scope == "global":
446
+ return _global_root / "global.db"
447
+ if _project_db_instance:
448
+ # Find the project root from the project DB path
449
+ proj_root = pantheon_project()
450
+ if proj_root:
451
+ return proj_root / ".pantheon" / "persistence" / "project.db"
452
+ return None
453
+
454
+
455
+ # ── Main Entrypoint ─────────────────────────────────────────────────────────────
456
+
457
+ if __name__ == "__main__":
458
+ mcp.run()
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env python3
2
+ """Pantheon MCP Resources Server.
3
+
4
+ Provides MCP resources for the Pantheon agent framework:
5
+ - Static resources: agents list, skills list, routing.yml
6
+ - Template resources: agent by name, skill by name, deepwork
7
+ plans/status, memory-bank files
8
+
9
+ Usage:
10
+ python scripts/mcp_resources_server.py
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import yaml
20
+ from _pantheon_paths import pantheon_home, pantheon_project
21
+ from mcp.server.fastmcp import FastMCP
22
+
23
+ # ── Path Resolution ──────────────────────────────────────────────────────────
24
+ _PANTHEON_HOME: Path = pantheon_home()
25
+ _PANTHEON_PROJECT: Path | None = pantheon_project()
26
+
27
+ # ── FastMCP App ───────────────────────────────────────────────────────────────
28
+ mcp = FastMCP(
29
+ "Pantheon Resources",
30
+ instructions="MCP Resources for the Pantheon agent framework. "
31
+ "Access agents, skills, routing configuration, deepwork plans, "
32
+ "and memory-bank files via the pantheon:// URI scheme.",
33
+ )
34
+
35
+ # ── Helpers ───────────────────────────────────────────────────────────────────
36
+
37
+
38
+ def _parse_yaml_frontmatter(filepath: Path) -> dict[str, Any]:
39
+ """Parse YAML frontmatter from a markdown file."""
40
+ content = filepath.read_text(encoding="utf-8")
41
+ match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
42
+ if not match:
43
+ return {}
44
+ try:
45
+ return dict(yaml.safe_load(match.group(1)) or {})
46
+ except yaml.YAMLError:
47
+ return {}
48
+
49
+
50
+ def _get_role_from_frontmatter(frontmatter: dict[str, Any]) -> str:
51
+ """Extract the role/description string from agent frontmatter."""
52
+ desc = frontmatter.get("description", "")
53
+ if not isinstance(desc, str) or not desc:
54
+ return "Pantheon agent"
55
+ if "—" in desc:
56
+ return desc.split("—")[0].strip()
57
+ return desc
58
+
59
+
60
+ # ── Static Resources ──────────────────────────────────────────────────────────
61
+
62
+
63
+ @mcp.resource(
64
+ "pantheon://agents",
65
+ description="List of all Pantheon agents with roles from YAML frontmatter",
66
+ )
67
+ async def list_agents() -> str:
68
+ """Return a markdown list of all agents with their roles."""
69
+ agents_dir = _PANTHEON_HOME / "agents"
70
+ if not agents_dir.is_dir():
71
+ return "Agents directory not found."
72
+
73
+ agents: list[str] = []
74
+ for f in sorted(agents_dir.iterdir()):
75
+ if f.suffix == ".md" and f.stem.lower() != "readme":
76
+ frontmatter = _parse_yaml_frontmatter(f)
77
+ name = frontmatter.get("name", f.stem)
78
+ role = _get_role_from_frontmatter(frontmatter)
79
+ agents.append(f"- **{name}** — {role}")
80
+
81
+ return "\n".join(agents) if agents else "No agents found."
82
+
83
+
84
+ @mcp.resource(
85
+ "pantheon://skills",
86
+ description="List of all Pantheon skills with descriptions",
87
+ )
88
+ async def list_skills() -> str:
89
+ """Return a markdown list of all skills with descriptions."""
90
+ skills_dir = _PANTHEON_HOME / "skills"
91
+ if not skills_dir.is_dir():
92
+ return "Skills directory not found."
93
+
94
+ skills: list[str] = []
95
+ for f in sorted(skills_dir.iterdir()):
96
+ if f.is_dir():
97
+ skill_file = f / "SKILL.md"
98
+ if skill_file.exists():
99
+ frontmatter = _parse_yaml_frontmatter(skill_file)
100
+ name = frontmatter.get("name", f.name)
101
+ desc = frontmatter.get("description", "No description")
102
+ skills.append(f"- **{name}** — {desc}")
103
+
104
+ return "\n".join(skills) if skills else "No skills found."
105
+
106
+
107
+ @mcp.resource(
108
+ "pantheon://routing",
109
+ description="Full content of routing.yml (canonical routing source)",
110
+ )
111
+ async def get_routing() -> str:
112
+ """Return the full content of routing.yml."""
113
+ routing_file = _PANTHEON_HOME / "routing.yml"
114
+ if not routing_file.exists():
115
+ return "routing.yml not found."
116
+ return routing_file.read_text(encoding="utf-8")
117
+
118
+
119
+ # ── Template Resources ────────────────────────────────────────────────────────
120
+
121
+
122
+ @mcp.resource(
123
+ "pantheon://agents/{agent_name}",
124
+ description="Content of an agent file by name (case-insensitive lookup)",
125
+ )
126
+ async def get_agent(agent_name: str) -> str:
127
+ """Return the full content of an agent file, case-insensitively."""
128
+ agents_dir = _PANTHEON_HOME / "agents"
129
+ name_lower = agent_name.lower()
130
+ for f in agents_dir.iterdir():
131
+ if f.suffix == ".md" and f.stem.lower() == name_lower:
132
+ return f.read_text(encoding="utf-8")
133
+ return f"Agent '{agent_name}' not found."
134
+
135
+
136
+ @mcp.resource(
137
+ "pantheon://deepwork/{slug}",
138
+ description="PLAN.md content for a deepwork task slug",
139
+ )
140
+ async def get_deepwork_plan(slug: str) -> str:
141
+ """Return PLAN.md content for a deepwork slug."""
142
+ if _PANTHEON_PROJECT is None:
143
+ return f"Deepwork '{slug}' not found. (PANTHEON_PROJECT not set)"
144
+ plan_file = _PANTHEON_PROJECT / ".pantheon" / "deepwork" / slug / "PLAN.md"
145
+ if not plan_file.exists():
146
+ return f"Deepwork '{slug}' not found."
147
+ return plan_file.read_text(encoding="utf-8")
148
+
149
+
150
+ @mcp.resource(
151
+ "pantheon://deepwork/{slug}/status",
152
+ description="STATUS.md content for a deepwork task slug "
153
+ "(or default IN_PROGRESS message if missing)",
154
+ )
155
+ async def get_deepwork_status(slug: str) -> str:
156
+ """Return STATUS.md content for a deepwork slug, or a default message."""
157
+ if _PANTHEON_PROJECT is None:
158
+ return "STATUS.md not found. (PANTHEON_PROJECT not set)"
159
+ status_file = _PANTHEON_PROJECT / ".pantheon" / "deepwork" / slug / "STATUS.md"
160
+ if not status_file.exists():
161
+ return "STATUS.md not found. Current state: IN PROGRESS"
162
+ return status_file.read_text(encoding="utf-8")
163
+
164
+
165
+ @mcp.resource(
166
+ "pantheon://skills/{name}",
167
+ description="Content of a skill's SKILL.md file by name",
168
+ )
169
+ async def get_skill(name: str) -> str:
170
+ """Return SKILL.md content for a skill directory."""
171
+ skill_file = _PANTHEON_HOME / "skills" / name / "SKILL.md"
172
+ if not skill_file.exists():
173
+ return f"Skill '{name}' not found."
174
+ return skill_file.read_text(encoding="utf-8")
175
+
176
+
177
+ @mcp.resource(
178
+ "pantheon://memory-bank/{path}",
179
+ description="Content of a .pantheon/memory-bank/ file by relative path "
180
+ "(path traversal blocked).",
181
+ )
182
+ async def get_memory_bank(path: str) -> str:
183
+ """Return content of a memory-bank file.
184
+
185
+ Security: resolves absolute path and verifies it stays within
186
+ .pantheon/memory-bank/ to prevent directory traversal attacks.
187
+ """
188
+ if _PANTHEON_PROJECT is None:
189
+ return "Memory bank not available. (PANTHEON_PROJECT not set)"
190
+ resolved = (_PANTHEON_PROJECT / ".pantheon" / "memory-bank" / path).resolve()
191
+ mb_dir = (_PANTHEON_PROJECT / ".pantheon" / "memory-bank").resolve()
192
+
193
+ if not str(resolved).startswith(str(mb_dir)):
194
+ return "Path traversal blocked: access denied."
195
+
196
+ if not resolved.exists() or not resolved.is_file():
197
+ return f"File '{path}' not found."
198
+
199
+ return resolved.read_text(encoding="utf-8")
200
+
201
+
202
+ # ── Main Entrypoint ───────────────────────────────────────────────────────────
203
+
204
+ if __name__ == "__main__":
205
+ mcp.run()