pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.3

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.
package/README.md CHANGED
@@ -8,7 +8,7 @@ want useful structure without giving up control of their code.
8
8
  [Português (Brasil)](README.pt-BR.md) ·
9
9
  [Repository](https://github.com/ils15/pantheon-opencode) · [MIT License](LICENSE)
10
10
 
11
- [![Version](https://img.shields.io/github/v/release/ils15/pantheon-opencode?label=version)](https://github.com/ils15/pantheon-opencode/releases/tag/v1.5.0)
11
+ [![Version](https://img.shields.io/github/v/release/ils15/pantheon-opencode?label=version)](https://github.com/ils15/pantheon-opencode/releases/latest)
12
12
  [![CI](https://img.shields.io/github/actions/workflow/status/ils15/pantheon-opencode/ci.yml?branch=main&label=CI)](https://github.com/ils15/pantheon-opencode/actions)
13
13
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22306637.svg)](https://doi.org/10.5281/zenodo.22306637)
14
14
 
@@ -70,13 +70,14 @@ clear handoff between stages of work.
70
70
 
71
71
  ## Status
72
72
 
73
- Current release: **v1.5.0**. Pantheon is designed for OpenCode and depends on
74
- the availability and configuration of OpenCode and any optional services you
75
- choose to use. Check the [releases](https://github.com/ils15/pantheon-opencode/releases)
76
- and [changelog](CHANGELOG.md) for the latest changes.
73
+ Operational checkout version: **v1.5.0-beta.2** (candidate; publication is not
74
+ asserted here). Pantheon is designed for OpenCode and depends on the
75
+ availability and configuration of OpenCode and any optional services you choose
76
+ to use. Check the [releases](https://github.com/ils15/pantheon-opencode/releases)
77
+ and [changelog](CHANGELOG.md) for the latest published changes.
77
78
 
78
79
 
79
- ## What's new in 1.5.0
80
+ ## What's new in 1.5.0-beta.2
80
81
 
81
82
  - OpenCode-only installer: platform guides consolidated into a single
82
83
  [OpenCode guide](docs/platforms/opencode.md).
@@ -95,7 +96,7 @@ and [changelog](CHANGELOG.md) for the latest changes.
95
96
  [Beta2 agent economy policy](docs/ws3-token-opt-measurements.md#beta2-agent-economy-policy).
96
97
  - A `--prompts` installer flag is planned for a future release.
97
98
 
98
- ## OpenCode V1/V2 — Dual Version (1.5.0)
99
+ ## OpenCode V1/V2 — Dual Version (1.5.0-beta.2)
99
100
 
100
101
  Pantheon has two **exclusive** OpenCode plugin contracts. Ordinary OpenCode
101
102
  configuration may be shared, but the Pantheon plugin registration is selected
@@ -153,9 +154,21 @@ The installer still writes the compatibility settings required by the selected
153
154
  OpenCode host, such as `experimental.subagent_depth`; this does not convert a
154
155
  V1 plugin into V2 or provide V2 with V1 hooks.
155
156
 
156
- ## Beta releases
157
+ ## Releases
157
158
 
158
- A pull request labeled exactly `release:beta` triggers the beta release path. See [docs/RELEASING.md](docs/RELEASING.md) for validation and recovery details.
159
+ Publication is authorized **only** by an explicit `workflow_dispatch` of the
160
+ `Release` workflow (`release_channel` input selects beta or stable). PR labels,
161
+ pushes, merges, and tags never publish anything, and every validation gate is
162
+ fail-closed: only an explicit PASS authorizes release evidence. See
163
+ [docs/RELEASING.md](docs/RELEASING.md) for validation and recovery details.
164
+
165
+ Release validation keeps each manifest with its lockfile: the root
166
+ `package.json` + `package-lock.json` and the TUI
167
+ `src/plugins/tui/package.json` + `src/plugins/tui/package-lock.json`. Both use
168
+ `npm ci --ignore-scripts`; an `npm ci` failure blocks the run and there is no
169
+ `npm install` fallback. A release carries one `.tgz` tarball, computes the
170
+ SHA-256 of that same artifact, and binds the tarball and GitHub release to the
171
+ full `TARGET_SHA`; a second pack is not interchangeable.
159
172
 
160
173
  ## Sandbox validation (V1/V2)
161
174
 
@@ -164,9 +177,9 @@ package as a real user inside an isolated sandbox (own `HOME`, npm prefix and
164
177
  venv) — never the dev environment. It checks OpenCode V1 (`opencode`) and V2
165
178
  (`opencode2`) side by side: binaries, MCP connectivity, `doctor`, and — with
166
179
  `--prompts` — a prompt battery covering the `pantheon://agents` resource,
167
- memory store/recall, filesystem writes and agent delegation. Environmental
168
- failures (network, provider auth, Docker) are classified as `AMBIENTAL` and
169
- never fail the run; only real failures do.
180
+ memory store/recall, filesystem writes and agent delegation. The gate is
181
+ fail-closed: every required check must return an explicit PASS; timeouts,
182
+ auth/network/provider failures and missing prerequisites block the run.
170
183
 
171
184
  ```bash
172
185
  scripts/test-opencode-v1-v2-sandbox.sh --prepare # tarball + install + init in the sandbox
@@ -180,6 +193,9 @@ Modes are combinable (e.g. `--prepare --run v1 --prompts`). Binaries are
180
193
  resolved strictly inside the sandbox npm prefix — a non-prepared sandbox fails
181
194
  fast instead of silently testing the host installation.
182
195
 
196
+ This validates the prepared isolated sandbox only. A PASS is not proof of
197
+ support for every real host or for host configurations that were not exercised.
198
+
183
199
  Env overrides:
184
200
 
185
201
  | Variable | Default | Purpose |
@@ -193,6 +209,15 @@ Env overrides:
193
209
  Exit codes: `0` no real failures · `1` real failure (see `prompts-report.md`
194
210
  in the sandbox root) · `2` usage error · `3` sandbox not prepared.
195
211
 
212
+ ### Intentional memory MCP divergence
213
+
214
+ `scripts/memory_mcp_server.py` and `src/mcp/memory_mcp_server.py` are
215
+ intentionally different. The standalone `scripts/` copy keeps the lightweight
216
+ `memory_*` contract; the installed `src/mcp/` copy additionally exposes the
217
+ optional codemap schema and `code_index`, `code_query`, and `code_neighbors`.
218
+ The other shared MCP copies remain identical. Do not overwrite one memory copy
219
+ with the other.
220
+
196
221
  ## Documentation
197
222
 
198
223
  - [Installation](docs/INSTALLATION.md) · [Quick start](docs/QUICKSTART.md)
@@ -210,9 +235,10 @@ or pull request.
210
235
 
211
236
  ## Citation and DOI
212
237
 
213
- Pantheon is released under the [MIT License](LICENSE). For the published v1.4.3
214
- record, use the [Zenodo DOI](https://doi.org/10.5281/zenodo.22306637); citation
215
- metadata is also available in [CITATION.cff](CITATION.cff).
238
+ Pantheon is released under the [MIT License](LICENSE). For the historical
239
+ published v1.4.3 record only, use the [Zenodo DOI](https://doi.org/10.5281/zenodo.22306637);
240
+ it is not the current operational version. Citation metadata is also available
241
+ in [CITATION.cff](CITATION.cff).
216
242
 
217
243
  Canonical repository: <https://github.com/ils15/pantheon-opencode>
218
244
 
package/README.pt-BR.md CHANGED
@@ -8,7 +8,7 @@ e equipes que querem mais estrutura sem perder o controle do próprio código.
8
8
  [English](README.md) ·
9
9
  [Repositório](https://github.com/ils15/pantheon-opencode) · [Licença MIT](LICENSE)
10
10
 
11
- [![Versão](https://img.shields.io/github/v/release/ils15/pantheon-opencode?label=versão)](https://github.com/ils15/pantheon-opencode/releases/tag/v1.5.0)
11
+ [![Versão](https://img.shields.io/github/v/release/ils15/pantheon-opencode?label=versão)](https://github.com/ils15/pantheon-opencode/releases/latest)
12
12
  [![CI](https://img.shields.io/github/actions/workflow/status/ils15/pantheon-opencode/ci.yml?branch=main&label=CI)](https://github.com/ils15/pantheon-opencode/actions)
13
13
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22306637.svg)](https://doi.org/10.5281/zenodo.22306637)
14
14
 
@@ -71,13 +71,14 @@ verificações repetíveis e passagens claras entre etapas do trabalho.
71
71
 
72
72
  ## Status
73
73
 
74
- Versão atual: **v1.5.0**. O Pantheon foi feito para OpenCode e depende da
74
+ Versão operacional neste checkout: **v1.5.0-beta.2** (candidata; esta página
75
+ não afirma publicação). O Pantheon foi feito para OpenCode e depende da
75
76
  disponibilidade e da configuração do OpenCode e dos serviços opcionais que você
76
77
  escolher. Veja as [releases](https://github.com/ils15/pantheon-opencode/releases)
77
- e o [changelog](CHANGELOG.md) para acompanhar as mudanças.
78
+ e o [changelog](CHANGELOG.md) para acompanhar as mudanças publicadas.
78
79
 
79
80
 
80
- ## Novidades da 1.5.0
81
+ ## Novidades da 1.5.0-beta.2
81
82
 
82
83
  - Instalador exclusivo para OpenCode: guias de plataformas consolidados em um
83
84
  único [guia OpenCode](docs/platforms/opencode.md).
@@ -93,7 +94,7 @@ e o [changelog](CHANGELOG.md) para acompanhar as mudanças.
93
94
  lado — veja [Validação em sandbox](#validação-em-sandbox-v1v2).
94
95
  - A flag `--prompts` do instalador está planejada para uma release futura.
95
96
 
96
- ## OpenCode V1/V2 — Versão dupla (1.5.0)
97
+ ## OpenCode V1/V2 — Versão dupla (1.5.0-beta.2)
97
98
 
98
99
  O Pantheon tem dois contratos de plugin OpenCode **exclusivos**. A configuração
99
100
  comum do OpenCode pode ser compartilhada, mas o registro do plugin Pantheon é
@@ -153,9 +154,21 @@ O instalador continua gravando as configurações de compatibilidade exigidas
153
154
  pelo host OpenCode selecionado, como `experimental.subagent_depth`; isso não
154
155
  converte um plugin V1 em V2 nem dá hooks V1 ao V2.
155
156
 
156
- ## Releases beta
157
+ ## Releases
157
158
 
158
- Um pull request com o label exato `release:beta` aciona o fluxo de release beta. Consulte [docs/RELEASING.md](docs/RELEASING.md) para detalhes de validação e recuperação.
159
+ A publicação é autorizada **somente** por um `workflow_dispatch` explícito do
160
+ workflow `Release` (o input `release_channel` escolhe beta ou stable). Labels
161
+ de PR, push, merge e tag nunca publicam nada, e todos os gates de validação são
162
+ fail-closed: somente um PASS explícito autoriza release evidence. Consulte
163
+ [docs/RELEASING.md](docs/RELEASING.md) para detalhes de validação e recuperação.
164
+
165
+ A validação mantém cada manifest junto do seu lockfile: o root
166
+ `package.json` + `package-lock.json` e o TUI
167
+ `src/plugins/tui/package.json` + `src/plugins/tui/package-lock.json`. Ambos usam
168
+ `npm ci --ignore-scripts`; uma falha em `npm ci` bloqueia a execução e não há
169
+ fallback para `npm install`. Uma release carrega um único tarball `.tgz`, calcula
170
+ o SHA-256 desse mesmo artefato e vincula o tarball e o GitHub Release ao
171
+ `TARGET_SHA` completo; um segundo pack não é intercambiável.
159
172
 
160
173
  ## Validação em sandbox (V1/V2)
161
174
 
@@ -165,8 +178,9 @@ prefix npm e venv próprios) — nunca o ambiente de desenvolvimento. Ele verifi
165
178
  OpenCode V1 (`opencode`) e V2 (`opencode2`) lado a lado: binários, conectividade
166
179
  MCP, `doctor` e — com `--prompts` — uma bateria de prompts cobrindo o recurso
167
180
  `pantheon://agents`, memory store/recall, escrita no filesystem e delegação de
168
- agente. Falhas ambientais (rede, auth de provider, Docker) são classificadas
169
- como `AMBIENTAL` e nunca falham a execução; apenas falhas reais falham.
181
+ agente. O gate é fail-closed: todo check obrigatório precisa terminar em PASS
182
+ explícito; timeout, falha de auth/rede/provider e pré-requisitos ausentes
183
+ bloqueiam a execução.
170
184
 
171
185
  ```bash
172
186
  scripts/test-opencode-v1-v2-sandbox.sh --prepare # tarball + install + init no sandbox
@@ -180,6 +194,9 @@ Os modos são combináveis (ex.: `--prepare --run v1 --prompts`). Os binários s
180
194
  resolvidos estritamente dentro do prefix npm do sandbox — um sandbox não
181
195
  preparado falha rápido em vez de testar silenciosamente a instalação do host.
182
196
 
197
+ Isso valida somente o sandbox isolado e preparado. Um PASS não prova suporte
198
+ para todo host real nem para configurações de host que não foram exercitadas.
199
+
183
200
  Variáveis de ambiente:
184
201
 
185
202
  | Variável | Padrão | Finalidade |
@@ -194,6 +211,15 @@ Códigos de saída: `0` sem falhas reais · `1` falha real (veja
194
211
  `prompts-report.md` na raiz do sandbox) · `2` erro de uso · `3` sandbox não
195
212
  preparado.
196
213
 
214
+ ### Divergência intencional do memory MCP
215
+
216
+ `scripts/memory_mcp_server.py` e `src/mcp/memory_mcp_server.py` são
217
+ intencionalmente diferentes. A cópia independente em `scripts/` mantém o
218
+ contrato leve de `memory_*`; a cópia instalada em `src/mcp/` também expõe o
219
+ schema opcional de codemap e `code_index`, `code_query` e `code_neighbors`. As
220
+ outras cópias compartilhadas permanecem idênticas. Não sobrescreva uma cópia de
221
+ memória com a outra.
222
+
197
223
  ## Documentação
198
224
 
199
225
  - [Instalação](docs/INSTALLATION.md) · [Início rápido](docs/QUICKSTART.md)
@@ -211,9 +237,10 @@ uma issue ou pull request.
211
237
 
212
238
  ## Citação e DOI
213
239
 
214
- O Pantheon é distribuído sob a [Licença MIT](LICENSE). Para o registro publicado
215
- da v1.4.3, use o [DOI do Zenodo](https://doi.org/10.5281/zenodo.22306637); os
216
- metadados de citação também estão em [CITATION.cff](CITATION.cff).
240
+ O Pantheon é distribuído sob a [Licença MIT](LICENSE). Para o registro histórico
241
+ publicado da v1.4.3 apenas, use o [DOI do Zenodo](https://doi.org/10.5281/zenodo.22306637);
242
+ essa não é a versão operacional atual. Os metadados de citação também estão em
243
+ [CITATION.cff](CITATION.cff).
217
244
 
218
245
  Repositório canônico: <https://github.com/ils15/pantheon-opencode>
219
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.5.0-beta.2",
3
+ "version": "1.5.0-beta.3",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -28,7 +28,9 @@
28
28
  "version:status": "node scripts/versioning.mjs status",
29
29
  "version:apply": "node scripts/versioning.mjs apply",
30
30
  "version:check": "node scripts/version-check.mjs",
31
- "release:dry-run": "node scripts/versioning.mjs status && node scripts/release-notes.mjs && npm pack --dry-run",
31
+ "release:dry-run": "node scripts/versioning.mjs status && node scripts/release-notes.mjs && node scripts/package-evidence.mjs",
32
+ "package:evidence": "node scripts/package-evidence.mjs",
33
+ "package:validate": "node scripts/validate-package.mjs",
32
34
  "publish:next": "npm publish --tag next --access public",
33
35
  "lint": "npx biome ci .",
34
36
  "typecheck": "npx tsc --noEmit",
@@ -47,7 +49,9 @@
47
49
  },
48
50
  "files": [
49
51
  "LICENSE",
52
+ "package-lock.json",
50
53
  "plugin.json",
54
+ "pyproject.toml",
51
55
  "scripts/install/*.mjs",
52
56
  "scripts/*.py",
53
57
  "src/plugin.ts",
@@ -84,7 +88,7 @@
84
88
  ],
85
89
  "dependencies": {
86
90
  "@opencode-ai/plugin": "1.18.18",
87
- "js-yaml": "^4.3.0",
91
+ "js-yaml": "^4.3.2",
88
92
  "zod": "4.1.8"
89
93
  },
90
94
  "devDependencies": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon",
3
3
  "publisher": "ils15",
4
- "version": "1.5.0",
4
+ "version": "1.5.0-beta.3",
5
5
  "displayName": "Pantheon",
6
6
  "description": "Conductor-Delegate orchestration platform with 14 specialized agents, 21 skills, and OpenCode support.",
7
7
  "agents": "./src/agents",
package/pyproject.toml ADDED
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.backends._legacy:_Backend"
4
+
5
+ [project]
6
+ name = "pantheon"
7
+ version = "1.5.0-beta.3"
8
+ description = "Multi-agent orchestration framework"
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+
12
+ [tool.pytest.ini_options]
13
+ testpaths = ["tests"]
14
+ asyncio_mode = "auto"
15
+ addopts = "-v --tb=short"
16
+
17
+ [tool.ruff]
18
+ line-length = 88
19
+ target-version = "py311"
20
+
21
+ [tool.ruff.lint]
22
+ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "PL", "RUF", "C901"]
23
+ ignore = ["E501"]
24
+
25
+ [tool.ruff.lint.per-file-ignores]
26
+ "__init__.py" = ["F401"]
27
+ # Legacy command-line scripts intentionally use hyphenated filenames.
28
+ "scripts/ci-validate-yaml.py" = ["N999"]
29
+ "scripts/scrub-secrets.py" = ["N999"]
30
+ # These modules defer optional/heavy imports until the corresponding command is used.
31
+ "scripts/vector_memory/*.py" = ["PLC0415"]
32
+ "tests/test_code_mode_args.py" = ["PLC0415"]
33
+ "tests/test_code_mode_sandbox.py" = ["PLC0415", "N806"]
34
+ "tests/test_code_mode_server.py" = ["PLC0415"]
35
+ "tests/test_eval_store.py" = ["PLC0415", "PLR2004"]
36
+ "tests/test_memory_mcp_server.py" = ["PLC0415", "PLR2004"]
37
+ # Existing public APIs and test fixtures are intentionally broad/simple; refactoring
38
+ # them would be behavior-affecting rather than a mechanical lint correction.
39
+ "scripts/code_mode_server.py" = ["PLR0913"]
40
+ "src/mcp/code_mode_server.py" = ["PLR0913"]
41
+ "scripts/mcp_persistence_server.py" = ["PLR0913"]
42
+ "src/mcp/mcp_persistence_server.py" = ["PLR0913"]
43
+ "scripts/memory_mcp_server.py" = ["C901"]
44
+ "scripts/vector_memory/index.py" = ["C901", "PLR0913", "PLR2004", "PLW0603", "PLW2901"]
45
+ "scripts/vector_memory/cli.py" = ["PLR2004"]
46
+ "scripts/vector_memory/query.py" = ["PLR0913"]
47
+ "src/mcp/eval_store.py" = ["PLR2004"]
48
+ "tests/test_code_mode_frontmatter.py" = ["PLR2004"]
49
+ "tests/test_eval_judge.py" = ["PLR2004"]
50
+ "tests/test_eval_static.py" = ["PLR2004"]
51
+ "tests/test_mcp_persistence_server.py" = ["PLR2004"]
52
+
53
+ [tool.mypy]
54
+ python_version = "3.11"
55
+ strict = true
56
+ ignore_missing_imports = true
57
+ disallow_untyped_defs = true
@@ -41,18 +41,20 @@ MAX_SCRIPT_TIMEOUT: int = 300
41
41
  # ── Env Allowlist ──────────────────────────────────────────────────────────────
42
42
  # Only these variables are passed to script subprocesses. Everything else
43
43
  # (secrets, cloud credentials, etc.) is stripped to limit blast radius.
44
- _CODE_MODE_ENV_ALLOWLIST: frozenset[str] = frozenset({
45
- "PATH",
46
- "HOME",
47
- "LANG",
48
- "PANTHEON_HOME",
49
- "PANTHEON_PROJECT",
50
- "OPENAI_API_KEY",
51
- "OPENAI_BASE_URL",
52
- "EVAL_JUDGE_MODEL",
53
- "SHELL",
54
- "USER",
55
- })
44
+ _CODE_MODE_ENV_ALLOWLIST: frozenset[str] = frozenset(
45
+ {
46
+ "PATH",
47
+ "HOME",
48
+ "LANG",
49
+ "PANTHEON_HOME",
50
+ "PANTHEON_PROJECT",
51
+ "OPENAI_API_KEY",
52
+ "OPENAI_BASE_URL",
53
+ "EVAL_JUDGE_MODEL",
54
+ "SHELL",
55
+ "USER",
56
+ }
57
+ )
56
58
 
57
59
  # Optional extra vars that callers may opt-in to pass through. Populate
58
60
  # this set at startup if needed (e.g. ``CODE_MODE_EXTRA_ENV.add("MY_VAR")``).
@@ -127,6 +129,7 @@ def _detect_prlimit() -> str | None:
127
129
  return None
128
130
  return shutil.which("prlimit")
129
131
 
132
+
130
133
  # ── Scripts Directory Resolution ─────────────────────────────────────────────
131
134
  # Priority:
132
135
  # 1. /.opencode/.pantheon/code-mode/ (project install)
@@ -136,6 +139,49 @@ def _detect_prlimit() -> str | None:
136
139
  # fallback — package.json `files` includes .pantheon/code-mode/**)
137
140
 
138
141
 
142
+ def _is_within(path: Path, root: Path) -> bool:
143
+ """Return whether a resolved *path* is contained by a resolved *root*."""
144
+ try:
145
+ return os.path.commonpath((str(root), str(path))) == str(root)
146
+ except (OSError, ValueError):
147
+ return False
148
+
149
+
150
+ def _has_usable_scripts(scripts_dir: Path) -> bool:
151
+ """Return whether *scripts_dir* contains an executable code-mode target.
152
+
153
+ A directory is only a valid resolver candidate when it contains a regular
154
+ ``.py`` or ``.sh`` file that can pass the same direct-child and path
155
+ containment rules used by script execution. This prevents an empty
156
+ project overlay (or a directory containing only metadata) from masking a
157
+ lower-priority installation.
158
+ """
159
+ try:
160
+ if scripts_dir.is_symlink():
161
+ return False
162
+ resolved_dir = scripts_dir.resolve(strict=True)
163
+ if not resolved_dir.is_dir():
164
+ return False
165
+ for script_path in scripts_dir.iterdir():
166
+ if script_path.name.startswith("."):
167
+ continue
168
+ if script_path.suffix.lower() not in ALLOWED_EXTENSIONS:
169
+ continue
170
+ try:
171
+ resolved_script = script_path.resolve(strict=True)
172
+ except (OSError, RuntimeError):
173
+ continue
174
+ if (
175
+ resolved_script.is_file()
176
+ and resolved_script.parent == resolved_dir
177
+ and _is_within(resolved_script, resolved_dir)
178
+ ):
179
+ return True
180
+ except (OSError, RuntimeError):
181
+ return False
182
+ return False
183
+
184
+
139
185
  def _packaged_scripts_dir() -> Path | None:
140
186
  """Return the code-mode dir shipped inside the installed package, if any.
141
187
 
@@ -144,22 +190,27 @@ def _packaged_scripts_dir() -> Path | None:
144
190
  Returns None when no packaged copy exists.
145
191
  """
146
192
  try:
147
- here = Path(__file__).resolve()
148
- except Exception:
193
+ here = Path(__file__).resolve(strict=True)
194
+ except (OSError, RuntimeError):
149
195
  return None
150
196
  for parent in [here.parent, *here.parents]:
151
197
  try:
152
198
  candidate = parent / ".pantheon" / "code-mode"
153
199
  except Exception:
154
200
  continue
155
- try:
156
- if candidate.is_dir():
157
- return candidate
158
- except OSError:
159
- continue
201
+ if _has_usable_scripts(candidate):
202
+ return candidate
160
203
  return None
161
204
 
162
205
 
206
+ def _resolve_scripts_dir(candidates: list[Path]) -> Path | None:
207
+ """Select the first usable candidate, then try the packaged fallback."""
208
+ for candidate in candidates:
209
+ if _has_usable_scripts(candidate):
210
+ return candidate
211
+ return _packaged_scripts_dir()
212
+
213
+
163
214
  _PANTHEON_HOME: Path = pantheon_home()
164
215
  _SCRIPTS_DIR_CANDIDATES: list[Path] = []
165
216
  _proj = pantheon_project()
@@ -168,15 +219,9 @@ if _proj is not None:
168
219
  _SCRIPTS_DIR_CANDIDATES.append(_proj / ".pantheon" / "code-mode")
169
220
  _SCRIPTS_DIR_CANDIDATES.append(_PANTHEON_HOME / ".pantheon" / "code-mode")
170
221
 
171
- SCRIPTS_DIR: Path = _PANTHEON_HOME / ".pantheon" / "code-mode" # default
172
- for _candidate in _SCRIPTS_DIR_CANDIDATES:
173
- if _candidate.is_dir():
174
- SCRIPTS_DIR = _candidate
175
- break
176
- else:
177
- _packaged = _packaged_scripts_dir()
178
- if _packaged is not None:
179
- SCRIPTS_DIR = _packaged
222
+ SCRIPTS_DIR: Path = _resolve_scripts_dir(_SCRIPTS_DIR_CANDIDATES) or (
223
+ _PANTHEON_HOME / ".pantheon" / "code-mode"
224
+ )
180
225
 
181
226
  # ── FastMCP App ───────────────────────────────────────────────────────────────
182
227
  mcp = FastMCP(
@@ -208,11 +253,21 @@ def _validate_script_name(script_name: str) -> Path:
208
253
  allowed = ", ".join(sorted(ALLOWED_EXTENSIONS))
209
254
  raise ValueError(f"Extension '{ext}' not allowed. Allowed: {allowed}")
210
255
 
211
- script_path = (SCRIPTS_DIR / name).resolve()
212
- if not str(script_path).startswith(str(SCRIPTS_DIR.resolve())):
256
+ if SCRIPTS_DIR.is_symlink():
257
+ raise ValueError(f"Invalid script name: '{script_name}'")
258
+
259
+ try:
260
+ scripts_root = SCRIPTS_DIR.resolve(strict=True)
261
+ script_path = (SCRIPTS_DIR / name).resolve(strict=True)
262
+ except (FileNotFoundError, OSError, RuntimeError):
263
+ raise ValueError(f"Script '{script_name}' not found") from None
264
+
265
+ if (
266
+ not _is_within(script_path, scripts_root)
267
+ or script_path.parent != scripts_root
268
+ or not script_path.is_file()
269
+ ):
213
270
  raise ValueError(f"Invalid script name: '{script_name}'")
214
- if not script_path.exists():
215
- raise ValueError(f"Script '{script_name}' not found")
216
271
 
217
272
  return script_path
218
273
 
@@ -286,7 +341,9 @@ def _parse_frontmatter(script_path: Path) -> dict[str, Any]:
286
341
  break
287
342
  if not stripped.startswith("#"):
288
343
  return {} # non-comment line inside the block → not frontmatter
289
- body_lines.append(stripped[1:].lstrip() if stripped.startswith("# ") else stripped[1:])
344
+ body_lines.append(
345
+ stripped[1:].lstrip() if stripped.startswith("# ") else stripped[1:]
346
+ )
290
347
  else:
291
348
  return {} # no closing delimiter
292
349
 
@@ -378,7 +435,9 @@ async def list_code_mode_scripts() -> str:
378
435
 
379
436
  @mcp.resource(
380
437
  "pantheon://code-mode/scripts/{script_name}",
381
- description="Content of a code-mode script by name, with parsed frontmatter metadata",
438
+ description=(
439
+ "Content of a code-mode script by name, with parsed frontmatter metadata"
440
+ ),
382
441
  )
383
442
  async def get_code_mode_script(script_name: str) -> str:
384
443
  """Return the source content of a code-mode script plus its metadata."""
@@ -486,8 +545,14 @@ async def execute_code_script(
486
545
  await proc.wait()
487
546
  duration_ms = int((time.monotonic() - started) * 1000)
488
547
  return _build_result(
489
- "", "", -1, timed_out=True, duration_ms=duration_ms,
490
- metadata=metadata, json_output=json_output, timeout_s=timeout_s,
548
+ "",
549
+ "",
550
+ -1,
551
+ timed_out=True,
552
+ duration_ms=duration_ms,
553
+ metadata=metadata,
554
+ json_output=json_output,
555
+ timeout_s=timeout_s,
491
556
  )
492
557
  except FileNotFoundError:
493
558
  return _build_result(
@@ -1207,4 +1207,10 @@ export async function installOpenCode(
1207
1207
  const { writeActivePreset } = await import('./model-picker.mjs')
1208
1208
  writeActivePreset(target, opts.preset, { source: 'cli' })
1209
1209
  }
1210
+
1211
+ // Fail-closed: accumulated stats.errors (bad sources, missing requirements,
1212
+ // failed health checks) must never masquerade as a successful installation.
1213
+ if (stats.errors > 0) {
1214
+ throw new Error(`installation completed with ${stats.errors} error(s); see output above`)
1215
+ }
1210
1216
  }
@@ -5,7 +5,6 @@
5
5
  import { execSync } from 'node:child_process'
6
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
7
7
  import { isAbsolute, join, resolve } from 'node:path'
8
- import { warning } from './cli-ui.mjs'
9
8
  import { ROOT, writeIfChanged } from './shared.mjs'
10
9
 
11
10
  /**
@@ -95,31 +94,9 @@ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false }
95
94
  result.skipped += copyResult.skipped
96
95
 
97
96
  // Install plugin dependencies (@opentui/core, @opentui/solid, solid-js).
98
- // npm ci is deterministic when the copied lockfile is present. The legacy
99
- // fallback is opt-in because npm install may rewrite the dependency graph.
97
+ // Lockfile installation is the only accepted dependency path.
100
98
  if (!dryRun) {
101
- try {
102
- execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: dstDir, stdio: 'pipe' })
103
- } catch (e) {
104
- if (process.env.PANTHEON_ALLOW_NPM_INSTALL_FALLBACK === '1') {
105
- warning(`npm ci failed; explicit fallback enabled: ${e.message}`)
106
- try {
107
- execSync('npm install --omit=dev --no-audit --no-fund --package-lock=false', {
108
- cwd: dstDir,
109
- stdio: 'pipe',
110
- })
111
- } catch (fallbackError) {
112
- warning(`Failed to install TUI plugin dependencies: ${fallbackError.message}`)
113
- result.errors++
114
- }
115
- } else {
116
- warning(
117
- `npm ci failed; dependencies were not changed. Set ` +
118
- `PANTHEON_ALLOW_NPM_INSTALL_FALLBACK=1 to explicitly allow npm install: ${e.message}`,
119
- )
120
- result.errors++
121
- }
122
- }
99
+ execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: dstDir, stdio: 'pipe' })
123
100
  }
124
101
 
125
102
  return result
@@ -133,14 +110,7 @@ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false }
133
110
  */
134
111
  export function installPluginDependencies(pluginDir, { dryRun = false } = {}) {
135
112
  if (dryRun) return
136
- try {
137
- execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: pluginDir, stdio: 'pipe' })
138
- } catch (e) {
139
- warning(
140
- `npm ci failed; dependencies were not changed. Set ` +
141
- `PANTHEON_ALLOW_NPM_INSTALL_FALLBACK=1 to explicitly allow npm install: ${e.message}`,
142
- )
143
- }
113
+ execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: pluginDir, stdio: 'pipe' })
144
114
  }
145
115
 
146
116
  /**