pantheon-opencode 1.3.4 → 1.3.5-beta.52.c75b04d

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
@@ -183,6 +183,39 @@ export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
183
183
  opencode
184
184
  ```
185
185
 
186
+ ### OpenCode V1/V2 — Dual Version
187
+
188
+ Pantheon's shared config is **V1-shaped and valid under both OpenCode versions**:
189
+
190
+ | | V1 | V2 (beta) |
191
+ |---|---|---|
192
+ | Binary | `opencode` (1.18.18) | `opencode2` (v0.0.0-next-17444) |
193
+ | Config locations | `~/.config/opencode/`, `<project>/opencode.json`, `.opencode/` | Same — V2 reads the same locations |
194
+ | Config handling | Loads V1 shape directly | Normalizes V1 fields **in memory** — no rewrite, no native V2 conversion |
195
+ | State DB | `~/.local/share/opencode/opencode.db` | `~/.local/share/opencode/opencode-v2.db` (isolated via `OPENCODE_DB`) |
196
+ | Service port | 49374 | 49375 (must differ from V1) |
197
+
198
+ The `pantheon_cost` report preserves the V1 database default. Set
199
+ `PANTHEON_OPENCODE_VERSION=v1` or `v2` to select the corresponding state DB
200
+ explicitly (`opencode.db` or `opencode-v2.db`). For custom installations,
201
+ `PANTHEON_COST_DB=/absolute/path/to/opencode.db` takes precedence over the
202
+ version selector; an explicit `dbPath` supplied by the tool caller takes
203
+ precedence over both. The resolver never probes the other version's database,
204
+ and reports an actionable error when the selected DB is missing or has an
205
+ incompatible schema.
206
+
207
+ **What changed for V2 compatibility (Phase 3):**
208
+
209
+ - `subagent_depth` moved from top-level to `experimental.subagent_depth` — V2 silently ignores the top-level key. The installer migrates existing configs automatically.
210
+ - Instruction content consolidated into `AGENTS.md` (generated by `scripts/build-agents-md.mjs` from `src/instructions/*.instructions.md`) — V2 accepts but does **not** load the `instructions` config key; both versions auto-load `AGENTS.md`.
211
+ - `npx pantheon-opencode init --version v2` labels an install for V2 (informational — the config is shared).
212
+
213
+ **Known V2 beta gaps:**
214
+
215
+ - `instructions` config key accepted but not loaded → content lives in `AGENTS.md`.
216
+ - `formatter` / `lsp` config not executed.
217
+ - `--subtask` delegation flag is a no-op.
218
+
186
219
  ### Background Delegation
187
220
 
188
221
  Zeus (and other root sessions) can dispatch agents as **background child sessions**
@@ -853,9 +886,15 @@ to Mnemosyne so counts stay accurate and consistent.
853
886
 
854
887
  ### What CI enforces automatically
855
888
 
856
- `release.yml` validates version consistency across all manifests (`package.json`,
857
- `plugin.json`, `CHANGELOG.md`, and the README badge) before publishing. If they diverge,
858
- the release is blocked until Mnemosyne reconciles them.
889
+ `release.yml` validates version consistency across the package manifests
890
+ (`package.json`, `plugin.json`, `pyproject.toml`, and the TUI package) before
891
+ publishing. If they diverge, the release is blocked until they are reconciled.
892
+
893
+ Beta releases are calculated from npm's published `latest` at workflow runtime:
894
+ the default is the next patch, formatted as
895
+ `<next-stable>-beta.<PR>.<short-sha>`. Labels `release:beta:minor` and
896
+ `release:beta:major` request minor or major calculation. The workflow owns the
897
+ manifest update, tag, and publish; it does not assume a fixed future version.
859
898
 
860
899
  ### Anti-patterns
861
900
 
@@ -6,6 +6,13 @@
6
6
  *
7
7
  * Thin CLI wrapper that parses arguments and delegates to
8
8
  * installOpenCode() from scripts/install/opencode.mjs.
9
+ *
10
+ * Dual-version install (Phase 3): the generated config is V1-shaped and valid
11
+ * under BOTH OpenCode V1 (`opencode` 1.18.x) and V2 (`opencode2`
12
+ * v0.0.0-next-17444). V2 reads the same config locations and normalizes V1
13
+ * fields in memory — no native V2 conversion. `--version v2` labels the
14
+ * install for the operator; state isolation is a runtime concern
15
+ * (OPENCODE_DB ~/.local/share/opencode/opencode-v2.db, service port 49375).
9
16
  */
10
17
  import fs from 'fs'
11
18
  import os from 'os'
@@ -43,6 +50,7 @@ function printUsage() {
43
50
  console.log(' npx pantheon-opencode init --interactive # Force interactive TUI mode')
44
51
  console.log(' npx pantheon-opencode init --headless # Force non-interactive mode')
45
52
  console.log(' npx pantheon-opencode init -y # Skip confirmations, use defaults')
53
+ console.log(' npx pantheon-opencode init --version v2 # Label install for OpenCode V2 (beta)')
46
54
  console.log(' npx pantheon-opencode init --preset <name> # Install and activate model preset')
47
55
  console.log(' npx pantheon-opencode set-tier <name> # Set active model preset (global)')
48
56
  console.log(
@@ -182,6 +190,14 @@ async function main() {
182
190
  const autoYes = args.includes('--yes') || args.includes('-y')
183
191
  const presetIndex = args.indexOf('--preset')
184
192
  const presetOpt = presetIndex >= 0 ? (args[presetIndex + 1] ?? null) : null
193
+ // --version v1|v2 labels the install target (informational; the config is
194
+ // shared and V1-shaped under both versions). Invalid values fail fast.
195
+ const versionIndex = args.indexOf('--version')
196
+ const versionOpt = versionIndex >= 0 ? (args[versionIndex + 1] ?? null) : null
197
+ if (versionOpt !== null && versionOpt !== 'v1' && versionOpt !== 'v2') {
198
+ console.error(`❌ Invalid --version "${versionOpt}" — expected v1 or v2`)
199
+ process.exit(1)
200
+ }
185
201
 
186
202
  const components = ['agents', 'skills', 'instructions', 'commands', 'plugins']
187
203
  if (!skipMCP) components.push('runtime')
@@ -203,6 +219,7 @@ async function main() {
203
219
  headless: forceHeadless,
204
220
  yes: autoYes,
205
221
  preset: presetOpt,
222
+ version: versionOpt ?? 'v1',
206
223
  })
207
224
  } catch (err) {
208
225
  console.error(
package/opencode.json CHANGED
@@ -11,7 +11,9 @@
11
11
  }
12
12
  },
13
13
  "default_agent": "zeus",
14
- "subagent_depth": 2,
14
+ "experimental": {
15
+ "subagent_depth": 2
16
+ },
15
17
  "model": "opencode-go/deepseek-v4-flash",
16
18
  "small_model": "opencode-go/deepseek-v4-flash",
17
19
  "permission": {
@@ -28,7 +30,6 @@
28
30
  "pip *": "allow"
29
31
  }
30
32
  },
31
- "instructions": ["AGENTS.md", "src/instructions/*.instructions.md"],
32
33
  "mcp": {
33
34
  "bifrost": {
34
35
  "type": "remote",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.3.4",
3
+ "version": "1.3.5-beta.52.c75b04d",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -10,6 +10,7 @@
10
10
  "postinstall": "node scripts/postinstall.mjs",
11
11
  "prepare": "husky",
12
12
  "setup": "node bin/pantheon-init.mjs init",
13
+ "build:agents-md": "node scripts/build-agents-md.mjs",
13
14
  "setup:lite": "node bin/pantheon-init.mjs init --clean --components agents,skills,instructions",
14
15
  "doctor": "node scripts/doctor.mjs",
15
16
  "prune": "node scripts/prune.mjs",
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * build-agents-md.mjs — Generate the repo AGENTS.md from canonical sources.
4
+ *
5
+ * AGENTS.md is the single instruction file BOTH OpenCode V1 and V2 load
6
+ * (V1 auto-discovers it, V2 loads it explicitly; the `instructions` config
7
+ * key is accepted-but-ignored by V2). To keep instruction content in parity
8
+ * across both versions without duplication, this script consolidates:
9
+ *
10
+ * (a) a static header (project description + agent table + setup notes)
11
+ * (b) every src/instructions/*.instructions.md body, embedded under a
12
+ * `## <name>` section marker (name taken from frontmatter)
13
+ *
14
+ * into one generated AGENTS.md. src/instructions/*.instructions.md remains
15
+ * the source of truth — AGENTS.md is a committed build artifact, never
16
+ * hand-edited.
17
+ *
18
+ * Usage:
19
+ * node scripts/build-agents-md.mjs # write AGENTS.md to repo root
20
+ * node scripts/build-agents-md.mjs --check # exit 1 if AGENTS.md is stale
21
+ *
22
+ * No runtime dependencies (Node >= 18, stdlib only). Idempotent: running it
23
+ * twice produces byte-identical output.
24
+ */
25
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
26
+ import { dirname, join } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url))
30
+ export const ROOT = join(__dirname, '..')
31
+ const INSTRUCTIONS_DIR = join(ROOT, 'src', 'instructions')
32
+ const AGENTS_MD_PATH = join(ROOT, 'AGENTS.md')
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Static header — preserved verbatim from the original AGENTS.md (agent table,
36
+ // setup notes, sandbox rules, conventions). Backticks are escaped for the
37
+ // template literal.
38
+ // ---------------------------------------------------------------------------
39
+ const STATIC_HEADER = `# Pantheon Agent System — OpenCode
40
+
41
+ This project uses the Pantheon multi-agent framework with 14 specialized agents.
42
+
43
+ ## Available Agents
44
+
45
+ | Agent | Role |
46
+ |-------|------|
47
+ | @aphrodite | Frontend specialist — React 19, TypeScript strict, WCAG accessibility, responsive design, TDD, modern API patterns, deprecated npm detection. Calls apollo for discovery, sends to themis for review. |
48
+ | @apollo | Read-only investigation scout — 3–10 parallel searches across codebase, external docs, and GitHub. Called by: athena, zeus, hermes, aphrodite, demeter. No edits, no commands. |
49
+ | @athena | Strategic planner & architect — research-first, plan-only, never implements. Plans include quality gates (ruff/Biome, dep detection, LTS policy). Calls apollo for discovery. |
50
+ | @demeter | Database specialist — SQLAlchemy 2.0, Alembic, query optimization, N+1 prevention, TDD migrations, modern DB libs. Calls apollo for discovery, sends to themis. |
51
+ | @gaia | Remote sensing domain specialist — satellite image processing, spectral analysis, SAR, change detection, time series, ML/DL classification. Read-only analysis of geospatial data. |
52
+ | @hephaestus | AI tooling & pipelines specialist — LangChain/LangGraph chains, RAG architecture, vector stores, embedding strategies. Forges AI infrastructure. Calls apollo, sends to themis. |
53
+ | @hermes | Backend specialist — FastAPI, Python, async, TDD (RED→GREEN→REFACTOR), modern Python stdlib, obsolete lib detection. Calls apollo for discovery, sends to themis. |
54
+ | @iris | GitHub operations specialist — branches, pull requests, issues, releases, tags. Called by zeus after review. Never pushes or merges without explicit human approval. Integrates with VS Code GitHub Pull Requests extension. |
55
+ | @mnemosyne | Memory bank quality owner — initializes .pantheon/memory-bank/, writes ADRs and task records on explicit request. Called by zeus. Never invoked automatically after phases. |
56
+ | @nyx | Observability & monitoring specialist — OpenTelemetry tracing, token/cost tracking, agent performance analytics, LangSmith integration. Calls apollo for discovery, sends to themis. |
57
+ | @prometheus | Infrastructure + model provider specialist — Docker, CI/CD, multi-model routing, cost optimization, provider abstraction |
58
+ | @talos | Hotfix express lane — direct fixes for small bugs, CSS, typos, minor logic. No TDD ceremony, no orchestration overhead. Standalone, no subagents. Escalates complex issues to zeus. |
59
+ | @themis | Quality & security gate — ruff/Biome linting, dead/legacy code detection, OWASP Top 10, coverage >80%, correctness, deprecation audit. Called by implementers; escalates blockers to zeus. |
60
+ | @zeus | Central orchestrator — never implements. Delegates to: athena, apollo, hermes, aphrodite, demeter, prometheus, themis, iris, mnemosyne, talos, hephaestus, nyx |
61
+
62
+ ## OpenCode Setup
63
+
64
+ See [INSTALLATION.md](docs/INSTALLATION.md) for setup instructions.
65
+
66
+ - Build: \`npm test\`
67
+ - Test: \`npm test\`
68
+ - Lint: \`npm run lint\`
69
+
70
+ ## Teste de Instalação Global (sandbox)
71
+
72
+ Para validar a instalação global do pacote pantheon-opencode COMO UM USUÁRIO REAL, use o sandbox isolado em \`~/pantheon-sandbox/\` (fora do repo, HOME + prefix npm + venv próprios). O ambiente de dev mistura 3 instalações + config global + venv — NÃO serve para testar instalação/empacotamento.
73
+
74
+ - Rodar: \`bash ~/pantheon-sandbox/run-test.sh\` (opencode mcp list 5/5 connected + doctor 0 erros + abre TUI isolado)
75
+ - Regra para agentes: ao validar instalação global (npm pack, \`init\`, MCPs, hooks), usar o sandbox — NUNCA testar no ambiente de dev
76
+ - Descarte: \`rm -rf ~/pantheon-sandbox\`
77
+ - Detalhes: ver \`~/pantheon-sandbox/README.md\`
78
+
79
+ ## Conventions
80
+
81
+ - TDD: Write failing test first, then implement
82
+ - Coverage minimum: 80%
83
+ - Async/await on all I/O
84
+ - Type hints on all functions
85
+ - PRs always update the README — every pull request must document newly added features, behaviors, env vars, or commands in the README before being opened.
86
+ `
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Frontmatter parsing (stdlib only — no yaml dependency)
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Parse YAML-ish frontmatter from an instruction file.
94
+ *
95
+ * Only the `name` field is extracted (single-line scalar). The frontmatter
96
+ * block itself is stripped from the embedded body.
97
+ *
98
+ * @param {string} content - Raw file content
99
+ * @returns {{ name: string, body: string } | null}
100
+ */
101
+ function parseInstruction(content) {
102
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
103
+ if (!match) return null
104
+ const fm = match[1]
105
+ const nameMatch = fm.match(/^name:\s*["']?([^"'\r\n]+)["']?\s*$/m)
106
+ if (!nameMatch) return null
107
+ return {
108
+ name: nameMatch[1].trim(),
109
+ body: content.slice(match[0].length).trimEnd() + '\n',
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Read all src/instructions/*.instructions.md files, sorted by filename for
115
+ * deterministic output.
116
+ *
117
+ * @returns {Array<{ file: string, name: string, body: string }>}
118
+ */
119
+ export function readInstructions() {
120
+ if (!existsSync(INSTRUCTIONS_DIR)) return []
121
+ const files = readdirSync(INSTRUCTIONS_DIR)
122
+ .filter((f) => f.endsWith('.instructions.md'))
123
+ .sort()
124
+ const out = []
125
+ for (const file of files) {
126
+ const content = readFileSync(join(INSTRUCTIONS_DIR, file), 'utf8')
127
+ const parsed = parseInstruction(content)
128
+ if (parsed) out.push({ file, ...parsed })
129
+ }
130
+ return out
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Generation
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Generate the full AGENTS.md content.
139
+ *
140
+ * @param {Array<{ file: string, name: string, body: string }>} instructions
141
+ * @returns {string}
142
+ */
143
+ export function generateAgentsMd(instructions) {
144
+ const sections = [
145
+ '<!-- Generated by scripts/build-agents-md.mjs — do not edit directly. -->',
146
+ '<!-- Source of truth: src/instructions/*.instructions.md (AGENTS.md is a committed build artifact). -->',
147
+ '',
148
+ STATIC_HEADER.trimEnd(),
149
+ '',
150
+ '---',
151
+ '',
152
+ '# Agent Instructions',
153
+ '',
154
+ 'The following sections are consolidated from `src/instructions/*.instructions.md`',
155
+ 'so both OpenCode V1 (auto-loads AGENTS.md) and V2 (loads AGENTS.md, ignores the',
156
+ '`instructions` config key) receive identical instruction content.',
157
+ '',
158
+ ]
159
+ for (const instr of instructions) {
160
+ sections.push(
161
+ `<!-- Source: src/instructions/${instr.file} -->`,
162
+ `## ${instr.name}`,
163
+ '',
164
+ instr.body.trimEnd(),
165
+ '',
166
+ )
167
+ }
168
+ return sections.join('\n').trimEnd() + '\n'
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // CLI entry point
173
+ // ---------------------------------------------------------------------------
174
+
175
+ function main() {
176
+ const checkOnly = process.argv.includes('--check')
177
+ const instructions = readInstructions()
178
+ const content = generateAgentsMd(instructions)
179
+
180
+ if (checkOnly) {
181
+ const existing = existsSync(AGENTS_MD_PATH) ? readFileSync(AGENTS_MD_PATH, 'utf8') : ''
182
+ if (existing !== content) {
183
+ console.error(`❌ AGENTS.md is stale — run: node scripts/build-agents-md.mjs`)
184
+ process.exit(1)
185
+ }
186
+ console.log(`✅ AGENTS.md is up to date (${instructions.length} instruction sections)`)
187
+ return
188
+ }
189
+
190
+ writeFileSync(AGENTS_MD_PATH, content, 'utf8')
191
+ const lines = content.split('\n').length
192
+ console.log(`✅ Generated AGENTS.md (${instructions.length} instruction sections, ${lines} lines)`)
193
+ }
194
+
195
+ main()
package/scripts/cost.mjs CHANGED
@@ -26,6 +26,22 @@ function fail(message) {
26
26
  process.exitCode = 1
27
27
  }
28
28
 
29
+ function assertCompatibleSchema(db) {
30
+ const table = db
31
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message'")
32
+ .get()
33
+ if (!table)
34
+ throw new Error('incompatible opencode.db schema: required "message" table is missing')
35
+ const columns = db.prepare('PRAGMA table_info(message)').all()
36
+ const names = new Set(columns.map((column) => column.name))
37
+ const missing = ['data', 'time_created'].filter((name) => !names.has(name))
38
+ if (missing.length > 0) {
39
+ throw new Error(
40
+ `incompatible opencode.db schema: message table is missing ${missing.join(', ')}; select the matching V1/V2 database`,
41
+ )
42
+ }
43
+ }
44
+
29
45
  function main() {
30
46
  const [, , dbPath, daysArg] = process.argv
31
47
  const days = Number(daysArg ?? '7')
@@ -35,6 +51,7 @@ function main() {
35
51
 
36
52
  try {
37
53
  const db = new DatabaseSync(dbPath, { readOnly: true })
54
+ assertCompatibleSchema(db)
38
55
  const since = Date.now() - days * 86_400_000
39
56
  const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
40
57
  db.close()
@@ -1,6 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * opencode.mjs — OpenCode platform installer
4
+ *
5
+ * Dual-version install (Phase 3): the generated config is V1-shaped and valid
6
+ * under BOTH OpenCode V1 (`opencode` 1.18.x) and V2 (`opencode2`
7
+ * v0.0.0-next-17444). V2 reads the same config locations and normalizes V1
8
+ * fields in memory — do NOT convert to native V2 format. Known V2 beta gaps
9
+ * handled here: top-level `subagent_depth` is silently ignored (migrated to
10
+ * `experimental.subagent_depth`), and the `instructions` config key is
11
+ * accepted-but-not-loaded (content consolidated into AGENTS.md, which both
12
+ * versions load). Pass --version v2 to pantheon-init for an informational
13
+ * label; state isolation is handled at runtime via OPENCODE_DB.
4
14
  */
5
15
 
6
16
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
@@ -221,6 +231,20 @@ export async function installOpenCode(
221
231
  const componentSet = new Set(components)
222
232
  const stats = summary.opencode
223
233
 
234
+ // V1/V2 dual-version awareness (Phase 3): the SAME config works under both
235
+ // OpenCode V1 (`opencode`) and V2 (`opencode2`, beta v0.0.0-next-17444).
236
+ // V2 normalizes V1 fields in memory (no rewrite) and isolates its state DB
237
+ // via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db) with a distinct
238
+ // service port (49375 vs V1's 49374). The installer writes one shared
239
+ // config; the --version flag only labels the install for the operator.
240
+ const version = opts.version === 'v2' ? 'v2' : 'v1'
241
+ if (version === 'v2') {
242
+ info(
243
+ 'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
244
+ '(~/.local/share/opencode/opencode-v2.db), service port 49375',
245
+ )
246
+ }
247
+
224
248
  // Interactive mode handling
225
249
  const stdinTTY = process.stdin.isTTY && process.stdout.isTTY
226
250
  const forceInteractive = opts.interactive === true
@@ -333,8 +357,13 @@ export async function installOpenCode(
333
357
  }
334
358
 
335
359
  // -----------------------------------------------------------------------
336
- // 2.5 Install instructions: AGENTS.md + instructions/ (--components instructions)
360
+ // 2.5 Install instructions: AGENTS.md only (--components instructions)
337
361
  // -----------------------------------------------------------------------
362
+ // AGENTS.md is the single instruction file both V1 and V2 load; the
363
+ // generated file embeds every src/instructions/*.instructions.md body, so
364
+ // the individual files are NOT copied anymore (nothing references
365
+ // <config>/instructions/ after the section-D merge fix — copying them would
366
+ // just leave stale duplicates on disk).
338
367
  if (componentSet.has('instructions')) {
339
368
  section('\uD83D\uDCCB Instructions')
340
369
  // AGENTS.md
@@ -346,14 +375,6 @@ export async function installOpenCode(
346
375
  if (status === 'created') stats.created++
347
376
  else stats.skipped++
348
377
  }
349
- // instructions/ directory
350
- const srcInstr = join(ROOT, 'src', 'instructions')
351
- const dstInstr = join(target, 'instructions')
352
- if (existsSync(srcInstr)) {
353
- const instrResult = syncDir(srcInstr, dstInstr, dryRun, clean)
354
- stats.created += instrResult.created
355
- stats.skipped += instrResult.skipped
356
- }
357
378
  }
358
379
 
359
380
  // -----------------------------------------------------------------------
@@ -661,11 +682,36 @@ export async function installOpenCode(
661
682
  if (!config.default_agent && pantheonConfig.default_agent) {
662
683
  config.default_agent = pantheonConfig.default_agent
663
684
  }
685
+ // Merge top-level model/small_model from the repo config when the target
686
+ // config has no top-level `model` (fresh sandbox installs have none). The
687
+ // repo's opencode.json carries the default pair
688
+ // (opencode-go/deepseek-v4-flash); a user-set model is never overwritten.
689
+ if (!config.model && pantheonConfig.model) {
690
+ config.model = pantheonConfig.model
691
+ if (!config.small_model && pantheonConfig.small_model) {
692
+ config.small_model = pantheonConfig.small_model
693
+ }
694
+ }
664
695
  // Merge subagent_depth HERE (the config merge always runs, including on a
665
696
  // fresh install) instead of a file-existence-gated block, so run #1 already
666
697
  // produces the final config — run #2 must be a byte-identical no-op (#19).
667
- if (config.subagent_depth === undefined) {
668
- config.subagent_depth = 2
698
+ //
699
+ // V1/V2 compat (Phase 3): top-level `subagent_depth` is in V2's
700
+ // unsupportedTopLevel list and is SILENTLY IGNORED. The accepted form is
701
+ // `experimental.subagent_depth`. Migrate any existing top-level value into
702
+ // experimental (preserving the user's choice) and default fresh installs to
703
+ // experimental.subagent_depth = 2. V1 reads experimental.subagent_depth
704
+ // too, so the V1-shape config stays valid under both versions.
705
+ if (config.experimental === undefined || typeof config.experimental !== 'object') {
706
+ config.experimental = {}
707
+ }
708
+ if (config.subagent_depth !== undefined) {
709
+ if (config.experimental.subagent_depth === undefined) {
710
+ config.experimental.subagent_depth = config.subagent_depth
711
+ }
712
+ delete config.subagent_depth
713
+ } else if (config.experimental.subagent_depth === undefined) {
714
+ config.experimental.subagent_depth = 2
669
715
  }
670
716
 
671
717
  // --------------------------------------------------------------------
@@ -695,16 +741,24 @@ export async function installOpenCode(
695
741
  }
696
742
  }
697
743
 
698
- // The runtime hooks plugin MUST always be registered. The packaged
699
- // opencode.json no longer carries dev-machine plugin paths (removed in
700
- // 3e552cc), so config.plugin would otherwise stay empty and the
701
- // pantheon-hooks runtime would never load. Register it unconditionally,
702
- // replacing any stale entry for the same file (same basename-dedup as
703
- // above) so installs and upgrades are idempotent and hermetic.
704
- const hooksPlugin = resolveInstalledPlugin('src/plugins/pantheon-hooks.ts')
705
- const hooksFile = basename(hooksPlugin)
706
- config.plugin = config.plugin.filter((p) => basename(p) !== hooksFile)
707
- config.plugin.push(hooksPlugin)
744
+ // The two Pantheon plugins MUST always be registered, on fresh installs AND
745
+ // upgrades. The packaged opencode.json carries no `plugin` key (dev-machine
746
+ // paths removed in 3e552cc), so config.plugin would otherwise only contain
747
+ // whatever the user had — a fresh install would register NOTHING and lose
748
+ // delegation tools, GoalLoop, cost command, hashline and vision. Register
749
+ // both unconditionally, replacing any stale entry for the same file
750
+ // (basename-dedup) so installs and upgrades are idempotent and hermetic
751
+ // while preserving any user plugins.
752
+ const ensurePantheonPlugin = (ref) => {
753
+ const resolved = resolveInstalledPlugin(ref)
754
+ const file = basename(resolved)
755
+ config.plugin = config.plugin.filter((p) => basename(p) !== file)
756
+ config.plugin.push(resolved)
757
+ }
758
+ // Root-level delegation plugin (PR #45): tools, GoalLoop, cost, hashline.
759
+ ensurePantheonPlugin('src/plugin.ts')
760
+ // Runtime hooks plugin: chat hooks, hook-runner, TUI wiring.
761
+ ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
708
762
 
709
763
  if (!config.provider && pantheonConfig.provider) {
710
764
  config.provider = deepClone(pantheonConfig.provider)
@@ -747,10 +801,25 @@ export async function installOpenCode(
747
801
  // --------------------------------------------------------------------
748
802
  // D. Merge instructions paths
749
803
  // --------------------------------------------------------------------
750
- const pantheonInstructions = ['AGENTS.md', 'instructions/*.instructions.md']
804
+ // AGENTS.md is the single instruction file both V1 and V2 load (V1
805
+ // auto-discovers it, V2 loads it explicitly). The generated AGENTS.md
806
+ // (scripts/build-agents-md.mjs) already embeds every
807
+ // src/instructions/*.instructions.md body, so the `instructions/*.md`
808
+ // glob is no longer needed — keeping it would duplicate content under V1
809
+ // and is ignored under V2 anyway. Only ensure AGENTS.md is present.
810
+ const pantheonInstructions = ['AGENTS.md']
751
811
  if (!config.instructions) {
752
812
  config.instructions = [...pantheonInstructions]
753
813
  } else {
814
+ // Strip stale per-file instruction globs from PREVIOUS installs
815
+ // (e.g. `instructions/*.instructions.md` / `src/instructions/*.md`).
816
+ // The merge is additive-only otherwise, so without this filter existing
817
+ // configs would keep loading instruction content twice under V1
818
+ // (generated AGENTS.md + individual files). User entries that are not
819
+ // *.instructions.md globs are preserved.
820
+ config.instructions = config.instructions.filter(
821
+ (instr) => typeof instr !== 'string' || !instr.endsWith('.instructions.md'),
822
+ )
754
823
  for (const instr of pantheonInstructions) {
755
824
  if (!config.instructions.includes(instr)) {
756
825
  config.instructions.push(instr)
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /** Compute and optionally apply the beta version for a release PR. */
3
+
4
+ import { readFileSync, writeFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+
7
+ export const MANIFESTS = [
8
+ { file: 'package.json', kind: 'json' },
9
+ { file: 'pyproject.toml', kind: 'toml' },
10
+ { file: 'plugin.json', kind: 'json' },
11
+ { file: 'src/plugins/tui/package.json', kind: 'json' },
12
+ ]
13
+
14
+ const STABLE = /^(\d+)\.(\d+)\.(\d+)$/
15
+
16
+ export function nextStableVersion(latest, intent = 'patch') {
17
+ const match = STABLE.exec(String(latest))
18
+ if (!match) throw new Error(`npm latest is not a stable semver: "${latest}"`)
19
+ const [major, minor, patch] = match.slice(1).map(Number)
20
+ if (!['patch', 'minor', 'major'].includes(intent)) {
21
+ throw new Error(`release intent must be patch, minor, or major: "${intent}"`)
22
+ }
23
+ if (intent === 'major') return `${major + 1}.0.0`
24
+ if (intent === 'minor') return `${major}.${minor + 1}.0`
25
+ return `${major}.${minor}.${patch + 1}`
26
+ }
27
+
28
+ export function betaVersion(latest, intent, pr, sha) {
29
+ if (!/^\d+$/.test(String(pr)) || Number(pr) < 1) throw new Error(`invalid PR number: "${pr}"`)
30
+ const shortSha = String(sha).slice(0, 7)
31
+ if (!/^[0-9a-f]{7,}$/i.test(shortSha)) throw new Error(`invalid commit SHA: "${sha}"`)
32
+ return `${nextStableVersion(latest, intent)}-beta.${pr}.${shortSha}`
33
+ }
34
+
35
+ function writeVersion(root, { file, kind }, version) {
36
+ const path = join(root, file)
37
+ const raw = readFileSync(path, 'utf8')
38
+ if (kind === 'toml') {
39
+ const updated = raw.replace(/^(version\s*=\s*")[^"]+("\s*)$/m, `$1${version}$2`)
40
+ if (updated === raw) throw new Error(`no version field in ${file}`)
41
+ writeFileSync(path, updated)
42
+ return
43
+ }
44
+ const content = JSON.parse(raw)
45
+ content.version = version
46
+ writeFileSync(path, `${JSON.stringify(content, null, 2)}\n`)
47
+ }
48
+
49
+ export function applyBetaVersion(root, version) {
50
+ for (const manifest of MANIFESTS) writeVersion(root, manifest, version)
51
+ // Keep npm's lockfile metadata consistent without changing dependency data.
52
+ const lockPath = join(root, 'package-lock.json')
53
+ const lock = JSON.parse(readFileSync(lockPath, 'utf8'))
54
+ lock.version = version
55
+ if (lock.packages?.['']) lock.packages[''].version = version
56
+ writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
57
+ }
58
+
59
+ const args = new Map(
60
+ process.argv.slice(2).map((arg) => {
61
+ const [key, ...value] = arg.split('=')
62
+ return [key.replace(/^--/, ''), value.join('=')]
63
+ }),
64
+ )
65
+
66
+ if (args.has('latest')) {
67
+ try {
68
+ const version = betaVersion(
69
+ args.get('latest'),
70
+ args.get('intent') || 'patch',
71
+ args.get('pr'),
72
+ args.get('sha'),
73
+ )
74
+ if (args.has('apply')) applyBetaVersion(process.cwd(), version)
75
+ console.log(version)
76
+ } catch (error) {
77
+ console.error(`release-beta-version: ${error.message}`)
78
+ process.exit(1)
79
+ }
80
+ }