pantheon-opencode 1.6.0-beta.11 → 1.6.0-beta.13

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 (53) hide show
  1. package/AGENTS.global.md +3 -3
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +85 -31
  5. package/README.pt-BR.md +7 -4
  6. package/bin/pantheon-init.mjs +278 -102
  7. package/package.json +5 -3
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +77 -13
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +191 -18
  14. package/scripts/install/cli-ui.mjs +73 -0
  15. package/scripts/install/file-inventory.mjs +555 -0
  16. package/scripts/install/interactive.mjs +69 -391
  17. package/scripts/install/migrate.mjs +45 -38
  18. package/scripts/install/model-picker.mjs +63 -22
  19. package/scripts/install/opencode-version.mjs +9 -1
  20. package/scripts/install/opencode.mjs +279 -79
  21. package/scripts/install/plan-artifact.mjs +517 -0
  22. package/scripts/install/plan.mjs +165 -3
  23. package/scripts/install/plugin.mjs +118 -14
  24. package/scripts/install/prompt-result.mjs +2 -0
  25. package/scripts/install/shared.mjs +188 -65
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/venv.mjs +15 -6
  28. package/scripts/install/verify.mjs +128 -0
  29. package/scripts/install/vision-prompt.mjs +2 -0
  30. package/scripts/install-mcp.mjs +1 -1
  31. package/scripts/manifest-inventory.mjs +49 -3
  32. package/scripts/node-engine-policy.mjs +85 -0
  33. package/scripts/package-evidence.mjs +6 -0
  34. package/scripts/postinstall.mjs +26 -26
  35. package/scripts/reconcile.mjs +325 -0
  36. package/scripts/sync-tui.mjs +5 -1
  37. package/scripts/uninstall.mjs +258 -34
  38. package/scripts/update.mjs +164 -0
  39. package/scripts/validate-node-engine-policy.mjs +13 -0
  40. package/scripts/version-check.mjs +34 -1
  41. package/scripts/versioning.mjs +9 -1
  42. package/skills-lock.json +3 -3
  43. package/src/agents/mnemosyne.md +7 -2
  44. package/src/instructions/memory-protocol.instructions.md +2 -2
  45. package/src/instructions/zeus-memory-operations.instructions.md +4 -3
  46. package/src/pantheon/v2-unsupported.mjs +2 -3
  47. package/src/plugins/tui/dist/tui-v2.js +2 -2
  48. package/src/plugins/tui/dist/tui.js +1 -1
  49. package/src/plugins/tui/package-lock.json +3 -3
  50. package/src/plugins/tui/package.json +2 -2
  51. package/src/skills/artifact-management/SKILL.md +5 -1
  52. package/src/skills/memory-bank/SKILL.md +5 -1
  53. package/src/skills/worktrees/SKILL.md +30 -11
@@ -30,8 +30,14 @@
30
30
  */
31
31
 
32
32
  import { createHash } from 'node:crypto'
33
- import { lstatSync, readFileSync } from 'node:fs'
34
- import { isAbsolute, relative } from 'node:path'
33
+ import { lstatSync, readdirSync, readFileSync, rmdirSync, unlinkSync } from 'node:fs'
34
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
35
+ import {
36
+ assertNoSymlinkAncestors,
37
+ assertPathWithin,
38
+ assertSafeRemoval,
39
+ verifyPathRemoved,
40
+ } from './verify.mjs'
35
41
 
36
42
  /** The action vocabulary shared by the recorder, the summary and the formatter. */
37
43
  export const ACTIONS = Object.freeze({
@@ -86,6 +92,8 @@ function normalizeChange(change) {
86
92
  path: String(change?.path ?? ''),
87
93
  action,
88
94
  bytes: Number.isFinite(change?.bytes) ? change.bytes : 0,
95
+ outputFingerprint:
96
+ typeof change?.outputFingerprint === 'string' ? change.outputFingerprint : null,
89
97
  // Scored by default: most writers feed the install summary. Synthetic and
90
98
  // state-only entries opt out explicitly with `scored: false`.
91
99
  scored: !(change && change.scored === false),
@@ -118,6 +126,11 @@ export function recordChange(change, recorder = NOOP_RECORDER) {
118
126
  recorder(normalizeChange(change))
119
127
  }
120
128
 
129
+ /** Return the non-displayed SHA-256 fingerprint used by reviewed plan artifacts. */
130
+ export function fingerprintOutput(content) {
131
+ return createHash('sha256').update(content).digest('hex')
132
+ }
133
+
121
134
  /** Sort helper: display order first, then path for determinism. */
122
135
  function compareFiles(a, b) {
123
136
  const order = DISPLAY_ORDER.indexOf(a.action) - DISPLAY_ORDER.indexOf(b.action)
@@ -156,7 +169,13 @@ export function summarizeChanges(entries = []) {
156
169
  const change = normalizeChange(entry)
157
170
  let record = byPath.get(change.path)
158
171
  if (!record) {
159
- record = { path: change.path, action: change.action, bytes: change.bytes, scored: false }
172
+ record = {
173
+ path: change.path,
174
+ action: change.action,
175
+ bytes: change.bytes,
176
+ outputFingerprint: change.outputFingerprint,
177
+ scored: false,
178
+ }
160
179
  byPath.set(change.path, record)
161
180
  }
162
181
  // The LAST writer decides the net on-disk outcome. A later no-write
@@ -164,6 +183,7 @@ export function summarizeChanges(entries = []) {
164
183
  // computed a would-be update (e.g. unregister→register toggling).
165
184
  record.action = change.action
166
185
  record.bytes = change.bytes
186
+ record.outputFingerprint = change.outputFingerprint
167
187
  // Only a genuine change can score a path; a no-write never does.
168
188
  if (isChange(change.action) && change.scored) record.scored = true
169
189
  }
@@ -238,6 +258,28 @@ export function snapshot(filePath) {
238
258
  } catch {
239
259
  return { exists: false, sha256: null, size: 0, mode: null }
240
260
  }
261
+ if (stat.isDirectory()) {
262
+ // Directory snapshots include the direct child inventory. Descendant
263
+ // directories/files are snapshotted separately by collectRemovalTree().
264
+ const entries = readdirSync(filePath, { withFileTypes: true })
265
+ .map((entry) => [
266
+ entry.name,
267
+ entry.isDirectory()
268
+ ? 'directory'
269
+ : entry.isFile()
270
+ ? 'file'
271
+ : entry.isSymbolicLink()
272
+ ? 'symlink'
273
+ : 'other',
274
+ ])
275
+ .sort(([left], [right]) => left.localeCompare(right))
276
+ return {
277
+ exists: true,
278
+ sha256: createHash('sha256').update(JSON.stringify(entries)).digest('hex'),
279
+ size: stat.size,
280
+ mode: stat.mode,
281
+ }
282
+ }
241
283
  if (!stat.isFile()) {
242
284
  return { exists: true, sha256: null, size: stat.size, mode: stat.mode }
243
285
  }
@@ -255,6 +297,126 @@ export function snapshot(filePath) {
255
297
  }
256
298
  }
257
299
 
300
+ /**
301
+ * Enumerate a path and its complete subtree as removal operations with
302
+ * pre-apply snapshots. Symlinks are treated as leaf entries and never followed.
303
+ *
304
+ * @param {string} rootPath
305
+ * @returns {{path: string, snapshot: object}[]}
306
+ */
307
+ export function collectRemovalTree(rootPath) {
308
+ const entries = []
309
+ const visit = (filePath, allowMissing = false) => {
310
+ let stat
311
+ try {
312
+ stat = lstatSync(filePath)
313
+ } catch (error) {
314
+ if (allowMissing && error?.code === 'ENOENT') return
315
+ throw error
316
+ }
317
+ entries.push({ path: filePath, snapshot: snapshot(filePath) })
318
+ if (stat.isDirectory()) {
319
+ for (const name of readdirSync(filePath).sort()) visit(`${filePath}${sep}${name}`)
320
+ }
321
+ }
322
+ visit(rootPath, true)
323
+ return entries
324
+ }
325
+
326
+ /** Record every path that a recursive removal will delete. */
327
+ export function recordRemovalTree(rootPath, recorder, { scored = false } = {}) {
328
+ const entries = collectRemovalTree(rootPath)
329
+ for (const entry of entries) {
330
+ recordChange({ path: entry.path, action: ACTIONS.REMOVE, bytes: 0, scored }, recorder)
331
+ }
332
+ return entries
333
+ }
334
+
335
+ /**
336
+ * Fail closed unless the exact reviewed subtree and its snapshots still match.
337
+ *
338
+ * @param {string} rootPath
339
+ * @param {Set<string>|null} reviewedRemovals
340
+ * @param {Map<string, object>|null} reviewedSnapshots
341
+ */
342
+ export function validateRemovalTree(rootPath, reviewedRemovals, reviewedSnapshots) {
343
+ if (!reviewedRemovals || !reviewedSnapshots) {
344
+ throw new Error(`Refusing destructive removal without a reviewed snapshot: ${rootPath}`)
345
+ }
346
+ const root = resolve(rootPath)
347
+ const isWithinRoot = (filePath) => {
348
+ const rel = relative(root, resolve(filePath))
349
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
350
+ }
351
+ const expected = new Set(
352
+ [...reviewedRemovals].filter(isWithinRoot).map((filePath) => resolve(filePath)),
353
+ )
354
+ const current = collectRemovalTree(rootPath)
355
+ const actual = new Set(current.map((entry) => resolve(entry.path)))
356
+ if (actual.size !== expected.size || [...actual].some((filePath) => !expected.has(filePath))) {
357
+ throw new Error(`Removal set changed after review: ${rootPath}`)
358
+ }
359
+ for (const entry of current) {
360
+ const filePath = resolve(entry.path)
361
+ const before = reviewedSnapshots.get(filePath)
362
+ const now = entry.snapshot
363
+ if (
364
+ !before ||
365
+ before.exists !== now.exists ||
366
+ before.sha256 !== now.sha256 ||
367
+ before.size !== now.size ||
368
+ before.mode !== now.mode
369
+ ) {
370
+ throw new Error(`Removal target drifted after review: ${entry.path}`)
371
+ }
372
+ }
373
+ return true
374
+ }
375
+
376
+ /**
377
+ * Remove only the reviewed entries, without recursive deletion that could
378
+ * consume a path added after preflight. Directory removal uses rmdir, which
379
+ * fails closed if any unreviewed entry appears during cleanup.
380
+ */
381
+ export function removeReviewedTree(rootPath, reviewedRemovals, reviewedSnapshots) {
382
+ validateRemovalTree(rootPath, reviewedRemovals, reviewedSnapshots)
383
+ const entries = collectRemovalTree(rootPath)
384
+ for (const entry of entries.reverse()) {
385
+ const info = lstatSync(entry.path)
386
+ if (info.isDirectory()) {
387
+ assertSafeRemoval(rootPath, entry.path)
388
+ try {
389
+ rmdirSync(entry.path)
390
+ } catch (error) {
391
+ if (error?.code === 'ENOTEMPTY' || error?.code === 'EEXIST') {
392
+ throw new Error(`Removal set changed after review: ${entry.path}`, { cause: error })
393
+ }
394
+ throw error
395
+ }
396
+ } else {
397
+ // Unlinking a reviewed symlink leaf is safe and preserves historical
398
+ // cleanup behavior; reject linked ancestors so the parent is never
399
+ // redirected outside the reviewed root.
400
+ assertPathWithin(rootPath, entry.path)
401
+ assertNoSymlinkAncestors(entry.path, { includeLeaf: false })
402
+ const before = reviewedSnapshots?.get(resolve(entry.path))
403
+ const now = snapshot(entry.path)
404
+ if (
405
+ !before ||
406
+ before.exists !== now.exists ||
407
+ before.sha256 !== now.sha256 ||
408
+ before.size !== now.size ||
409
+ before.mode !== now.mode
410
+ ) {
411
+ throw new Error(`Removal target drifted after review: ${entry.path}`)
412
+ }
413
+ unlinkSync(entry.path)
414
+ }
415
+ verifyPathRemoved(entry.path)
416
+ }
417
+ return true
418
+ }
419
+
258
420
  /**
259
421
  * Compare the preview's SCORED change count against this call's install
260
422
  * summary delta. Returns a descriptor on mismatch, or `null` when they agree.
@@ -4,8 +4,15 @@
4
4
 
5
5
  import { execSync } from 'node:child_process'
6
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
7
- import { isAbsolute, join, resolve } from 'node:path'
7
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
8
+ import { ACTIONS, recordChange, recordRemovalTree, removeReviewedTree, snapshot } from './plan.mjs'
8
9
  import { ROOT, writeIfChanged } from './shared.mjs'
10
+ import {
11
+ assertNoSymlinkAncestors,
12
+ assertPathWithin,
13
+ assertSafeRemoval,
14
+ verifyPathRemoved,
15
+ } from './verify.mjs'
9
16
 
10
17
  /**
11
18
  * Copy a plugin's runtime payload (the root TUI loader, bundled dist/*,
@@ -18,22 +25,37 @@ import { ROOT, writeIfChanged } from './shared.mjs'
18
25
  * @param {boolean} [options.dryRun=false] - If true, don't write files
19
26
  * @param {'v1'|'v2'} [options.version='v1'] - Host plugin generation
20
27
  * @param {(change: object) => void} [options.recorder] - optional change recorder for preview
28
+ * @param {Set<string>|null} [options.reviewedRemovals] - paths approved by the current plan
29
+ * @param {Map<string, object>|null} [options.reviewedSnapshots] - pre-apply path snapshots
21
30
  * @returns {{ created: number, skipped: number }}
22
31
  */
23
32
  export function copyPluginFiles(
24
33
  srcDir,
25
34
  dstDir,
26
- { dryRun = false, version = 'v1', recorder = null } = {},
35
+ {
36
+ dryRun = false,
37
+ version = 'v1',
38
+ recorder = null,
39
+ reviewedRemovals = null,
40
+ reviewedSnapshots = null,
41
+ } = {},
27
42
  ) {
28
43
  const result = { created: 0, skipped: 0 }
44
+ const distDir = join(dstDir, 'dist')
29
45
 
30
- if (!dryRun) mkdirSync(dstDir, { recursive: true })
31
- if (!dryRun) mkdirSync(join(dstDir, 'dist'), { recursive: true })
46
+ if (!dryRun) {
47
+ assertNoSymlinkAncestors(dstDir)
48
+ mkdirSync(dstDir, { recursive: true })
49
+ assertNoSymlinkAncestors(dstDir)
50
+ assertPathWithin(dstDir, distDir)
51
+ assertNoSymlinkAncestors(distDir)
52
+ mkdirSync(distDir, { recursive: true })
53
+ assertNoSymlinkAncestors(distDir)
54
+ }
32
55
 
33
56
  // Drop legacy raw copies from previous installs (root index.tsx + dist/tui.tsx).
34
- if (!dryRun) {
35
- rmSync(join(dstDir, 'index.tsx'), { force: true })
36
- rmSync(join(dstDir, 'dist', 'tui.tsx'), { force: true })
57
+ for (const legacyPath of [join(dstDir, 'index.tsx'), join(distDir, 'tui.tsx')]) {
58
+ removeLegacyFile(legacyPath, dstDir, { dryRun, recorder, reviewedRemovals, reviewedSnapshots })
37
59
  }
38
60
 
39
61
  // Copy dist/* files
@@ -41,7 +63,9 @@ export function copyPluginFiles(
41
63
  if (existsSync(distSrc)) {
42
64
  for (const f of readdirSync(distSrc)) {
43
65
  const c = readFileSync(join(distSrc, f), 'utf8')
44
- const status = writeIfChanged(join(dstDir, 'dist', f), c, dryRun, { recorder })
66
+ const destination = join(distDir, f)
67
+ assertPathWithin(dstDir, destination)
68
+ const status = writeIfChanged(destination, c, dryRun, { recorder })
45
69
  if (status === 'created') result.created++
46
70
  else result.skipped++
47
71
  }
@@ -88,8 +112,13 @@ export function copyPluginFiles(
88
112
  )
89
113
  if (entryStatus === 'created') result.created++
90
114
  else result.skipped++
91
- } else if (!dryRun) {
92
- rmSync(v2ServerDestination, { force: true })
115
+ } else {
116
+ removeLegacyFile(v2ServerDestination, dstDir, {
117
+ dryRun,
118
+ recorder,
119
+ reviewedRemovals,
120
+ reviewedSnapshots,
121
+ })
93
122
  }
94
123
 
95
124
  // Copy the lockfile so dependency installation is reproducible. A missing
@@ -109,6 +138,49 @@ export function copyPluginFiles(
109
138
  return result
110
139
  }
111
140
 
141
+ function removeLegacyFile(
142
+ filePath,
143
+ rootDir,
144
+ { dryRun, recorder, reviewedRemovals, reviewedSnapshots },
145
+ ) {
146
+ assertSafeRemoval(rootDir, filePath)
147
+ const present = existsSync(filePath)
148
+ if (dryRun) {
149
+ recordChange(
150
+ {
151
+ path: filePath,
152
+ action: present ? ACTIONS.REMOVE : ACTIONS.UNCHANGED,
153
+ bytes: 0,
154
+ scored: false,
155
+ },
156
+ recorder,
157
+ )
158
+ return
159
+ }
160
+ if (!present) return
161
+
162
+ const normalizedPath = resolve(filePath)
163
+ if (reviewedRemovals && !reviewedRemovals.has(normalizedPath)) {
164
+ throw new Error(`Refusing unreviewed plugin removal: ${filePath}`)
165
+ }
166
+ const expected = reviewedSnapshots?.get(normalizedPath)
167
+ if (reviewedSnapshots && (!expected || !sameSnapshot(snapshot(filePath), expected))) {
168
+ throw new Error(`Plugin removal target drifted after review: ${filePath}`)
169
+ }
170
+ rmSync(filePath, { force: true })
171
+ verifyPathRemoved(filePath)
172
+ recordChange({ path: filePath, action: ACTIONS.REMOVE, bytes: 0, scored: false }, recorder)
173
+ }
174
+
175
+ function sameSnapshot(left, right) {
176
+ return (
177
+ left.exists === right.exists &&
178
+ left.sha256 === right.sha256 &&
179
+ left.size === right.size &&
180
+ left.mode === right.mode
181
+ )
182
+ }
183
+
112
184
  /**
113
185
  * Install a plugin from source to destination.
114
186
  * @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
@@ -122,17 +194,49 @@ export function copyPluginFiles(
122
194
  export function installPlugin(
123
195
  srcDir,
124
196
  dstDir,
125
- { dryRun = false, clean = false, version = 'v1', recorder = null } = {},
197
+ {
198
+ dryRun = false,
199
+ clean = false,
200
+ version = 'v1',
201
+ recorder = null,
202
+ reviewedRemovals = null,
203
+ reviewedSnapshots = null,
204
+ } = {},
126
205
  ) {
127
206
  const result = { created: 0, skipped: 0, errors: 0 }
207
+ const cleanTargetExisted = clean && existsSync(dstDir)
128
208
 
129
- if (clean && existsSync(dstDir) && !dryRun) {
130
- rmSync(dstDir, { recursive: true, force: true })
209
+ assertNoSymlinkAncestors(dstDir)
210
+ if (cleanTargetExisted) {
211
+ assertSafeRemoval(dirname(dstDir), dstDir)
212
+ if (dryRun) {
213
+ recordRemovalTree(dstDir, recorder)
214
+ } else {
215
+ removeReviewedTree(dstDir, reviewedRemovals, reviewedSnapshots)
216
+ }
131
217
  }
132
218
 
133
- const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun, version, recorder })
219
+ const copyResult = copyPluginFiles(srcDir, dstDir, {
220
+ dryRun,
221
+ version,
222
+ recorder,
223
+ reviewedRemovals,
224
+ reviewedSnapshots,
225
+ })
134
226
  result.created += copyResult.created
135
227
  result.skipped += copyResult.skipped
228
+ if (cleanTargetExisted) {
229
+ // The clean pass removes these directories before rebuilding their files;
230
+ // keep the net plan truthful while the separate removal review still lists
231
+ // every path actually cleared.
232
+ recordChange({ path: dstDir, action: ACTIONS.UNCHANGED, bytes: 0, scored: false }, recorder)
233
+ if (existsSync(join(srcDir, 'dist'))) {
234
+ recordChange(
235
+ { path: join(dstDir, 'dist'), action: ACTIONS.UNCHANGED, bytes: 0, scored: false },
236
+ recorder,
237
+ )
238
+ }
239
+ }
136
240
 
137
241
  // Install plugin dependencies (@opentui/core, @opentui/solid, solid-js).
138
242
  // Lockfile installation is the only accepted dependency path.
@@ -0,0 +1,2 @@
1
+ /** Sentinel returned when the user cancels an interactive authorization prompt. */
2
+ export const PROMPT_CANCELED = Symbol('pantheon-prompt-canceled')