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,763 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * doctor.mjs — Pantheon health check CLI tool
5
+ *
6
+ * Validates that a Pantheon installation is working correctly:
7
+ * - Agent file presence across canonical → platform
8
+ * - MCP configuration (gh_grep, Context7, etc.)
9
+ * - Permission/frontmatter mismatches
10
+ * - Sync freshness
11
+ * - Git status
12
+ *
13
+ * Usage:
14
+ * node scripts/doctor.mjs # auto-detect, cwd
15
+ * node scripts/doctor.mjs --target /path/to/project
16
+ * node scripts/doctor.mjs --platform opencode
17
+ * node scripts/doctor.mjs --fix # attempt auto-fixes
18
+ * node scripts/doctor.mjs --verbose
19
+ * node scripts/doctor.mjs --help
20
+ *
21
+ * Exit codes:
22
+ * 0 = all green
23
+ * 1 = warnings only
24
+ * 2 = errors (needs attention)
25
+ */
26
+
27
+ import { spawnSync } from 'node:child_process'
28
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
29
+ import { dirname, join } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Paths
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const __dirname = dirname(fileURLToPath(import.meta.url))
37
+ const ROOT = join(__dirname, '..')
38
+ const AGENTS_DIR = join(ROOT, 'agents')
39
+ const PLATFORM_DIR = join(ROOT, 'platform')
40
+
41
+ // All known platforms (must match platform/ subdirectories with adapter.json)
42
+ const _ALL_PLATFORMS = ['opencode', 'claude', 'cursor', 'windsurf', 'copilot', 'continue', 'cline']
43
+
44
+ const PLATFORM_LABELS = {
45
+ opencode: 'OpenCode',
46
+ claude: 'Claude Code',
47
+ cursor: 'Cursor',
48
+ windsurf: 'Windsurf',
49
+ copilot: 'VS Code / Copilot',
50
+ continue: 'Continue.dev',
51
+ cline: 'Cline',
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // State
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** @type {{ pass: number; warn: number; error: number; info: number }} */
59
+ const counts = { pass: 0, warn: 0, error: 0, info: 0 }
60
+
61
+ const results = []
62
+ let exitCode = 0
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // CLI arg parsing
66
+ // ---------------------------------------------------------------------------
67
+
68
+ function parseArgs(argv) {
69
+ const args = {
70
+ target: null,
71
+ platform: null,
72
+ fix: false,
73
+ verbose: false,
74
+ help: false,
75
+ }
76
+
77
+ for (let i = 2; i < argv.length; i++) {
78
+ switch (argv[i]) {
79
+ case '--target':
80
+ args.target = argv[++i]
81
+ break
82
+ case '--platform':
83
+ args.platform = argv[++i]
84
+ break
85
+ case '--fix':
86
+ args.fix = true
87
+ break
88
+ case '--verbose':
89
+ args.verbose = true
90
+ break
91
+ case '--help':
92
+ case '-h':
93
+ args.help = true
94
+ break
95
+ default:
96
+ console.warn(`⚠️ Unknown option: ${argv[i]}`)
97
+ break
98
+ }
99
+ }
100
+
101
+ if (!args.target) {
102
+ args.target = process.cwd()
103
+ }
104
+
105
+ // Resolve to absolute
106
+ if (!args.target.startsWith('/')) {
107
+ args.target = join(process.cwd(), args.target)
108
+ }
109
+
110
+ return args
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Output helpers
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const ICON = {
118
+ pass: '\u2705', // ✅
119
+ warn: '\u26A0\uFE0F', // ⚠️
120
+ error: '\u274C', // ❌
121
+ info: '\u2139\uFE0F', // ℹ️
122
+ }
123
+
124
+ function emit(category, message) {
125
+ counts[category]++
126
+ results.push({ category, message })
127
+ if (category === 'pass' && !process.argv.includes('--verbose')) return
128
+ const prefix = ICON[category] ?? category
129
+ console.log(` ${prefix} ${message}`)
130
+ }
131
+
132
+ function pass(msg) {
133
+ emit('pass', msg)
134
+ }
135
+ function warn(msg) {
136
+ emit('warn', msg)
137
+ if (exitCode < 1) exitCode = 1
138
+ }
139
+ function error(msg) {
140
+ emit('error', msg)
141
+ exitCode = 2
142
+ }
143
+ function info(msg) {
144
+ emit('info', msg)
145
+ }
146
+
147
+ function section(title) {
148
+ console.log(`\n${'='.repeat(60)}`)
149
+ console.log(` ${title}`)
150
+ console.log(`${'='.repeat(60)}`)
151
+ }
152
+
153
+ function showHelp() {
154
+ console.log(`
155
+ Pantheon Doctor — Health Check CLI
156
+
157
+ Usage:
158
+ node scripts/doctor.mjs auto-detect, cwd
159
+ node scripts/doctor.mjs --target /path/to/project target project
160
+ node scripts/doctor.mjs --platform opencode specific platform
161
+ node scripts/doctor.mjs --fix attempt auto-fixes
162
+ node scripts/doctor.mjs --verbose detailed output
163
+ node scripts/doctor.mjs --help show this help
164
+
165
+ Checks:
166
+ A. Agent Files — canonical vs platform sync
167
+ B. MCP Configuration — opencode.json MCP settings
168
+ C. Permission Checks — frontmatter validation
169
+ D. Sync Status — npm run sync freshness
170
+ E. Git Status — uncommitted changes
171
+
172
+ Exit codes:
173
+ 0 — all checks pass
174
+ 1 — warnings
175
+ 2 — errors
176
+
177
+ Report bugs: https://github.com/ils15/pantheon/issues
178
+ `)
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Helper utilities
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Collect canonical agent names from agents/*.agent.md
187
+ * @returns {string[]} sorted list of agent names (without extension)
188
+ */
189
+ function getCanonicalAgentNames() {
190
+ if (!existsSync(AGENTS_DIR)) return []
191
+ return readdirSync(AGENTS_DIR)
192
+ .filter((f) => f.endsWith('.agent.md'))
193
+ .map((f) => f.replace(/\.agent\.md$/, ''))
194
+ .sort()
195
+ }
196
+
197
+ /**
198
+ * Read JSON from a file, returning null on failure.
199
+ * @param {string} filePath
200
+ * @returns {object|null}
201
+ */
202
+ function readJson(filePath) {
203
+ try {
204
+ if (!existsSync(filePath)) return null
205
+ const raw = readFileSync(filePath, 'utf8')
206
+ return JSON.parse(raw)
207
+ } catch {
208
+ return null
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Spawn a process and return { status, stdout, stderr }.
214
+ */
215
+ function spawn(prog, args, cwd = null) {
216
+ const result = spawnSync(prog, args, {
217
+ cwd: cwd ?? ROOT,
218
+ encoding: 'utf8',
219
+ maxBuffer: 16 * 1024 * 1024,
220
+ })
221
+ return {
222
+ status: result.status,
223
+ stdout: (result.stdout ?? '').trim(),
224
+ stderr: (result.stderr ?? '').trim(),
225
+ }
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Check A: Agent Files
230
+ // ---------------------------------------------------------------------------
231
+
232
+ function checkAgentFiles(args) {
233
+ section('A. Agent Files')
234
+
235
+ const canonical = getCanonicalAgentNames()
236
+ if (canonical.length === 0) {
237
+ error('No canonical agent files found in agents/')
238
+ return
239
+ }
240
+ pass(`${canonical.length} canonical agents found in agents/`)
241
+
242
+ // Determine which platforms to check
243
+ const platformDirs = readdirSync(PLATFORM_DIR, { withFileTypes: true })
244
+ .filter((d) => d.isDirectory() && d.name !== '_template' && d.name !== 'plans')
245
+ .map((d) => d.name)
246
+ .filter((name) => !args.platform || name === args.platform)
247
+
248
+ if (platformDirs.length === 0) {
249
+ warn(`No platform directories found in platform/`)
250
+ return
251
+ }
252
+
253
+ for (const platform of platformDirs) {
254
+ const adapterPath = join(PLATFORM_DIR, platform, 'adapter.json')
255
+ if (!existsSync(adapterPath)) {
256
+ info(`${platform}: no adapter.json — skipping`)
257
+ continue
258
+ }
259
+
260
+ const adapter = readJson(adapterPath)
261
+ if (!adapter) {
262
+ warn(`${platform}: adapter.json parse error — skipping`)
263
+ continue
264
+ }
265
+
266
+ const label = PLATFORM_LABELS[platform] ?? platform
267
+ const outputDir = adapter.outputDir ?? 'agents'
268
+ const ext = adapter.fileExtension ?? '.md'
269
+ const agentDir = join(PLATFORM_DIR, platform, outputDir)
270
+
271
+ if (!existsSync(agentDir)) {
272
+ if (adapter.skipAgentSync) {
273
+ info(`${label}: agent sync skipped (skipAgentSync) — n/a`)
274
+ continue
275
+ }
276
+ warn(`${label}: output directory missing: ${agentDir}`)
277
+ continue
278
+ }
279
+
280
+ if (adapter.skipAgentSync) {
281
+ info(`${label}: agent sync skipped (skipAgentSync) — skipping agent check`)
282
+ continue
283
+ }
284
+
285
+ const platformFiles = readdirSync(agentDir)
286
+ .filter((f) => statSync(join(agentDir, f)).isFile() && f.endsWith(ext))
287
+ .map((f) => f.replace(new RegExp(`${ext}$`), ''))
288
+
289
+ // Check every canonical agent exists in platform output
290
+ let allPresent = true
291
+ const missing = []
292
+ for (const agent of canonical) {
293
+ if (!platformFiles.includes(agent)) {
294
+ missing.push(agent)
295
+ allPresent = false
296
+ }
297
+ }
298
+
299
+ // Detect phantom agents (in platform but not canonical — stale)
300
+ const phantom = platformFiles.filter((f) => !canonical.includes(f))
301
+
302
+ if (allPresent && phantom.length === 0) {
303
+ pass(`${label}: all ${canonical.length} agents present`)
304
+ } else {
305
+ if (missing.length > 0) {
306
+ error(`${label}: ${missing.length} canonical agent(s) missing: ${missing.join(', ')}`)
307
+ }
308
+ if (phantom.length > 0) {
309
+ warn(`${label}: ${phantom.length} stale agent(s) in output: ${phantom.join(', ')}`)
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Check B: MCP Configuration
317
+ // ---------------------------------------------------------------------------
318
+
319
+ function checkMcpConfig(args) {
320
+ section('B. MCP Configuration')
321
+
322
+ // Determine which opencode.json to check:
323
+ // 1. Target project's opencode.json
324
+ // 2. Pantheon ROOT opencode.json
325
+ const candidates = [
326
+ { label: 'project root', path: join(args.target, 'opencode.json') },
327
+ { label: 'repository root', path: join(ROOT, 'opencode.json') },
328
+ ]
329
+
330
+ // Also check user config if target matches Pantheon ROOT
331
+ if (args.target === ROOT) {
332
+ candidates.push({
333
+ label: 'user config',
334
+ path: join(ROOT, '.opencode', 'opencode.json'),
335
+ })
336
+ } else {
337
+ const homeConfig = join(process.env.HOME ?? '~', '.config', 'opencode', 'opencode.json')
338
+ candidates.push({ label: 'user config', path: homeConfig })
339
+ }
340
+
341
+ /** @type {{label:string, data:object}[]} */
342
+ const configs = []
343
+ for (const c of candidates) {
344
+ const data = readJson(c.path)
345
+ if (data) {
346
+ configs.push({ label: c.label, data, path: c.path })
347
+ }
348
+ }
349
+
350
+ if (configs.length === 0) {
351
+ warn('No opencode.json found in target or user config')
352
+ return
353
+ }
354
+
355
+ for (const cfg of configs) {
356
+ info(`Checking ${cfg.label} opencode.json`)
357
+
358
+ const mcp = cfg.data.mcp
359
+ if (!mcp || typeof mcp !== 'object') {
360
+ warn(`${cfg.label}: no "mcp" section`)
361
+ continue
362
+ }
363
+
364
+ // 1. gh_grep MCP check
365
+ const ghGrep = mcp.gh_grep
366
+ if (ghGrep) {
367
+ if (ghGrep.type === 'remote' && ghGrep.url === 'https://mcp.grep.app') {
368
+ pass(`${cfg.label}: gh_grep MCP configured correctly`)
369
+ } else {
370
+ const gotUrl = ghGrep.url ?? '<missing>'
371
+ const detail =
372
+ ghGrep.type !== 'remote'
373
+ ? `type is "${ghGrep.type}" instead of "remote"`
374
+ : `url is "${gotUrl}"`
375
+ warn(`${cfg.label}: gh_grep MCP may need review — ${detail}`)
376
+ }
377
+ } else {
378
+ warn(`${cfg.label}: gh_grep MCP not configured`)
379
+ }
380
+
381
+ // 2. Context7 MCP check
382
+ const context7 = mcp.context7
383
+ if (context7) {
384
+ const isLocal = context7.type === 'local'
385
+ const hasCommand =
386
+ Array.isArray(context7.command) &&
387
+ context7.command.length > 0 &&
388
+ context7.command.some((s) => s.includes('context7-mcp') || s.includes('context7'))
389
+ const enabled = context7.enabled !== false
390
+
391
+ if (isLocal && hasCommand && enabled) {
392
+ pass(`${cfg.label}: Context7 MCP configured correctly`)
393
+ } else {
394
+ const details = []
395
+ if (!isLocal) details.push(`type should be "local"`)
396
+ if (!hasCommand) details.push("command missing or doesn't mention context7-mcp")
397
+ if (!enabled) details.push('not enabled')
398
+ warn(`${cfg.label}: Context7 MCP issues — ${details.join(', ')}`)
399
+ }
400
+ } else {
401
+ warn(`${cfg.label}: Context7 MCP not configured`)
402
+ }
403
+
404
+ // 3. Check for deprecated "tools" field format
405
+ // In opencode.json, tools was deprecated in favor of "permission" field
406
+ if (cfg.data.tools && !cfg.data.permission) {
407
+ warn(`${cfg.label}: uses deprecated top-level "tools" field — migrate to "permission"`)
408
+ } else if (cfg.data.tools && cfg.data.permission) {
409
+ info(`${cfg.label}: has both "tools" and "permission" — verify "tools" is intentional`)
410
+ }
411
+
412
+ // 4. Check MCP servers list — verify tool references
413
+ // Look at agent files for mcpServers and cross-reference with opencode.json MCP config
414
+ if (args.verbose) {
415
+ checkAgentMcpReferences(cfg)
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Cross-reference agent mcpServers references with opencode.json MCP config entries.
422
+ */
423
+ function checkAgentMcpReferences(cfg) {
424
+ const agentFiles = readdirSync(AGENTS_DIR).filter((f) => f.endsWith('.agent.md'))
425
+ const mcpNames = new Set(Object.keys(cfg.data.mcp ?? {}))
426
+ const referencedNames = new Set()
427
+
428
+ for (const file of agentFiles) {
429
+ const content = readFileSync(join(AGENTS_DIR, file), 'utf8')
430
+ const match = content.match(/^---\n([\s\S]*?)\n---/)
431
+ if (!match) continue
432
+
433
+ try {
434
+ // Minimal YAML parse for mcpServers field
435
+ const fmRaw = match[1]
436
+ const mcpMatch = fmRaw.match(/mcpServers:\s*\n([\s\S]*?)(?=\n\w|\n---|$)/)
437
+ if (!mcpMatch) continue
438
+
439
+ const listRaw = mcpMatch[1]
440
+ const nameMatches = listRaw.matchAll(/^\s+-\s+name:\s*['"]?([\w-]+)['"]?\s*$/gm)
441
+ for (const nm of nameMatches) {
442
+ referencedNames.add(nm[1])
443
+ }
444
+ } catch {
445
+ // skip unparseable
446
+ }
447
+ }
448
+
449
+ for (const ref of referencedNames) {
450
+ if (!mcpNames.has(ref)) {
451
+ warn(`${cfg.label}: agent references MCP "${ref}" but it's not in opencode.json mcp config`)
452
+ }
453
+ }
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Check C: Permission Mismatches
458
+ // ---------------------------------------------------------------------------
459
+
460
+ function checkPermissionMismatches(args) {
461
+ section('C. Permission Mismatches')
462
+
463
+ // Locate the validation script
464
+ const validator = join(ROOT, 'scripts', 'validate-agent-frontmatter.py')
465
+ if (!existsSync(validator)) {
466
+ warn('validate-agent-frontmatter.py not found — cannot check permissions')
467
+ return
468
+ }
469
+
470
+ // Determine which agent directories to check
471
+ // Validation script checks ~/.config/opencode/agents and platform/opencode/agents
472
+ // Let's run it regardless
473
+ info('Running validate-agent-frontmatter.py...')
474
+
475
+ const result = spawn('python3', [validator])
476
+
477
+ if (result.status === 0) {
478
+ pass('Frontmatter validation passed')
479
+ if (result.stdout && args.verbose) {
480
+ console.log(` ${result.stdout}`)
481
+ }
482
+ } else {
483
+ // Parse output for errors and warnings
484
+ const lines = (result.stderr || '').split('\n').filter(Boolean)
485
+ const errLines = lines.filter((l) => l.startsWith('\u274C')) // ❌
486
+ const warnLines = lines.filter((l) => l.includes('\u26A0')) // ⚠️
487
+
488
+ if (errLines.length > 0) {
489
+ error(`${errLines.length} frontmatter error(s) found`)
490
+ if (args.verbose) {
491
+ for (const line of errLines) {
492
+ console.log(` ${line}`)
493
+ }
494
+ }
495
+ }
496
+
497
+ if (warnLines.length > 0) {
498
+ warn(`${warnLines.length} frontmatter warning(s) found`)
499
+ if (args.verbose) {
500
+ for (const line of warnLines) {
501
+ console.log(` ${line}`)
502
+ }
503
+ }
504
+ }
505
+
506
+ // If there were only warnings but no errors, the script exits 0
507
+ // If it exited non-zero, there are real errors
508
+ if (errLines.length === 0 && result.status !== 0) {
509
+ error('Frontmatter validation script failed')
510
+ if (result.stderr && args.verbose) {
511
+ console.log(` ${result.stderr}`)
512
+ }
513
+ }
514
+ }
515
+
516
+ // Also do a quick inline check for agents with bash: deny missing the ⛔ section
517
+ info('Checking permission.body consistency...')
518
+ const canonical = getCanonicalAgentNames()
519
+ let bodyIssues = 0
520
+
521
+ for (const agent of canonical) {
522
+ const filePath = join(AGENTS_DIR, `${agent}.agent.md`)
523
+ if (!existsSync(filePath)) continue
524
+
525
+ const content = readFileSync(filePath, 'utf8')
526
+
527
+ // Quick frontmatter parse
528
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
529
+ if (!fmMatch) continue
530
+
531
+ const rawFm = fmMatch[1]
532
+ const body = fmMatch[2]
533
+
534
+ // Check bash: deny → must have ⛔ TOOLS NOT AVAILABLE
535
+ const bashDeny = /permission:[\s\S]*?bash:\s*deny/.test(rawFm)
536
+ if (bashDeny && !body.includes('\u26D4 TOOLS NOT AVAILABLE'.normalize())) {
537
+ warn(`${agent}: permission.bash is deny but body missing "⛔ TOOLS NOT AVAILABLE"`)
538
+ bodyIssues++
539
+ }
540
+
541
+ // Check edit: deny → should not reference "edit" in body
542
+ const editDeny = /permission:[\s\S]*?edit:\s*deny/.test(rawFm)
543
+ if (editDeny) {
544
+ const editCount = (body.match(/\bedit\b/gi) || []).length
545
+ if (editCount > 0) {
546
+ warn(`${agent}: permission.edit is deny but "edit" appears ${editCount} time(s) in body`)
547
+ bodyIssues++
548
+ }
549
+ }
550
+ }
551
+
552
+ if (bodyIssues === 0) {
553
+ pass('All permission.body consistency checks passed')
554
+ }
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // Check D: Sync Status
559
+ // ---------------------------------------------------------------------------
560
+
561
+ function checkSyncStatus(args) {
562
+ section('D. Sync Status')
563
+
564
+ const syncScript = join(ROOT, 'scripts', 'sync-platforms.mjs')
565
+ if (!existsSync(syncScript)) {
566
+ warn('sync-platforms.mjs not found')
567
+ return
568
+ }
569
+
570
+ // Use the validate-sync approach: dry-run with clean
571
+ const result = spawn(process.execPath, [syncScript, '--dry-run', '--clean'])
572
+ // sync-platforms exits 1 when there are changes in dry-run mode
573
+
574
+ if (result.status === 0) {
575
+ pass('All platforms are in sync with canonical agents')
576
+ } else if (result.status === 1) {
577
+ // Parse output to determine what would change
578
+ const lines = (result.stdout || '').split('\n').filter(Boolean)
579
+ const updateLines = lines.filter((l) => l.includes('would update') || l.includes('~'))
580
+ const staleLines = lines.filter((l) => l.includes('stale'))
581
+
582
+ const updateCount = updateLines.length
583
+ const staleCount = staleLines.length
584
+
585
+ warn(
586
+ `Platforms are out of sync (${updateCount} file(s) would change${staleCount > 0 ? `, ${staleCount} stale` : ''})`,
587
+ )
588
+ if (args.verbose || updateCount + staleCount < 20) {
589
+ for (const line of updateLines) {
590
+ console.log(` ${line}`)
591
+ }
592
+ for (const line of staleLines) {
593
+ console.log(` ${line}`)
594
+ }
595
+ }
596
+
597
+ if (args.fix) {
598
+ info('Attempting --fix: running npm run sync...')
599
+ const syncResult = spawn('npm', ['run', 'sync'])
600
+ if (syncResult.status === 0) {
601
+ pass('Sync completed successfully')
602
+ } else {
603
+ error(`Sync failed (exit ${syncResult.status})`)
604
+ if (syncResult.stderr) {
605
+ console.log(` ${syncResult.stderr}`)
606
+ }
607
+ }
608
+ } else {
609
+ info('Use --fix to run "npm run sync" automatically')
610
+ }
611
+ } else {
612
+ error(`Sync dry-run failed (exit ${result.status})`)
613
+ if (result.stderr) {
614
+ console.log(` ${result.stderr}`)
615
+ }
616
+ }
617
+ }
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Check E: Git Status
621
+ // ---------------------------------------------------------------------------
622
+
623
+ function checkGitStatus(args) {
624
+ section('E. Git Status')
625
+
626
+ // Check if the target is a git repo
627
+ const gitRoot = spawn('git', ['rev-parse', '--show-toplevel'])
628
+ if (gitRoot.status !== 0) {
629
+ info('Not a git repository — skipping')
630
+ return
631
+ }
632
+
633
+ // Check for uncommitted changes in agents/ and platform/
634
+ const gitStatus = spawn('git', ['status', '--porcelain', '--', 'agents/', 'platform/'])
635
+
636
+ if (gitStatus.status !== 0) {
637
+ warn('Could not check git status')
638
+ return
639
+ }
640
+
641
+ const lines = gitStatus.stdout ? gitStatus.stdout.split('\n').filter(Boolean) : []
642
+
643
+ if (lines.length === 0) {
644
+ pass('No uncommitted changes in agents/ or platform/')
645
+ return
646
+ }
647
+
648
+ // Categorize by type
649
+ const modified = []
650
+ const added = []
651
+ const deleted = []
652
+ const untracked = []
653
+
654
+ for (const line of lines) {
655
+ const statusCode = line.substring(0, 2).trim()
656
+ const filePath = line.substring(3)
657
+ if (statusCode === 'M' || statusCode === 'MM') modified.push(filePath)
658
+ else if (statusCode === 'A') added.push(filePath)
659
+ else if (statusCode === 'D') deleted.push(filePath)
660
+ else if (statusCode === '?' || statusCode === '??') untracked.push(filePath)
661
+ else modified.push(filePath)
662
+ }
663
+
664
+ const total = lines.length
665
+ const details = []
666
+ if (modified.length > 0) details.push(`${modified.length} modified`)
667
+ if (added.length > 0) details.push(`${added.length} staged`)
668
+ if (deleted.length > 0) details.push(`${deleted.length} deleted`)
669
+ if (untracked.length > 0) details.push(`${untracked.length} untracked`)
670
+
671
+ warn(`${total} uncommitted change(s) in agents/ or platform/ (${details.join(', ')})`)
672
+ if (args.verbose) {
673
+ for (const f of modified.slice(0, 10)) {
674
+ console.log(` M ${f}`)
675
+ }
676
+ for (const f of added.slice(0, 5)) {
677
+ console.log(` A ${f}`)
678
+ }
679
+ for (const f of deleted.slice(0, 5)) {
680
+ console.log(` D ${f}`)
681
+ }
682
+ for (const f of untracked.slice(0, 5)) {
683
+ console.log(` ?? ${f}`)
684
+ }
685
+ if (lines.length > 20) {
686
+ console.log(` ... and ${lines.length - 20} more`)
687
+ }
688
+ }
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // Summary
693
+ // ---------------------------------------------------------------------------
694
+
695
+ function printSummary(targetPath) {
696
+ console.log(`\n${'='.repeat(60)}`)
697
+ console.log(' Summary')
698
+ console.log(`${'='.repeat(60)}`)
699
+ console.log(` ${ICON.pass} ${counts.pass} passed`)
700
+ console.log(` ${ICON.warn} ${counts.warn} warnings`)
701
+ console.log(` ${ICON.error} ${counts.error} errors`)
702
+ console.log(` ${ICON.info} ${counts.info} info`)
703
+ console.log('')
704
+
705
+ if (exitCode === 0) {
706
+ console.log(' ✅ All checks passed!')
707
+ } else if (exitCode === 1) {
708
+ console.log(' ⚠️ Warnings found — review above (some may be auto-fixable with --fix)')
709
+ } else {
710
+ console.log(' ❌ Errors found — needs attention')
711
+ }
712
+
713
+ console.log(`\n Target: ${process.argv.includes('--target') ? targetPath : 'current directory'}`)
714
+ console.log('')
715
+ }
716
+
717
+ // ---------------------------------------------------------------------------
718
+ // Main
719
+ // ---------------------------------------------------------------------------
720
+
721
+ function main() {
722
+ const args = parseArgs(process.argv)
723
+
724
+ if (args.help) {
725
+ showHelp()
726
+ process.exit(0)
727
+ }
728
+
729
+ if (!existsSync(args.target)) {
730
+ console.error(`❌ Target directory does not exist: ${args.target}`)
731
+ process.exit(2)
732
+ }
733
+
734
+ // Verify it looks like a Pantheon project (has agents/ and platform/)
735
+ const agentsOk = existsSync(join(args.target, 'agents'))
736
+ const platformOk = existsSync(join(args.target, 'platform'))
737
+
738
+ if (!agentsOk && !platformOk) {
739
+ // Maybe they pointed at a project root that isn't Pantheon
740
+ // Check if this is a Pantheon install target (has opencode.json with mcp)
741
+ const opencodeJson = readJson(join(args.target, 'opencode.json'))
742
+ if (!opencodeJson) {
743
+ warn(`Target does not appear to be a Pantheon project (no agents/ or platform/ directory)`)
744
+ }
745
+ }
746
+
747
+ const _isPantheonRoot =
748
+ join(args.target, 'scripts', 'doctor.mjs') === join(ROOT, 'scripts', 'doctor.mjs') ||
749
+ existsSync(join(args.target, 'scripts', 'doctor.mjs')) === false
750
+
751
+ // Run checks
752
+ checkAgentFiles(args)
753
+ checkMcpConfig(args)
754
+ checkPermissionMismatches(args)
755
+ checkSyncStatus(args)
756
+ checkGitStatus(args)
757
+
758
+ printSummary(args.target)
759
+
760
+ process.exit(exitCode)
761
+ }
762
+
763
+ main()