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
@@ -0,0 +1,449 @@
1
+ /**
2
+ * plan.mjs — Per-file change planning for the Pantheon installer.
3
+ *
4
+ * Beta 11, Setup phase 1: `pantheon-opencode init ... --dry-run` must print the
5
+ * REAL per-file change set (create / update / remove / unchanged) that the SAME
6
+ * code path would produce, and write nothing.
7
+ *
8
+ * The plan is built by a change recorder threaded through the writer helpers as
9
+ * an OPTIONAL argument whose default is a module-level no-op singleton, so any
10
+ * caller (or writer) that is not instrumented keeps byte-identical behavior.
11
+ *
12
+ * NET-OUTCOME semantics: several writers can touch the same path in one pass
13
+ * (the TUI `unregister`→`register` toggle; the code-mode `syncDir` payload and
14
+ * its generated `manifest.json`). The LAST writer decides the net disk result,
15
+ * so a later no-write (skipped/unchanged) leaves the path UNCHANGED even when an
16
+ * earlier writer computed a would-be update. A no-write never promotes a path to
17
+ * a scored change.
18
+ *
19
+ * "Scored" vs "unscored": writers that increment the install summary
20
+ * (`stats.created`) alongside a genuine change are SCORED. Artifacts that are
21
+ * not part of that summary (the synthetic `.venv` entry, the state-only
22
+ * `.pantheon/install-state.json` write, the backup-set manifest, and the TUI
23
+ * stale-ref cleanup) are recorded UNSCORED so they still appear in the printed
24
+ * plan but never affect the consistency guard.
25
+ *
26
+ * Security: the recorder, the summary and the formatter deal in paths, actions
27
+ * and byte sizes ONLY. File contents and hashes stay internal — hashes are kept
28
+ * so a later phase can detect drift between a preview and an apply, and are
29
+ * never printed.
30
+ */
31
+
32
+ import { createHash } from 'node:crypto'
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'
41
+
42
+ /** The action vocabulary shared by the recorder, the summary and the formatter. */
43
+ export const ACTIONS = Object.freeze({
44
+ CREATE: 'create',
45
+ UPDATE: 'update',
46
+ REMOVE: 'remove',
47
+ UNCHANGED: 'unchanged',
48
+ CONFLICT: 'conflict',
49
+ })
50
+
51
+ const ACTION_VALUES = new Set(Object.values(ACTIONS))
52
+
53
+ /** Whether an action represents a real change to the target (not a no-write). */
54
+ function isChange(action) {
55
+ return action === ACTIONS.CREATE || action === ACTIONS.UPDATE || action === ACTIONS.REMOVE
56
+ }
57
+
58
+ // Display order for the formatted plan.
59
+ const DISPLAY_ORDER = Object.freeze([
60
+ ACTIONS.CREATE,
61
+ ACTIONS.UPDATE,
62
+ ACTIONS.REMOVE,
63
+ ACTIONS.CONFLICT,
64
+ ACTIONS.UNCHANGED,
65
+ ])
66
+
67
+ /**
68
+ * The module-level no-op recorder. Instrumented writers whose caller did not
69
+ * pass a recorder fall back to this, so their behavior is byte-identical to an
70
+ * uninstrumented run (no allocation, no side effects).
71
+ */
72
+ export function noopRecorder() {}
73
+
74
+ /** The default recorder instance — the no-op singleton. */
75
+ export const NOOP_RECORDER = noopRecorder
76
+
77
+ /**
78
+ * Whether a recorder will actually collect anything. Writers in hot loops use
79
+ * this to skip `Buffer.byteLength(content)` on the common (uninstrumented) path.
80
+ *
81
+ * @param {unknown} recorder
82
+ * @returns {boolean}
83
+ */
84
+ export function isRecording(recorder) {
85
+ return typeof recorder === 'function' && recorder !== NOOP_RECORDER
86
+ }
87
+
88
+ /** Normalize an arbitrary change into the `{ path, action, bytes, scored }` shape. */
89
+ function normalizeChange(change) {
90
+ const action = change && ACTION_VALUES.has(change.action) ? change.action : ACTIONS.UNCHANGED
91
+ return {
92
+ path: String(change?.path ?? ''),
93
+ action,
94
+ bytes: Number.isFinite(change?.bytes) ? change.bytes : 0,
95
+ outputFingerprint:
96
+ typeof change?.outputFingerprint === 'string' ? change.outputFingerprint : null,
97
+ // Scored by default: most writers feed the install summary. Synthetic and
98
+ // state-only entries opt out explicitly with `scored: false`.
99
+ scored: !(change && change.scored === false),
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Create a recorder that collects changes in memory. `recorder` is the function
105
+ * to thread into the writer helpers; `entries` holds the raw recorded changes.
106
+ *
107
+ * @returns {{ recorder: (change: object) => void, entries: object[] }}
108
+ */
109
+ export function createRecorder() {
110
+ const entries = []
111
+ const recorder = (change) => {
112
+ entries.push(normalizeChange(change))
113
+ }
114
+ return { recorder, entries }
115
+ }
116
+
117
+ /**
118
+ * Record a single change. Writers call this instead of touching fs bookkeeping.
119
+ * With no explicit recorder the module-level no-op singleton is used.
120
+ *
121
+ * @param {{ path: string, action: string, bytes?: number, scored?: boolean }} change
122
+ * @param {(change: object) => void} [recorder]
123
+ */
124
+ export function recordChange(change, recorder = NOOP_RECORDER) {
125
+ if (!isRecording(recorder)) return
126
+ recorder(normalizeChange(change))
127
+ }
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
+
134
+ /** Sort helper: display order first, then path for determinism. */
135
+ function compareFiles(a, b) {
136
+ const order = DISPLAY_ORDER.indexOf(a.action) - DISPLAY_ORDER.indexOf(b.action)
137
+ if (order !== 0) return order
138
+ return a.path.localeCompare(b.path)
139
+ }
140
+
141
+ function zeroTotals() {
142
+ return { create: 0, update: 0, remove: 0, unchanged: 0, conflict: 0 }
143
+ }
144
+
145
+ /** Tally a change into one of the five action buckets. */
146
+ function tally(totals, action) {
147
+ if (Object.hasOwn(totals, action)) totals[action] += 1
148
+ }
149
+
150
+ /**
151
+ * Collapse recorded changes into the final change set: one entry per path,
152
+ * reflecting the NET disk outcome, plus totals.
153
+ *
154
+ * The last record for a path decides the action, so a later no-write wins over
155
+ * an earlier would-be write. A path is scored only if at least one of its
156
+ * CHANGE records was scored AND the net action is still a change — a no-write
157
+ * can never turn a path into a scored change.
158
+ *
159
+ * @param {object[]} entries
160
+ * @returns {{
161
+ * files: object[],
162
+ * totals: { create: number, update: number, remove: number, unchanged: number, conflict: number },
163
+ * scored: { create: number, update: number, remove: number, unchanged: number, conflict: number },
164
+ * }}
165
+ */
166
+ export function summarizeChanges(entries = []) {
167
+ const byPath = new Map()
168
+ for (const entry of entries ?? []) {
169
+ const change = normalizeChange(entry)
170
+ let record = byPath.get(change.path)
171
+ if (!record) {
172
+ record = {
173
+ path: change.path,
174
+ action: change.action,
175
+ bytes: change.bytes,
176
+ outputFingerprint: change.outputFingerprint,
177
+ scored: false,
178
+ }
179
+ byPath.set(change.path, record)
180
+ }
181
+ // The LAST writer decides the net on-disk outcome. A later no-write
182
+ // (skipped/unchanged) leaves the path unchanged even if an earlier writer
183
+ // computed a would-be update (e.g. unregister→register toggling).
184
+ record.action = change.action
185
+ record.bytes = change.bytes
186
+ record.outputFingerprint = change.outputFingerprint
187
+ // Only a genuine change can score a path; a no-write never does.
188
+ if (isChange(change.action) && change.scored) record.scored = true
189
+ }
190
+
191
+ const files = [...byPath.values()].sort(compareFiles)
192
+ for (const file of files) {
193
+ if (!isChange(file.action)) file.scored = false
194
+ }
195
+
196
+ const totals = zeroTotals()
197
+ const scored = zeroTotals()
198
+ for (const file of files) {
199
+ tally(totals, file.action)
200
+ if (file.scored) tally(scored, file.action)
201
+ }
202
+ return { files, totals, scored }
203
+ }
204
+
205
+ /** Human-readable byte size. The caller never sees raw bytes or hashes. */
206
+ function formatBytes(bytes) {
207
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
208
+ if (bytes < 1024) return `${bytes} B`
209
+ const kb = bytes / 1024
210
+ if (kb < 1024) return `${kb.toFixed(1)} KB`
211
+ return `${(kb / 1024).toFixed(1)} MB`
212
+ }
213
+
214
+ /** Path shown in the plan: relative to the target when it lives inside it. */
215
+ export function displayPath(target, filePath) {
216
+ if (!target) return filePath
217
+ const rel = relative(target, filePath)
218
+ if (rel === '') return '.'
219
+ return rel.startsWith('..') || isAbsolute(rel) ? filePath : rel
220
+ }
221
+
222
+ /**
223
+ * Render the secret-safe plan: paths + action + byte size ONLY. No contents,
224
+ * no hashes.
225
+ *
226
+ * @param {string} target - install target (used to relativize paths)
227
+ * @param {{ files: object[], totals: object }} plan
228
+ * @returns {string}
229
+ */
230
+ export function formatChangePlan(target, plan) {
231
+ const { files, totals } = plan
232
+ const lines = []
233
+ lines.push(`Change plan — ${target}`)
234
+ for (const file of files) {
235
+ const isWrite = file.action === ACTIONS.CREATE || file.action === ACTIONS.UPDATE
236
+ const size = isWrite ? ` (${formatBytes(file.bytes)})` : ''
237
+ lines.push(` ${file.action.padEnd(9)} ${displayPath(target, file.path)}${size}`)
238
+ }
239
+ lines.push(` ${'─'.repeat(28)}`)
240
+ lines.push(
241
+ ` ${totals.create} create · ${totals.update} update · ${totals.remove} remove · ` +
242
+ `${totals.unchanged} unchanged · ${totals.conflict} conflict`,
243
+ )
244
+ lines.push(' No files were written.')
245
+ return lines.join('\n')
246
+ }
247
+
248
+ /**
249
+ * Snapshot a path for later drift comparison. Hashes stay internal.
250
+ *
251
+ * @param {string} filePath
252
+ * @returns {{ exists: boolean, sha256: string|null, size: number, mode: number|null }}
253
+ */
254
+ export function snapshot(filePath) {
255
+ let stat
256
+ try {
257
+ stat = lstatSync(filePath)
258
+ } catch {
259
+ return { exists: false, sha256: null, size: 0, mode: null }
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
+ }
283
+ if (!stat.isFile()) {
284
+ return { exists: true, sha256: null, size: stat.size, mode: stat.mode }
285
+ }
286
+ let buffer
287
+ try {
288
+ buffer = readFileSync(filePath)
289
+ } catch {
290
+ return { exists: true, sha256: null, size: stat.size, mode: stat.mode }
291
+ }
292
+ return {
293
+ exists: true,
294
+ sha256: createHash('sha256').update(buffer).digest('hex'),
295
+ size: buffer.length,
296
+ mode: stat.mode,
297
+ }
298
+ }
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
+
420
+ /**
421
+ * Compare the preview's SCORED change count against this call's install
422
+ * summary delta. Returns a descriptor on mismatch, or `null` when they agree.
423
+ *
424
+ * Only the CHANGED count is gated (`create + update + remove` vs
425
+ * `stats.created`). `stats.skipped` deliberately mixes genuine file-unchanged
426
+ * outcomes with logical no-op skips that never touch a file (e.g. the V2 TUI
427
+ * registration skip), so it cannot be reconciled against `plan.scored.unchanged`
428
+ * without making the guard cry wolf. The meaningful invariant is: every change
429
+ * the summary counted must have been recorded — a preview that silently missed
430
+ * a writer is exactly a `stats.created` with no recorded change.
431
+ *
432
+ * Both sides must be scoped to a SINGLE `installOpenCode` call.
433
+ *
434
+ * @param {{ scored: object }} plan
435
+ * @param {{ created: number, skipped: number }} stats - per-call delta
436
+ * @returns {{ changed: number, unchanged: number, recordedChanged: number, recordedUnchanged: number }|null}
437
+ */
438
+ export function planStatsMismatch(plan, stats) {
439
+ const changed = plan.scored.create + plan.scored.update + plan.scored.remove
440
+ if (changed !== stats.created) {
441
+ return {
442
+ changed,
443
+ unchanged: plan.scored.unchanged,
444
+ recordedChanged: stats.created,
445
+ recordedUnchanged: stats.skipped,
446
+ }
447
+ }
448
+ return null
449
+ }