pantheon-opencode 1.5.1 → 1.6.0-beta.1

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 (62) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/CHANGELOG.md +22 -0
  6. package/README.md +86 -52
  7. package/README.pt-BR.md +63 -43
  8. package/commands/pantheon-optimize.md +2 -2
  9. package/package.json +13 -1
  10. package/plugin.json +1 -1
  11. package/pyproject.toml +8 -15
  12. package/scripts/build-agents-md.mjs +2 -2
  13. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  14. package/scripts/ci-validate-yaml.py +7 -5
  15. package/scripts/code_mode_server.py +116 -24
  16. package/scripts/doctor.mjs +85 -28
  17. package/scripts/install/cli-ui.mjs +1 -1
  18. package/scripts/install/config-migration.mjs +2 -2
  19. package/scripts/install/health-check.mjs +6 -19
  20. package/scripts/install/interactive.mjs +6 -6
  21. package/scripts/install/migrate.mjs +1 -1
  22. package/scripts/install/state.mjs +27 -2
  23. package/scripts/mcp_persistence_server.py +390 -76
  24. package/scripts/memory_mcp_server.py +370 -196
  25. package/scripts/postinstall.mjs +0 -7
  26. package/scripts/probe-context-rehydrate.mjs +28 -11
  27. package/scripts/prune.mjs +2 -2
  28. package/scripts/sync-tui.mjs +11 -5
  29. package/scripts/test-opencode-v2-sandbox.sh +1759 -0
  30. package/scripts/validate-routing.mjs +40 -0
  31. package/skills-lock.json +9 -10
  32. package/src/agents/mnemosyne.md +15 -16
  33. package/src/agents/zeus.md +1 -2
  34. package/src/mcp/code_mode_server.py +116 -24
  35. package/src/mcp/mcp_codemap_module.py +559 -129
  36. package/src/mcp/mcp_persistence_server.py +390 -76
  37. package/src/mcp/memory_mcp_server.py +371 -248
  38. package/src/mcp/pantheon_vision_server.py +136 -24
  39. package/src/mcp/requirements-mcp.txt +5 -3
  40. package/src/mcp/requirements-vision.txt +2 -0
  41. package/src/pantheon/model-command.ts +18 -7
  42. package/src/pantheon/todo-enforcer.ts +1 -1
  43. package/src/pantheon/vision.ts +1 -1
  44. package/src/plugin-v2.ts +134 -104
  45. package/src/plugins/tui/dist/tui.js +1 -1
  46. package/src/plugins/tui/package-lock.json +20 -20
  47. package/src/plugins/tui/package.json +12 -12
  48. package/src/routing.yml +0 -1
  49. package/src/skills/README.md +31 -36
  50. package/src/skills/auto-continue/SKILL.md +25 -75
  51. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  52. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  53. package/.pantheon/code-mode/eval-run.py +0 -197
  54. package/.pantheon/code-mode/eval-static.py +0 -321
  55. package/.pantheon/code-mode/session-end-save.py +0 -186
  56. package/.pantheon/code-mode/session-end-save.sh +0 -18
  57. package/scripts/recover-zenodo-deposition.mjs +0 -146
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  59. package/scripts/validate-zenodo-release.mjs +0 -154
  60. package/scripts/zenodo-artifact.mjs +0 -21
  61. package/scripts/zenodo-release-state.mjs +0 -53
  62. package/src/skills/orchestration-workflow/SKILL.md +0 -311
@@ -0,0 +1,213 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+
7
+ export const REQUIRED_NODE_VERSION = 'v24.15.0'
8
+ export const LINE_THRESHOLD_NUMERATOR = 4
9
+ export const LINE_THRESHOLD_DENOMINATOR = 5
10
+
11
+ function integerField(value, name, minimum) {
12
+ const label = minimum === 0 ? 'non-negative' : 'positive'
13
+ if (!/^\d+$/.test(value)) {
14
+ throw new Error(`${name} must be a ${label} integer`)
15
+ }
16
+ const parsed = Number(value)
17
+ if (!Number.isSafeInteger(parsed) || parsed < minimum) {
18
+ throw new Error(`${name} must be a ${label} integer`)
19
+ }
20
+ return parsed
21
+ }
22
+
23
+ function normalizeSourcePath(source, repoRoot) {
24
+ const path = source.startsWith('file:') ? fileURLToPath(source) : source
25
+ return resolve(repoRoot, path)
26
+ }
27
+
28
+ function exactlyOneField(lines, name) {
29
+ const values = lines.filter((line) => line.startsWith(`${name}:`))
30
+ if (values.length !== 1) {
31
+ throw new Error(`target LCOV record must contain exactly one ${name} field`)
32
+ }
33
+ return values[0].slice(name.length + 1)
34
+ }
35
+
36
+ function optionalPair(lines, foundName, hitName) {
37
+ const foundFields = lines.filter((line) => line.startsWith(`${foundName}:`))
38
+ const hitFields = lines.filter((line) => line.startsWith(`${hitName}:`))
39
+ if (foundFields.length === 0 && hitFields.length === 0) return null
40
+ if (foundFields.length !== 1 || hitFields.length !== 1) {
41
+ throw new Error(`target LCOV record must contain one ${foundName}/${hitName} pair`)
42
+ }
43
+
44
+ const found = integerField(foundFields[0].slice(foundName.length + 1), foundName, 0)
45
+ const hit = integerField(hitFields[0].slice(hitName.length + 1), hitName, 0)
46
+ if (hit > found) throw new Error(`${hitName} ${hit} exceeds ${foundName} ${found}`)
47
+ return { hit, found }
48
+ }
49
+
50
+ /** Parse and validate the single LCOV record for the exact target source. */
51
+ export function parseTargetLcov(report, repoRoot, targetPath) {
52
+ if (typeof report !== 'string' || report.trim() === '') {
53
+ throw new Error('LCOV report is empty')
54
+ }
55
+ const normalizedRoot = resolve(repoRoot)
56
+ const normalizedTarget = resolve(targetPath)
57
+ const records = []
58
+ let current = []
59
+ for (const line of report.split(/\r?\n/)) {
60
+ if (line === 'end_of_record') {
61
+ if (current.length > 0) records.push(current)
62
+ current = []
63
+ } else if (line.trim() !== '') {
64
+ current.push(line)
65
+ }
66
+ }
67
+ if (current.length > 0) records.push(current)
68
+
69
+ const targetRecords = []
70
+ for (const lines of records) {
71
+ const sourceFields = lines.filter((line) => line.startsWith('SF:'))
72
+ const matchingSources = sourceFields.filter(
73
+ (line) => normalizeSourcePath(line.slice(3), normalizedRoot) === normalizedTarget,
74
+ )
75
+ if (matchingSources.length === 0) continue
76
+ if (sourceFields.length !== 1 || matchingSources.length !== 1) {
77
+ throw new Error('target LCOV record must contain exactly one SF entry')
78
+ }
79
+ targetRecords.push(lines)
80
+ }
81
+
82
+ if (targetRecords.length !== 1) {
83
+ throw new Error(
84
+ `expected exactly one LCOV record for ${normalizedTarget}; found ${targetRecords.length} LCOV records`,
85
+ )
86
+ }
87
+
88
+ const lines = targetRecords[0]
89
+ const lineRecords = new Map()
90
+ for (const entry of lines.filter((line) => line.startsWith('DA:'))) {
91
+ const fields = entry.slice(3).split(',')
92
+ if (fields.length < 2 || fields.length > 3) {
93
+ throw new Error('DA entry must contain a line number and execution count')
94
+ }
95
+ const lineNumber = integerField(fields[0], 'DA line number', 1)
96
+ const count = integerField(fields[1], 'DA count', 0)
97
+ if (lineRecords.has(lineNumber)) {
98
+ throw new Error(`duplicate DA line number ${lineNumber}`)
99
+ }
100
+ lineRecords.set(lineNumber, count)
101
+ }
102
+
103
+ const found = integerField(exactlyOneField(lines, 'LF'), 'LF', 1)
104
+ const hit = integerField(exactlyOneField(lines, 'LH'), 'LH', 0)
105
+ if (hit > found) throw new Error(`LH ${hit} exceeds LF ${found}`)
106
+ if (found !== lineRecords.size) {
107
+ throw new Error(`LF ${found} does not equal ${lineRecords.size} unique DA entries`)
108
+ }
109
+
110
+ const coveredRecords = [...lineRecords.values()].filter((count) => count > 0).length
111
+ if (hit !== coveredRecords) {
112
+ throw new Error(`LH ${hit} does not equal ${coveredRecords} covered DA entries`)
113
+ }
114
+
115
+ return {
116
+ sourcePath: normalizedTarget,
117
+ lines: { hit, found },
118
+ branches: optionalPair(lines, 'BRF', 'BRH'),
119
+ functions: optionalPair(lines, 'FNF', 'FNH'),
120
+ }
121
+ }
122
+
123
+ /** Compare using integer arithmetic so the 80% boundary cannot round down. */
124
+ export function passesLineThreshold(coverage) {
125
+ return (
126
+ BigInt(coverage.lines.hit) * BigInt(LINE_THRESHOLD_DENOMINATOR) >=
127
+ BigInt(coverage.lines.found) * BigInt(LINE_THRESHOLD_NUMERATOR)
128
+ )
129
+ }
130
+
131
+ function formatMetric(metric) {
132
+ if (metric == null || metric.found === 0) return 'not reported'
133
+ return `${metric.hit}/${metric.found} (${((metric.hit / metric.found) * 100).toFixed(2)}%)`
134
+ }
135
+
136
+ function listTypeScriptTests(repoRoot) {
137
+ const directory = join(repoRoot, 'tests', 'pantheon')
138
+ return readdirSync(directory)
139
+ .filter((file) => file.endsWith('.test.ts'))
140
+ .sort()
141
+ .map((file) => join(directory, file))
142
+ }
143
+
144
+ function runCoverage() {
145
+ if (process.version !== REQUIRED_NODE_VERSION) {
146
+ throw new Error(
147
+ `plugin-v2 coverage requires Node ${REQUIRED_NODE_VERSION}; got ${process.version}`,
148
+ )
149
+ }
150
+
151
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
152
+ const targetPath = resolve(repoRoot, 'src', 'plugin-v2.ts')
153
+ const testFiles = listTypeScriptTests(repoRoot)
154
+ if (testFiles.length === 0) throw new Error('no tests/pantheon/*.test.ts files found')
155
+
156
+ const tempRoot = mkdtempSync(join(tmpdir(), 'pantheon-plugin-v2-coverage-'))
157
+ try {
158
+ const lcovPath = join(tempRoot, 'coverage.lcov')
159
+ const nodeOptions = [process.env.NODE_OPTIONS, '--conditions=import'].filter(Boolean).join(' ')
160
+ const args = [
161
+ '--experimental-test-coverage',
162
+ '--enable-source-maps',
163
+ '--import',
164
+ 'tsx',
165
+ '--test',
166
+ '--test-concurrency=1',
167
+ '--test-reporter=spec',
168
+ '--test-reporter=lcov',
169
+ '--test-reporter-destination=stdout',
170
+ `--test-reporter-destination=${lcovPath}`,
171
+ ...testFiles,
172
+ ]
173
+ const result = spawnSync(process.execPath, args, {
174
+ cwd: repoRoot,
175
+ encoding: 'utf8',
176
+ env: { ...process.env, NODE_OPTIONS: nodeOptions },
177
+ maxBuffer: 32 * 1024 * 1024,
178
+ })
179
+ if (result.stdout) process.stdout.write(result.stdout)
180
+ if (result.stderr) process.stderr.write(result.stderr)
181
+ if (result.error) throw new Error(`TypeScript test command failed: ${result.error.message}`)
182
+ if (result.status !== 0) {
183
+ throw new Error(`TypeScript test command exited ${result.status ?? 'without a status'}`)
184
+ }
185
+
186
+ let report
187
+ try {
188
+ report = readFileSync(lcovPath, 'utf8')
189
+ } catch (error) {
190
+ throw new Error(`LCOV report was not generated: ${error.message}`)
191
+ }
192
+ const coverage = parseTargetLcov(report, repoRoot, targetPath)
193
+ console.log(`Coverage source: ${coverage.sourcePath}`)
194
+ console.log(`Lines: ${formatMetric(coverage.lines)}`)
195
+ console.log(`Branches (informational): ${formatMetric(coverage.branches)}`)
196
+ console.log(`Functions (informational): ${formatMetric(coverage.functions)}`)
197
+ if (!passesLineThreshold(coverage)) {
198
+ throw new Error('src/plugin-v2.ts line coverage is below the required 80%')
199
+ }
200
+ console.log('src/plugin-v2.ts line coverage: PASS (>= 80%)')
201
+ } finally {
202
+ rmSync(tempRoot, { recursive: true, force: true })
203
+ }
204
+ }
205
+
206
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
207
+ try {
208
+ runCoverage()
209
+ } catch (error) {
210
+ console.error(`Plugin V2 coverage gate: ${error.message}`)
211
+ process.exitCode = 1
212
+ }
213
+ }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """Validate agent YAML frontmatter in CI."""
3
+
3
4
  import os
4
5
  import re
5
6
  import sys
@@ -7,18 +8,19 @@ import sys
7
8
  import yaml
8
9
 
9
10
  errors = 0
10
- for f in sorted(os.listdir('src/agents')):
11
- if not f.endswith('.md'):
11
+ for f in sorted(os.listdir("src/agents")):
12
+ if not f.endswith(".md"):
12
13
  continue
13
14
  with open(f"src/agents/{f}", encoding="utf-8") as agent_file:
14
15
  content = agent_file.read()
15
- match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
16
+ match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
16
17
  if match:
17
18
  try:
18
19
  yaml.safe_load(match.group(1))
19
20
  except yaml.YAMLError as e:
20
- print(f'FAIL: {f}: {e}')
21
+ print(f"FAIL: {f}: {e}")
21
22
  errors += 1
22
23
 
23
- print(f'{sum(1 for _ in os.listdir("src/agents") if _.endswith(".md")) - errors}/{sum(1 for _ in os.listdir("src/agents") if _.endswith(".md"))} agents valid')
24
+ agent_count = sum(1 for _ in os.listdir("src/agents") if _.endswith(".md"))
25
+ print(f"{agent_count - errors}/{agent_count} agents valid")
24
26
  sys.exit(errors)
@@ -20,6 +20,11 @@ import hashlib
20
20
  import json
21
21
  import logging
22
22
  import os
23
+
24
+ try:
25
+ import resource
26
+ except (ImportError, OSError, ValueError): # pragma: no cover - platform-specific
27
+ resource = None # type: ignore[assignment]
23
28
  import shutil
24
29
  import signal
25
30
  import stat
@@ -125,6 +130,88 @@ def _build_script_env(
125
130
  return env
126
131
 
127
132
 
133
+ # Additional tasks a sandboxed script may create beyond the UID's current
134
+ # count. RLIMIT_NPROC is a per-real-UID limit on the *total* number of
135
+ # processes AND threads, so the sandbox floor must clear everything the host
136
+ # already owns before a script can fork at all. This headroom is the actual
137
+ # anti-fork-bomb budget: the script still cannot exceed current + headroom.
138
+ NPROC_HEADROOM: int = 64
139
+
140
+
141
+ def _uid_task_count() -> int | None:
142
+ """Count the tasks (processes + threads) this real UID already owns.
143
+
144
+ The kernel compares the post-fork total against RLIMIT_NPROC, so this is
145
+ the number the sandbox limit has to exceed. Returns *None* when the count
146
+ cannot be established (no readable ``/proc``), which makes the caller
147
+ fail open on ``--nproc`` rather than pin a value that may be too low and
148
+ break every script.
149
+
150
+ The tally is one too high per process: ``/proc/<pid>/task`` already lists
151
+ the thread-group leader, so the extra ``1`` counts it twice. That bias
152
+ only widens the derived limit (the fail-open direction), so it is left in
153
+ place deliberately.
154
+ """
155
+ uid = os.getuid()
156
+ count = 0
157
+ try:
158
+ pids = os.listdir("/proc")
159
+ except OSError:
160
+ return None
161
+ for pid in pids:
162
+ if not pid.isdigit():
163
+ continue
164
+ proc_dir = f"/proc/{pid}"
165
+ try:
166
+ if os.stat(proc_dir).st_uid != uid:
167
+ continue
168
+ # ``/proc/<pid>/task`` already includes the thread-group leader,
169
+ # so the extra 1 counts it twice (over-count by one per process).
170
+ count += 1 + len(os.listdir(f"{proc_dir}/task"))
171
+ except OSError:
172
+ # Process exited between listdir and stat, or is not ours to read.
173
+ continue
174
+ return count
175
+
176
+
177
+ def _sandbox_nproc() -> str | None:
178
+ """Return the ``--nproc`` value for a sandboxed script, or *None*.
179
+
180
+ Derived from the host rather than hardcoded: the value is the UID's
181
+ current task count plus ``NPROC_HEADROOM``, clamped to the hard limit
182
+ (``prlimit`` fails with EPERM above it, and an unprivileged process cannot
183
+ raise its soft limit past the hard one).
184
+
185
+ Returns *None* when the count or the limit cannot be read, in which case
186
+ the caller omits ``--nproc``. ``--as`` and ``--cpu`` still apply, so the
187
+ sandbox keeps its memory and CPU boundaries.
188
+ """
189
+ current = _uid_task_count()
190
+ if current is None:
191
+ _log.warning(
192
+ "code-mode: cannot count this UID's tasks; omitting --nproc, so "
193
+ "the sandboxed script's fork budget is unbounded"
194
+ )
195
+ return None
196
+ if resource is None:
197
+ _log.warning(
198
+ "code-mode: cannot read RLIMIT_NPROC; omitting --nproc, so the "
199
+ "sandboxed script's fork budget is unbounded"
200
+ )
201
+ return None
202
+ try:
203
+ _soft, hard = resource.getrlimit(resource.RLIMIT_NPROC)
204
+ except (OSError, ValueError):
205
+ _log.warning(
206
+ "code-mode: cannot read RLIMIT_NPROC; omitting --nproc, so the "
207
+ "sandboxed script's fork budget is unbounded"
208
+ )
209
+ return None
210
+ if hard == getattr(resource, "RLIM_INFINITY", -1):
211
+ return str(current + NPROC_HEADROOM)
212
+ return str(max(1, min(current + NPROC_HEADROOM, hard)))
213
+
214
+
128
215
  def _prlimit_prefix(
129
216
  prlimit_path: str | None,
130
217
  timeout_s: int = SCRIPT_TIMEOUT,
@@ -132,21 +219,34 @@ def _prlimit_prefix(
132
219
  """Return a ``prlimit`` command prefix to wrap a subprocess, or *None*.
133
220
 
134
221
  On Linux with ``prlimit`` available the prefix adds:
135
- --nproc=512 --as=1073741824 --cpu=<timeout_s + 5>
136
-
137
- On non-Linux or when *prlimit_path* is falsy, returns *None* (fail-open).
222
+ --nproc=<uid task count + NPROC_HEADROOM> --as=1073741824
223
+ --cpu=<timeout_s + 5>
224
+
225
+ ``--nproc`` is derived per invocation by :func:`_sandbox_nproc` rather
226
+ than hardcoded. RLIMIT_NPROC is a per-real-UID total across processes AND
227
+ threads, so a fixed value below the host's current usage makes the very
228
+ first ``fork()`` inside the sandbox fail with EAGAIN — which is exactly
229
+ what a hardcoded ``--nproc=512`` did on a host already running more than
230
+ 512 tasks. The isolation property is unchanged: a script still cannot
231
+ create more than ``NPROC_HEADROOM`` additional tasks.
232
+
233
+ On non-Linux, when *prlimit_path* is falsy, or when the nproc limit cannot
234
+ be derived, the flag is omitted (fail-open); ``--as``/``--cpu`` remain.
138
235
  """
139
236
  if not prlimit_path:
140
237
  return None
141
238
 
142
239
  cpu_limit = timeout_s + 5
143
- return [
144
- prlimit_path,
145
- "--nproc=512",
240
+ prefix = [prlimit_path]
241
+ nproc = _sandbox_nproc()
242
+ if nproc is not None:
243
+ prefix.append(f"--nproc={nproc}")
244
+ prefix += [
146
245
  "--as=1073741824", # 1 GiB in bytes
147
246
  f"--cpu={cpu_limit}",
148
247
  "--",
149
248
  ]
249
+ return prefix
150
250
 
151
251
 
152
252
  def _detect_prlimit() -> str | None:
@@ -226,7 +326,9 @@ def _packaged_scripts_dir() -> Path | None:
226
326
  return None
227
327
 
228
328
 
229
- def _resolve_scripts_dir(candidates: list[Path], *, allow_packaged: bool = True) -> Path | None:
329
+ def _resolve_scripts_dir(
330
+ candidates: list[Path], *, allow_packaged: bool = True
331
+ ) -> Path | None:
230
332
  """Select the first usable candidate, optionally trying the package copy."""
231
333
  for candidate in candidates:
232
334
  if _has_usable_scripts(candidate):
@@ -377,9 +479,7 @@ def _load_manifest(scripts_dir: Path | None = None) -> dict[str, str]:
377
479
  ) from None
378
480
 
379
481
  if not isinstance(data, dict):
380
- raise ManifestError(
381
- "CORRUPT_DATA", "Code-mode manifest must be a JSON object."
382
- )
482
+ raise ManifestError("CORRUPT_DATA", "Code-mode manifest must be a JSON object.")
383
483
 
384
484
  if data.get("version") != 1:
385
485
  raise ManifestError(
@@ -527,7 +627,6 @@ def _contract_result(
527
627
  return f"[{status}] {message}"
528
628
 
529
629
 
530
-
531
630
  def _format_output(
532
631
  stdout: str,
533
632
  stderr: str,
@@ -818,22 +917,15 @@ async def execute_code_script(
818
917
  timeout_s=timeout_s,
819
918
  status="TIMEOUT",
820
919
  )
821
- except FileNotFoundError:
822
- return _build_result(
823
- "",
824
- f"Script '{script_name}' not found or interpreter missing.",
825
- -1,
826
- timed_out=False,
827
- duration_ms=0,
828
- metadata=metadata,
829
- json_output=json_output,
830
- timeout_s=timeout_s,
831
- status="UNAVAILABLE",
832
- )
833
920
  except OSError as e:
921
+ error = (
922
+ f"Script '{script_name}' not found or interpreter missing."
923
+ if isinstance(e, FileNotFoundError)
924
+ else f"Failed to execute script: {e}"
925
+ )
834
926
  return _build_result(
835
927
  "",
836
- f"Failed to execute script: {e}",
928
+ error,
837
929
  -1,
838
930
  timed_out=False,
839
931
  duration_ms=0,
@@ -88,15 +88,25 @@ function parseArgs(argv) {
88
88
  args.target = process.cwd()
89
89
  }
90
90
 
91
- if (args.profile === 'auto') {
92
- args.profile = args.target.includes('pantheon-sandbox') ? 'sandbox' : 'global'
93
- }
94
-
95
91
  // Resolve to absolute
96
92
  if (!args.target.startsWith('/')) {
97
93
  args.target = join(process.cwd(), args.target)
98
94
  }
99
95
 
96
+ if (args.profile === 'auto') {
97
+ const target = resolve(args.target)
98
+ // A checkout is not a managed OpenCode installation: its repository
99
+ // config intentionally contains only development MCPs. Use the lite
100
+ // policy for `npm run doctor` so a fresh checkout does not require a
101
+ // user's global MCP registry or pre-existing venv.
102
+ args.profile =
103
+ target === resolve(ROOT)
104
+ ? 'lite'
105
+ : args.target.includes('pantheon-sandbox')
106
+ ? 'sandbox'
107
+ : 'global'
108
+ }
109
+
100
110
  return args
101
111
  }
102
112
 
@@ -159,7 +169,7 @@ function showHelp() {
159
169
  C. Permission Checks — frontmatter validation
160
170
  D. OpenCode Status — OpenCode artifact layout
161
171
  E. Git Status — uncommitted changes
162
- F. Runtime Layer — venv python + pinned pip dependencies
172
+ F. Runtime Layer — runtime Python + pinned pip dependencies
163
173
  H3. Plugin Drift — registered plugin version vs this package
164
174
  K. Node Runtime — node:sqlite availability for pantheon_cost
165
175
 
@@ -818,12 +828,14 @@ function checkGitStatus(args) {
818
828
  }
819
829
 
820
830
  // ---------------------------------------------------------------------------
821
- // Check F: Runtime Layer — Venv, Python & pinned dependencies
831
+ // Check F: Runtime Layer — Runtime Python & pinned dependencies
822
832
  // ---------------------------------------------------------------------------
823
833
  // The MCP servers run on the venv Python under ~/.config/opencode/.venv
824
- // (same resolution as scripts/install-mcp.mjs). This layer verifies the venv
825
- // exists, the interpreter responds, and the installed packages satisfy the
826
- // EXACT pins in src/mcp/requirements-mcp.txt and requirements-vision.txt
834
+ // (same resolution as scripts/install-mcp.mjs) after installation. A
835
+ // repository checkout/CI job is not an installed target, however, so it may
836
+ // intentionally have no venv. In that case the doctor uses PANTHEON_PYTHON or
837
+ // python3 and still validates the exact pins. Missing or mismatched packages
838
+ // remain blocking errors; the fallback never makes the runtime check advisory
827
839
  // (issue #18 / #21).
828
840
 
829
841
  /** Parse `pip freeze` output into a normalized name → version map. */
@@ -906,31 +918,53 @@ function checkRequirementLine(line, installed) {
906
918
  }
907
919
  }
908
920
 
921
+ /** Classify whether a required runtime file is available to the doctor. */
922
+ export function classifyRequiredRuntimeFile(exists) {
923
+ return exists ? 'present' : 'error'
924
+ }
925
+
926
+ /** Validate a required runtime requirements file without downgrading absence. */
927
+ export function validateRequiredRuntimeFile(filePath) {
928
+ const present = classifyRequiredRuntimeFile(existsSync(filePath)) === 'present'
929
+ return {
930
+ ok: present,
931
+ message: present
932
+ ? `requirements file present: ${filePath}`
933
+ : `requirements file not found: ${filePath}`,
934
+ }
935
+ }
936
+
909
937
  function checkVenvLayer(args) {
910
- section('F. Runtime Layer — Venv, Python & Dependencies')
938
+ section('F. Runtime Layer — Runtime Python & Dependencies')
911
939
 
912
- const venvPython = resolveRuntimePython(args)
940
+ const runtimePython = resolveRuntimePython(args)
913
941
 
914
- if (!existsSync(venvPython)) {
915
- if (args.profile === 'lite') {
916
- info(`Venv python not found — runtime layer skipped for lite profile`)
942
+ if (!isAbsolute(runtimePython) || !existsSync(runtimePython)) {
943
+ if (runtimePython === 'python3') {
944
+ info('No dedicated .venv found — validating the Python interpreter on PATH')
917
945
  } else {
918
- error(`Venv python not found: ${venvPython} (run \`pantheon-opencode init\` to create it)`)
946
+ error(
947
+ `Runtime Python not found: ${runtimePython} (set PANTHEON_PYTHON or run \`pantheon-opencode init\` to create a .venv)`,
948
+ )
949
+ return
919
950
  }
920
- return
921
951
  }
922
- pass(`Venv python exists: ${venvPython}`)
952
+ if (isAbsolute(runtimePython) && existsSync(runtimePython)) {
953
+ pass(`Runtime Python exists: ${runtimePython}`)
954
+ }
923
955
 
924
- const probe = spawn(venvPython, ['-c', 'import sys; print(sys.version.split()[0])'])
956
+ const probe = spawn(runtimePython, ['-c', 'import sys; print(sys.version.split()[0])'])
925
957
  if (probe.status !== 0) {
926
- error(`Venv python does not respond (exit ${probe.status}): ${probe.stderr}`)
958
+ error(
959
+ `Runtime Python does not respond (exit ${probe.status}): ${probe.stderr || 'interpreter is unavailable'}`,
960
+ )
927
961
  return
928
962
  }
929
- pass(`Venv python responds (Python ${probe.stdout})`)
963
+ pass(`Runtime Python responds (Python ${probe.stdout})`)
930
964
 
931
- const freeze = spawn(venvPython, ['-m', 'pip', 'freeze'])
965
+ const freeze = spawn(runtimePython, ['-m', 'pip', 'freeze'])
932
966
  if (freeze.status !== 0) {
933
- warn(`pip freeze failed (exit ${freeze.status}) — cannot verify pinned versions`)
967
+ error(`pip freeze failed (exit ${freeze.status}) — cannot verify pinned versions`)
934
968
  return
935
969
  }
936
970
  const installed = parsePipFreeze(freeze.stdout)
@@ -940,8 +974,9 @@ function checkVenvLayer(args) {
940
974
  join(ROOT, 'src', 'mcp', 'requirements-vision.txt'),
941
975
  ]
942
976
  for (const reqFile of reqFiles) {
943
- if (!existsSync(reqFile)) {
944
- warn(`requirements file not found: ${reqFile}`)
977
+ const requirement = validateRequiredRuntimeFile(reqFile)
978
+ if (!requirement.ok) {
979
+ error(requirement.message)
945
980
  continue
946
981
  }
947
982
  const base = basename(reqFile)
@@ -1073,7 +1108,9 @@ export function validateCodeModeManifest(codeModeDir) {
1073
1108
 
1074
1109
  const actualScripts = readdirSync(codeModeDir).filter(
1075
1110
  (name) =>
1076
- (name.endsWith('.py') || name.endsWith('.sh')) && statSync(join(codeModeDir, name)).isFile(),
1111
+ !name.startsWith('eval-') &&
1112
+ (name.endsWith('.py') || name.endsWith('.sh')) &&
1113
+ statSync(join(codeModeDir, name)).isFile(),
1077
1114
  )
1078
1115
  const missing = listed.filter((name) => !existsSync(join(codeModeDir, name)))
1079
1116
  const extra = actualScripts.filter((name) => !Object.hasOwn(data.scripts, name))
@@ -1104,12 +1141,32 @@ export function checkCodeModeManifest(args) {
1104
1141
  pass(`✅ Code-mode manifest valid: ${result.count}/${result.total} scripts verified`)
1105
1142
  }
1106
1143
 
1107
- /** Resolve the venv created by init for either a project or global install. */
1144
+ /**
1145
+ * Resolve the Python used by the runtime checks.
1146
+ *
1147
+ * Installed targets use the venv created by init. A checkout in CI does not
1148
+ * have that installation state, however, so the explicit PANTHEON_PYTHON
1149
+ * override and the PATH fallback make the contract reproducible without
1150
+ * silently skipping dependency validation.
1151
+ */
1108
1152
  export function resolveRuntimePython(args) {
1153
+ const env = args.env ?? process.env
1154
+ if (typeof env.PANTHEON_PYTHON === 'string' && env.PANTHEON_PYTHON.trim() !== '') {
1155
+ return env.PANTHEON_PYTHON.trim()
1156
+ }
1157
+
1109
1158
  const target = resolve(args.target)
1110
1159
  const isProject = existsSync(join(target, '.opencode'))
1111
- const runtimeRoot = isProject ? target : resolveOpenCodeConfigDir(args.env ?? process.env)
1112
- return join(runtimeRoot, '.venv', 'bin', 'python3')
1160
+ const runtimeRoot = isProject ? target : resolveOpenCodeConfigDir(env)
1161
+ const venvCandidates =
1162
+ process.platform === 'win32'
1163
+ ? [
1164
+ join(runtimeRoot, '.venv', 'Scripts', 'python.exe'),
1165
+ join(runtimeRoot, '.venv', 'Scripts', 'python'),
1166
+ ]
1167
+ : [join(runtimeRoot, '.venv', 'bin', 'python3'), join(runtimeRoot, '.venv', 'bin', 'python')]
1168
+ const existing = venvCandidates.find((candidate) => existsSync(candidate))
1169
+ return existing ?? 'python3'
1113
1170
  }
1114
1171
 
1115
1172
  /** Return relative script arguments as absolute paths using an MCP entry cwd. */
@@ -159,7 +159,7 @@ export function spinner(message) {
159
159
  }
160
160
  }
161
161
 
162
- export function printSummary(target, platforms, stats) {
162
+ export function printSummary(target, _platforms, stats) {
163
163
  if (_quiet) return
164
164
 
165
165
  const S = strings()
@@ -116,7 +116,7 @@ const PROVIDER_RENAME_REVERSE = Object.fromEntries(
116
116
  * V2 shape:
117
117
  * { package: "aisdk:ai-sdk-openai", settings: { baseURL: "...", apiKey: "..." }, models: { ... } }
118
118
  */
119
- function convertProviderV1toV2(name, config) {
119
+ function convertProviderV1toV2(_name, config) {
120
120
  const result = { ...config }
121
121
 
122
122
  // npm → package (with aisdk: prefix)
@@ -171,7 +171,7 @@ function convertProviderV1toV2(name, config) {
171
171
  /**
172
172
  * Convert a single V2 provider config back to V1.
173
173
  */
174
- function convertProviderV2toV1(name, config) {
174
+ function convertProviderV2toV1(_name, config) {
175
175
  const result = { ...config }
176
176
 
177
177
  // package → npm (strip aisdk: prefix)