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.
Files changed (71) hide show
  1. package/AGENTS.global.md +42 -18
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +122 -38
  5. package/README.pt-BR.md +5 -4
  6. package/bin/pantheon-init.mjs +322 -101
  7. package/package.json +7 -6
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +128 -10
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +648 -0
  14. package/scripts/install/cli-ui.mjs +79 -1
  15. package/scripts/install/exit-codes.mjs +26 -0
  16. package/scripts/install/file-inventory.mjs +555 -0
  17. package/scripts/install/interactive.mjs +15 -1
  18. package/scripts/install/migrate.mjs +32 -6
  19. package/scripts/install/model-picker.mjs +63 -22
  20. package/scripts/install/opencode-version.mjs +9 -1
  21. package/scripts/install/opencode.mjs +761 -63
  22. package/scripts/install/plan-artifact.mjs +517 -0
  23. package/scripts/install/plan.mjs +449 -0
  24. package/scripts/install/plugin.mjs +136 -18
  25. package/scripts/install/shared.mjs +249 -94
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/strings.mjs +20 -0
  28. package/scripts/install/venv.mjs +25 -6
  29. package/scripts/install/verify.mjs +128 -0
  30. package/scripts/install/vision-prompt.mjs +13 -1
  31. package/scripts/install-mcp.mjs +1 -1
  32. package/scripts/manifest-inventory.mjs +49 -3
  33. package/scripts/mcp_persistence.py +50 -15
  34. package/scripts/node-engine-policy.mjs +74 -0
  35. package/scripts/package-evidence.mjs +6 -0
  36. package/scripts/postinstall.mjs +3 -4
  37. package/scripts/prune.mjs +9 -3
  38. package/scripts/reconcile.mjs +325 -0
  39. package/scripts/restore.mjs +222 -0
  40. package/scripts/uninstall.mjs +258 -34
  41. package/scripts/update.mjs +164 -0
  42. package/scripts/validate-node-engine-policy.mjs +13 -0
  43. package/scripts/version-check.mjs +34 -1
  44. package/scripts/versioning.mjs +9 -1
  45. package/skills-lock.json +4 -4
  46. package/src/agents/iris.md +6 -0
  47. package/src/agents/mnemosyne.md +7 -2
  48. package/src/agents/zeus.md +19 -62
  49. package/src/instructions/agent-return-format.instructions.md +17 -15
  50. package/src/instructions/memory-protocol.instructions.md +3 -2
  51. package/src/instructions/test-economy.instructions.md +21 -0
  52. package/src/instructions/zeus-anti-stall.instructions.md +6 -6
  53. package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
  54. package/src/instructions/zeus-memory-operations.instructions.md +11 -6
  55. package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
  56. package/src/mcp/mcp_persistence.py +50 -15
  57. package/src/pantheon/cost-command.ts +5 -2
  58. package/src/pantheon/model-agents.ts +19 -0
  59. package/src/pantheon/model-command.ts +218 -56
  60. package/src/pantheon/v2-model-overrides.ts +275 -0
  61. package/src/pantheon/v2-tools.ts +18 -29
  62. package/src/pantheon/v2-unsupported.mjs +2 -3
  63. package/src/plugin-v2.ts +13 -0
  64. package/src/plugins/tui/dist/tui-v2.js +2 -2
  65. package/src/plugins/tui/dist/tui.js +1 -1
  66. package/src/plugins/tui/package-lock.json +3 -3
  67. package/src/plugins/tui/package.json +2 -2
  68. package/src/skills/artifact-management/SKILL.md +5 -1
  69. package/src/skills/auto-continue/SKILL.md +14 -6
  70. package/src/skills/memory-bank/SKILL.md +5 -1
  71. 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}`,
@@ -10,10 +10,12 @@
10
10
  */
11
11
 
12
12
  import { spawnSync } from 'node:child_process'
13
- import { existsSync, rmSync } from 'node:fs'
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(target, { dryRun = false, skipInstall = false, force = false } = {}) {
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 (!dryRun) {
64
+ if (dryRun) {
65
+ recordRemovalTree(venvPath, recorder)
66
+ } else {
53
67
  console.log(' Removing existing .venv (--force)...')
54
- rmSync(venvPath, { recursive: true })
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
- if (!existsSync(pythonBin)) {
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
  }
@@ -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 semantic search, recall, knowledge graph, compression, and verification',
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 === 'manifest'),
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 = entry.role === 'lock' ? [['version'], ['packages', '', 'version']] : [['version']]
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) or len(tail) > MAX_TAIL_ITEMS:
918
- raise ValueError("tail exceeds its size limit")
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) or len(line.encode()) > MAX_TAIL_LINE_LENGTH:
921
- raise ValueError("tail entries exceed their size limit")
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("tail payload exceeds its size limit")
924
- if sum(len(line.encode()) for line in tail) > MAX_CONTEXT_CONTENT_BYTES:
925
- raise ValueError("tail payload exceeds its size limit")
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
- if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
1024
- errors.add(prefix, f"exceeds the {MAX_CONTEXT_CONTENT_BYTES}-byte limit")
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
- if len(parsed) > MAX_TAIL_ITEMS or any(
1034
- not isinstance(line, str) or len(line.encode()) > MAX_TAIL_LINE_LENGTH
1035
- for line in parsed
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. When it parses to an object, 'goal', "
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
@@ -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
- const REQUIRED_NODE_MAJOR = 18
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 (nodeMajor < REQUIRED_NODE_MAJOR) {
12
+ if (!isSupportedNodeVersion(process.versions.node)) {
14
13
  console.error(
15
- `❌ Node.js >= ${REQUIRED_NODE_MAJOR} required (current: ${process.versions.node})`,
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 --apply
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 dir
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) {