pantheon-opencode 1.4.3 → 1.5.0-beta.10

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 (118) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/manifest.json +21 -0
  12. package/.pantheon/code-mode/session-end-save.py +186 -0
  13. package/.pantheon/code-mode/session-end-save.sh +18 -0
  14. package/.pantheon/code-mode/test-allowed.sh +5 -0
  15. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  16. package/.pantheon/code-mode/test-env.sh +7 -0
  17. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  18. package/.pantheon/code-mode/test-timeout.sh +6 -0
  19. package/CHANGELOG.md +1026 -0
  20. package/README.md +292 -1080
  21. package/README.pt-BR.md +308 -0
  22. package/bin/pantheon-init.mjs +207 -31
  23. package/opencode.json +1 -0
  24. package/package.json +34 -14
  25. package/plugin.json +15 -0
  26. package/pyproject.toml +65 -0
  27. package/scripts/_pantheon_paths.py +48 -7
  28. package/scripts/ci-validate-yaml.py +7 -2
  29. package/scripts/code_mode_server.py +451 -44
  30. package/scripts/cost.mjs +30 -14
  31. package/scripts/doctor.mjs +306 -129
  32. package/scripts/generate-preset-docs.mjs +127 -66
  33. package/scripts/generate-prompts.sh +2 -2
  34. package/scripts/install/cli-ui.mjs +23 -10
  35. package/scripts/install/config-migration.mjs +399 -0
  36. package/scripts/install/health-check.mjs +3 -3
  37. package/scripts/install/model-picker.mjs +72 -23
  38. package/scripts/install/opencode-version.mjs +47 -0
  39. package/scripts/install/opencode.mjs +396 -121
  40. package/scripts/install/plugin.mjs +89 -49
  41. package/scripts/install/shared.mjs +55 -159
  42. package/scripts/install/strings.mjs +129 -0
  43. package/scripts/install-mcp.mjs +45 -232
  44. package/scripts/manifest-inventory.mjs +410 -0
  45. package/scripts/mcp_persistence_server.py +798 -60
  46. package/scripts/mcp_resources_server.py +144 -40
  47. package/scripts/memory_mcp_server.py +2 -2
  48. package/scripts/package-evidence.mjs +242 -0
  49. package/scripts/probe-context-rehydrate.mjs +240 -0
  50. package/scripts/probe-pantheon-cost.mjs +313 -0
  51. package/scripts/recover-zenodo-deposition.mjs +146 -0
  52. package/scripts/release-beta-version.mjs +56 -60
  53. package/scripts/sync-artifacts.mjs +169 -0
  54. package/scripts/sync-tui.mjs +135 -42
  55. package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
  56. package/scripts/uninstall.mjs +298 -954
  57. package/scripts/validate-zenodo-release.mjs +154 -0
  58. package/scripts/validation-policy.mjs +7 -7
  59. package/scripts/version-check.mjs +113 -108
  60. package/scripts/versioning.mjs +230 -86
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/agents/mnemosyne.md +1 -1
  64. package/src/agents/zeus.md +27 -16
  65. package/src/mcp/_pantheon_paths.py +48 -7
  66. package/src/mcp/code_mode_server.py +451 -44
  67. package/src/mcp/eval_store.py +1 -1
  68. package/src/mcp/mcp_persistence_server.py +798 -60
  69. package/src/mcp/mcp_resources_server.py +144 -40
  70. package/src/mcp/pantheon_vision_server.py +1 -1
  71. package/src/mcp/toon_codec.py +491 -0
  72. package/src/pantheon/auto-wake.ts +1 -5
  73. package/src/pantheon/background-job-board.ts +110 -2
  74. package/src/pantheon/compaction-handoff.ts +304 -0
  75. package/src/pantheon/cost-command.ts +356 -69
  76. package/src/pantheon/delegate-manager.ts +766 -0
  77. package/src/pantheon/delegation-classifier.ts +307 -0
  78. package/src/pantheon/delegation-enforce.ts +13 -12
  79. package/src/pantheon/delegation-finalize.ts +406 -0
  80. package/src/pantheon/delegation-notify.ts +212 -0
  81. package/src/pantheon/delegation-toast.ts +73 -0
  82. package/src/pantheon/delegation.ts +1337 -0
  83. package/src/pantheon/file-persistence-salvage.ts +64 -0
  84. package/src/pantheon/file-persistence.ts +194 -45
  85. package/src/pantheon/goal-loop.ts +2 -2
  86. package/src/pantheon/goal-store.ts +1 -1
  87. package/src/pantheon/hashline/tool.ts +2 -2
  88. package/src/pantheon/model-command.ts +2 -1
  89. package/src/pantheon/native-probe.ts +234 -0
  90. package/src/pantheon/native-task-status.ts +139 -0
  91. package/src/pantheon/permission-eval.ts +284 -0
  92. package/src/pantheon/provider-capabilities.ts +221 -0
  93. package/src/pantheon/provider-stream-tracker.ts +182 -0
  94. package/src/pantheon/shared-board.ts +37 -0
  95. package/src/pantheon/step-cap.ts +110 -0
  96. package/src/pantheon/task-result-guard.ts +107 -0
  97. package/src/pantheon/token-opt.ts +689 -0
  98. package/src/pantheon/tool-ceiling.ts +159 -0
  99. package/src/pantheon/toon-codec.ts +481 -0
  100. package/src/pantheon/tui-version-check.ts +1 -3
  101. package/src/pantheon/v2-bridge.ts +129 -0
  102. package/src/pantheon/v2-events.ts +148 -0
  103. package/src/pantheon/v2-hooks.ts +287 -0
  104. package/src/pantheon/v2-tool-definitions.ts +331 -0
  105. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  106. package/src/plugin-v2/index.ts +21 -0
  107. package/src/plugin-v2.ts +824 -0
  108. package/src/plugin.ts +261 -51
  109. package/src/plugins/pantheon-hooks.ts +51 -0
  110. package/src/plugins/tui/dist/tui.d.ts +116 -24
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +416 -149
  113. package/src/plugins/tui/dist/tui.tsx +456 -87
  114. package/src/plugins/tui/package-lock.json +51 -51
  115. package/src/plugins/tui/package.json +7 -7
  116. package/src/plugins/tui/src/index.tsx +462 -95
  117. package/src/skills/README.md +0 -2
  118. package/src/pantheon/tool-context.ts +0 -41
@@ -4,16 +4,15 @@
4
4
  * doctor.mjs — Pantheon health check CLI tool
5
5
  *
6
6
  * Validates that a Pantheon installation is working correctly:
7
- * - Agent file presence across canonical → platform
7
+ * - Canonical agent file presence
8
8
  * - MCP configuration (gh_grep, Context7, etc.)
9
9
  * - Permission/frontmatter mismatches
10
- * - Sync freshness
10
+ * - OpenCode artifact freshness
11
11
  * - Git status
12
12
  *
13
13
  * Usage:
14
14
  * node scripts/doctor.mjs # auto-detect, cwd
15
15
  * node scripts/doctor.mjs --target /path/to/project
16
- * node scripts/doctor.mjs --platform opencode
17
16
  * node scripts/doctor.mjs --fix # attempt auto-fixes
18
17
  * node scripts/doctor.mjs --verbose
19
18
  * node scripts/doctor.mjs --help
@@ -23,7 +22,8 @@
23
22
  */
24
23
 
25
24
  import { spawn as spawnAsync, spawnSync } from 'node:child_process'
26
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
25
+ import { createHash } from 'node:crypto'
26
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
27
27
  import { homedir } from 'node:os'
28
28
  import { basename, dirname, join, resolve } from 'node:path'
29
29
  import { fileURLToPath } from 'node:url'
@@ -35,15 +35,6 @@ import { fileURLToPath } from 'node:url'
35
35
  const __dirname = dirname(fileURLToPath(import.meta.url))
36
36
  const ROOT = join(__dirname, '..')
37
37
  const AGENTS_DIR = join(ROOT, 'src', 'agents')
38
- const PLATFORM_DIR = join(ROOT, 'platform')
39
-
40
- // All known platforms (must match platform/ subdirectories with adapter.json)
41
- const _ALL_PLATFORMS = ['opencode']
42
-
43
- const PLATFORM_LABELS = {
44
- opencode: 'OpenCode',
45
- }
46
-
47
38
  // ---------------------------------------------------------------------------
48
39
  // State
49
40
  // ---------------------------------------------------------------------------
@@ -73,9 +64,6 @@ function parseArgs(argv) {
73
64
  case '--target':
74
65
  args.target = argv[++i]
75
66
  break
76
- case '--platform':
77
- args.platform = argv[++i]
78
- break
79
67
  case '--fix':
80
68
  args.fix = true
81
69
  break
@@ -157,19 +145,18 @@ function showHelp() {
157
145
  Usage:
158
146
  node scripts/doctor.mjs auto-detect, cwd
159
147
  node scripts/doctor.mjs --target /path/to/project target project
160
- node scripts/doctor.mjs --platform opencode specific platform
161
148
  node scripts/doctor.mjs --profile global|lite|sandbox profile policy
162
149
  node scripts/doctor.mjs --fix attempt auto-fixes
163
150
  node scripts/doctor.mjs --verbose detailed output
164
151
  node scripts/doctor.mjs --help show this help
165
152
 
166
153
  Checks:
167
- A. Agent Files — canonical vs platform sync
154
+ A. Agent Files — canonical agent files
168
155
  B. MCP Configuration — opencode.json MCP settings (Config layer)
169
156
  B.5 MCP Spawn Paths — local MCP exe/args resolve to existing files
170
157
  B.6 MCP Runtime Smoke — spawn each local MCP + JSON-RPC initialize handshake
171
158
  C. Permission Checks — frontmatter validation
172
- D. Sync Status — npm run sync freshness
159
+ D. OpenCode Status — OpenCode artifact layout
173
160
  E. Git Status — uncommitted changes
174
161
  F. Runtime Layer — venv python + pinned pip dependencies
175
162
 
@@ -256,7 +243,7 @@ function spawn(prog, args, cwd = null) {
256
243
  // Check A: Agent Files
257
244
  // ---------------------------------------------------------------------------
258
245
 
259
- function checkAgentFiles(args) {
246
+ function checkAgentFiles() {
260
247
  section('A. Agent Files')
261
248
 
262
249
  const canonical = getCanonicalAgentNames()
@@ -265,86 +252,6 @@ function checkAgentFiles(args) {
265
252
  return
266
253
  }
267
254
  pass(`${canonical.length} canonical agents found in agents/`)
268
-
269
- // Determine which platforms to check
270
- if (!existsSync(PLATFORM_DIR)) {
271
- info(
272
- `Platform component not distributed in this package — platform check skipped (${args.profile} profile)`,
273
- )
274
- return
275
- }
276
- const platformDirs = readdirSync(PLATFORM_DIR, { withFileTypes: true })
277
- .filter((d) => d.isDirectory() && d.name !== '_template' && d.name !== 'plans')
278
- .map((d) => d.name)
279
- .filter((name) => !args.platform || name === args.platform)
280
-
281
- if (platformDirs.length === 0) {
282
- info(
283
- `No platform directories found in platform/ — platform check skipped (${args.profile} profile)`,
284
- )
285
- return
286
- }
287
-
288
- for (const platform of platformDirs) {
289
- const adapterPath = join(PLATFORM_DIR, platform, 'adapter.json')
290
- if (!existsSync(adapterPath)) {
291
- info(`${platform}: no adapter.json — skipping`)
292
- continue
293
- }
294
-
295
- const adapter = readJson(adapterPath)
296
- if (!adapter) {
297
- warn(`${platform}: adapter.json parse error — skipping`)
298
- continue
299
- }
300
-
301
- const label = PLATFORM_LABELS[platform] ?? platform
302
- const outputDir = adapter.outputDir ?? 'agents'
303
- const ext = adapter.fileExtension ?? '.md'
304
- const agentDir = join(PLATFORM_DIR, platform, outputDir)
305
-
306
- if (!existsSync(agentDir)) {
307
- if (adapter.skipAgentSync) {
308
- info(`${label}: agent sync skipped (skipAgentSync) — n/a`)
309
- continue
310
- }
311
- warn(`${label}: output directory missing: ${agentDir}`)
312
- continue
313
- }
314
-
315
- if (adapter.skipAgentSync) {
316
- info(`${label}: agent sync skipped (skipAgentSync) — skipping agent check`)
317
- continue
318
- }
319
-
320
- const platformFiles = readdirSync(agentDir)
321
- .filter((f) => statSync(join(agentDir, f)).isFile() && f.endsWith(ext))
322
- .map((f) => f.replace(new RegExp(`${ext}$`), ''))
323
-
324
- // Check every canonical agent exists in platform output
325
- let allPresent = true
326
- const missing = []
327
- for (const agent of canonical) {
328
- if (!platformFiles.includes(agent)) {
329
- missing.push(agent)
330
- allPresent = false
331
- }
332
- }
333
-
334
- // Detect phantom agents (in platform but not canonical — stale)
335
- const phantom = platformFiles.filter((f) => !canonical.includes(f))
336
-
337
- if (allPresent && phantom.length === 0) {
338
- pass(`${label}: all ${canonical.length} agents present`)
339
- } else {
340
- if (missing.length > 0) {
341
- error(`${label}: ${missing.length} canonical agent(s) missing: ${missing.join(', ')}`)
342
- }
343
- if (phantom.length > 0) {
344
- warn(`${label}: ${phantom.length} stale agent(s) in output: ${phantom.join(', ')}`)
345
- }
346
- }
347
- }
348
255
  }
349
256
 
350
257
  // ---------------------------------------------------------------------------
@@ -364,10 +271,12 @@ export function resolveOpenCodeConfigDir(env = process.env) {
364
271
  * @returns {{label:string, path:string, data:object}[]}
365
272
  */
366
273
  export function collectMcpConfigs(args) {
367
- const candidates = [
368
- { label: 'project root', path: join(args.target, 'opencode.json') },
369
- { label: 'repository root', path: join(ROOT, 'opencode.json') },
370
- ]
274
+ const candidates = [{ label: 'project root', path: join(args.target, 'opencode.json') }]
275
+ // The package's own template is useful when doctor is run from the
276
+ // repository, but must not be treated as a user's config from an installed
277
+ // package. Otherwise every global/project sandbox reports false MCP errors.
278
+ if (resolve(args.target) === resolve(ROOT))
279
+ candidates.push({ label: 'repository root', path: join(ROOT, 'opencode.json') })
371
280
 
372
281
  // OpenCode resolves user configuration independently of cwd. Keep the
373
282
  // project-local .opencode config for repository checks, but always include
@@ -481,7 +390,7 @@ function checkMcpConfig(args) {
481
390
  // ENOENT-on-spawn failure class (dead hardcoded path, ambiguous relative
482
391
  // path) before opencode tries to spawn the server.
483
392
  for (const [name, mcpEntry] of Object.entries(mcp)) {
484
- if (!mcpEntry || mcpEntry.type !== 'local') continue
393
+ if (mcpEntry?.type !== 'local') continue
485
394
  const cmd = mcpEntry.command
486
395
  if (!Array.isArray(cmd) || cmd.length === 0) {
487
396
  warn(`${cfg.label}: MCP "${name}" has no command array`)
@@ -495,11 +404,13 @@ function checkMcpConfig(args) {
495
404
  )
496
405
  continue
497
406
  }
498
- if (!existsSync(exe)) {
499
- error(`${cfg.label}: MCP "${name}" executable does not exist: ${exe}`)
407
+ const effectiveCwd = resolveMcpCwd(mcpEntry, cfg.path)
408
+ const effectiveExe = exe.startsWith('/') ? exe : resolve(effectiveCwd, exe)
409
+ if (!existsSync(effectiveExe)) {
410
+ error(`${cfg.label}: MCP "${name}" executable does not exist: ${effectiveExe}`)
500
411
  continue
501
412
  }
502
- const deadArgs = cmd.slice(1).filter((a) => a.startsWith('/') && !existsSync(a))
413
+ const deadArgs = resolveMcpScriptPaths(mcpEntry, cfg.path).filter((path) => !existsSync(path))
503
414
  if (deadArgs.length > 0) {
504
415
  error(`${cfg.label}: MCP "${name}" has missing script path(s): ${deadArgs.join(', ')}`)
505
416
  }
@@ -620,9 +531,13 @@ async function checkMcpRuntimeSmoke(args) {
620
531
  const local = []
621
532
  for (const cfg of collectMcpConfigs(args)) {
622
533
  for (const [name, entry] of Object.entries(cfg.data.mcp ?? {})) {
623
- if (!entry || entry.type !== 'local') continue
534
+ if (entry?.type !== 'local') continue
624
535
  if (!Array.isArray(entry.command) || entry.command.length === 0) continue
625
- local.push({ name, command: entry.command, cwd: dirname(cfg.path), source: cfg.label })
536
+ const cwd = resolveMcpCwd(entry, cfg.path)
537
+ const command = entry.command.map((part, index) =>
538
+ index === 0 || part.startsWith('-') || part.startsWith('/') ? part : resolve(cwd, part),
539
+ )
540
+ local.push({ name, command, cwd, source: cfg.label })
626
541
  }
627
542
  }
628
543
 
@@ -702,8 +617,7 @@ function checkPermissionMismatches(args) {
702
617
  }
703
618
 
704
619
  // Determine which agent directories to check
705
- // Validation script checks ~/.config/opencode/agents and platform/opencode/agents
706
- // Let's run it regardless
620
+ // Validate the canonical agents and the OpenCode installation independently.
707
621
  info('Running validate-agent-frontmatter.py...')
708
622
 
709
623
  const result = spawn('python3', [validator])
@@ -789,14 +703,35 @@ function checkPermissionMismatches(args) {
789
703
  }
790
704
 
791
705
  // ---------------------------------------------------------------------------
792
- // Check D: Sync Status
706
+ // Check D: OpenCode Status
793
707
  // ---------------------------------------------------------------------------
794
708
 
795
- function checkSyncStatus(_args) {
796
- section('D. Sync Status')
797
-
798
- // Platform sync removed in v1.0 (OpenCode-only)
799
- info('Sync check skipped — platform sync was removed in v1.0')
709
+ function checkSyncStatus(args) {
710
+ section('D. OpenCode Status')
711
+ const agentsDir = join(args.target, '.opencode', 'agents')
712
+ if (existsSync(agentsDir)) {
713
+ pass('OpenCode agents directory is present')
714
+ // Content gate: validate pantheon://agents CONTENT (zeus/hermes present),
715
+ // not just connectivity/directory presence.
716
+ const agentFiles = readdirSync(agentsDir).filter((f) => f.endsWith('.md'))
717
+ if (agentFiles.length === 0) {
718
+ warn('OpenCode agents directory is empty — pantheon://agents will serve no agents')
719
+ } else {
720
+ const names = new Set(agentFiles.map((f) => f.replace(/\.md$/, '').toLowerCase()))
721
+ const missingCanonical = ['zeus', 'hermes'].filter((n) => !names.has(n))
722
+ if (missingCanonical.length > 0) {
723
+ error(
724
+ `pantheon://agents content check failed — missing canonical agents in ${agentsDir}: ${missingCanonical.join(', ')}`,
725
+ )
726
+ } else {
727
+ pass(
728
+ `pantheon://agents content validated (zeus, hermes + ${agentFiles.length - 2} more agents)`,
729
+ )
730
+ }
731
+ }
732
+ } else {
733
+ info('OpenCode agents directory not present — project may use global agents')
734
+ }
800
735
  }
801
736
 
802
737
  // ---------------------------------------------------------------------------
@@ -813,8 +748,16 @@ function checkGitStatus(args) {
813
748
  return
814
749
  }
815
750
 
816
- // Check for uncommitted changes in agents/ and platform/
817
- const gitStatus = spawn('git', ['status', '--porcelain', '--', 'agents/', 'platform/'])
751
+ // Check the repository source and OpenCode configuration paths.
752
+ const gitStatus = spawn('git', [
753
+ 'status',
754
+ '--porcelain',
755
+ '--',
756
+ 'src/agents/',
757
+ 'src/instructions/',
758
+ '.opencode/',
759
+ 'opencode.json',
760
+ ])
818
761
 
819
762
  if (gitStatus.status !== 0) {
820
763
  warn('Could not check git status')
@@ -824,7 +767,7 @@ function checkGitStatus(args) {
824
767
  const lines = gitStatus.stdout ? gitStatus.stdout.split('\n').filter(Boolean) : []
825
768
 
826
769
  if (lines.length === 0) {
827
- pass('No uncommitted changes in agents/ or platform/')
770
+ pass('No uncommitted changes in Pantheon/OpenCode paths')
828
771
  return
829
772
  }
830
773
 
@@ -851,7 +794,7 @@ function checkGitStatus(args) {
851
794
  if (deleted.length > 0) details.push(`${deleted.length} deleted`)
852
795
  if (untracked.length > 0) details.push(`${untracked.length} untracked`)
853
796
 
854
- warn(`${total} uncommitted change(s) in agents/ or platform/ (${details.join(', ')})`)
797
+ warn(`${total} uncommitted change(s) in Pantheon/OpenCode paths (${details.join(', ')})`)
855
798
  if (args.verbose) {
856
799
  for (const f of modified.slice(0, 10)) {
857
800
  console.log(` M ${f}`)
@@ -963,8 +906,7 @@ function checkRequirementLine(line, installed) {
963
906
  function checkVenvLayer(args) {
964
907
  section('F. Runtime Layer — Venv, Python & Dependencies')
965
908
 
966
- const configDir = resolveOpenCodeConfigDir()
967
- const venvPython = join(configDir, '.venv', 'bin', 'python3')
909
+ const venvPython = resolveRuntimePython(args)
968
910
 
969
911
  if (!existsSync(venvPython)) {
970
912
  if (args.profile === 'lite') {
@@ -1013,6 +955,236 @@ function checkVenvLayer(args) {
1013
955
  }
1014
956
  }
1015
957
 
958
+ // ---------------------------------------------------------------------------
959
+ // Check F.2: Code-Mode Scripts Directory
960
+ // ---------------------------------------------------------------------------
961
+
962
+ /**
963
+ * Verify the code-mode scripts directory exists for the resolved runtime
964
+ * root (project → <target>/.opencode, otherwise the global config dir).
965
+ * When absent, create it with a warning so the pantheon-code-mode MCP
966
+ * server has a stable home (mirrors the installer's runtime seeding).
967
+ */
968
+ export function checkCodeModeDir(args) {
969
+ section('F.2 Code-Mode Scripts')
970
+ const env = args.env ?? process.env
971
+ const target = resolve(env.PANTHEON_PROJECT ?? args.target)
972
+ const projectCandidates = [
973
+ join(target, '.opencode', '.pantheon', 'code-mode'),
974
+ join(target, '.pantheon', 'code-mode'),
975
+ ]
976
+ // Seeding semantics: an existing project candidate wins; a project
977
+ // install (.opencode present) gets its runtime dir created when missing.
978
+ // Only targets with no project layout fall back to the global config dir.
979
+ const codeModeDir =
980
+ projectCandidates.find((candidate) => existsSync(candidate)) ??
981
+ (existsSync(join(target, '.opencode'))
982
+ ? projectCandidates[0]
983
+ : join(resolveOpenCodeConfigDir(env), '.pantheon', 'code-mode'))
984
+ if (existsSync(codeModeDir)) {
985
+ pass(`Code-mode scripts directory exists: ${codeModeDir}`)
986
+ return codeModeDir
987
+ }
988
+ warn(`Code-mode scripts directory missing — creating empty ${codeModeDir}`)
989
+ try {
990
+ mkdirSync(codeModeDir, { recursive: true })
991
+ info(`Created ${codeModeDir} (run \`pantheon-opencode init --components runtime\` to seed it)`)
992
+ } catch (err) {
993
+ error(`Cannot create code-mode directory ${codeModeDir}: ${err.message}`)
994
+ }
995
+ return codeModeDir
996
+ }
997
+
998
+ /** Resolve code-mode using the same project-first rule as the MCP server. */
999
+ export function resolveCodeModeDir(args) {
1000
+ const env = args.env ?? process.env
1001
+ const target = resolve(env.PANTHEON_PROJECT ?? args.target)
1002
+ const projectCandidates = [
1003
+ join(target, '.opencode', '.pantheon', 'code-mode'),
1004
+ join(target, '.pantheon', 'code-mode'),
1005
+ ]
1006
+ const projectDir = projectCandidates.find((candidate) => existsSync(candidate))
1007
+ if (projectDir) return projectDir
1008
+ return join(resolveOpenCodeConfigDir(env), '.pantheon', 'code-mode')
1009
+ }
1010
+
1011
+ /** Validate a code-mode manifest without modifying it. */
1012
+ export function validateCodeModeManifest(codeModeDir) {
1013
+ const manifestPath = join(codeModeDir, 'manifest.json')
1014
+ if (!existsSync(manifestPath)) {
1015
+ return { ok: false, message: `Code-mode manifest missing: ${manifestPath}` }
1016
+ }
1017
+
1018
+ let data
1019
+ try {
1020
+ data = JSON.parse(readFileSync(manifestPath, 'utf8'))
1021
+ } catch {
1022
+ return { ok: false, message: `Code-mode manifest malformed: ${manifestPath}` }
1023
+ }
1024
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
1025
+ return { ok: false, message: 'Code-mode manifest malformed: root must be an object' }
1026
+ }
1027
+ if (data.version !== 1) {
1028
+ return { ok: false, message: 'Code-mode manifest malformed: version must be 1' }
1029
+ }
1030
+ if (!data.scripts || typeof data.scripts !== 'object' || Array.isArray(data.scripts)) {
1031
+ return { ok: false, message: 'Code-mode manifest malformed: scripts must be an object' }
1032
+ }
1033
+
1034
+ const listed = Object.keys(data.scripts)
1035
+ const invalid = listed.filter(
1036
+ (name) => typeof data.scripts[name] !== 'string' || !/^[a-f0-9]{64}$/i.test(data.scripts[name]),
1037
+ )
1038
+ if (invalid.length) {
1039
+ return { ok: false, message: `Code-mode manifest invalid digest: ${invalid.join(', ')}` }
1040
+ }
1041
+
1042
+ const actualScripts = readdirSync(codeModeDir).filter(
1043
+ (name) =>
1044
+ (name.endsWith('.py') || name.endsWith('.sh')) && statSync(join(codeModeDir, name)).isFile(),
1045
+ )
1046
+ const missing = listed.filter((name) => !existsSync(join(codeModeDir, name)))
1047
+ const extra = actualScripts.filter((name) => !Object.hasOwn(data.scripts, name))
1048
+ const differing = listed.filter((name) => {
1049
+ const path = join(codeModeDir, name)
1050
+ if (!existsSync(path)) return false
1051
+ const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
1052
+ return digest !== data.scripts[name].toLowerCase()
1053
+ })
1054
+ if (missing.length || extra.length || differing.length) {
1055
+ const names = [...new Set([...missing, ...extra, ...differing])]
1056
+ return {
1057
+ ok: false,
1058
+ message: `Code-mode manifest mismatch: ${differing.length + missing.length + extra.length}/${listed.length} scripts differ — ${names.join(', ')}`,
1059
+ }
1060
+ }
1061
+ return { ok: true, count: listed.length, total: actualScripts.length }
1062
+ }
1063
+
1064
+ /** Run the diagnostic manifest check after directory validation. */
1065
+ export function checkCodeModeManifest(args) {
1066
+ const codeModeDir = resolveCodeModeDir(args)
1067
+ const result = validateCodeModeManifest(codeModeDir)
1068
+ if (!result.ok) {
1069
+ error(result.message)
1070
+ return
1071
+ }
1072
+ pass(`✅ Code-mode manifest valid: ${result.count}/${result.total} scripts verified`)
1073
+ }
1074
+
1075
+ /** Resolve the venv created by init for either a project or global install. */
1076
+ export function resolveRuntimePython(args) {
1077
+ const target = resolve(args.target)
1078
+ const isProject = existsSync(join(target, '.opencode'))
1079
+ const runtimeRoot = isProject ? target : resolveOpenCodeConfigDir(args.env ?? process.env)
1080
+ return join(runtimeRoot, '.venv', 'bin', 'python3')
1081
+ }
1082
+
1083
+ /** Return relative script arguments as absolute paths using an MCP entry cwd. */
1084
+ export function resolveMcpScriptPaths(entry, configPath = '') {
1085
+ const cwd = resolveMcpCwd(entry, configPath)
1086
+ if (!Array.isArray(entry?.command)) return []
1087
+ return entry.command
1088
+ .slice(1)
1089
+ .filter(
1090
+ (arg) =>
1091
+ typeof arg === 'string' &&
1092
+ !arg.startsWith('-') &&
1093
+ (arg.endsWith('.py') || arg.includes('/')),
1094
+ )
1095
+ .map((arg) => (arg.startsWith('/') ? arg : resolve(cwd, arg)))
1096
+ }
1097
+
1098
+ /** Resolve an MCP entry's cwd relative to the config file, as OpenCode does. */
1099
+ export function resolveMcpCwd(entry, configPath = '') {
1100
+ const configured = entry?.cwd
1101
+ if (typeof configured === 'string' && configured !== '') {
1102
+ return configured.startsWith('/') ? configured : resolve(dirname(configPath), configured)
1103
+ }
1104
+ return dirname(configPath)
1105
+ }
1106
+
1107
+ // ---------------------------------------------------------------------------
1108
+ // Check H2: installed-version drift (beta.5)
1109
+ // ---------------------------------------------------------------------------
1110
+
1111
+ /**
1112
+ * Prerelease-aware comparison for pantheon versions (X.Y.Z or X.Y.Z-beta.N).
1113
+ * A stable release outranks any beta of the same core version. Returns
1114
+ * <0, 0, >0 (a - b). The shared compareVersions/parseVersion above ignores
1115
+ * the `-beta.N` suffix (it only reads 4 numeric groups), which would make
1116
+ * every beta pair compare equal.
1117
+ */
1118
+ function comparePantheonVersions(a, b) {
1119
+ const parse = (v) => {
1120
+ const m = String(v)
1121
+ .trim()
1122
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/)
1123
+ if (!m) return null
1124
+ return {
1125
+ core: [+m[1], +m[2], +m[3]],
1126
+ beta: m[4] === undefined ? Number.POSITIVE_INFINITY : +m[4],
1127
+ }
1128
+ }
1129
+ const pa = parse(a)
1130
+ const pb = parse(b)
1131
+ if (!pa || !pb) return 0
1132
+ for (let i = 0; i < 3; i++) {
1133
+ if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]
1134
+ }
1135
+ return pa.beta - pb.beta
1136
+ }
1137
+
1138
+ /**
1139
+ * Compare the version recorded in the installation marker
1140
+ * (<config>/.pantheon/install-state.json, written by the postinstall sync)
1141
+ * against this package's version. When the package is newer, the config
1142
+ * merge/venv/MCP wiring may be stale — point the user at `update`.
1143
+ * Advisory only: a missing marker (pre-beta.5 install) is not an error.
1144
+ */
1145
+ export function checkInstallVersionDrift(args) {
1146
+ section('H2 Installed Version')
1147
+ const env = args.env ?? process.env
1148
+ let pkgVersion = null
1149
+ try {
1150
+ pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1151
+ } catch {
1152
+ return 'no-marker'
1153
+ }
1154
+ if (!pkgVersion) return 'no-marker'
1155
+
1156
+ const statePath = join(resolveOpenCodeConfigDir(env), '.pantheon', 'install-state.json')
1157
+ let installed = null
1158
+ if (existsSync(statePath)) {
1159
+ try {
1160
+ installed = JSON.parse(readFileSync(statePath, 'utf8')).pantheon_version ?? null
1161
+ } catch {
1162
+ installed = null
1163
+ }
1164
+ }
1165
+ if (!installed) {
1166
+ info('No installation version marker found (pre-beta.5 install) — skipping drift check')
1167
+ return 'no-marker'
1168
+ }
1169
+ const drift = comparePantheonVersions(pkgVersion, installed)
1170
+ if (drift > 0) {
1171
+ warn(
1172
+ `Package v${pkgVersion} is newer than the last-synced installation (v${installed}) — ` +
1173
+ 'run `npx pantheon-opencode update` (or re-run init) to refresh config, venv and MCP entries',
1174
+ )
1175
+ return 'package-newer'
1176
+ }
1177
+ if (drift < 0) {
1178
+ warn(
1179
+ `Installation marker (v${installed}) is newer than this package (v${pkgVersion}) — ` +
1180
+ 'the doctor is running from a different install than the last sync',
1181
+ )
1182
+ return 'install-newer'
1183
+ }
1184
+ pass(`Installation in sync with package v${pkgVersion}`)
1185
+ return 'sync'
1186
+ }
1187
+
1016
1188
  // ---------------------------------------------------------------------------
1017
1189
  // Check G: AGENTS.md freshness
1018
1190
  // ---------------------------------------------------------------------------
@@ -1285,16 +1457,18 @@ async function main() {
1285
1457
  process.exit(2)
1286
1458
  }
1287
1459
 
1288
- // Verify it looks like a Pantheon project (has agents/ and platform/)
1289
- const agentsOk = existsSync(join(args.target, 'agents'))
1290
- const platformOk = existsSync(join(args.target, 'platform'))
1460
+ // Verify it looks like a Pantheon project or an OpenCode install target.
1461
+ const agentsOk = existsSync(join(args.target, 'src', 'agents'))
1462
+ const opencodeOk = existsSync(join(args.target, '.opencode'))
1291
1463
 
1292
- if (!agentsOk && !platformOk) {
1464
+ if (!agentsOk && !opencodeOk) {
1293
1465
  // Maybe they pointed at a project root that isn't Pantheon
1294
1466
  // Check if this is a Pantheon install target (has opencode.json with mcp)
1295
1467
  const opencodeJson = readJson(join(args.target, 'opencode.json'))
1296
1468
  if (!opencodeJson) {
1297
- warn(`Target does not appear to be a Pantheon project (no agents/ or platform/ directory)`)
1469
+ warn(
1470
+ 'Target does not appear to be a Pantheon project (no src/agents/ or .opencode/ directory)',
1471
+ )
1298
1472
  }
1299
1473
  }
1300
1474
 
@@ -1306,6 +1480,9 @@ async function main() {
1306
1480
  checkAgentFiles(args)
1307
1481
  checkMcpConfig(args)
1308
1482
  checkVenvLayer(args)
1483
+ checkCodeModeDir(args)
1484
+ checkCodeModeManifest(args)
1485
+ checkInstallVersionDrift(args)
1309
1486
  await checkMcpRuntimeSmoke(args)
1310
1487
  checkPermissionMismatches(args)
1311
1488
  checkSyncStatus(args)