pantheon-opencode 1.6.0-beta.1 → 1.6.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.
- package/AGENTS.global.md +182 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +326 -0
- package/README.md +496 -32
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +26 -3
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +21 -14
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +374 -56
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +1 -1
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +627 -179
- package/scripts/install/plugin.mjs +43 -9
- package/scripts/install/shared.mjs +120 -12
- package/scripts/install/vision-prompt.mjs +161 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +1 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -129
- package/src/instructions/agent-return-format.instructions.md +18 -3
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +11 -110
- package/src/instructions/zeus-anti-stall.instructions.md +14 -61
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
- package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-tools.ts +429 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1145 -219
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +39 -30
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/opencode.json +0 -58
package/scripts/doctor.mjs
CHANGED
|
@@ -29,6 +29,8 @@ import { homedir } from 'node:os'
|
|
|
29
29
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
30
30
|
import { fileURLToPath } from 'node:url'
|
|
31
31
|
|
|
32
|
+
import { listV2UnsupportedFeatures } from '../src/pantheon/v2-unsupported.mjs'
|
|
33
|
+
|
|
32
34
|
// ---------------------------------------------------------------------------
|
|
33
35
|
// Paths
|
|
34
36
|
// ---------------------------------------------------------------------------
|
|
@@ -1000,7 +1002,7 @@ function checkVenvLayer(args) {
|
|
|
1000
1002
|
/**
|
|
1001
1003
|
* Return whether *dir* contains an executable code-mode target.
|
|
1002
1004
|
*
|
|
1003
|
-
* Mirrors `_has_usable_scripts` in `scripts/
|
|
1005
|
+
* Mirrors `_has_usable_scripts` in `scripts/code_mode.py`: a directory
|
|
1004
1006
|
* is only a valid resolver candidate when it is a real (non-symlink) directory
|
|
1005
1007
|
* containing a regular `.py`/`.sh` direct child. This prevents an empty project
|
|
1006
1008
|
* overlay from masking a lower-priority installation that actually ships
|
|
@@ -1430,6 +1432,153 @@ export function checkPluginVersionDrift(args) {
|
|
|
1430
1432
|
return 'sync'
|
|
1431
1433
|
}
|
|
1432
1434
|
|
|
1435
|
+
// ---------------------------------------------------------------------------
|
|
1436
|
+
// Check H4: V2 feature reduction (the surface a migrating user loses)
|
|
1437
|
+
// ---------------------------------------------------------------------------
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Whether an opencode.json registers the V2 generation of the Pantheon plugin.
|
|
1441
|
+
*
|
|
1442
|
+
* The V2 contract is the PLURAL `plugins` key; V1 is the singular `plugin` key.
|
|
1443
|
+
* So the key alone is not the test — a config can legitimately carry a
|
|
1444
|
+
* third-party entry in `plugins`. What identifies the generation is a Pantheon
|
|
1445
|
+
* V2 entry inside it. Matched on the `plugin-v2` marker so all three real
|
|
1446
|
+
* spellings are recognised: the canonical DIRECTORY (`src/plugin-v2`), an
|
|
1447
|
+
* absolute path to it, and the package export (`pantheon-opencode/plugin-v2`).
|
|
1448
|
+
*
|
|
1449
|
+
* @param {unknown} ref
|
|
1450
|
+
* @returns {boolean}
|
|
1451
|
+
*/
|
|
1452
|
+
function isV2PluginRef(ref) {
|
|
1453
|
+
const candidate =
|
|
1454
|
+
typeof ref === 'string'
|
|
1455
|
+
? ref
|
|
1456
|
+
: ref && typeof ref === 'object'
|
|
1457
|
+
? (ref.path ?? ref.source ?? ref.id ?? ref.name)
|
|
1458
|
+
: undefined
|
|
1459
|
+
return typeof candidate === 'string' && candidate.includes('plugin-v2')
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Whether any reachable config registers the V2 generation of the Pantheon
|
|
1464
|
+
* plugin. Split out of {@link collectV2UnsupportedFeatures} because "is the V2
|
|
1465
|
+
* generation registered" and "does the seed list anything" are TWO facts: the
|
|
1466
|
+
* first is about the user's config, the second about this repository's seed. A
|
|
1467
|
+
* single `features.length === 0` cannot tell them apart, and conflating them
|
|
1468
|
+
* let an emptied seed print "no V2 generation registered — nothing reduced".
|
|
1469
|
+
*
|
|
1470
|
+
* @param {{path:string, data:object}[]} configs
|
|
1471
|
+
* @returns {boolean}
|
|
1472
|
+
*/
|
|
1473
|
+
export function isV2GenerationRegistered(configs) {
|
|
1474
|
+
return configs.some((cfg) => {
|
|
1475
|
+
const list = cfg.data?.plugins
|
|
1476
|
+
return Array.isArray(list) && list.some(isV2PluginRef)
|
|
1477
|
+
})
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* The V2 feature-reduction markers to report for a set of collected configs.
|
|
1482
|
+
*
|
|
1483
|
+
* Returns the shared seed verbatim when ANY reachable config registers the V2
|
|
1484
|
+
* generation, and an empty list otherwise. The strings come from
|
|
1485
|
+
* `src/pantheon/v2-unsupported.mjs` — the same seed `getUnsupportedFeatures()`
|
|
1486
|
+
* is built from — so doctor cannot report a reduction the plugin does not, or
|
|
1487
|
+
* miss one it does. Nothing is reworded here.
|
|
1488
|
+
*
|
|
1489
|
+
* An empty result is ambiguous on its own and deliberately not interpreted
|
|
1490
|
+
* here: ask {@link isV2GenerationRegistered} for the registration fact, or
|
|
1491
|
+
* {@link describeV2Reduction} to get both at once.
|
|
1492
|
+
*
|
|
1493
|
+
* @param {{path:string, data:object}[]} configs
|
|
1494
|
+
* @returns {string[]}
|
|
1495
|
+
*/
|
|
1496
|
+
export function collectV2UnsupportedFeatures(configs) {
|
|
1497
|
+
return isV2GenerationRegistered(configs) ? listV2UnsupportedFeatures() : []
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Decide what H4 prints, from the two distinct facts rather than from one list
|
|
1502
|
+
* length.
|
|
1503
|
+
*
|
|
1504
|
+
* Three cases, and the middle one used to be silently reported as the first:
|
|
1505
|
+
*
|
|
1506
|
+
* - not registered → info. A V1 install is a supported configuration with
|
|
1507
|
+
* nothing to reduce, and saying so plainly is correct.
|
|
1508
|
+
* - registered, seed EMPTY → warn. This is a packaging fault in THIS repo,
|
|
1509
|
+
* not an absence of reduction: the V2 surface really is reduced and the
|
|
1510
|
+
* report is missing. It must never claim "nothing reduced".
|
|
1511
|
+
* - registered, seed populated → warn, listing every marker.
|
|
1512
|
+
*
|
|
1513
|
+
* Pure and exported so the empty-seed branch is testable without stubbing the
|
|
1514
|
+
* frozen seed module. `status` is returned alongside the text so a caller never
|
|
1515
|
+
* has to recover the case by matching on the message.
|
|
1516
|
+
*
|
|
1517
|
+
* @param {{registered:boolean, features:string[]}} input
|
|
1518
|
+
* @returns {{status:'reported'|'empty-seed'|'no-v2-generation', level:'info'|'warn', message:string}}
|
|
1519
|
+
*/
|
|
1520
|
+
export function describeV2Reduction({ registered, features }) {
|
|
1521
|
+
if (!registered) {
|
|
1522
|
+
return {
|
|
1523
|
+
status: 'no-v2-generation',
|
|
1524
|
+
level: 'info',
|
|
1525
|
+
message: 'No V2 plugin generation registered — nothing reduced',
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if (features.length === 0) {
|
|
1529
|
+
return {
|
|
1530
|
+
status: 'empty-seed',
|
|
1531
|
+
level: 'warn',
|
|
1532
|
+
message:
|
|
1533
|
+
'Pantheon IS registered on the V2 plugin generation, but the shared ' +
|
|
1534
|
+
'unsupported-feature seed is empty, so the reduction cannot be reported. ' +
|
|
1535
|
+
"That is a fault in this installation's reporting, not an absence of " +
|
|
1536
|
+
'reduction: the V2 surface still drops the 3 goal tools and the ' +
|
|
1537
|
+
'caller/target delegation matrix.',
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
status: 'reported',
|
|
1542
|
+
level: 'warn',
|
|
1543
|
+
message:
|
|
1544
|
+
'Pantheon is registered on the V2 plugin generation, which registers 3 tools ' +
|
|
1545
|
+
"(hashline_edit, pantheon_cost, pantheon_model) instead of the V1 surface's 6 plus " +
|
|
1546
|
+
`the BackgroundJobBoard and the caller/target delegation matrix. Unsupported: ${features.join(', ')}`,
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Report the V2 feature reduction through doctor's own output shape
|
|
1552
|
+
* (section + warn/info), so it lands in the health summary like every other
|
|
1553
|
+
* finding and never changes the exit code: a V2 install is a supported
|
|
1554
|
+
* configuration, not a broken one.
|
|
1555
|
+
*
|
|
1556
|
+
* @param {{target:string, env?:object}} args
|
|
1557
|
+
* @returns {'reported'|'empty-seed'|'no-v2-generation'|'no-config'}
|
|
1558
|
+
*/
|
|
1559
|
+
export function checkV2UnsupportedFeatures(args) {
|
|
1560
|
+
section('H4. V2 Feature Reduction')
|
|
1561
|
+
|
|
1562
|
+
const configs = collectMcpConfigs(args)
|
|
1563
|
+
if (configs.length === 0) {
|
|
1564
|
+
info('No opencode.json was found — V2 feature reduction check skipped')
|
|
1565
|
+
return 'no-config'
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Branch on REGISTRATION, not on the marker count: an empty seed on a
|
|
1569
|
+
// registered V2 generation is a reporting fault and must not be reported as
|
|
1570
|
+
// the reassuring "nothing reduced".
|
|
1571
|
+
const registered = isV2GenerationRegistered(configs)
|
|
1572
|
+
const verdict = describeV2Reduction({
|
|
1573
|
+
registered,
|
|
1574
|
+
features: collectV2UnsupportedFeatures(configs),
|
|
1575
|
+
})
|
|
1576
|
+
if (verdict.level === 'info') info(verdict.message)
|
|
1577
|
+
else warn(verdict.message)
|
|
1578
|
+
|
|
1579
|
+
return verdict.status
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1433
1582
|
// ---------------------------------------------------------------------------
|
|
1434
1583
|
// Check G: AGENTS.md freshness
|
|
1435
1584
|
// ---------------------------------------------------------------------------
|
|
@@ -1802,6 +1951,7 @@ async function main() {
|
|
|
1802
1951
|
checkAgentsMdFreshness(args)
|
|
1803
1952
|
checkSubagentDepthPlacement(args)
|
|
1804
1953
|
checkPermissionTaskPresence(args)
|
|
1954
|
+
checkV2UnsupportedFeatures(args)
|
|
1805
1955
|
|
|
1806
1956
|
printSummary(args.target)
|
|
1807
1957
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* agents-md.mjs — Dynamic AGENTS.md generator from canonical agent frontmatter
|
|
4
4
|
*
|
|
5
|
-
* Reads all agents/*.
|
|
5
|
+
* Reads all canonical src/agents/*.md files, extracts name + description from frontmatter,
|
|
6
6
|
* and generates AGENTS.md content dynamically. Eliminates hardcoded agent tables
|
|
7
7
|
* in platform installers (claude.mjs, cursor.mjs, windsurf.mjs, cline.mjs, etc.).
|
|
8
8
|
*
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
22
22
|
import { dirname, join } from 'node:path'
|
|
23
23
|
import { fileURLToPath } from 'node:url'
|
|
24
|
-
import
|
|
24
|
+
import { load } from 'js-yaml'
|
|
25
25
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Paths
|
|
@@ -47,7 +47,7 @@ function parseAgentFrontmatter(content) {
|
|
|
47
47
|
if (!match) return null
|
|
48
48
|
|
|
49
49
|
try {
|
|
50
|
-
const fm =
|
|
50
|
+
const fm = load(match[1])
|
|
51
51
|
if (!fm || typeof fm !== 'object' || !fm.name) return null
|
|
52
52
|
return {
|
|
53
53
|
name: String(fm.name),
|
|
@@ -64,7 +64,7 @@ function parseAgentFrontmatter(content) {
|
|
|
64
64
|
// ---------------------------------------------------------------------------
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Read all canonical agents/*.
|
|
67
|
+
* Read all canonical src/agents/*.md files, parse frontmatter,
|
|
68
68
|
* and return an array of agent metadata objects sorted alphabetically by name.
|
|
69
69
|
*
|
|
70
70
|
* Skips files that don't match the `*.agent.md` pattern, files without
|
|
@@ -29,9 +29,10 @@ function deepClone(obj) {
|
|
|
29
29
|
// V1 → V2 helpers
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
|
|
32
|
-
/** Action name mapping for permissions (V1 key → V2 action) */
|
|
32
|
+
/** Action name mapping for permissions (V1 key → V2 action), including task → subagent. */
|
|
33
33
|
const V1_PERMISSION_TO_ACTION = {
|
|
34
34
|
bash: 'shell',
|
|
35
|
+
task: 'subagent',
|
|
35
36
|
skill: 'skill',
|
|
36
37
|
edit: 'edit',
|
|
37
38
|
websearch: 'websearch',
|
|
@@ -229,8 +230,11 @@ function convertProviderV2toV1(_name, config) {
|
|
|
229
230
|
/**
|
|
230
231
|
* Convert V1 MCP config to V2.
|
|
231
232
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
233
|
+
* The server names are arbitrary — a user calls theirs whatever they like, and
|
|
234
|
+
* which MCP servers exist is entirely their configuration, never ours.
|
|
235
|
+
*
|
|
236
|
+
* V1: { myserver: { type: "remote", url: "...", enabled: true, timeout: 30000 } }
|
|
237
|
+
* V2 installer output: { myserver: { type: "remote", url: "...", enabled: true } }
|
|
234
238
|
*
|
|
235
239
|
* OpenCode 1.18.18 does not have an `mcp.servers` wrapper. It interprets
|
|
236
240
|
* `servers` as the name of an MCP server and then rejects it with
|
|
@@ -285,6 +289,81 @@ function convertMcpV2toV1(mcpConfig) {
|
|
|
285
289
|
return convertMcpV1toV2(mcpConfig)
|
|
286
290
|
}
|
|
287
291
|
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// MCP entrypoint path migration
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Entrypoint filename renames: every historical Python MCP entrypoint shared
|
|
298
|
+
* the `server.py` token, so a broad `pkill -f server.py` (a common idiom in
|
|
299
|
+
* agent and scratch tooling) matched the entire fleet and killed it as
|
|
300
|
+
* collateral. The five entrypoints now carry distinct tokens.
|
|
301
|
+
*
|
|
302
|
+
* The rename alone is a BREAKING change for existing installs: their
|
|
303
|
+
* `opencode.json` carries literal paths like `scripts/memory_mcp_server.py`,
|
|
304
|
+
* and a path that no longer resolves fails every MCP server silently (the
|
|
305
|
+
* same class of failure as the dead plugin surface). This map is the single
|
|
306
|
+
* source of truth for the stale → current basename rewrite that repairs them.
|
|
307
|
+
*/
|
|
308
|
+
export const MCP_ENTRYPOINT_RENAMES = Object.freeze({
|
|
309
|
+
'mcp_resources_server.py': 'mcp_resources.py',
|
|
310
|
+
'code_mode_server.py': 'code_mode.py',
|
|
311
|
+
'memory_mcp_server.py': 'memory_mcp.py',
|
|
312
|
+
'mcp_persistence_server.py': 'mcp_persistence.py',
|
|
313
|
+
'pantheon_vision_server.py': 'pantheon_vision.py',
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Rewrite a single path/argument string when its basename is a renamed MCP
|
|
318
|
+
* entrypoint. The directory prefix is preserved verbatim, so both relative
|
|
319
|
+
* (`scripts/memory_mcp_server.py`) and absolute/hand-edited paths heal without
|
|
320
|
+
* discarding a user's chosen location. Non-matching values pass through.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} value
|
|
323
|
+
* @returns {string}
|
|
324
|
+
*/
|
|
325
|
+
function renameMcpEntrypointPath(value) {
|
|
326
|
+
if (typeof value !== 'string') return value
|
|
327
|
+
const slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
|
|
328
|
+
const base = value.slice(slash + 1)
|
|
329
|
+
const renamed = MCP_ENTRYPOINT_RENAMES[base]
|
|
330
|
+
if (!renamed) return value
|
|
331
|
+
return value.slice(0, value.length - base.length) + renamed
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Heal stale MCP entrypoint paths in a flat named-server map, in place.
|
|
336
|
+
*
|
|
337
|
+
* Scans every server — not only the `pantheon-*` keys — because a user who
|
|
338
|
+
* hand-edited their config may have renamed the key while keeping the
|
|
339
|
+
* Pantheon path. Both `command` (installer writes `[python, script]`) and
|
|
340
|
+
* `args` (the standalone installer writes `[script]`) are covered, since
|
|
341
|
+
* which array carries the script differs between installers.
|
|
342
|
+
*
|
|
343
|
+
* @param {object} mcp - the `mcp` map from a config (mutated in place)
|
|
344
|
+
* @returns {number} count of rewritten strings (0 = already current)
|
|
345
|
+
*/
|
|
346
|
+
export function migrateMcpEntrypointPaths(mcp) {
|
|
347
|
+
if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)) return 0
|
|
348
|
+
|
|
349
|
+
let changed = 0
|
|
350
|
+
for (const server of Object.values(mcp)) {
|
|
351
|
+
if (!server || typeof server !== 'object') continue
|
|
352
|
+
for (const field of ['command', 'args']) {
|
|
353
|
+
const list = server[field]
|
|
354
|
+
if (!Array.isArray(list)) continue
|
|
355
|
+
for (let i = 0; i < list.length; i++) {
|
|
356
|
+
const next = renameMcpEntrypointPath(list[i])
|
|
357
|
+
if (next !== list[i]) {
|
|
358
|
+
list[i] = next
|
|
359
|
+
changed++
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return changed
|
|
365
|
+
}
|
|
366
|
+
|
|
288
367
|
// ---------------------------------------------------------------------------
|
|
289
368
|
// Top-level key mappings
|
|
290
369
|
// ---------------------------------------------------------------------------
|
|
@@ -304,6 +383,227 @@ const V2_TO_V1_RENAMES = Object.fromEntries(
|
|
|
304
383
|
Object.entries(V1_TO_V2_RENAMES).map(([k, v]) => [v, k]),
|
|
305
384
|
)
|
|
306
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Agent fields owned by the framework. Mirrors the exported MANAGED_FIELDS in
|
|
388
|
+
* install/opencode.mjs plus `source`, which the installer writes next to them.
|
|
389
|
+
* The migration runs before that merge, so it has to apply the same precedence
|
|
390
|
+
* itself when both `agent` and `agents` coexist: these come from the V1 block,
|
|
391
|
+
* every other field stays the user's.
|
|
392
|
+
*
|
|
393
|
+
* `permissions` is the V2 name — the converter renames the V1 `permission`
|
|
394
|
+
* block before the merge runs.
|
|
395
|
+
*
|
|
396
|
+
* `description` is deliberately absent, and this list is deliberately narrower
|
|
397
|
+
* than what the installer writes. The installer writes a description only when
|
|
398
|
+
* it creates an agent (install/opencode.mjs, the new-agent branch); on an agent
|
|
399
|
+
* that already exists it leaves the value alone, under its own comment "Existing
|
|
400
|
+
* agent fields belong to the user". So a description is framework-owned for a
|
|
401
|
+
* NEW agent and user-owned for an EXISTING one. Listing it here would make the
|
|
402
|
+
* migration stricter than the installer and would overwrite a description the
|
|
403
|
+
* installer deliberately preserves. mergeManagedAgents carries the value across
|
|
404
|
+
* instead, so it survives the merge without being treated as framework-owned.
|
|
405
|
+
*
|
|
406
|
+
* KNOWN DEBT: the installer still writes the V1 spelling `permission`, while V2
|
|
407
|
+
* reads `permissions`. An agent that goes through this merge can therefore end
|
|
408
|
+
* up carrying both keys, with `permission` left as dead weight that nothing
|
|
409
|
+
* reads. Tracked for the next slice; not fixed here, because picking a winner
|
|
410
|
+
* changes what an install writes.
|
|
411
|
+
*/
|
|
412
|
+
export const MANAGED_AGENT_FIELDS = Object.freeze([
|
|
413
|
+
'source',
|
|
414
|
+
'temperature',
|
|
415
|
+
'color',
|
|
416
|
+
'permissions',
|
|
417
|
+
'mode',
|
|
418
|
+
'hidden',
|
|
419
|
+
'disable_model_invocation',
|
|
420
|
+
])
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Compare this module's managed-agent list against the installer's.
|
|
424
|
+
*
|
|
425
|
+
* The two merge paths have to agree: the migration reimplements the installer's
|
|
426
|
+
* merge because it runs before it. A field present on only one side is silent
|
|
427
|
+
* divergence — either the migration overwrites a field the installer leaves
|
|
428
|
+
* alone, or it stops mirroring one the installer now writes. Both directions
|
|
429
|
+
* matter, so the result is reported as a pair of differences rather than a
|
|
430
|
+
* boolean.
|
|
431
|
+
*
|
|
432
|
+
* Names are compared after the converter's renames, because that is the
|
|
433
|
+
* spelling the merge actually sees.
|
|
434
|
+
*
|
|
435
|
+
* @param {string[]} installerFields - MANAGED_FIELDS as exported by install/opencode.mjs
|
|
436
|
+
* @returns {{ onlyInMigration: string[], onlyInInstaller: string[] }}
|
|
437
|
+
*/
|
|
438
|
+
export function managedAgentFieldDrift(installerFields) {
|
|
439
|
+
const toV2Name = (field) => V1_TO_V2_RENAMES[field] ?? field
|
|
440
|
+
const migration = new Set(MANAGED_AGENT_FIELDS.map(toV2Name))
|
|
441
|
+
const installer = new Set(installerFields.map(toV2Name))
|
|
442
|
+
return {
|
|
443
|
+
onlyInMigration: [...migration].filter((f) => !installer.has(f)).sort(),
|
|
444
|
+
onlyInInstaller: [...installer].filter((f) => !migration.has(f)).sort(),
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** @returns {boolean} true for a plain name→config map (not null/array) */
|
|
449
|
+
function isAgentMap(value) {
|
|
450
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Merge the converted V1 `agent` block into an existing V2 `agents` base.
|
|
455
|
+
*
|
|
456
|
+
* Agents present in both are merged field by field: the base is kept, keys the
|
|
457
|
+
* base does not have are filled in from the V1 block, and MANAGED_AGENT_FIELDS
|
|
458
|
+
* are overwritten by the V1 values. Agents present in only one block are taken
|
|
459
|
+
* as they are — a union, not a replacement.
|
|
460
|
+
*
|
|
461
|
+
* Filling the gaps matters, and it is not description-specific: any key present
|
|
462
|
+
* only in the V1 block used to vanish here — `model`, `provider` and `mcp` too.
|
|
463
|
+
* `description` is the visible case, because the installer never writes one for
|
|
464
|
+
* an existing agent, so the V1 block is the only place that value can come from
|
|
465
|
+
* and the agent lost it on every install, permanently. Filling gaps can never
|
|
466
|
+
* destroy a base value; on conflict the base still wins, as before.
|
|
467
|
+
*
|
|
468
|
+
* @param {object} base - existing V2 `agents` map
|
|
469
|
+
* @param {object} managed - converted V1 `agent` map
|
|
470
|
+
* @returns {object} merged map
|
|
471
|
+
*/
|
|
472
|
+
function mergeManagedAgents(base, managed) {
|
|
473
|
+
const merged = { ...base }
|
|
474
|
+
|
|
475
|
+
for (const [name, agentConfig] of Object.entries(managed)) {
|
|
476
|
+
const existing = merged[name]
|
|
477
|
+
if (!isAgentMap(existing) || !isAgentMap(agentConfig)) {
|
|
478
|
+
merged[name] = agentConfig
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
const entry = { ...existing }
|
|
482
|
+
for (const [field, value] of Object.entries(agentConfig)) {
|
|
483
|
+
if (!(field in entry)) entry[field] = value
|
|
484
|
+
}
|
|
485
|
+
for (const field of MANAGED_AGENT_FIELDS) {
|
|
486
|
+
if (field in agentConfig) entry[field] = agentConfig[field]
|
|
487
|
+
}
|
|
488
|
+
merged[name] = entry
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return merged
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// context_sandbox: V1 top-level block → V2 plugin options
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
//
|
|
498
|
+
// `context_sandbox` is a Pantheon-owned, V1 top-level setting (src/plugin.ts
|
|
499
|
+
// reads `config.context_sandbox`). OpenCode V2 no longer carries custom
|
|
500
|
+
// top-level keys: a sandbox probe against V2.0.25 showed `context_sandbox`
|
|
501
|
+
// exits 0 but is SILENTLY DROPPED — identical to an arbitrary unknown key and
|
|
502
|
+
// with no warning — while a value nested under a plugin entry's `options`
|
|
503
|
+
// survives verbatim (plugin options are not schema-validated). The V2 adapter
|
|
504
|
+
// reads the setting as `ctx.options.context_sandbox`.
|
|
505
|
+
//
|
|
506
|
+
// So the migration DUPLICATES the V1 top-level block into the exact Pantheon V2
|
|
507
|
+
// plugin entry's options. The top-level block is deliberately LEFT IN PLACE so
|
|
508
|
+
// a user who downgrades to V1 keeps the setting. Precedence per leaf:
|
|
509
|
+
// explicit V2 plugin option > legacy top-level fill > runtime defaults
|
|
510
|
+
// (defaults are never written by the installer — the adapter resolves them).
|
|
511
|
+
//
|
|
512
|
+
// A null or type-invalid known field is not silently coerced: the merge throws
|
|
513
|
+
// BEFORE the config is written, so a malformed block aborts the install instead
|
|
514
|
+
// of persisting a value that changes runtime behaviour.
|
|
515
|
+
|
|
516
|
+
/** Tool-keyed leaf fields of `context_sandbox.limits`. */
|
|
517
|
+
export const CONTEXT_SANDBOX_LIMIT_FIELDS = Object.freeze({
|
|
518
|
+
read: Object.freeze(['maxLines', 'keepHead', 'keepTail']),
|
|
519
|
+
grep: Object.freeze(['maxResults', 'keepTop']),
|
|
520
|
+
glob: Object.freeze(['maxFiles', 'keepTop']),
|
|
521
|
+
webfetch: Object.freeze(['maxChars', 'keepHead']),
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
/** @returns {boolean} true for a plain name→value map (not null/array) */
|
|
525
|
+
function isPlainObject(value) {
|
|
526
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Validate one `context_sandbox` block (top-level legacy or V2 plugin option).
|
|
531
|
+
*
|
|
532
|
+
* `undefined` (key absent) is valid. Anything else must be a plain object with
|
|
533
|
+
* an optional boolean `enabled` and an optional `limits` object whose known
|
|
534
|
+
* tool groups are plain objects carrying only non-negative finite numbers.
|
|
535
|
+
* Unknown keys are ignored (the runtime resolver does the same).
|
|
536
|
+
*
|
|
537
|
+
* @param {unknown} block
|
|
538
|
+
* @param {string} label - human-readable origin, used in the thrown message
|
|
539
|
+
* @throws {Error} on a null or type-invalid known field
|
|
540
|
+
*/
|
|
541
|
+
function assertSandboxBlock(block, label) {
|
|
542
|
+
if (block === undefined) return
|
|
543
|
+
if (!isPlainObject(block)) {
|
|
544
|
+
throw new Error(`${label} must be an object when present`)
|
|
545
|
+
}
|
|
546
|
+
if ('enabled' in block && typeof block.enabled !== 'boolean') {
|
|
547
|
+
throw new Error(`${label}.enabled must be a boolean`)
|
|
548
|
+
}
|
|
549
|
+
if ('limits' in block) {
|
|
550
|
+
if (!isPlainObject(block.limits)) {
|
|
551
|
+
throw new Error(`${label}.limits must be an object`)
|
|
552
|
+
}
|
|
553
|
+
for (const [tool, fields] of Object.entries(CONTEXT_SANDBOX_LIMIT_FIELDS)) {
|
|
554
|
+
const group = block.limits[tool]
|
|
555
|
+
if (group === undefined) continue
|
|
556
|
+
if (!isPlainObject(group)) {
|
|
557
|
+
throw new Error(`${label}.limits.${tool} must be an object`)
|
|
558
|
+
}
|
|
559
|
+
for (const field of fields) {
|
|
560
|
+
if (!(field in group)) continue
|
|
561
|
+
const value = group[field]
|
|
562
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
563
|
+
throw new Error(`${label}.limits.${tool}.${field} must be a non-negative number`)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Merge a legacy (V1 top-level) context_sandbox block into an optional explicit
|
|
572
|
+
* V2 block. Values are compared per leaf, so `enabled: false` and a numeric `0`
|
|
573
|
+
* are preserved (not mistaken for "unset").
|
|
574
|
+
*
|
|
575
|
+
* @param {object|undefined} v2Explicit - existing `plugins[...].options.context_sandbox`
|
|
576
|
+
* @param {object|undefined} legacy - top-level `context_sandbox`
|
|
577
|
+
* @returns {object} merged block (only keys that were actually set)
|
|
578
|
+
* @throws {Error} when either block is null/type-invalid
|
|
579
|
+
*/
|
|
580
|
+
export function mergeContextSandboxBlocks(v2Explicit, legacy) {
|
|
581
|
+
assertSandboxBlock(v2Explicit, 'plugins[...].options.context_sandbox')
|
|
582
|
+
assertSandboxBlock(legacy, 'context_sandbox')
|
|
583
|
+
|
|
584
|
+
const explicit = isPlainObject(v2Explicit) ? v2Explicit : {}
|
|
585
|
+
const base = isPlainObject(legacy) ? legacy : {}
|
|
586
|
+
const merged = {}
|
|
587
|
+
|
|
588
|
+
if ('enabled' in explicit) merged.enabled = explicit.enabled
|
|
589
|
+
else if ('enabled' in base) merged.enabled = base.enabled
|
|
590
|
+
|
|
591
|
+
const mergedLimits = {}
|
|
592
|
+
for (const [tool, fields] of Object.entries(CONTEXT_SANDBOX_LIMIT_FIELDS)) {
|
|
593
|
+
const explicitGroup = isPlainObject(explicit.limits?.[tool]) ? explicit.limits[tool] : null
|
|
594
|
+
const baseGroup = isPlainObject(base.limits?.[tool]) ? base.limits[tool] : null
|
|
595
|
+
const group = {}
|
|
596
|
+
for (const field of fields) {
|
|
597
|
+
if (explicitGroup && field in explicitGroup) group[field] = explicitGroup[field]
|
|
598
|
+
else if (baseGroup && field in baseGroup) group[field] = baseGroup[field]
|
|
599
|
+
}
|
|
600
|
+
if (Object.keys(group).length > 0) mergedLimits[tool] = group
|
|
601
|
+
}
|
|
602
|
+
if (Object.keys(mergedLimits).length > 0) merged.limits = mergedLimits
|
|
603
|
+
|
|
604
|
+
return merged
|
|
605
|
+
}
|
|
606
|
+
|
|
307
607
|
// ---------------------------------------------------------------------------
|
|
308
608
|
// Public API
|
|
309
609
|
// ---------------------------------------------------------------------------
|
|
@@ -333,7 +633,20 @@ export function migrateV1toV2(config) {
|
|
|
333
633
|
}
|
|
334
634
|
agents[agentName] = ac
|
|
335
635
|
}
|
|
336
|
-
|
|
636
|
+
// An `agents` (V2) block may already exist — the installed opencode.json
|
|
637
|
+
// carries both, in either key order. Renaming onto it with a plain
|
|
638
|
+
// replace dropped the whole managed merge, so merge instead.
|
|
639
|
+
const base = result.agents
|
|
640
|
+
result.agents = isAgentMap(base) ? mergeManagedAgents(base, agents) : agents
|
|
641
|
+
} else if (key === 'agents') {
|
|
642
|
+
// V2 block: it is the base. The singular `agent` key may already have
|
|
643
|
+
// been converted above, in which case merge the two.
|
|
644
|
+
const managed = result.agents
|
|
645
|
+
if (isAgentMap(value)) {
|
|
646
|
+
result.agents = isAgentMap(managed) ? mergeManagedAgents(value, managed) : value
|
|
647
|
+
} else if (!isAgentMap(managed)) {
|
|
648
|
+
result.agents = value
|
|
649
|
+
}
|
|
337
650
|
} else if (key === 'provider' && typeof value === 'object' && value !== null) {
|
|
338
651
|
const providers = {}
|
|
339
652
|
for (const [provName, provConfig] of Object.entries(value)) {
|
|
@@ -27,7 +27,7 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
|
|
|
27
27
|
const visionServer = resolveVisionServer(target)
|
|
28
28
|
|
|
29
29
|
// Check 1: Critical runtime scripts exist
|
|
30
|
-
const scripts = ['
|
|
30
|
+
const scripts = ['mcp_persistence.py', '_pantheon_paths.py']
|
|
31
31
|
for (const script of scripts) {
|
|
32
32
|
const path = join(target, 'scripts', script)
|
|
33
33
|
if (existsSync(path)) {
|
|
@@ -44,8 +44,8 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
|
|
|
44
44
|
check: visionServer.check,
|
|
45
45
|
detail: visionServer.detail,
|
|
46
46
|
})
|
|
47
|
-
const installedVisionServer = join(target, 'scripts', '
|
|
48
|
-
const canonicalVisionServer = join(target, 'src', 'mcp', '
|
|
47
|
+
const installedVisionServer = join(target, 'scripts', 'pantheon_vision.py')
|
|
48
|
+
const canonicalVisionServer = join(target, 'src', 'mcp', 'pantheon_vision.py')
|
|
49
49
|
if (existsSync(installedVisionServer) && existsSync(canonicalVisionServer)) {
|
|
50
50
|
try {
|
|
51
51
|
if (
|
|
@@ -54,7 +54,7 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
|
|
|
54
54
|
) {
|
|
55
55
|
results.failed.push({
|
|
56
56
|
check: 'pantheon-vision source drift',
|
|
57
|
-
detail: 'installed script differs from src/mcp/
|
|
57
|
+
detail: 'installed script differs from src/mcp/pantheon_vision.py',
|
|
58
58
|
})
|
|
59
59
|
}
|
|
60
60
|
} catch {
|
|
@@ -66,7 +66,7 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
|
|
|
66
66
|
}
|
|
67
67
|
} else {
|
|
68
68
|
results.failed.push({
|
|
69
|
-
check: 'src/mcp/
|
|
69
|
+
check: 'src/mcp/pantheon_vision.py',
|
|
70
70
|
detail: 'NOT FOUND — canonical vision server source is unavailable',
|
|
71
71
|
})
|
|
72
72
|
}
|
|
@@ -104,12 +104,7 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// Check 2: Syntax check on each MCP server script
|
|
107
|
-
const mcpScripts = [
|
|
108
|
-
'mcp_persistence_server.py',
|
|
109
|
-
'mcp_resources_server.py',
|
|
110
|
-
'code_mode_server.py',
|
|
111
|
-
'memory_mcp_server.py',
|
|
112
|
-
]
|
|
107
|
+
const mcpScripts = ['mcp_persistence.py', 'mcp_resources.py', 'code_mode.py', 'memory_mcp.py']
|
|
113
108
|
for (const script of mcpScripts) {
|
|
114
109
|
const path = join(target, 'scripts', script)
|
|
115
110
|
if (existsSync(path) && python) {
|
|
@@ -135,12 +130,12 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
|
|
|
135
130
|
})
|
|
136
131
|
if (result.status === 0) {
|
|
137
132
|
results.passed.push({
|
|
138
|
-
check: 'syntax:
|
|
133
|
+
check: 'syntax:pantheon_vision.py',
|
|
139
134
|
detail: 'valid',
|
|
140
135
|
})
|
|
141
136
|
} else {
|
|
142
137
|
results.failed.push({
|
|
143
|
-
check: 'syntax:
|
|
138
|
+
check: 'syntax:pantheon_vision.py',
|
|
144
139
|
detail: result.stderr.toString().trim().split('\n').slice(-1)[0],
|
|
145
140
|
})
|
|
146
141
|
}
|
|
@@ -173,13 +168,20 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
|
|
|
173
168
|
if (python) {
|
|
174
169
|
const mcpResult = spawnSync(
|
|
175
170
|
python,
|
|
176
|
-
[
|
|
171
|
+
[
|
|
172
|
+
'-c',
|
|
173
|
+
'import importlib.metadata as metadata; from fastmcp import FastMCP; ' +
|
|
174
|
+
'print(f\'mcp={metadata.version("mcp")} fastmcp={metadata.version("fastmcp")} {FastMCP.__module__}\')',
|
|
175
|
+
],
|
|
177
176
|
{ stdio: 'pipe' },
|
|
178
177
|
)
|
|
179
178
|
if (mcpResult.status === 0) {
|
|
180
179
|
results.passed.push({ check: 'mcp-sdk', detail: mcpResult.stdout.toString().trim() })
|
|
181
180
|
} else {
|
|
182
|
-
results.failed.push({
|
|
181
|
+
results.failed.push({
|
|
182
|
+
check: 'mcp-sdk',
|
|
183
|
+
detail: 'MCP SDK or standalone FastMCP is unavailable — all MCP servers will fail',
|
|
184
|
+
})
|
|
183
185
|
}
|
|
184
186
|
}
|
|
185
187
|
// Check 4: the memory server's only search backend is the stdlib's own
|
|
@@ -224,13 +226,13 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
|
|
|
224
226
|
function resolveVisionServer(target) {
|
|
225
227
|
const candidates = [
|
|
226
228
|
{
|
|
227
|
-
path: join(target, 'src', 'mcp', '
|
|
228
|
-
check: 'src/mcp/
|
|
229
|
+
path: join(target, 'src', 'mcp', 'pantheon_vision.py'),
|
|
230
|
+
check: 'src/mcp/pantheon_vision.py',
|
|
229
231
|
detail: 'canonical source exists',
|
|
230
232
|
},
|
|
231
233
|
{
|
|
232
|
-
path: join(target, 'scripts', '
|
|
233
|
-
check: 'scripts/
|
|
234
|
+
path: join(target, 'scripts', 'pantheon_vision.py'),
|
|
235
|
+
check: 'scripts/pantheon_vision.py',
|
|
234
236
|
detail: 'installed copy exists (deployed from src/mcp)',
|
|
235
237
|
},
|
|
236
238
|
]
|