pantheon-opencode 1.6.0-beta.10 → 1.6.0-beta.12
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 +42 -18
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +122 -38
- package/README.pt-BR.md +5 -4
- package/bin/pantheon-init.mjs +322 -101
- package/package.json +7 -6
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +128 -10
- package/scripts/check-plugin-v2-coverage.mjs +7 -7
- package/scripts/doctor.mjs +242 -19
- package/scripts/install/backup.mjs +648 -0
- package/scripts/install/cli-ui.mjs +79 -1
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/file-inventory.mjs +555 -0
- package/scripts/install/interactive.mjs +15 -1
- package/scripts/install/migrate.mjs +32 -6
- package/scripts/install/model-picker.mjs +63 -22
- package/scripts/install/opencode-version.mjs +9 -1
- package/scripts/install/opencode.mjs +761 -63
- package/scripts/install/plan-artifact.mjs +517 -0
- package/scripts/install/plan.mjs +449 -0
- package/scripts/install/plugin.mjs +136 -18
- package/scripts/install/shared.mjs +249 -94
- package/scripts/install/state.mjs +63 -8
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +25 -6
- package/scripts/install/verify.mjs +128 -0
- package/scripts/install/vision-prompt.mjs +13 -1
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/manifest-inventory.mjs +49 -3
- package/scripts/mcp_persistence.py +50 -15
- package/scripts/node-engine-policy.mjs +74 -0
- package/scripts/package-evidence.mjs +6 -0
- package/scripts/postinstall.mjs +3 -4
- package/scripts/prune.mjs +9 -3
- package/scripts/reconcile.mjs +325 -0
- package/scripts/restore.mjs +222 -0
- package/scripts/uninstall.mjs +258 -34
- package/scripts/update.mjs +164 -0
- package/scripts/validate-node-engine-policy.mjs +13 -0
- package/scripts/version-check.mjs +34 -1
- package/scripts/versioning.mjs +9 -1
- package/skills-lock.json +4 -4
- package/src/agents/iris.md +6 -0
- package/src/agents/mnemosyne.md +7 -2
- package/src/agents/zeus.md +19 -62
- package/src/instructions/agent-return-format.instructions.md +17 -15
- package/src/instructions/memory-protocol.instructions.md +3 -2
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +6 -6
- package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
- package/src/instructions/zeus-memory-operations.instructions.md +11 -6
- package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
- package/src/mcp/mcp_persistence.py +50 -15
- package/src/pantheon/cost-command.ts +5 -2
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +18 -29
- package/src/pantheon/v2-unsupported.mjs +2 -3
- package/src/plugin-v2.ts +13 -0
- package/src/plugins/tui/dist/tui-v2.js +2 -2
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +3 -3
- package/src/plugins/tui/package.json +2 -2
- package/src/skills/artifact-management/SKILL.md +5 -1
- package/src/skills/auto-continue/SKILL.md +14 -6
- package/src/skills/memory-bank/SKILL.md +5 -1
- package/src/skills/worktrees/SKILL.md +30 -11
|
@@ -47,6 +47,16 @@ const TABLE = {
|
|
|
47
47
|
prereqMissing:
|
|
48
48
|
'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
|
|
49
49
|
v2Migrating: 'Migrating config to V2 native format...',
|
|
50
|
+
nothingApplied: 'Nothing applied.',
|
|
51
|
+
planOnlyNoYes: 'Plan only — re-run with --yes to apply.',
|
|
52
|
+
driftHeader: 'State changed since preview — aborting before any write:',
|
|
53
|
+
driftAbort: 'No files were written. Re-run to re-review.',
|
|
54
|
+
applyFailed: (msg) => `❌ Apply failed: ${msg}`,
|
|
55
|
+
alreadyApplied: 'Paths already applied — no automatic rollback:',
|
|
56
|
+
restoreRecoveryHint: (dir) => `Recover with: pantheon-opencode restore (backup at ${dir})`,
|
|
57
|
+
restoreNoBackup:
|
|
58
|
+
'No backup was created (the failure occurred before the first write); the target is unchanged.',
|
|
59
|
+
dryRunNothingWritten: 'Preview only — no files were written.',
|
|
50
60
|
// summary (printSummary)
|
|
51
61
|
summaryInstalled: (target) => `OpenCode installed in ${target}`,
|
|
52
62
|
summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
|
|
@@ -95,6 +105,16 @@ const TABLE = {
|
|
|
95
105
|
prereqMissing:
|
|
96
106
|
'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
|
|
97
107
|
v2Migrating: 'Migrando config para o formato nativo V2...',
|
|
108
|
+
nothingApplied: 'Nada foi aplicado.',
|
|
109
|
+
planOnlyNoYes: 'Apenas o plano — rode de novo com --yes para aplicar.',
|
|
110
|
+
driftHeader: 'O estado mudou desde o preview — abortando antes de qualquer escrita:',
|
|
111
|
+
driftAbort: 'Nenhum arquivo foi escrito. Rode de novo para revisar.',
|
|
112
|
+
applyFailed: (msg) => `❌ Falha ao aplicar: ${msg}`,
|
|
113
|
+
alreadyApplied: 'Caminhos já aplicados — sem rollback automático:',
|
|
114
|
+
restoreRecoveryHint: (dir) => `Recupere com: pantheon-opencode restore (backup em ${dir})`,
|
|
115
|
+
restoreNoBackup:
|
|
116
|
+
'Nenhum backup foi criado (a falha ocorreu antes da primeira escrita); o alvo está intacto.',
|
|
117
|
+
dryRunNothingWritten: 'Somente prévia — nenhum arquivo foi escrito.',
|
|
98
118
|
// summary (printSummary)
|
|
99
119
|
summaryInstalled: (target) => `OpenCode instalado em ${target}`,
|
|
100
120
|
summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
|
package/scripts/install/venv.mjs
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawnSync } from 'node:child_process'
|
|
13
|
-
import { existsSync
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
14
|
import { dirname, join } from 'node:path'
|
|
15
15
|
import { fileURLToPath } from 'node:url'
|
|
16
16
|
|
|
17
|
+
import { ACTIONS, recordChange, recordRemovalTree, removeReviewedTree } from './plan.mjs'
|
|
18
|
+
|
|
17
19
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
18
20
|
const ROOT = join(__dirname, '..', '..')
|
|
19
21
|
|
|
@@ -40,24 +42,37 @@ export function venvPythonPath(target) {
|
|
|
40
42
|
* Set up a Python virtual environment and install MCP dependencies.
|
|
41
43
|
*
|
|
42
44
|
* @param {string} target - Target installation directory (e.g. ~/.config/opencode)
|
|
43
|
-
* @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean }} [options]
|
|
45
|
+
* @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean, recorder?: (change: object) => void, reviewedRemovals?: Set<string>|null, reviewedSnapshots?: Map<string, object>|null }} [options]
|
|
44
46
|
* @returns {{ venvPath: string, python: string }}
|
|
45
47
|
*/
|
|
46
|
-
export function setupVenv(
|
|
48
|
+
export function setupVenv(
|
|
49
|
+
target,
|
|
50
|
+
{
|
|
51
|
+
dryRun = false,
|
|
52
|
+
skipInstall = false,
|
|
53
|
+
force = false,
|
|
54
|
+
recorder = null,
|
|
55
|
+
reviewedRemovals = null,
|
|
56
|
+
reviewedSnapshots = null,
|
|
57
|
+
} = {},
|
|
58
|
+
) {
|
|
47
59
|
const venvPath = join(target, '.venv')
|
|
48
60
|
const pythonBin = venvPythonPath(target)
|
|
49
61
|
|
|
50
62
|
// Step 1: Force recreate venv if --force
|
|
51
63
|
if (force && existsSync(venvPath)) {
|
|
52
|
-
if (
|
|
64
|
+
if (dryRun) {
|
|
65
|
+
recordRemovalTree(venvPath, recorder)
|
|
66
|
+
} else {
|
|
53
67
|
console.log(' Removing existing .venv (--force)...')
|
|
54
|
-
|
|
68
|
+
removeReviewedTree(venvPath, reviewedRemovals, reviewedSnapshots)
|
|
55
69
|
}
|
|
56
70
|
console.log(' ✅ .venv removed')
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
// Step 1b: Create venv if not exists
|
|
60
|
-
|
|
74
|
+
const venvExists = existsSync(pythonBin)
|
|
75
|
+
if (!venvExists) {
|
|
61
76
|
if (!dryRun) {
|
|
62
77
|
console.log(' Creating .venv...')
|
|
63
78
|
const result = spawnSync('python3', ['-m', 'venv', venvPath], {
|
|
@@ -69,8 +84,12 @@ export function setupVenv(target, { dryRun = false, skipInstall = false, force =
|
|
|
69
84
|
}
|
|
70
85
|
}
|
|
71
86
|
console.log(' ✅ .venv created')
|
|
87
|
+
// A single synthetic entry for the venv directory — never enumerate its
|
|
88
|
+
// thousands of files. Unscored: the install summary does not count it.
|
|
89
|
+
recordChange({ path: venvPath, action: ACTIONS.CREATE, bytes: 0, scored: false }, recorder)
|
|
72
90
|
} else {
|
|
73
91
|
console.log(' ⏭️ .venv already exists')
|
|
92
|
+
recordChange({ path: venvPath, action: ACTIONS.UNCHANGED, bytes: 0, scored: false }, recorder)
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
// Step 2: Install MCP dependencies
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
fsyncSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { isAbsolute, parse, relative, resolve, sep } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** Reject symlinked path components before a managed filesystem operation. */
|
|
14
|
+
export function assertNoSymlinkAncestors(filePath, { includeLeaf = true } = {}) {
|
|
15
|
+
const absolute = resolve(filePath)
|
|
16
|
+
const root = parse(absolute).root
|
|
17
|
+
const parts = absolute.slice(root.length).split(sep).filter(Boolean)
|
|
18
|
+
const checked = parts.slice(0, includeLeaf ? parts.length : Math.max(0, parts.length - 1))
|
|
19
|
+
let current = root
|
|
20
|
+
for (const [index, part] of checked.entries()) {
|
|
21
|
+
current = current ? `${current}${current.endsWith(sep) ? '' : sep}${part}` : part
|
|
22
|
+
let info
|
|
23
|
+
try {
|
|
24
|
+
info = lstatSync(current)
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return true
|
|
27
|
+
throw error
|
|
28
|
+
}
|
|
29
|
+
if (info.isSymbolicLink()) throw new Error(`Path contains a symlink: ${current}`)
|
|
30
|
+
if (index < checked.length - 1 && !info.isDirectory()) {
|
|
31
|
+
throw new Error(`Path ancestor is not a directory: ${current}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Require a destination to remain lexically inside its selected target. */
|
|
38
|
+
export function assertPathWithin(rootPath, destinationPath) {
|
|
39
|
+
const root = resolve(rootPath)
|
|
40
|
+
const destination = resolve(destinationPath)
|
|
41
|
+
const fromRoot = relative(root, destination)
|
|
42
|
+
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
43
|
+
throw new Error(`Path escapes selected target: ${destinationPath}`)
|
|
44
|
+
}
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Validate lexical containment and reject links before deleting a path. */
|
|
49
|
+
export function assertSafeRemoval(rootPath, destinationPath) {
|
|
50
|
+
assertPathWithin(rootPath, destinationPath)
|
|
51
|
+
assertNoSymlinkAncestors(destinationPath)
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read back a regular-file write without ever including file contents in errors. */
|
|
56
|
+
export function verifyRegularFile(filePath, expected) {
|
|
57
|
+
const info = lstatSync(filePath)
|
|
58
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
59
|
+
throw new Error(`Managed-file write did not produce a regular file: ${filePath}`)
|
|
60
|
+
}
|
|
61
|
+
const actual = readFileSync(filePath)
|
|
62
|
+
const expectedBuffer = Buffer.isBuffer(expected) ? expected : Buffer.from(expected)
|
|
63
|
+
if (!actual.equals(expectedBuffer)) {
|
|
64
|
+
throw new Error(`Managed-file read-back verification failed: ${filePath}`)
|
|
65
|
+
}
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Atomically write a managed file using a private, exclusively-created temp.
|
|
71
|
+
* Existing destinations are replaced only after path checks and a temp read-back.
|
|
72
|
+
*/
|
|
73
|
+
export function writeFileAtomically(filePath, content) {
|
|
74
|
+
const temporary = `${filePath}.tmp-${process.pid}`
|
|
75
|
+
assertNoSymlinkAncestors(filePath)
|
|
76
|
+
assertRegularFileOrMissing(filePath)
|
|
77
|
+
assertNoSymlinkAncestors(temporary)
|
|
78
|
+
|
|
79
|
+
let fd
|
|
80
|
+
let createdTemporary = false
|
|
81
|
+
try {
|
|
82
|
+
fd = openSync(temporary, 'wx', 0o600)
|
|
83
|
+
createdTemporary = true
|
|
84
|
+
writeFileSync(fd, content)
|
|
85
|
+
fsyncSync(fd)
|
|
86
|
+
closeSync(fd)
|
|
87
|
+
fd = undefined
|
|
88
|
+
|
|
89
|
+
verifyRegularFile(temporary, content)
|
|
90
|
+
assertNoSymlinkAncestors(filePath)
|
|
91
|
+
assertRegularFileOrMissing(filePath)
|
|
92
|
+
renameSync(temporary, filePath)
|
|
93
|
+
createdTemporary = false
|
|
94
|
+
verifyRegularFile(filePath, content)
|
|
95
|
+
} finally {
|
|
96
|
+
if (fd !== undefined) closeSync(fd)
|
|
97
|
+
if (createdTemporary) {
|
|
98
|
+
try {
|
|
99
|
+
assertNoSymlinkAncestors(temporary)
|
|
100
|
+
unlinkSync(temporary)
|
|
101
|
+
} catch {
|
|
102
|
+
// Preserve the write error; do not follow or remove an unsafe path.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertRegularFileOrMissing(filePath) {
|
|
109
|
+
try {
|
|
110
|
+
const info = lstatSync(filePath)
|
|
111
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
112
|
+
throw new Error(`Refusing to replace non-regular file: ${filePath}`)
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error?.code !== 'ENOENT') throw error
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Verify a managed path was removed, without following symlinks. */
|
|
120
|
+
export function verifyPathRemoved(filePath) {
|
|
121
|
+
try {
|
|
122
|
+
lstatSync(filePath)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return true
|
|
125
|
+
throw error
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`Managed-path removal verification failed: ${filePath}`)
|
|
128
|
+
}
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* The write mirrors the `/pantheon-vision` command: atomic, `.bak` backup,
|
|
17
17
|
* and a MERGE — existing keys are never overwritten.
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
20
20
|
import { homedir } from 'node:os'
|
|
21
21
|
import { dirname, join } from 'node:path'
|
|
22
22
|
import { writeConfigWithBackup } from './shared.mjs'
|
|
23
|
+
import { verifyRegularFile } from './verify.mjs'
|
|
23
24
|
|
|
24
25
|
const CONFIG_FILENAME = 'opencode-vision.json'
|
|
25
26
|
/** `provider/model` — exactly one slash, no surrounding whitespace. */
|
|
@@ -81,6 +82,17 @@ export function mergeVisionModel(
|
|
|
81
82
|
const status = writeConfigWithBackup(configPath, content, false, {
|
|
82
83
|
expectedContent: existingRaw,
|
|
83
84
|
beforeRecheck,
|
|
85
|
+
// This file is GLOBAL and lives outside the config dir, so the target-scoped
|
|
86
|
+
// exact backup set never captures it. Keep its own `<file>.bak` recovery.
|
|
87
|
+
beforeSwap: (existing) => {
|
|
88
|
+
if (existing === null) return
|
|
89
|
+
try {
|
|
90
|
+
copyFileSync(configPath, `${configPath}.bak`)
|
|
91
|
+
verifyRegularFile(`${configPath}.bak`, readFileSync(configPath))
|
|
92
|
+
} catch {
|
|
93
|
+
// Best-effort: a backup failure must not block the write.
|
|
94
|
+
}
|
|
95
|
+
},
|
|
84
96
|
})
|
|
85
97
|
return { path: configPath, wrote: status === 'created', reason: status }
|
|
86
98
|
}
|
package/scripts/install-mcp.mjs
CHANGED
|
@@ -238,7 +238,7 @@ export const MCPS = {
|
|
|
238
238
|
tier: 1,
|
|
239
239
|
name: 'Pantheon Memory',
|
|
240
240
|
description:
|
|
241
|
-
'Pantheon Memory MCP Server — persistent memory with
|
|
241
|
+
'Pantheon Memory MCP Server — persistent SQLite memory with FTS5/BM25 keyword search, exact-key recall, and explicit maintenance; no embedding/vector search',
|
|
242
242
|
platforms: {
|
|
243
243
|
opencode: {
|
|
244
244
|
type: 'local',
|
|
@@ -57,6 +57,18 @@ export const MANIFEST_INVENTORY = Object.freeze([
|
|
|
57
57
|
name: 'pantheon-tui',
|
|
58
58
|
valuePath: ['name'],
|
|
59
59
|
}),
|
|
60
|
+
Object.freeze({
|
|
61
|
+
id: 'zenodo-release-metadata',
|
|
62
|
+
role: 'metadata',
|
|
63
|
+
file: '.zenodo.json',
|
|
64
|
+
kind: 'json',
|
|
65
|
+
}),
|
|
66
|
+
Object.freeze({
|
|
67
|
+
id: 'citation-release-metadata',
|
|
68
|
+
role: 'metadata',
|
|
69
|
+
file: 'CITATION.cff',
|
|
70
|
+
kind: 'cff',
|
|
71
|
+
}),
|
|
60
72
|
Object.freeze({
|
|
61
73
|
id: 'root-lock',
|
|
62
74
|
role: 'lock',
|
|
@@ -76,7 +88,7 @@ export const MANIFEST_INVENTORY = Object.freeze([
|
|
|
76
88
|
])
|
|
77
89
|
|
|
78
90
|
export const VERSION_MANIFEST_INVENTORY = Object.freeze(
|
|
79
|
-
MANIFEST_INVENTORY.filter(({ role }) => role
|
|
91
|
+
MANIFEST_INVENTORY.filter(({ role }) => role !== 'lock'),
|
|
80
92
|
)
|
|
81
93
|
|
|
82
94
|
export const LOCK_INVENTORY = Object.freeze(
|
|
@@ -99,11 +111,18 @@ function readToml(file, text) {
|
|
|
99
111
|
return parsed
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
function readCff(file, text) {
|
|
115
|
+
const match = /^version:\s*([^\s#]+)\s*(?:#.*)?$/m.exec(text)
|
|
116
|
+
if (!match) throw new Error(`${file} is missing top-level version metadata`)
|
|
117
|
+
return { version: match[1] }
|
|
118
|
+
}
|
|
119
|
+
|
|
102
120
|
function readDocument(root, entry) {
|
|
103
121
|
const path = join(root, entry.file)
|
|
104
122
|
if (!existsSync(path)) throw new Error(`manifest inventory file is missing: ${entry.file}`)
|
|
105
123
|
const text = readFileSync(path, 'utf8')
|
|
106
124
|
if (entry.kind === 'toml') return { entry, path, text, data: readToml(entry.file, text) }
|
|
125
|
+
if (entry.kind === 'cff') return { entry, path, text, data: readCff(entry.file, text) }
|
|
107
126
|
let data
|
|
108
127
|
try {
|
|
109
128
|
data = JSON.parse(text)
|
|
@@ -175,6 +194,24 @@ function validateLock(document, manifestsByFile, allowVersionDivergence) {
|
|
|
175
194
|
if (!allowVersionDivergence && rootPackage.version !== manifestVersionValue) {
|
|
176
195
|
throw new Error(`${entry.file} packages[""] version diverges from ${entry.packageFile}`)
|
|
177
196
|
}
|
|
197
|
+
|
|
198
|
+
if (entry.file === 'package-lock.json') {
|
|
199
|
+
const tuiManifest = manifestsByFile.get('src/plugins/tui/package.json')
|
|
200
|
+
const tuiWorkspace = data.packages?.['src/plugins/tui']
|
|
201
|
+
if (!tuiManifest) throw new Error('package-lock.json has no TUI workspace manifest partner')
|
|
202
|
+
if (!tuiWorkspace || typeof tuiWorkspace !== 'object' || Array.isArray(tuiWorkspace)) {
|
|
203
|
+
throw new Error('package-lock.json is missing packages["src/plugins/tui"]')
|
|
204
|
+
}
|
|
205
|
+
if (tuiWorkspace.name !== 'pantheon-tui') {
|
|
206
|
+
throw new Error('package-lock.json TUI workspace name must be pantheon-tui')
|
|
207
|
+
}
|
|
208
|
+
assertSemver(tuiWorkspace.version, 'package-lock.json TUI workspace version')
|
|
209
|
+
if (!allowVersionDivergence && tuiWorkspace.version !== manifestVersion(tuiManifest)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
'package-lock.json TUI workspace version diverges from src/plugins/tui/package.json',
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
178
215
|
}
|
|
179
216
|
|
|
180
217
|
/** Read and validate every entry before a caller is allowed to write anything. */
|
|
@@ -186,7 +223,7 @@ export function validateInventory(root, { allowVersionDivergence = false } = {})
|
|
|
186
223
|
.map((document) => [document.entry.file, document]),
|
|
187
224
|
)
|
|
188
225
|
const versions = documents
|
|
189
|
-
.filter(({ entry }) => entry.role === 'manifest')
|
|
226
|
+
.filter(({ entry }) => entry.role === 'manifest' || entry.role === 'metadata')
|
|
190
227
|
.map((document) => ({ file: document.entry.file, version: manifestVersion(document) }))
|
|
191
228
|
documents.filter(({ entry }) => entry.role === 'manifest').forEach(manifestName)
|
|
192
229
|
const sourceVersion = versions[0].version
|
|
@@ -309,8 +346,17 @@ function replaceTomlProjectVersion(text, version, file) {
|
|
|
309
346
|
|
|
310
347
|
function prepareDocumentVersion(document, version) {
|
|
311
348
|
const { entry, text } = document
|
|
349
|
+
if (entry.role === 'metadata') {
|
|
350
|
+
const current = manifestVersion(document)
|
|
351
|
+
return text.split(current).join(version)
|
|
352
|
+
}
|
|
312
353
|
if (entry.kind === 'toml') return replaceTomlProjectVersion(text, version, entry.file)
|
|
313
|
-
const paths =
|
|
354
|
+
const paths =
|
|
355
|
+
entry.role === 'lock'
|
|
356
|
+
? entry.file === 'package-lock.json'
|
|
357
|
+
? [['version'], ['packages', '', 'version'], ['packages', 'src/plugins/tui', 'version']]
|
|
358
|
+
: [['version'], ['packages', '', 'version']]
|
|
359
|
+
: [['version']]
|
|
314
360
|
return replaceJsonStringValues(text, paths, version, entry.file)
|
|
315
361
|
}
|
|
316
362
|
|
|
@@ -914,15 +914,35 @@ def _validate_delegations(delegations: object) -> None:
|
|
|
914
914
|
|
|
915
915
|
def _validate_tail(tail: object, payload_bytes: int | None = None) -> list[str]:
|
|
916
916
|
"""Validate bounded serialized tail lines."""
|
|
917
|
-
if not isinstance(tail, list)
|
|
918
|
-
raise ValueError("tail
|
|
917
|
+
if not isinstance(tail, list):
|
|
918
|
+
raise ValueError("tail must be an array")
|
|
919
|
+
if len(tail) > MAX_TAIL_ITEMS:
|
|
920
|
+
raise ValueError(
|
|
921
|
+
f"tail has {len(tail)} items; maximum is {MAX_TAIL_ITEMS}; "
|
|
922
|
+
"remove tail entries or omit tail"
|
|
923
|
+
)
|
|
919
924
|
for line in tail:
|
|
920
|
-
if not isinstance(line, str)
|
|
921
|
-
raise ValueError("tail entries
|
|
925
|
+
if not isinstance(line, str):
|
|
926
|
+
raise ValueError("tail entries must be strings")
|
|
927
|
+
line_bytes = len(line.encode())
|
|
928
|
+
if line_bytes > MAX_TAIL_LINE_LENGTH:
|
|
929
|
+
raise ValueError(
|
|
930
|
+
f"tail entries are {line_bytes:,} UTF-8 bytes; maximum is "
|
|
931
|
+
f"{MAX_TAIL_LINE_LENGTH:,}; remove or shorten tail entries, "
|
|
932
|
+
"or omit tail"
|
|
933
|
+
)
|
|
922
934
|
if payload_bytes is not None and payload_bytes > MAX_CONTEXT_CONTENT_BYTES:
|
|
923
|
-
raise ValueError(
|
|
924
|
-
|
|
925
|
-
|
|
935
|
+
raise ValueError(
|
|
936
|
+
f"tail payload is {payload_bytes:,} UTF-8 bytes; maximum is "
|
|
937
|
+
f"{MAX_CONTEXT_CONTENT_BYTES:,}; remove tail entries or omit tail"
|
|
938
|
+
)
|
|
939
|
+
tail_bytes = sum(len(line.encode()) for line in tail)
|
|
940
|
+
if tail_bytes > MAX_CONTEXT_CONTENT_BYTES:
|
|
941
|
+
raise ValueError(
|
|
942
|
+
f"tail contains {tail_bytes:,} UTF-8 bytes across strings; maximum "
|
|
943
|
+
f"is {MAX_CONTEXT_CONTENT_BYTES:,} bytes; remove tail entries or "
|
|
944
|
+
"omit tail"
|
|
945
|
+
)
|
|
926
946
|
return tail
|
|
927
947
|
|
|
928
948
|
|
|
@@ -1020,8 +1040,14 @@ def _collect_context_content_errors(
|
|
|
1020
1040
|
if not isinstance(content, str):
|
|
1021
1041
|
errors.shape(prefix, "a string", content)
|
|
1022
1042
|
return
|
|
1023
|
-
|
|
1024
|
-
|
|
1043
|
+
content_bytes = len(content.encode())
|
|
1044
|
+
if content_bytes > MAX_CONTEXT_CONTENT_BYTES:
|
|
1045
|
+
errors.add(
|
|
1046
|
+
prefix,
|
|
1047
|
+
f"is {content_bytes:,} UTF-8 bytes; maximum is "
|
|
1048
|
+
f"{MAX_CONTEXT_CONTENT_BYTES:,} bytes. Reduce content or omit "
|
|
1049
|
+
"optional tail if it contributes",
|
|
1050
|
+
)
|
|
1025
1051
|
return
|
|
1026
1052
|
try:
|
|
1027
1053
|
parsed = json.loads(content)
|
|
@@ -1030,11 +1056,10 @@ def _collect_context_content_errors(
|
|
|
1030
1056
|
# it as a checkpoint. It is still bounded above.
|
|
1031
1057
|
return
|
|
1032
1058
|
if isinstance(parsed, list) and key == "tail":
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
errors.add(f"{prefix}.tail", "exceeds its size limit")
|
|
1059
|
+
try:
|
|
1060
|
+
_validate_tail(parsed)
|
|
1061
|
+
except ValueError as exc:
|
|
1062
|
+
errors.reroot(prefix, str(exc))
|
|
1038
1063
|
return
|
|
1039
1064
|
if not isinstance(parsed, dict):
|
|
1040
1065
|
# The message already names 'content': do not prepend the path again.
|
|
@@ -1164,6 +1189,11 @@ def _upsert_context_entry(
|
|
|
1164
1189
|
name="context_save",
|
|
1165
1190
|
description="Save a context checkpoint for a session/phase. "
|
|
1166
1191
|
"Stores structured JSON in persistence KV with a key-specific TTL. "
|
|
1192
|
+
"The serialized content is limited to 65,536 UTF-8 bytes. An included "
|
|
1193
|
+
"tail may contain at most 100 strings, each at most 4,096 UTF-8 bytes, "
|
|
1194
|
+
"with at most 65,536 UTF-8 bytes across all tail strings. Oversized "
|
|
1195
|
+
"content or tail is rejected, never truncated; remove or omit optional "
|
|
1196
|
+
"tail data when applicable. "
|
|
1167
1197
|
"content is a JSON STRING; when it parses to an object, 'goal', 'phase', "
|
|
1168
1198
|
"'delegations' and 'heartbeat' must each be JSON OBJECTS and 'tail' must "
|
|
1169
1199
|
"be a JSON ARRAY. 'phase' is an object {current,total,name} — NOT a phase "
|
|
@@ -1180,7 +1210,12 @@ async def context_save(
|
|
|
1180
1210
|
str,
|
|
1181
1211
|
Field(
|
|
1182
1212
|
description=(
|
|
1183
|
-
"JSON checkpoint string
|
|
1213
|
+
"JSON checkpoint string, limited to 65,536 UTF-8 bytes. An "
|
|
1214
|
+
"included 'tail' may contain at most 100 strings, each at "
|
|
1215
|
+
"most 4,096 UTF-8 bytes, with at most 65,536 UTF-8 bytes "
|
|
1216
|
+
"across all tail strings. Oversized input is rejected, never "
|
|
1217
|
+
"truncated; remove or omit optional 'tail' when applicable. "
|
|
1218
|
+
"When it parses to an object, 'goal', "
|
|
1184
1219
|
"'phase', 'delegations' and 'heartbeat' must be objects and "
|
|
1185
1220
|
"'tail' must be an array; all are optional. 'phase' is an "
|
|
1186
1221
|
"object {current,total,name}, not a counter. Example: "
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const MIN_SUPPORTED_NODE_VERSION = '26.4.0'
|
|
5
|
+
export const REQUIRED_NODE_ENGINE_RANGE = `>=${MIN_SUPPORTED_NODE_VERSION}`
|
|
6
|
+
|
|
7
|
+
function parseNodeVersion(version) {
|
|
8
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+([\w.-]+))?$/.exec(String(version))
|
|
9
|
+
if (!match) return null
|
|
10
|
+
return { parts: match.slice(1, 4).map(Number), prerelease: match[4] !== undefined }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Return whether a runtime version meets Pantheon's supported Node floor. */
|
|
14
|
+
export function isSupportedNodeVersion(version) {
|
|
15
|
+
const parsed = parseNodeVersion(version)
|
|
16
|
+
const minimum = parseNodeVersion(MIN_SUPPORTED_NODE_VERSION)
|
|
17
|
+
if (!parsed || !minimum || parsed.prerelease) return false
|
|
18
|
+
for (let index = 0; index < minimum.parts.length; index += 1) {
|
|
19
|
+
if (parsed.parts[index] !== minimum.parts[index]) {
|
|
20
|
+
return parsed.parts[index] > minimum.parts[index]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readJson(root, file, errors) {
|
|
27
|
+
try {
|
|
28
|
+
const document = JSON.parse(readFileSync(join(root, file), 'utf8'))
|
|
29
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) {
|
|
30
|
+
throw new Error('expected a JSON object')
|
|
31
|
+
}
|
|
32
|
+
return document
|
|
33
|
+
} catch (error) {
|
|
34
|
+
errors.push(`${file}: ${error.message}`)
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function checkEngineRange(value, label, errors) {
|
|
40
|
+
if (value !== REQUIRED_NODE_ENGINE_RANGE) {
|
|
41
|
+
errors.push(`${label} must declare engines.node as "${REQUIRED_NODE_ENGINE_RANGE}"`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Validate Node support claims in package manifests and their npm locks. */
|
|
46
|
+
export function validateNodeEnginePolicy(root) {
|
|
47
|
+
const errors = []
|
|
48
|
+
const rootPackage = readJson(root, 'package.json', errors)
|
|
49
|
+
const tuiPackage = readJson(root, 'src/plugins/tui/package.json', errors)
|
|
50
|
+
const rootLock = readJson(root, 'package-lock.json', errors)
|
|
51
|
+
const tuiLock = readJson(root, 'src/plugins/tui/package-lock.json', errors)
|
|
52
|
+
|
|
53
|
+
if (rootPackage) checkEngineRange(rootPackage.engines?.node, 'package.json', errors)
|
|
54
|
+
if (tuiPackage) checkEngineRange(tuiPackage.engines?.node, 'src/plugins/tui/package.json', errors)
|
|
55
|
+
|
|
56
|
+
const rootLockPackage = rootLock?.packages?.['']
|
|
57
|
+
if (!rootLockPackage) errors.push('package-lock.json is missing packages[""]')
|
|
58
|
+
else checkEngineRange(rootLockPackage.engines?.node, 'package-lock.json packages[""]', errors)
|
|
59
|
+
|
|
60
|
+
const workspacePackage = rootLock?.packages?.['src/plugins/tui']
|
|
61
|
+
if (!workspacePackage) errors.push('package-lock.json is missing the TUI workspace package')
|
|
62
|
+
else checkEngineRange(workspacePackage.engines?.node, 'package-lock.json TUI workspace', errors)
|
|
63
|
+
|
|
64
|
+
const tuiLockPackage = tuiLock?.packages?.['']
|
|
65
|
+
if (!tuiLockPackage) errors.push('src/plugins/tui/package-lock.json is missing packages[""]')
|
|
66
|
+
else
|
|
67
|
+
checkEngineRange(
|
|
68
|
+
tuiLockPackage.engines?.node,
|
|
69
|
+
'src/plugins/tui/package-lock.json packages[""]',
|
|
70
|
+
errors,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return errors
|
|
74
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { tmpdir } from 'node:os'
|
|
18
18
|
import { isAbsolute, join } from 'node:path'
|
|
19
19
|
import { validateInventory } from './manifest-inventory.mjs'
|
|
20
|
+
import { validateNodeEnginePolicy } from './node-engine-policy.mjs'
|
|
20
21
|
|
|
21
22
|
const CI_ARGS = ['ci', '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund']
|
|
22
23
|
const REQUIRED_PAYLOAD = [
|
|
@@ -32,6 +33,8 @@ const REQUIRED_PAYLOAD = [
|
|
|
32
33
|
'package/src/plugins/tui/dist/server.js',
|
|
33
34
|
'package/bin/pantheon-init.mjs',
|
|
34
35
|
'package/scripts/doctor.mjs',
|
|
36
|
+
'package/scripts/node-engine-policy.mjs',
|
|
37
|
+
'package/scripts/validate-node-engine-policy.mjs',
|
|
35
38
|
'package/.pantheon/code-mode/compress-inline.py',
|
|
36
39
|
]
|
|
37
40
|
|
|
@@ -132,6 +135,7 @@ function validatePayload(packageRoot, inventory) {
|
|
|
132
135
|
}
|
|
133
136
|
for (const document of inventory.documents) {
|
|
134
137
|
const { entry, path: sourcePath } = document
|
|
138
|
+
if (entry.role === 'metadata') continue
|
|
135
139
|
const packagedPath = join(packageRoot, entry.file)
|
|
136
140
|
if (entry.role === 'lock' && entry.file === 'package-lock.json' && !existsSync(packagedPath)) {
|
|
137
141
|
// npm-packlist excludes the package root lockfile by policy. It remains
|
|
@@ -181,6 +185,8 @@ function sha256(path) {
|
|
|
181
185
|
|
|
182
186
|
export function createPackageEvidence(root, { outputDir, targetSha } = {}) {
|
|
183
187
|
const inventory = validateInventory(root)
|
|
188
|
+
const engineErrors = validateNodeEnginePolicy(root)
|
|
189
|
+
if (engineErrors.length) fail(engineErrors.join('; '))
|
|
184
190
|
const sha = resolveTargetSha(root, targetSha)
|
|
185
191
|
const output = createOutputDirectory(outputDir)
|
|
186
192
|
let workDir
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -5,14 +5,13 @@
|
|
|
5
5
|
* Runs after `npm install` completes. Validates environment and prints
|
|
6
6
|
* setup instructions. Does NOT install Pantheon platform components.
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
|
-
const nodeMajor = parseInt(process.versions.node.split('.')[0], 10)
|
|
8
|
+
import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_VERSION } from './node-engine-policy.mjs'
|
|
10
9
|
|
|
11
10
|
try {
|
|
12
11
|
// Validate Node.js version
|
|
13
|
-
if (
|
|
12
|
+
if (!isSupportedNodeVersion(process.versions.node)) {
|
|
14
13
|
console.error(
|
|
15
|
-
`❌ Node.js >= ${
|
|
14
|
+
`❌ Pantheon requires Node.js >= ${MIN_SUPPORTED_NODE_VERSION} (current: ${process.versions.node})`,
|
|
16
15
|
)
|
|
17
16
|
process.exit(1)
|
|
18
17
|
}
|
package/scripts/prune.mjs
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
* SAFETY: prune NEVER touches the active config, the active venv, the package
|
|
10
10
|
* repo, or the installed package. It only:
|
|
11
11
|
* - lists old global `pantheon` installs (suggests `npm uninstall -g pantheon`)
|
|
12
|
-
* - removes stale `opencode.json.bak*` backups (age > threshold) with
|
|
12
|
+
* - removes stale LEGACY `opencode.json.bak*` backups (age > threshold) with
|
|
13
|
+
* --apply. The modern installer no longer writes that sidecar — it creates an
|
|
14
|
+
* exact backup set under `.pantheon/backups/` (retained by the installer, not
|
|
15
|
+
* pruned here) — so this scan only cleans up pre-phase-2 installs.
|
|
13
16
|
* - lists legacy directories whose opencode.json references dead/relative
|
|
14
17
|
* paths (removal requires --apply --remove-dirs as explicit confirmation)
|
|
15
18
|
*
|
|
@@ -102,8 +105,11 @@ export function findLegacyGlobalInstalls() {
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
// ---------------------------------------------------------------------------
|
|
105
|
-
// (b) Stale opencode.json.bak* backups (age > threshold) in the config
|
|
106
|
-
// → removable with --apply. Fresh backups are listed as kept.
|
|
108
|
+
// (b) Stale LEGACY opencode.json.bak* backups (age > threshold) in the config
|
|
109
|
+
// dir → removable with --apply. Fresh backups are listed as kept. The
|
|
110
|
+
// current installer does not create this sidecar (it keeps an exact,
|
|
111
|
+
// self-retaining backup set under .pantheon/backups/), so this only cleans
|
|
112
|
+
// up installs from before phase 2.
|
|
107
113
|
// ---------------------------------------------------------------------------
|
|
108
114
|
|
|
109
115
|
export function findStaleBackups(configDir, maxAgeDays = DEFAULT_BACKUP_AGE_DAYS) {
|