pantheon-opencode 1.5.0 → 1.5.2
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/CHANGELOG.md +27 -0
- package/README.md +63 -2
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/pyproject.toml +2 -1
- package/scripts/doctor.mjs +228 -2
- package/scripts/install/state.mjs +27 -2
- package/scripts/sync-tui.mjs +11 -5
- package/scripts/zenodo-artifact.mjs +77 -4
- package/src/agents/mnemosyne.md +11 -1
- package/src/agents/zeus.md +1 -0
- package/src/mcp/memory_mcp_server.py +81 -27
- package/src/plugins/pantheon-hooks.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts +19 -24
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +22 -81
- package/src/plugins/tui/package-lock.json +5 -2
- package/src/plugins/tui/package.json +4 -1
- package/src/plugins/tui/src/index.tsx +54 -151
- package/src/mcp/toon_codec.py +0 -489
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
18
18
|
|
|
19
19
|
## ✅ Closed Issues
|
|
20
20
|
|
|
21
|
+
## [v1.5.2] - 2026-09-22
|
|
22
|
+
|
|
23
|
+
## 🐞 Fixed
|
|
24
|
+
- **install** — guard undefined collection in init (#174)
|
|
25
|
+
- **release** — compare Zenodo file checksum in the format the API returns (#173)
|
|
26
|
+
## [v1.5.1] - 2026-09-22
|
|
27
|
+
|
|
28
|
+
## 🐞 Fixed
|
|
29
|
+
- **agents** — declare explicit read: allow for zeus
|
|
30
|
+
- **ci** — drop fastembed from CI, keep pytest collection green (#163)
|
|
31
|
+
- **plugin** — detect registered-plugin version drift after pantheon_delegate removal (#169)
|
|
32
|
+
- **config** — correct node floor and warn on unsupported runtime (#168)
|
|
33
|
+
- **mcp** — degrade gracefully when fastembed import fails (#165)
|
|
34
|
+
- **mcp** — drop dead toon_codec module from published package (#167)
|
|
35
|
+
- **tui** — remove dead pantheon_delegate refs after v1.5.0 V1 removal (#166)
|
|
36
|
+
- **mnemosyne** — grant scoped write to memory-bank and deepwork paths (#164)
|
|
37
|
+
|
|
38
|
+
## ✅ Closed Issues
|
|
39
|
+
- #94 - drop fastembed from CI, keep pytest collection green (#163)
|
|
40
|
+
- #158 - detect registered-plugin version drift after pantheon_delegate removal (#169)
|
|
41
|
+
- #114 - correct node floor and warn on unsupported runtime (#168)
|
|
42
|
+
- #160 - correct node floor and warn on unsupported runtime (#168)
|
|
43
|
+
- #159 - degrade gracefully when fastembed import fails (#165)
|
|
44
|
+
- #162 - drop dead toon_codec module from published package (#167)
|
|
45
|
+
- #161 - remove dead pantheon_delegate refs after v1.5.0 V1 removal (#166)
|
|
46
|
+
- #111 - grant scoped write to memory-bank and deepwork paths (#164)
|
|
47
|
+
|
|
21
48
|
## [v1.5.0] - 2026-09-16
|
|
22
49
|
|
|
23
50
|
### 🆕 What's New
|
package/README.md
CHANGED
|
@@ -30,7 +30,15 @@ way to plan work, make progress, check results, and keep useful project context.
|
|
|
30
30
|
|
|
31
31
|
## Start in 2 minutes
|
|
32
32
|
|
|
33
|
-
Requirements: [OpenCode 1.18.4+](https://opencode.ai/docs/) and Node.js
|
|
33
|
+
Requirements: [OpenCode 1.18.4+](https://opencode.ai/docs/) and Node.js
|
|
34
|
+
22.22.2+ (or 24.15.0+ / 26+).
|
|
35
|
+
|
|
36
|
+
Pantheon declares `engines.node` as `^22.22.2 || ^24.15.0 || >=26.0.0`. The
|
|
37
|
+
floor reflects what the dependency tree actually needs — the transitive
|
|
38
|
+
`ini@7` rejects earlier 22.x/24.x builds with `EBADENGINE` — and odd-numbered
|
|
39
|
+
Node releases (23, 25) are out of range. The `pantheon_cost` tool also needs
|
|
40
|
+
`node:sqlite`, which requires Node >= 22.5; `doctor` warns when the running
|
|
41
|
+
runtime cannot load it.
|
|
34
42
|
|
|
35
43
|
From the project where you want to use Pantheon:
|
|
36
44
|
|
|
@@ -246,7 +254,12 @@ Two freshness guarantees back it up:
|
|
|
246
254
|
re-run `init` just for file copies.
|
|
247
255
|
- **Drift detection** — the installer stamps the installed version in
|
|
248
256
|
`.pantheon/install-state.json` and `doctor` warns when the package is newer
|
|
249
|
-
than the last sync, pointing at `update`.
|
|
257
|
+
than the last sync, pointing at `update`. `doctor` also detects **plugin
|
|
258
|
+
version drift** (issue #158): when `opencode.json` registers a plugin path
|
|
259
|
+
inside a `node_modules/pantheon-opencode` copy whose `package.json` version
|
|
260
|
+
differs from the running package, it warns that the registered tool surface
|
|
261
|
+
is stale. Re-running `init`/`update` realigns the registration onto the
|
|
262
|
+
current package.
|
|
250
263
|
|
|
251
264
|
`init` also gained `--components agents,skills,...` (narrow install),
|
|
252
265
|
`--clean` (alias of `--force`), `--opencode-version auto`, atomic config
|
|
@@ -256,6 +269,42 @@ the install completes but MCP entries are omitted (with a warning) instead of
|
|
|
256
269
|
pointing at a broken interpreter. Installer messages auto-detect pt-BR via
|
|
257
270
|
`LANG`/`LC_ALL`.
|
|
258
271
|
|
|
272
|
+
## Migrating to 1.5.x (from 1.4.x)
|
|
273
|
+
|
|
274
|
+
1.5.0 removed the custom `pantheon_delegate` tool (and the V1 delegation
|
|
275
|
+
engine) in favor of OpenCode's native `task()`; see
|
|
276
|
+
[Delegation (native `task()`)](#delegation-native-task). Two things change on
|
|
277
|
+
an existing install:
|
|
278
|
+
|
|
279
|
+
1. **The tool disappears from the plugin surface.** `pantheon_delegate` is no
|
|
280
|
+
longer registered by `src/plugin.ts` or `src/plugin-v2.ts`. Agents now
|
|
281
|
+
delegate through `task()` only — no configuration is needed.
|
|
282
|
+
2. **A lockfile-pinned copy can keep the old tool alive.** `npm install` is
|
|
283
|
+
lockfile-authoritative: a `package-lock.json` pinned to `1.4.1` (which
|
|
284
|
+
satisfies `^1.4.1`) is never re-resolved, so a project's
|
|
285
|
+
`node_modules/pantheon-opencode` can stay on 1.4.x while the published
|
|
286
|
+
package moved on. The plugin path your `opencode.json` registers keeps
|
|
287
|
+
pointing at that stale copy, and you keep running the obsolete tool surface
|
|
288
|
+
— including `pantheon_delegate` — with no warning.
|
|
289
|
+
|
|
290
|
+
The fix is a realignment + a detector:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
# Realign the registered plugin path onto the current package (rewrites any
|
|
294
|
+
# node_modules/pantheon-opencode reference in opencode.json):
|
|
295
|
+
npx pantheon-opencode@latest init --yes --headless
|
|
296
|
+
# or, with a global install:
|
|
297
|
+
pantheon-opencode update
|
|
298
|
+
|
|
299
|
+
# Then verify no drift remains:
|
|
300
|
+
npx pantheon-opencode@latest doctor
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
`doctor` now reports a **Plugin Version Drift** warning (section H3) when the
|
|
304
|
+
registered plugin points into an installed copy whose version differs from the
|
|
305
|
+
running package, naming both versions and the removal (`pantheon_delegate` in
|
|
306
|
+
1.5.0). A healthy install reports no warning.
|
|
307
|
+
|
|
259
308
|
## Releases
|
|
260
309
|
|
|
261
310
|
Publication is authorized **only** by an explicit `workflow_dispatch` of the
|
|
@@ -331,6 +380,18 @@ and returns nothing. Prefer `background=true` dispatches with an explicit
|
|
|
331
380
|
`task_status(wait=true)` fan-in so large payloads are collected deterministically.
|
|
332
381
|
|
|
333
382
|
|
|
383
|
+
## Configuration (environment variables)
|
|
384
|
+
|
|
385
|
+
| Variable | Default | Description |
|
|
386
|
+
|---|---|---|
|
|
387
|
+
| `PANTHEON_MEMORY_EMBED` | `on` | `off` (also `0`, `false`, `no`) disables the `pantheon-memory` embedding/vector pipeline — search runs in FTS5-only mode with no model download and no `sqlite-vec` writes. Also forced `off` automatically when `fastembed` fails to import, so a broken embedding install never takes the server down (issue #159). |
|
|
388
|
+
|
|
389
|
+
The memory MCP degrades gracefully: when `fastembed` or `sqlite-vec` are
|
|
390
|
+
unavailable (network failure, incompatible wheel), the server still starts,
|
|
391
|
+
answers the MCP `initialize` handshake, and serves keyword search — only the
|
|
392
|
+
semantic vector ranking is unavailable.
|
|
393
|
+
|
|
394
|
+
|
|
334
395
|
## Documentation
|
|
335
396
|
|
|
336
397
|
- [Installation](docs/INSTALLATION.md) · [Quick start](docs/QUICKSTART.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-opencode",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"author": "ils15",
|
|
120
120
|
"license": "MIT",
|
|
121
121
|
"engines": {
|
|
122
|
-
"node": ">=
|
|
122
|
+
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
|
123
123
|
},
|
|
124
124
|
"preferGlobal": true
|
|
125
125
|
}
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon",
|
|
3
3
|
"publisher": "ils15",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.2",
|
|
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
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pantheon"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.2"
|
|
8
8
|
description = "Multi-agent orchestration framework"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
dependencies = []
|
|
@@ -39,6 +39,7 @@ ignore = ["E501"]
|
|
|
39
39
|
"scripts/vector_memory/*.py" = ["PLC0415"]
|
|
40
40
|
"tests/test_code_mode_sandbox.py" = ["PLC0415", "N806"]
|
|
41
41
|
"tests/test_code_mode_server.py" = ["PLC0415"]
|
|
42
|
+
"tests/test_codemap_mcp.py" = ["PLC0415"]
|
|
42
43
|
"tests/test_memory_mcp_server.py" = ["PLC0415", "PLR2004"]
|
|
43
44
|
# Existing public APIs and test fixtures are intentionally broad/simple; refactoring
|
|
44
45
|
# them would be behavior-affecting rather than a mechanical lint correction.
|
package/scripts/doctor.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - Permission/frontmatter mismatches
|
|
10
10
|
* - OpenCode artifact freshness
|
|
11
11
|
* - Git status
|
|
12
|
+
* - Node runtime (node:sqlite availability for pantheon_cost)
|
|
12
13
|
*
|
|
13
14
|
* Usage:
|
|
14
15
|
* node scripts/doctor.mjs # auto-detect, cwd
|
|
@@ -25,7 +26,7 @@ import { spawn as spawnAsync, spawnSync } from 'node:child_process'
|
|
|
25
26
|
import { createHash } from 'node:crypto'
|
|
26
27
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
27
28
|
import { homedir } from 'node:os'
|
|
28
|
-
import { basename, dirname, join, resolve } from 'node:path'
|
|
29
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
29
30
|
import { fileURLToPath } from 'node:url'
|
|
30
31
|
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
@@ -159,6 +160,8 @@ function showHelp() {
|
|
|
159
160
|
D. OpenCode Status — OpenCode artifact layout
|
|
160
161
|
E. Git Status — uncommitted changes
|
|
161
162
|
F. Runtime Layer — venv python + pinned pip dependencies
|
|
163
|
+
H3. Plugin Drift — registered plugin version vs this package
|
|
164
|
+
K. Node Runtime — node:sqlite availability for pantheon_cost
|
|
162
165
|
|
|
163
166
|
Exit codes:
|
|
164
167
|
0 — no blocking errors (warnings are advisory)
|
|
@@ -1161,7 +1164,10 @@ function comparePantheonVersions(a, b) {
|
|
|
1161
1164
|
for (let i = 0; i < 3; i++) {
|
|
1162
1165
|
if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]
|
|
1163
1166
|
}
|
|
1164
|
-
|
|
1167
|
+
// Infinity - Infinity is NaN, which would make equal stable versions compare
|
|
1168
|
+
// as drift for any caller using a strict === 0 check.
|
|
1169
|
+
if (pa.beta === pb.beta) return 0
|
|
1170
|
+
return pa.beta < pb.beta ? -1 : 1
|
|
1165
1171
|
}
|
|
1166
1172
|
|
|
1167
1173
|
/**
|
|
@@ -1214,6 +1220,159 @@ export function checkInstallVersionDrift(args) {
|
|
|
1214
1220
|
return 'sync'
|
|
1215
1221
|
}
|
|
1216
1222
|
|
|
1223
|
+
// ---------------------------------------------------------------------------
|
|
1224
|
+
// Check H3: Plugin version drift (issue #158)
|
|
1225
|
+
// ---------------------------------------------------------------------------
|
|
1226
|
+
// npm installs are lockfile-authoritative: a package-lock.json pinned to an
|
|
1227
|
+
// older pantheon-opencode (e.g. 1.4.1 satisfying ^1.4.1) is never re-resolved,
|
|
1228
|
+
// so an installed copy under node_modules/pantheon-opencode can lag several
|
|
1229
|
+
// releases behind the running package. The opencode.json keeps registering
|
|
1230
|
+
// that stale copy's plugin path and the user silently runs an obsolete tool
|
|
1231
|
+
// surface — 1.5.0 removed the pantheon_delegate tool, so a drifted install
|
|
1232
|
+
// still exposes it. This layer compares the version of the package a
|
|
1233
|
+
// registered plugin points into against this package's own version. Without
|
|
1234
|
+
// network access the "latest" version IS the running package's version.
|
|
1235
|
+
|
|
1236
|
+
/** Locates an installed copy of this package inside a dependency tree. */
|
|
1237
|
+
const INSTALLED_PACKAGE_MARKER = '/node_modules/pantheon-opencode/'
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Return the installed-package root containing a plugin path, or null when the
|
|
1241
|
+
* path is not inside a `node_modules/pantheon-opencode` tree.
|
|
1242
|
+
*
|
|
1243
|
+
* Only installed copies are compared: a dev checkout (e.g. <dev>/pantheon)
|
|
1244
|
+
* and unrelated directories named pantheon-opencode are deliberately not
|
|
1245
|
+
* flagged — the drift signal must never fire on a path the installer does not
|
|
1246
|
+
* own (see the third-party guards in scripts/install/opencode.mjs).
|
|
1247
|
+
*
|
|
1248
|
+
* @param {string|null|undefined} pluginPath
|
|
1249
|
+
* @returns {string|null}
|
|
1250
|
+
*/
|
|
1251
|
+
export function resolveInstalledPackageRoot(pluginPath) {
|
|
1252
|
+
if (typeof pluginPath !== 'string') return null
|
|
1253
|
+
const normalized = pluginPath.replaceAll('\\', '/')
|
|
1254
|
+
const index = normalized.toLowerCase().lastIndexOf(INSTALLED_PACKAGE_MARKER.toLowerCase())
|
|
1255
|
+
if (index === -1) return null
|
|
1256
|
+
return normalized.slice(0, index + INSTALLED_PACKAGE_MARKER.length - 1)
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Read the version of the installed pantheon-opencode package a registered
|
|
1261
|
+
* plugin path points into.
|
|
1262
|
+
* @param {string} pluginPath - absolute or config-relative plugin ref
|
|
1263
|
+
* @returns {{root: string, version: string}|null} null when not an installed copy
|
|
1264
|
+
*/
|
|
1265
|
+
function readInstalledPluginVersion(pluginPath) {
|
|
1266
|
+
const root = resolveInstalledPackageRoot(pluginPath)
|
|
1267
|
+
if (!root) return null
|
|
1268
|
+
const pkg = readJson(join(root, 'package.json'))
|
|
1269
|
+
if (!pkg || typeof pkg.version !== 'string') return null
|
|
1270
|
+
return { root, version: pkg.version }
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Classify plugin version drift between the package a registered plugin points
|
|
1275
|
+
* into and the running doctor's own package. Uses the prerelease-aware
|
|
1276
|
+
* comparator so a stable release outranks its own betas (1.5.0 > 1.5.0-beta.9).
|
|
1277
|
+
* Unreadable versions classify as "unknown" rather than drift, so a broken
|
|
1278
|
+
* package.json never produces a false warning.
|
|
1279
|
+
* @param {string|null} registeredVersion
|
|
1280
|
+
* @param {string|null} packageVersion
|
|
1281
|
+
* @returns {'sync'|'drift'|'unknown'}
|
|
1282
|
+
*/
|
|
1283
|
+
export function classifyPluginVersionDrift(registeredVersion, packageVersion) {
|
|
1284
|
+
if (!registeredVersion || !packageVersion) return 'unknown'
|
|
1285
|
+
return comparePantheonVersions(registeredVersion, packageVersion) === 0 ? 'sync' : 'drift'
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Collect the plugin refs registered across opencode.json configs, from both
|
|
1290
|
+
* the V1 `plugin` and V2 `plugins` lists. Each entry keeps the config it came
|
|
1291
|
+
* from so relative refs resolve against the config directory (as the loader
|
|
1292
|
+
* does), never against the doctor's cwd.
|
|
1293
|
+
* @param {{path: string, data: object}[]} configs
|
|
1294
|
+
* @returns {{path: string, configPath: string, configLabel: string}[]}
|
|
1295
|
+
*/
|
|
1296
|
+
export function collectRegisteredPluginRefs(configs) {
|
|
1297
|
+
const refs = []
|
|
1298
|
+
for (const cfg of configs) {
|
|
1299
|
+
for (const key of ['plugin', 'plugins']) {
|
|
1300
|
+
const list = cfg.data?.[key]
|
|
1301
|
+
if (!Array.isArray(list)) continue
|
|
1302
|
+
for (const ref of list) {
|
|
1303
|
+
if (typeof ref === 'string') {
|
|
1304
|
+
refs.push({ path: ref, configPath: cfg.path, configLabel: cfg.label })
|
|
1305
|
+
} else if (ref && typeof ref === 'object') {
|
|
1306
|
+
// Object-shaped entries carry their path under `path`/`source`.
|
|
1307
|
+
const objectPath = ['path', 'source', 'id', 'name'].find(
|
|
1308
|
+
(k) => typeof ref[k] === 'string',
|
|
1309
|
+
)
|
|
1310
|
+
if (objectPath) {
|
|
1311
|
+
refs.push({ path: ref[objectPath], configPath: cfg.path, configLabel: cfg.label })
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return refs
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Compare the version of the package each registered plugin points into (when
|
|
1322
|
+
* it is an installed copy under node_modules/pantheon-opencode) against this
|
|
1323
|
+
* package's own version, and warn on divergence. Advisory only — a drift
|
|
1324
|
+
* warning never changes the exit code, and a healthy install stays silent.
|
|
1325
|
+
* @param {object} args
|
|
1326
|
+
* @returns {'drift'|'sync'|'skip'|'no-plugin'|'no-version'}
|
|
1327
|
+
*/
|
|
1328
|
+
export function checkPluginVersionDrift(args) {
|
|
1329
|
+
section('H3. Plugin Version Drift')
|
|
1330
|
+
|
|
1331
|
+
const pkg = readJson(join(ROOT, 'package.json'))
|
|
1332
|
+
const packageVersion = pkg?.version
|
|
1333
|
+
if (!packageVersion) {
|
|
1334
|
+
info('Plugin version drift skipped — this package has no readable version')
|
|
1335
|
+
return 'no-version'
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const refs = collectRegisteredPluginRefs(collectMcpConfigs(args))
|
|
1339
|
+
if (refs.length === 0) {
|
|
1340
|
+
info('No plugin registrations found in opencode.json — version drift skipped')
|
|
1341
|
+
return 'no-plugin'
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const seen = new Set()
|
|
1345
|
+
let compared = 0
|
|
1346
|
+
let driftReported = false
|
|
1347
|
+
for (const ref of refs) {
|
|
1348
|
+
const absolute = isAbsolute(ref.path) ? ref.path : resolve(dirname(ref.configPath), ref.path)
|
|
1349
|
+
const installed = readInstalledPluginVersion(absolute)
|
|
1350
|
+
if (!installed) continue
|
|
1351
|
+
// The doctor running from the very tree the plugin points at is the
|
|
1352
|
+
// healthy global-install case — trivially in sync, never flagged.
|
|
1353
|
+
if (resolve(installed.root) === resolve(ROOT)) continue
|
|
1354
|
+
if (seen.has(installed.root)) continue
|
|
1355
|
+
seen.add(installed.root)
|
|
1356
|
+
compared++
|
|
1357
|
+
|
|
1358
|
+
if (classifyPluginVersionDrift(installed.version, packageVersion) !== 'drift') continue
|
|
1359
|
+
driftReported = true
|
|
1360
|
+
warn(
|
|
1361
|
+
`${ref.configLabel}: plugin is registered inside pantheon-opencode v${installed.version}, but this package is v${packageVersion} — the registered tool surface is stale (pantheon_delegate was removed in 1.5.0). Run \`npx pantheon-opencode init\`, or update the package, to realign the registered plugin path`,
|
|
1362
|
+
)
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (driftReported) return 'drift'
|
|
1366
|
+
if (compared === 0) {
|
|
1367
|
+
info(
|
|
1368
|
+
'No registered plugin points into an installed pantheon-opencode copy — version drift skipped',
|
|
1369
|
+
)
|
|
1370
|
+
return 'skip'
|
|
1371
|
+
}
|
|
1372
|
+
pass(`All registered plugins point at package v${packageVersion}`)
|
|
1373
|
+
return 'sync'
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1217
1376
|
// ---------------------------------------------------------------------------
|
|
1218
1377
|
// Check G: AGENTS.md freshness
|
|
1219
1378
|
// ---------------------------------------------------------------------------
|
|
@@ -1424,6 +1583,71 @@ export function classifyPermissionTaskCheck(profile, installedCount, missingCoun
|
|
|
1424
1583
|
return missingCount === 0 ? 'pass' : 'error'
|
|
1425
1584
|
}
|
|
1426
1585
|
|
|
1586
|
+
// ---------------------------------------------------------------------------
|
|
1587
|
+
// Check K: Node Runtime — node:sqlite availability (issue #114)
|
|
1588
|
+
// ---------------------------------------------------------------------------
|
|
1589
|
+
|
|
1590
|
+
// pantheon_cost uses node:sqlite as its read-only backend; below this Node
|
|
1591
|
+
// version the tool reports UNSUPPORTED (see the "Cost tool backend" README
|
|
1592
|
+
// section). Matches the floor documented for the PANTHEON_NODE fallback.
|
|
1593
|
+
const NODE_SQLITE_MIN = '22.5'
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Probe whether the running Node.js can load `node:sqlite` — the read-only
|
|
1597
|
+
* backend used by the `pantheon_cost` tool (issue #114). Runs the import in a
|
|
1598
|
+
* `--no-warnings` child so the experimental-feature notice never pollutes the
|
|
1599
|
+
* doctor output, and so a probe crash can never take down the doctor itself.
|
|
1600
|
+
*
|
|
1601
|
+
* @param {{ execPath?: string; module?: string }} [options]
|
|
1602
|
+
* @returns {{ available: boolean; version: string; reason?: string }}
|
|
1603
|
+
*/
|
|
1604
|
+
export function probeNodeSqlite(options = {}) {
|
|
1605
|
+
const execPath = options.execPath ?? process.execPath
|
|
1606
|
+
const target = options.module ?? 'node:sqlite'
|
|
1607
|
+
const result = spawn(execPath, [
|
|
1608
|
+
'--no-warnings',
|
|
1609
|
+
'--input-type=module',
|
|
1610
|
+
'-e',
|
|
1611
|
+
`import '${target}'`,
|
|
1612
|
+
])
|
|
1613
|
+
if (result.status === 0) return { available: true, version: process.version }
|
|
1614
|
+
const reason = (result.stderr || result.stdout || 'import failed').split('\n')[0]
|
|
1615
|
+
return { available: false, version: process.version, reason }
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Classify a node:sqlite probe result for the doctor. Kept free of I/O so the
|
|
1620
|
+
* contract is exercised deterministically (see tests/test_doctor_layers.mjs).
|
|
1621
|
+
*
|
|
1622
|
+
* @param {{ available: boolean; version: string }} probe
|
|
1623
|
+
* @returns {{ status: 'ok' | 'unsupported'; message: string }}
|
|
1624
|
+
*/
|
|
1625
|
+
export function classifyNodeSqliteProbe(probe) {
|
|
1626
|
+
if (probe.available) {
|
|
1627
|
+
return {
|
|
1628
|
+
status: 'ok',
|
|
1629
|
+
message: `node:sqlite available on ${probe.version} — pantheon_cost read-only backend supported`,
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
return {
|
|
1633
|
+
status: 'unsupported',
|
|
1634
|
+
message: `pantheon_cost will report UNSUPPORTED on Node ${probe.version} — node:sqlite requires Node >= ${NODE_SQLITE_MIN}`,
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function checkNodeRuntime() {
|
|
1639
|
+
section('K. Node Runtime')
|
|
1640
|
+
const outcome = classifyNodeSqliteProbe(probeNodeSqlite())
|
|
1641
|
+
if (outcome.status === 'ok') {
|
|
1642
|
+
pass(outcome.message)
|
|
1643
|
+
} else {
|
|
1644
|
+
warn(outcome.message)
|
|
1645
|
+
info(
|
|
1646
|
+
`Upgrade to Node >= ${NODE_SQLITE_MIN} (or a runtime with node:sqlite) to enable pantheon_cost`,
|
|
1647
|
+
)
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1427
1651
|
/**
|
|
1428
1652
|
* Return the final doctor status message without masking blocking errors.
|
|
1429
1653
|
* @param {{ error: number; warn: number }} summaryCounts
|
|
@@ -1506,12 +1730,14 @@ async function main() {
|
|
|
1506
1730
|
existsSync(join(args.target, 'scripts', 'doctor.mjs')) === false
|
|
1507
1731
|
|
|
1508
1732
|
// Run checks (layer order: Config → Venv → spawn paths → runtime smoke)
|
|
1733
|
+
checkNodeRuntime(args)
|
|
1509
1734
|
checkAgentFiles(args)
|
|
1510
1735
|
checkMcpConfig(args)
|
|
1511
1736
|
checkVenvLayer(args)
|
|
1512
1737
|
checkCodeModeDir(args)
|
|
1513
1738
|
checkCodeModeManifest(args)
|
|
1514
1739
|
checkInstallVersionDrift(args)
|
|
1740
|
+
checkPluginVersionDrift(args)
|
|
1515
1741
|
await checkMcpRuntimeSmoke(args)
|
|
1516
1742
|
checkPermissionMismatches(args)
|
|
1517
1743
|
checkSyncStatus(args)
|
|
@@ -108,7 +108,32 @@ export function upgradeStateV1toV2(state) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
|
-
*
|
|
111
|
+
* Guarantee the required v2 collections exist on a state object.
|
|
112
|
+
*
|
|
113
|
+
* A schema-v2 entry may be written by a partial writer — e.g. the postinstall
|
|
114
|
+
* version marker in sync-tui.mjs persisted only `pantheon_version` /
|
|
115
|
+
* `previous_version`, and writeState() then stamped it `schema_version: 2`.
|
|
116
|
+
* Such an entry has no `applied_migrations`, so the next `init` crashed in
|
|
117
|
+
* runMigrations() with
|
|
118
|
+
* "Cannot read properties of undefined (reading 'push')".
|
|
119
|
+
* Backfill the collections on read instead of trusting every writer.
|
|
120
|
+
* @param {object} state
|
|
121
|
+
* @returns {object} the same state object with required collections present
|
|
122
|
+
*/
|
|
123
|
+
function ensureV2Shape(state) {
|
|
124
|
+
if (!Array.isArray(state.applied_migrations)) state.applied_migrations = []
|
|
125
|
+
if (
|
|
126
|
+
!state.components ||
|
|
127
|
+
typeof state.components !== 'object' ||
|
|
128
|
+
Array.isArray(state.components)
|
|
129
|
+
) {
|
|
130
|
+
state.components = upgradeStateV1toV2(state).components
|
|
131
|
+
}
|
|
132
|
+
return state
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Normalize any state to v2 (handles v1 → v2 upgrade and partial v2 markers).
|
|
112
137
|
* @param {object} state
|
|
113
138
|
* @returns {object|null}
|
|
114
139
|
*/
|
|
@@ -116,7 +141,7 @@ function normalizeState(state) {
|
|
|
116
141
|
if (!state || typeof state !== 'object') return null
|
|
117
142
|
if (isV1State(state)) return upgradeStateV1toV2(state)
|
|
118
143
|
if (!state.schema_version || state.schema_version < 2) return upgradeStateV1toV2(state)
|
|
119
|
-
return state
|
|
144
|
+
return ensureV2Shape(state)
|
|
120
145
|
}
|
|
121
146
|
|
|
122
147
|
/**
|
package/scripts/sync-tui.mjs
CHANGED
|
@@ -193,7 +193,7 @@ async function main() {
|
|
|
193
193
|
// The plugin references in opencode.json already point INTO this package,
|
|
194
194
|
// so only the copies would go stale after a package update.
|
|
195
195
|
const { syncCopyArtifacts } = await import('./sync-artifacts.mjs')
|
|
196
|
-
const { readState, writeState } = await import('./install/state.mjs')
|
|
196
|
+
const { createInitialState, readState, writeState } = await import('./install/state.mjs')
|
|
197
197
|
const pkgVersion = readVersion(join(ROOT, 'package.json')) ?? 'unknown'
|
|
198
198
|
const synced = syncCopyArtifacts(configDir)
|
|
199
199
|
if (synced.errors.length > 0) {
|
|
@@ -206,10 +206,16 @@ async function main() {
|
|
|
206
206
|
// venv, MCP entries).
|
|
207
207
|
try {
|
|
208
208
|
const previous = readState(configDir)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
209
|
+
// Preserve the existing v2 manifest (applied_migrations, components, …).
|
|
210
|
+
// Writing a bare { pantheon_version, previous_version } object stamped
|
|
211
|
+
// it `schema_version: 2` while dropping `applied_migrations`, and the
|
|
212
|
+
// next `init` crashed in runMigrations() reading `.push` on undefined.
|
|
213
|
+
const state = previous ?? createInitialState(pkgVersion)
|
|
214
|
+
if (previous?.pantheon_version && previous.pantheon_version !== pkgVersion) {
|
|
215
|
+
state.previous_version = previous.pantheon_version
|
|
216
|
+
}
|
|
217
|
+
state.pantheon_version = pkgVersion
|
|
218
|
+
writeState(configDir, state)
|
|
213
219
|
if (previous?.pantheon_version && previous.pantheon_version !== pkgVersion) {
|
|
214
220
|
console.log(
|
|
215
221
|
` ⚠️ Installation moved ${previous.pantheon_version} → ${pkgVersion}. ` +
|
|
@@ -1,11 +1,75 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
|
|
4
|
+
/** Hex digest length for every algorithm the Zenodo deposition API may report. */
|
|
5
|
+
const DIGEST_LENGTH = { md5: 32, sha256: 64 }
|
|
6
|
+
|
|
4
7
|
export function sha256File(path) {
|
|
5
8
|
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
6
9
|
}
|
|
7
10
|
|
|
8
|
-
export function
|
|
11
|
+
export function md5File(path) {
|
|
12
|
+
return createHash('md5').update(readFileSync(path)).digest('hex')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalize a file checksum as returned by the Zenodo deposition files API.
|
|
17
|
+
*
|
|
18
|
+
* Zenodo computes an MD5 for every deposited file (developers.zenodo.org,
|
|
19
|
+
* "Deposition File" representation: "MD5 checksum of file, computed by our
|
|
20
|
+
* system"). The API emits it both as `md5:<hex>` and, because of a legacy
|
|
21
|
+
* serialization bug kept for backward compatibility (zenodo/zenodo#628), as a
|
|
22
|
+
* bare 32-character hex string without any algorithm prefix — the exact payload
|
|
23
|
+
* that broke the v1.5.1 run (e.g. `d3359dca2f998da8461abb3b84a32b6c`).
|
|
24
|
+
*
|
|
25
|
+
* A bare 64-character digest is treated as SHA-256, so if the API ever upgrades
|
|
26
|
+
* its algorithm the upload is still verified with a strong digest rather than
|
|
27
|
+
* accepted blindly. Anything unrecognized fails closed.
|
|
28
|
+
*/
|
|
29
|
+
export function parseZenodoChecksum(value) {
|
|
30
|
+
if (typeof value !== 'string') throw new Error('Zenodo file checksum is missing.')
|
|
31
|
+
const match = value
|
|
32
|
+
.trim()
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.match(/^(?:(md5|sha256):)?([0-9a-f]+)$/)
|
|
35
|
+
if (!match) throw new Error(`Unrecognized Zenodo checksum format: ${value}.`)
|
|
36
|
+
const algorithm =
|
|
37
|
+
match[1] ??
|
|
38
|
+
(match[2].length === DIGEST_LENGTH.md5
|
|
39
|
+
? 'md5'
|
|
40
|
+
: match[2].length === DIGEST_LENGTH.sha256
|
|
41
|
+
? 'sha256'
|
|
42
|
+
: null)
|
|
43
|
+
if (!algorithm) throw new Error(`Unrecognized Zenodo checksum length: ${value}.`)
|
|
44
|
+
if (match[2].length !== DIGEST_LENGTH[algorithm])
|
|
45
|
+
throw new Error(`Zenodo checksum length does not match ${algorithm}: ${value}.`)
|
|
46
|
+
return { algorithm, digest: match[2] }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function expectedDigests(expected) {
|
|
50
|
+
const provided = {}
|
|
51
|
+
if (typeof expected === 'string') {
|
|
52
|
+
const { algorithm, digest } = parseZenodoChecksum(expected)
|
|
53
|
+
provided[algorithm] = digest
|
|
54
|
+
} else if (expected && typeof expected === 'object') {
|
|
55
|
+
for (const [algorithm, digest] of Object.entries(expected)) {
|
|
56
|
+
if (digest === undefined || digest === null || digest === '') continue
|
|
57
|
+
if (!(algorithm in DIGEST_LENGTH))
|
|
58
|
+
throw new Error(`Unsupported local checksum algorithm: ${algorithm}.`)
|
|
59
|
+
if (
|
|
60
|
+
typeof digest !== 'string' ||
|
|
61
|
+
!new RegExp(`^[0-9a-f]{${DIGEST_LENGTH[algorithm]}}$`, 'i').test(digest.trim())
|
|
62
|
+
)
|
|
63
|
+
throw new Error(`Invalid local ${algorithm} checksum for verification.`)
|
|
64
|
+
provided[algorithm] = digest.trim().toLowerCase()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (Object.keys(provided).length === 0)
|
|
68
|
+
throw new Error('A local checksum is required to verify the Zenodo upload.')
|
|
69
|
+
return provided
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function findZenodoFile(record, filename, expected) {
|
|
9
73
|
if (!record || typeof record !== 'object' || !Array.isArray(record.files)) {
|
|
10
74
|
throw new Error('Zenodo record did not contain a files array.')
|
|
11
75
|
}
|
|
@@ -13,9 +77,18 @@ export function findZenodoFile(record, filename, checksum) {
|
|
|
13
77
|
if (matches.length > 1) throw new Error(`Zenodo record contains duplicate file: ${filename}.`)
|
|
14
78
|
if (matches.length === 0) return null
|
|
15
79
|
const file = matches[0]
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
80
|
+
const { algorithm, digest } = parseZenodoChecksum(file.checksum)
|
|
81
|
+
const provided = expectedDigests(expected)
|
|
82
|
+
const reference = provided[algorithm]
|
|
83
|
+
if (!reference) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Zenodo reported a ${algorithm} checksum for ${filename} but no local ${algorithm} checksum was provided.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
if (digest !== reference) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Zenodo ${algorithm} checksum mismatch for ${filename}: expected ${reference}, received ${digest}.`,
|
|
91
|
+
)
|
|
19
92
|
}
|
|
20
93
|
return file
|
|
21
94
|
}
|
package/src/agents/mnemosyne.md
CHANGED
|
@@ -34,7 +34,17 @@ permission:
|
|
|
34
34
|
bash: deny
|
|
35
35
|
read: allow
|
|
36
36
|
grep: allow
|
|
37
|
-
edit:
|
|
37
|
+
# Scoped write/edit: mnemosyne may create and modify files ONLY inside the
|
|
38
|
+
# memory-bank and deepwork directories. Everything else stays denied, keeping
|
|
39
|
+
# the SCOPE BOUNDARY below enforceable at the permission layer (issue #111).
|
|
40
|
+
write:
|
|
41
|
+
"*": deny
|
|
42
|
+
".pantheon/memory-bank/**": allow
|
|
43
|
+
".pantheon/deepwork/**": allow
|
|
44
|
+
edit:
|
|
45
|
+
"*": deny
|
|
46
|
+
".pantheon/memory-bank/**": allow
|
|
47
|
+
".pantheon/deepwork/**": allow
|
|
38
48
|
task:
|
|
39
49
|
"*": deny
|
|
40
50
|
---
|