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 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 22+.
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.0",
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": ">=22"
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.0",
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.0"
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.
@@ -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
- return pa.beta - pb.beta
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
- * Normalize any state to v2 (handles v1 → v2 upgrade).
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
  /**
@@ -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
- writeState(configDir, {
210
- pantheon_version: pkgVersion,
211
- previous_version: previous?.pantheon_version ?? null,
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 findZenodoFile(record, filename, checksum) {
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 expected = `sha256:${checksum}`
17
- if (file.checksum !== expected) {
18
- throw new Error(`Zenodo checksum mismatch for ${filename}.`)
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
  }
@@ -34,7 +34,17 @@ permission:
34
34
  bash: deny
35
35
  read: allow
36
36
  grep: allow
37
- edit: allow
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
  ---
@@ -3,6 +3,7 @@ description: "Orquestrador central — NUNCA implementa. Roteia para especialist
3
3
  mode: primary
4
4
  reasoning_effort: medium
5
5
  permission:
6
+ read: allow
6
7
  edit: deny
7
8
  bash: deny
8
9
  task: