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

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 (46) hide show
  1. package/AGENTS.global.md +42 -18
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +24 -0
  4. package/README.md +66 -21
  5. package/bin/pantheon-init.mjs +44 -1
  6. package/package.json +4 -4
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +1 -1
  9. package/scripts/build-agents-md.mjs +60 -6
  10. package/scripts/install/backup.mjs +475 -0
  11. package/scripts/install/cli-ui.mjs +6 -1
  12. package/scripts/install/exit-codes.mjs +26 -0
  13. package/scripts/install/interactive.mjs +15 -1
  14. package/scripts/install/migrate.mjs +41 -22
  15. package/scripts/install/opencode.mjs +562 -50
  16. package/scripts/install/plan.mjs +287 -0
  17. package/scripts/install/plugin.mjs +22 -8
  18. package/scripts/install/shared.mjs +89 -55
  19. package/scripts/install/strings.mjs +20 -0
  20. package/scripts/install/venv.mjs +13 -3
  21. package/scripts/install/vision-prompt.mjs +11 -1
  22. package/scripts/mcp_persistence.py +50 -15
  23. package/scripts/prune.mjs +9 -3
  24. package/scripts/restore.mjs +222 -0
  25. package/skills-lock.json +1 -1
  26. package/src/agents/iris.md +6 -0
  27. package/src/agents/zeus.md +19 -62
  28. package/src/instructions/agent-return-format.instructions.md +17 -15
  29. package/src/instructions/memory-protocol.instructions.md +3 -2
  30. package/src/instructions/test-economy.instructions.md +21 -0
  31. package/src/instructions/zeus-anti-stall.instructions.md +6 -6
  32. package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
  33. package/src/instructions/zeus-memory-operations.instructions.md +10 -6
  34. package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
  35. package/src/mcp/mcp_persistence.py +50 -15
  36. package/src/pantheon/cost-command.ts +5 -2
  37. package/src/pantheon/model-agents.ts +19 -0
  38. package/src/pantheon/model-command.ts +218 -56
  39. package/src/pantheon/v2-model-overrides.ts +275 -0
  40. package/src/pantheon/v2-tools.ts +18 -29
  41. package/src/plugin-v2.ts +13 -0
  42. package/src/plugins/tui/dist/tui-v2.js +2 -2
  43. package/src/plugins/tui/dist/tui.js +1 -1
  44. package/src/plugins/tui/package-lock.json +2 -2
  45. package/src/plugins/tui/package.json +1 -1
  46. package/src/skills/auto-continue/SKILL.md +14 -6
@@ -19,9 +19,10 @@
19
19
  import { createHash } from 'node:crypto'
20
20
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
21
21
  import { homedir } from 'node:os'
22
- import { dirname, isAbsolute, join, resolve } from 'node:path'
22
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
23
23
  import { listV2UnsupportedFeatures } from '../../src/pantheon/v2-unsupported.mjs'
24
24
  import { copyAgentPrompts, writeAgentsMdManaged } from '../build-agents-md.mjs'
25
+ import { backupSetPath, backupWarning, createBackupSet, nextBackupName } from './backup.mjs'
25
26
  import {
26
27
  bullet,
27
28
  colors,
@@ -33,6 +34,7 @@ import {
33
34
  spinner,
34
35
  step,
35
36
  success,
37
+ tag,
36
38
  warning,
37
39
  } from './cli-ui.mjs'
38
40
  import {
@@ -43,6 +45,17 @@ import {
43
45
  import { healthCheck } from './health-check.mjs'
44
46
  import { detectVersion, runMigrations } from './migrate.mjs'
45
47
  import { resolveOpenCodeVersion } from './opencode-version.mjs'
48
+ import {
49
+ ACTIONS,
50
+ createRecorder,
51
+ displayPath,
52
+ formatChangePlan,
53
+ isRecording,
54
+ planStatsMismatch,
55
+ recordChange,
56
+ snapshot,
57
+ summarizeChanges,
58
+ } from './plan.mjs'
46
59
  import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
47
60
  import {
48
61
  checkRuntimePrerequisites,
@@ -71,6 +84,170 @@ const COMPONENT_NAMES = [
71
84
  'runtime',
72
85
  ]
73
86
 
87
+ /**
88
+ * MCP server scripts the runtime component installs. Hoisted so the backup-set
89
+ * enumeration and the runtime writer share one source of truth.
90
+ */
91
+ const MCP_SCRIPTS = [
92
+ 'mcp_resources.py',
93
+ 'code_mode.py',
94
+ 'memory_mcp.py',
95
+ 'scrub-secrets.py',
96
+ '_pantheon_paths.py',
97
+ 'mcp_codemap_module.py',
98
+ 'mcp_persistence.py',
99
+ 'pantheon_vision.py',
100
+ 'eval_store.py',
101
+ 'themis_heuristic_scan.py',
102
+ ]
103
+
104
+ /** Recursively list regular files under `root`, relative to `root`. */
105
+ function walkFiles(root, base = root, out = []) {
106
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
107
+ const full = join(root, entry.name)
108
+ if (entry.isDirectory()) walkFiles(full, base, out)
109
+ else if (entry.isFile()) out.push(relative(base, full))
110
+ }
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * Enumerate what the backup set must preserve, derived from the selected
116
+ * components. It returns:
117
+ * - `entries`: paths (relative to `target`) the run can CREATE/UPDATE/REMOVE.
118
+ * For every managed destination tree we take the UNION of the files that
119
+ * already exist there (so a `--clean`, a stale removal, or an
120
+ * instructions-only refresh is captured) and the source-derived destination
121
+ * paths (so a file that will be created is recorded as absent). The union is
122
+ * a superset of the plan's changes — unchanged files are backed up too.
123
+ * - `notRevertible`: paths the run may delete or write but whose byte-for-byte
124
+ * copy is not feasible or is out of the config dir (bulk `.venv` / plugin
125
+ * `node_modules`, the global `tui.json` copies and the global vision config).
126
+ * These are recorded explicitly in the manifest rather than silently ignored.
127
+ * It never reads file contents.
128
+ *
129
+ * @param {string} target
130
+ * @param {Set<string>} componentSet
131
+ * @param {string} version
132
+ * @param {boolean} isGlobal
133
+ * @returns {{ entries: string[], notRevertible: string[] }}
134
+ */
135
+ function collectInstallBackupEntries(target, componentSet, version, isGlobal) {
136
+ const entries = []
137
+ const notRevertible = []
138
+ const rel = (abs) => relative(target, abs)
139
+ const addFile = (abs) => entries.push(rel(abs))
140
+ // Existing files under an in-target tree (covers removals / clean / refresh).
141
+ // `skipDirNames` prunes bulk subdirectories (e.g. plugin `node_modules`) so the
142
+ // walk stays cheap while everything else — including legacy files a clean pass
143
+ // deletes — is captured.
144
+ const addExisting = (dir, skipDirNames = null) => {
145
+ if (!existsSync(dir)) return
146
+ const walk = (base) => {
147
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
148
+ if (skipDirNames && entry.isDirectory() && skipDirNames.has(entry.name)) continue
149
+ const full = join(base, entry.name)
150
+ if (entry.isDirectory()) walk(full)
151
+ else if (entry.isFile()) addFile(full)
152
+ }
153
+ }
154
+ walk(dir)
155
+ }
156
+ // Destination paths derived from a source tree (covers files to be created).
157
+ const addSource = (srcDir, dstDir, filter = null) => {
158
+ if (!existsSync(srcDir)) return
159
+ for (const r of walkFiles(srcDir)) {
160
+ if (filter && !filter(r)) continue
161
+ addFile(join(dstDir, r))
162
+ }
163
+ }
164
+
165
+ const baseDir = isGlobal ? target : join(target, '.opencode')
166
+ const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
167
+ const agentDir = join(target, agentPrefix)
168
+
169
+ // Always touched.
170
+ addFile(join(target, 'opencode.json'))
171
+ addFile(join(target, '.pantheon', 'install-state.json'))
172
+ addFile(join(baseDir, 'opencode-vision.json'))
173
+ // The active-preset writer (writeActivePreset(target, …)) lives at
174
+ // <target>/.pantheon, NOT under <baseDir>, for both project and global scope.
175
+ addFile(join(target, '.pantheon', 'active-preset.json'))
176
+ addFile(join(target, '.pantheon', 'active-preset.json.bak'))
177
+
178
+ // The agent prompt dir is refreshed (existing-only) by an instructions-only
179
+ // run too, so enumerate it whenever EITHER component can write it.
180
+ if (componentSet.has('agents') || componentSet.has('instructions')) {
181
+ addExisting(agentDir)
182
+ }
183
+ if (componentSet.has('agents')) {
184
+ addFile(join(target, 'routing.yml'))
185
+ addSource(
186
+ join(ROOT, 'src', 'agents'),
187
+ agentDir,
188
+ (r) => r.endsWith('.md') && r.toLowerCase() !== 'readme.md',
189
+ )
190
+ }
191
+ if (componentSet.has('skills')) {
192
+ addExisting(join(baseDir, 'skills'))
193
+ addSource(join(ROOT, 'src', 'skills'), join(baseDir, 'skills'))
194
+ }
195
+ if (componentSet.has('instructions')) addFile(join(target, 'AGENTS.md'))
196
+ if (componentSet.has('prompts')) {
197
+ addExisting(join(target, 'prompts'))
198
+ addSource(join(ROOT, 'prompts'), join(target, 'prompts'))
199
+ }
200
+ if (componentSet.has('commands')) {
201
+ const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
202
+ addExisting(join(target, commandDir))
203
+ addSource(join(ROOT, 'commands'), join(target, commandDir), (r) => r.endsWith('.md'))
204
+ }
205
+ if (componentSet.has('plugins')) {
206
+ const dstPluginDir = isGlobal
207
+ ? join(target, 'plugins', 'pantheon-tui')
208
+ : join(target, '.opencode', 'plugins', 'pantheon-tui')
209
+ // Existing files under the installed plugin dir (legacy `index.tsx` /
210
+ // `dist/tui.tsx` and any extras a `--clean` run deletes) are captured, like
211
+ // every sibling tree — EXCEPT the bulk `node_modules`, which is regenerated
212
+ // by `npm ci` and listed as not revertible.
213
+ addExisting(dstPluginDir, new Set(['node_modules']))
214
+ addSource(join(ROOT, 'src', 'plugins', 'tui', 'dist'), join(dstPluginDir, 'dist'))
215
+ addFile(join(dstPluginDir, 'package.json'))
216
+ addFile(join(dstPluginDir, 'package-lock.json'))
217
+ addFile(join(dstPluginDir, 'tui.ts'))
218
+ addFile(join(dstPluginDir, 'index.ts'))
219
+ notRevertible.push(rel(join(dstPluginDir, 'node_modules')))
220
+ if (version === 'v1') {
221
+ const projectTui = join(baseDir, 'tui.json')
222
+ addFile(projectTui)
223
+ // Cross-location cleanup touches copies OUTSIDE the config dir.
224
+ for (const loc of tuiConfigLocations(target)) {
225
+ if (resolve(loc) !== resolve(projectTui)) notRevertible.push(loc)
226
+ }
227
+ }
228
+ }
229
+ if (componentSet.has('runtime')) {
230
+ const runtimeTarget = isGlobal ? target : join(target, '.opencode')
231
+ addExisting(join(runtimeTarget, 'scripts'))
232
+ for (const script of MCP_SCRIPTS) addFile(join(runtimeTarget, 'scripts', script))
233
+ addFile(join(runtimeTarget, 'requirements-vision.txt'))
234
+ addSource(join(ROOT, '.pantheon', 'code-mode'), join(runtimeTarget, '.pantheon', 'code-mode'))
235
+ addExisting(join(runtimeTarget, '.pantheon', 'code-mode'))
236
+ addFile(join(runtimeTarget, '.pantheon', 'tiers.json'))
237
+ // `--force` recreates the venv from scratch: bulk, not copied.
238
+ notRevertible.push(rel(join(target, '.venv')))
239
+ }
240
+
241
+ // The vision config is written GLOBALLY (outside the config dir).
242
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
243
+ const globalVision = join(xdg, 'opencode', 'opencode-vision.json')
244
+ if (resolve(globalVision) !== resolve(join(baseDir, 'opencode-vision.json'))) {
245
+ notRevertible.push(globalVision)
246
+ }
247
+
248
+ return { entries, notRevertible }
249
+ }
250
+
74
251
  /**
75
252
  * The product defaults an install seeds into the user's `opencode.json`.
76
253
  *
@@ -210,30 +387,40 @@ function deepClone(value) {
210
387
 
211
388
  /**
212
389
  * (Re)generate the code-mode manifest with SHA-256 digests for every bundled
213
- * script. This is the explicit installation opt-in: the MCP server only
214
- * executes scripts listed here whose on-disk hash matches. Missing or empty
215
- * directories approve nothing (0) — execution is never implicit.
390
+ * script, and write it to the destination. This is the explicit installation
391
+ * opt-in: the MCP server only executes scripts listed here whose on-disk hash
392
+ * matches. Missing or empty source approves nothing — execution is never
393
+ * implicit.
394
+ *
395
+ * This function is the SINGLE owner of `manifest.json`: `syncDir` deliberately
396
+ * excludes it (see the runtime block), so exactly one writer and one stats
397
+ * count exist for that path. The payload is derived from the SOURCE scripts
398
+ * (what will be installed), then compared against the CURRENT installed
399
+ * manifest, so the preview action matches what apply writes — including on a
400
+ * script upgrade, where the installed manifest is stale.
401
+ *
402
+ * @returns {{ approved: number, status: 'created'|'skipped'|'conflict'|null }}
216
403
  */
217
- function writeCodeModeManifest(dir, dryRun) {
218
- if (!existsSync(dir)) return 0
404
+ function writeCodeModeManifest(srcDir, dstDir, dryRun, recorder = null) {
405
+ if (!existsSync(srcDir)) return { approved: 0, status: null }
219
406
  let names
220
407
  try {
221
- names = readdirSync(dir)
408
+ names = readdirSync(srcDir)
222
409
  .filter((name) => /\.(sh|py)$/i.test(name) && !name.startsWith('.'))
223
410
  .sort()
224
411
  } catch {
225
- return 0
412
+ return { approved: 0, status: null }
226
413
  }
227
- if (names.length === 0) return 0
414
+ if (names.length === 0) return { approved: 0, status: null }
228
415
  const scripts = {}
229
416
  for (const name of names) {
230
417
  scripts[name] = createHash('sha256')
231
- .update(readFileSync(join(dir, name)))
418
+ .update(readFileSync(join(srcDir, name)))
232
419
  .digest('hex')
233
420
  }
234
421
  const payload = `${JSON.stringify({ version: 1, scripts }, null, 2)}\n`
235
- writeIfChanged(join(dir, 'manifest.json'), payload, dryRun)
236
- return names.length
422
+ const status = writeIfChanged(join(dstDir, 'manifest.json'), payload, dryRun, { recorder })
423
+ return { approved: names.length, status }
237
424
  }
238
425
 
239
426
  function readJsonConfig(filePath) {
@@ -594,11 +781,12 @@ export function tuiConfigLocations(target) {
594
781
  * @param {object} [opts]
595
782
  * @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
596
783
  * @param {boolean} [opts.dryRun=false]
784
+ * @param {(change: object) => void} [opts.recorder] - optional change recorder for preview
597
785
  * @returns {'created'|'skipped'} status for the target registration
598
786
  */
599
787
  export function syncTuiRegistration(
600
788
  target,
601
- { isGlobal = false, dryRun = false, version = 'v1' } = {},
789
+ { isGlobal = false, dryRun = false, version = 'v1', recorder = null } = {},
602
790
  ) {
603
791
  const configDir = isGlobal ? target : join(target, '.opencode')
604
792
  const targetTuiConfigPath = join(configDir, 'tui.json')
@@ -608,7 +796,7 @@ export function syncTuiRegistration(
608
796
  const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
609
797
  const tuiCopyDir = resolveTuiCopyTarget(configDir)
610
798
  if (existsSync(tuiSrcDir)) {
611
- copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version })
799
+ copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version, recorder })
612
800
  }
613
801
 
614
802
  // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
@@ -621,6 +809,7 @@ export function syncTuiRegistration(
621
809
  unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', {
622
810
  dryRun,
623
811
  knownPantheonPaths: [ownedCopy, tuiCopyDir],
812
+ recorder,
624
813
  })
625
814
  }
626
815
 
@@ -630,7 +819,7 @@ export function syncTuiRegistration(
630
819
  if (version === 'v2') return 'skipped'
631
820
 
632
821
  // 3. Register the copied directory in the selected target only.
633
- return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
822
+ return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun, recorder })
634
823
  }
635
824
 
636
825
  /**
@@ -639,13 +828,18 @@ export function syncTuiRegistration(
639
828
  * MCP entries from the config (entries pointing at a missing venv would
640
829
  * make every MCP fail to launch). `deps` is injectable for tests.
641
830
  */
642
- export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, deps = {}) {
831
+ export function setupRuntimePhase(
832
+ target,
833
+ { dryRun, clean, isGlobal, recorder = null },
834
+ stats,
835
+ deps = {},
836
+ ) {
643
837
  const S = strings()
644
838
  const setup = deps.setupVenv ?? setupVenv
645
839
  const check = deps.healthCheck ?? healthCheck
646
840
  try {
647
841
  const venvSpinner = spinner(S.settingUpRuntime)
648
- setup(target, { dryRun, force: clean })
842
+ setup(target, { dryRun, force: clean, recorder })
649
843
  venvSpinner(true)
650
844
  const runtimeTarget = isGlobal ? target : join(target, '.opencode')
651
845
  const health = check(runtimeTarget, { dryRun, pythonTarget: target })
@@ -758,12 +952,120 @@ export function applyMcpServerEntries(config, { runtimeTarget, venvPython }) {
758
952
  return { healed }
759
953
  }
760
954
 
955
+ /**
956
+ * Phase 4 drift detection: the planned targets whose on-disk state changed
957
+ * between the plan pass (snapshots) and the apply pass. A path counts as drifted
958
+ * when its existence, content hash, size, or mode differs — which includes a
959
+ * planned-NEW path that now exists. Returns the absolute paths (empty = no
960
+ * drift); callers print only these paths, never contents or hashes.
961
+ */
962
+ function detectSnapshotDrift(snapshots) {
963
+ if (!Array.isArray(snapshots)) return []
964
+ const changed = []
965
+ for (const before of snapshots) {
966
+ const now = snapshot(before.path)
967
+ if (
968
+ now.exists !== before.exists ||
969
+ now.sha256 !== before.sha256 ||
970
+ now.size !== before.size ||
971
+ now.mode !== before.mode
972
+ ) {
973
+ changed.push(before.path)
974
+ }
975
+ }
976
+ return changed
977
+ }
978
+
979
+ /**
980
+ * Remove lingering atomic-write temp siblings (`<file>.tmp-<pid>`) left behind
981
+ * when a writer aborted mid-write. Best-effort; skips the bulk dirs so a failure
982
+ * path stays cheap.
983
+ */
984
+ /**
985
+ * Remove lingering atomic-write temp siblings (`<file>.tmp-<pid>`) left when a
986
+ * writer aborted mid-write. Only files matching the installer's own temp
987
+ * pattern are removed, and the bulk/foreign dirs are skipped so a failure path
988
+ * stays cheap and never touches unrelated files.
989
+ */
990
+ function cleanupTempSiblings(root) {
991
+ if (!root) return
992
+ const suffix = `.tmp-${process.pid}`
993
+ const skip = new Set(['.git', '.pantheon', 'node_modules', '.venv'])
994
+ const walk = (dir) => {
995
+ let entries
996
+ try {
997
+ entries = readdirSync(dir, { withFileTypes: true })
998
+ } catch {
999
+ return
1000
+ }
1001
+ for (const entry of entries) {
1002
+ if (entry.isDirectory()) {
1003
+ if (skip.has(entry.name)) continue
1004
+ walk(join(dir, entry.name))
1005
+ } else if (entry.isFile() && entry.name.endsWith(suffix)) {
1006
+ try {
1007
+ rmSync(join(dir, entry.name), { force: true })
1008
+ } catch {
1009
+ // best-effort cleanup
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+ walk(root)
1015
+ }
1016
+
1017
+ /**
1018
+ * Phase 5 failure semantics. `installPass` is the whole installer; this thin
1019
+ * wrapper reports a mid-apply failure HONESTLY: it aborts the remaining writes
1020
+ * (the throw), cleans up any lingering atomic-write temp siblings, reports the
1021
+ * PATHS already applied (never contents), prints the recovery path, and
1022
+ * re-throws so the CLI exits non-zero. There is NO automatic rollback — the
1023
+ * exact backup set + `pantheon-opencode restore` is the recovery.
1024
+ *
1025
+ * Progress is tracked in `_lastRun` (set only by a non-dry pass, so the nested
1026
+ * dry plan pass never clobbers it). The RESOLVED target is read back from
1027
+ * `_lastRun.target` so the report and the sweep work when the caller passed
1028
+ * `undefined` (the default global install resolves to `~/.config/opencode`).
1029
+ */
1030
+ let _lastRun = null
1031
+
761
1032
  export async function installOpenCode(
762
1033
  target,
763
1034
  dryRun = false,
764
1035
  clean = false,
765
1036
  components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
766
1037
  opts = {},
1038
+ ) {
1039
+ // Reset per-call so a stale run can never leak into a fresh failure report.
1040
+ if (!dryRun) _lastRun = null
1041
+ try {
1042
+ return await installPass(target, dryRun, clean, components, opts)
1043
+ } catch (err) {
1044
+ const run = dryRun ? null : _lastRun
1045
+ if (run) {
1046
+ const S = strings()
1047
+ const paths = summarizeChanges(run.entries ?? [])
1048
+ .files.filter((f) => f.action !== ACTIONS.UNCHANGED && f.action !== ACTIONS.CONFLICT)
1049
+ .map((f) => displayPath(run.target, f.path))
1050
+ error(S.applyFailed(err instanceof Error ? err.message : String(err)))
1051
+ if (paths.length > 0) {
1052
+ warning(S.alreadyApplied)
1053
+ for (const p of paths) warning(` ${p}`)
1054
+ }
1055
+ warning(run.backupDir ? S.restoreRecoveryHint(run.backupDir) : S.restoreNoBackup)
1056
+ // Sweep only on the failure path (never a full walk on a clean apply).
1057
+ cleanupTempSiblings(run.target)
1058
+ }
1059
+ throw err
1060
+ }
1061
+ }
1062
+
1063
+ async function installPass(
1064
+ target,
1065
+ dryRun = false,
1066
+ clean = false,
1067
+ components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
1068
+ opts = {},
767
1069
  ) {
768
1070
  // Default target if not provided (global ~/.config/opencode)
769
1071
  if (!target) {
@@ -772,8 +1074,39 @@ export async function installOpenCode(
772
1074
 
773
1075
  const componentSet = new Set(components)
774
1076
  const stats = summary.opencode
1077
+ // Per-call baseline. `stats` is the module-level cumulative summary, so the
1078
+ // consistency guard must compare against THIS call's delta, not the total.
1079
+ const statsBefore = { created: stats.created, skipped: stats.skipped }
1080
+ // Number of planned changes, filled by a dry plan pass (used by the gate).
1081
+ let plannedChanges = 0
1082
+ // Phase 4: planned-target snapshots from the plan pass, used for drift checks.
1083
+ let plannedSnapshots = null
775
1084
  const S = strings()
776
1085
 
1086
+ // Beta 11 (Setup phase 1): a dry pass records every writer's intended change
1087
+ // so we can print the REAL per-file plan without writing anything. A recorder
1088
+ // ALWAYS runs now: a dry pass builds the preview/plan from it, and an apply
1089
+ // pass uses it to report which PATHS were already written if a writer throws
1090
+ // (Phase 5 failure semantics). The default is the module-level no-op recorder,
1091
+ // so an uninstrumented caller stays byte-identical.
1092
+ const ownRecorder = opts.recorder ? null : createRecorder()
1093
+ const preview = dryRun && ownRecorder ? ownRecorder : null
1094
+ let recorder = opts.recorder ?? ownRecorder.recorder
1095
+ // Test-only failure seam: throw after N recorded writes so the failure path is
1096
+ // deterministic. Never reachable from production callers (opts.deps is not a
1097
+ // CLI flag) and never applied to the internal dry plan pass.
1098
+ if (!dryRun && typeof opts.deps?.failAfter === 'number') {
1099
+ const base = recorder
1100
+ const limit = opts.deps.failAfter
1101
+ let applied = 0
1102
+ recorder = (change) => {
1103
+ base(change)
1104
+ if (++applied >= limit) throw new Error('injected failure (test-only failAfter seam)')
1105
+ }
1106
+ }
1107
+ // Track this apply so the wrapper can report the paths written on a failure.
1108
+ if (!dryRun) _lastRun = { target, entries: ownRecorder?.entries ?? [], backupDir: null }
1109
+
777
1110
  // V1/V2 dual-version awareness (Phase 3): the installer builds a unified
778
1111
  // config, then applies V2-native migration (migrateV1toV2) when
779
1112
  // --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
@@ -822,7 +1155,7 @@ export async function installOpenCode(
822
1155
 
823
1156
  if (!result.confirmed && !autoYes) {
824
1157
  info('Installation canceled.')
825
- return stats
1158
+ return { ...stats, applied: false, reason: 'canceled' }
826
1159
  }
827
1160
 
828
1161
  // Map target selection to actual path
@@ -873,7 +1206,108 @@ export async function installOpenCode(
873
1206
  const _subDir = isGlobal ? '' : '.opencode'
874
1207
  const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
875
1208
 
876
- configure({ dryRun })
1209
+ configure({ dryRun, quiet: opts.quiet === true })
1210
+
1211
+ // -----------------------------------------------------------------------
1212
+ // Plan pass + apply gate + drift check (Beta 11 Setup phases 3-4).
1213
+ // * the plan pass (a dry run — writes nothing) prints the plan and
1214
+ // snapshots every planned target;
1215
+ // * applying requires explicit authorization (Phase 3): a declined or
1216
+ // unauthorized run writes NOTHING;
1217
+ // * drift (Phase 4) aborts BEFORE any write if a planned target changed
1218
+ // between the plan pass and now.
1219
+ // A result with `applied: false` means the caller must not print success.
1220
+ // -----------------------------------------------------------------------
1221
+ const applyRequested = !dryRun
1222
+ if (applyRequested) {
1223
+ const savedStats = { ...stats }
1224
+ let planned
1225
+ try {
1226
+ // Use the LIVE selection: the wizard above may have changed componentSet.
1227
+ // An AUTHORIZED apply (`--yes`) is not a preview: compute the plan +
1228
+ // snapshots but stay silent. The gate path (no `--yes`) still prints the
1229
+ // plan for the review / decline preview.
1230
+ planned = await installOpenCode(target, true, clean, [...componentSet], {
1231
+ ...opts,
1232
+ planOnly: true,
1233
+ quiet: autoYes,
1234
+ quietPlan: autoYes,
1235
+ })
1236
+ } finally {
1237
+ Object.assign(stats, savedStats)
1238
+ // The plan pass left the UI in dry-run mode; restore this run's mode.
1239
+ configure({ dryRun, quiet: opts.quiet === true })
1240
+ }
1241
+ plannedSnapshots = planned?.snapshots ?? null
1242
+
1243
+ if (!autoYes) {
1244
+ const injected = opts.deps?.promptReview
1245
+ const canPrompt = typeof injected === 'function' || interactive
1246
+ if (canPrompt) {
1247
+ const { promptReview } = await import('./interactive.mjs')
1248
+ const ask = typeof injected === 'function' ? injected : promptReview
1249
+ const accepted = await ask(`Apply these ${planned?.planned ?? 0} changes?`, {
1250
+ default: false,
1251
+ })
1252
+ if (!accepted) {
1253
+ info(S.nothingApplied)
1254
+ return { ...stats, applied: false, reason: 'declined' }
1255
+ }
1256
+ } else {
1257
+ info(S.planOnlyNoYes)
1258
+ return { ...stats, applied: false, reason: 'not-authorized' }
1259
+ }
1260
+ }
1261
+
1262
+ // Test seam: mutate a target between the plan/authorization and the drift
1263
+ // check. Absent in production callers.
1264
+ if (typeof opts.deps?.betweenPlanAndApply === 'function') opts.deps.betweenPlanAndApply()
1265
+
1266
+ const drifted = detectSnapshotDrift(plannedSnapshots)
1267
+ if (drifted.length > 0) {
1268
+ warning(S.driftHeader)
1269
+ for (const p of drifted) warning(` ${displayPath(target, p)}`)
1270
+ warning(S.driftAbort)
1271
+ return {
1272
+ ...stats,
1273
+ applied: false,
1274
+ reason: 'drift',
1275
+ drift: drifted.map((p) => displayPath(target, p)),
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ // -----------------------------------------------------------------------
1281
+ // 0. Exact backup (Beta 11 Setup phase 2): preserve the CURRENT state of
1282
+ // every regular file this run can modify/remove INSIDE the config dir,
1283
+ // byte-for-byte, BEFORE the first modifying write. Bulk directories and
1284
+ // out-of-target global files are recorded in the manifest as
1285
+ // `notRevertible`. A dry run records the planned backup-manifest path only.
1286
+ // -----------------------------------------------------------------------
1287
+ const backupManifestPath = join(
1288
+ backupSetPath(target, nextBackupName(target, new Date())),
1289
+ 'manifest.json',
1290
+ )
1291
+ if (dryRun) {
1292
+ if (isRecording(recorder)) {
1293
+ recordChange(
1294
+ { path: backupManifestPath, action: ACTIONS.CREATE, bytes: 0, scored: false },
1295
+ recorder,
1296
+ )
1297
+ }
1298
+ } else {
1299
+ const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
1300
+ const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1301
+ const { dir: backupDir } = createBackupSet({
1302
+ configDir: target,
1303
+ entries: backupPlan.entries,
1304
+ notRevertible: backupPlan.notRevertible,
1305
+ packageVersion: pkgVersion,
1306
+ })
1307
+ if (_lastRun && _lastRun.target === target) _lastRun.backupDir = backupDir
1308
+ // Secret-safe: the path only. The exact wording is fixed (once per apply).
1309
+ console.warn(backupWarning(backupDir))
1310
+ }
877
1311
 
878
1312
  if (isGlobal) {
879
1313
  info(S.globalLayout)
@@ -898,7 +1332,7 @@ export async function installOpenCode(
898
1332
  rmSync(join(dstDir, f), { recursive: true, force: true })
899
1333
  }
900
1334
  }
901
- const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun)
1335
+ const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun, false, recorder)
902
1336
  stats.created += created
903
1337
  stats.skipped += skipped
904
1338
  agentStep(true)
@@ -912,7 +1346,9 @@ export async function installOpenCode(
912
1346
  const srcRouting = join(ROOT, 'src', 'routing.yml')
913
1347
  const dstRouting = join(target, 'routing.yml')
914
1348
  if (existsSync(srcRouting)) {
915
- const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun)
1349
+ const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun, {
1350
+ recorder,
1351
+ })
916
1352
  if (routingStatus === 'created') stats.created++
917
1353
  else stats.skipped++
918
1354
  }
@@ -939,6 +1375,7 @@ export async function installOpenCode(
939
1375
  target,
940
1376
  dryRun,
941
1377
  installSubDir,
1378
+ recorder,
942
1379
  )
943
1380
  stats.created += sCreated
944
1381
  stats.skipped += sSkipped
@@ -960,7 +1397,7 @@ export async function installOpenCode(
960
1397
  const dstAgentsMd = join(target, 'AGENTS.md')
961
1398
  if (existsSync(srcAgentsMd)) {
962
1399
  const content = readFileSync(srcAgentsMd, 'utf8')
963
- const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun)
1400
+ const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun, recorder)
964
1401
  if (status === 'skipped') stats.skipped++
965
1402
  else {
966
1403
  stats.created++
@@ -982,7 +1419,7 @@ export async function installOpenCode(
982
1419
  const installedAgents = join(target, agentPrefix)
983
1420
  const sourceAgents = join(ROOT, 'src', 'agents')
984
1421
  if (existsSync(installedAgents) && existsSync(sourceAgents)) {
985
- const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true)
1422
+ const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true, recorder)
986
1423
  stats.created += refreshed.created
987
1424
  stats.skipped += refreshed.skipped
988
1425
  }
@@ -997,7 +1434,7 @@ export async function installOpenCode(
997
1434
  const srcPrompts = join(ROOT, 'prompts')
998
1435
  const dstPrompts = join(target, 'prompts')
999
1436
  if (existsSync(srcPrompts)) {
1000
- const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean)
1437
+ const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean, null, recorder)
1001
1438
  stats.created += promptsResult.created
1002
1439
  stats.skipped += promptsResult.skipped
1003
1440
  }
@@ -1015,7 +1452,7 @@ export async function installOpenCode(
1015
1452
  const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
1016
1453
  const dstCmds = join(target, commandDir)
1017
1454
  if (existsSync(srcCmds)) {
1018
- const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'))
1455
+ const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'), recorder)
1019
1456
  stats.created += cmdResult.created
1020
1457
  stats.skipped += cmdResult.skipped
1021
1458
  }
@@ -1038,7 +1475,12 @@ export async function installOpenCode(
1038
1475
  const dstPluginDir = isGlobal
1039
1476
  ? join(target, 'plugins', 'pantheon-tui')
1040
1477
  : join(target, '.opencode', 'plugins', 'pantheon-tui')
1041
- const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean, version })
1478
+ const pluginStats = installPlugin(srcPluginDir, dstPluginDir, {
1479
+ dryRun,
1480
+ clean,
1481
+ version,
1482
+ recorder,
1483
+ })
1042
1484
  stats.created += pluginStats.created
1043
1485
  stats.skipped += pluginStats.skipped
1044
1486
  stats.errors += pluginStats.errors
@@ -1050,7 +1492,7 @@ export async function installOpenCode(
1050
1492
  if (componentSet.has('plugins')) {
1051
1493
  let tuiStatus
1052
1494
  try {
1053
- tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version })
1495
+ tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version, recorder })
1054
1496
  } catch (cause) {
1055
1497
  error(`TUI plugin setup failed: ${cause.message}`)
1056
1498
  stats.errors++
@@ -1069,18 +1511,7 @@ export async function installOpenCode(
1069
1511
  const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1070
1512
 
1071
1513
  // ── MCP server scripts ──
1072
- const mcpScripts = [
1073
- 'mcp_resources.py',
1074
- 'code_mode.py',
1075
- 'memory_mcp.py',
1076
- 'scrub-secrets.py',
1077
- '_pantheon_paths.py',
1078
- 'mcp_codemap_module.py',
1079
- 'mcp_persistence.py',
1080
- 'pantheon_vision.py',
1081
- 'eval_store.py',
1082
- 'themis_heuristic_scan.py',
1083
- ]
1514
+ const mcpScripts = MCP_SCRIPTS
1084
1515
  const srcScriptsDir = join(ROOT, 'scripts')
1085
1516
  // Canonical MCP server sources live in src/mcp/. Map them explicitly so the
1086
1517
  // sync always copies from the canonical source, never from the stale root
@@ -1108,7 +1539,7 @@ export async function installOpenCode(
1108
1539
  }
1109
1540
  const content = readFileSync(src, 'utf8')
1110
1541
  const dst = join(dstScriptsDir, script)
1111
- const status = writeIfChanged(dst, content, dryRun)
1542
+ const status = writeIfChanged(dst, content, dryRun, { recorder })
1112
1543
  if (status === 'created') {
1113
1544
  stats.created++
1114
1545
  // Make executable
@@ -1126,7 +1557,9 @@ export async function installOpenCode(
1126
1557
  const visionRequirements = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
1127
1558
  if (existsSync(visionRequirements)) {
1128
1559
  const destination = join(runtimeTarget, 'requirements-vision.txt')
1129
- const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun)
1560
+ const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun, {
1561
+ recorder,
1562
+ })
1130
1563
  if (status === 'created') stats.created++
1131
1564
  else stats.skipped++
1132
1565
  } else {
@@ -1138,7 +1571,17 @@ export async function installOpenCode(
1138
1571
  const srcCodeModeDir = join(ROOT, '.pantheon', 'code-mode')
1139
1572
  const dstCodeModeDir = join(runtimeTarget, '.pantheon', 'code-mode')
1140
1573
  if (existsSync(srcCodeModeDir)) {
1141
- const cmResult = syncDir(srcCodeModeDir, dstCodeModeDir, dryRun, clean)
1574
+ // manifest.json is owned by writeCodeModeManifest (below), not by the dir
1575
+ // sync. Excluding it here keeps exactly ONE writer/count for that path,
1576
+ // so the preview action and the install summary can never diverge.
1577
+ const cmResult = syncDir(
1578
+ srcCodeModeDir,
1579
+ dstCodeModeDir,
1580
+ dryRun,
1581
+ clean,
1582
+ (f) => f !== 'manifest.json',
1583
+ recorder,
1584
+ )
1142
1585
  stats.created += cmResult.created
1143
1586
  stats.skipped += cmResult.skipped
1144
1587
  } else {
@@ -1149,13 +1592,18 @@ export async function installOpenCode(
1149
1592
  warning(`Code-mode source not found: ${srcCodeModeDir} — creating empty ${dstCodeModeDir}`)
1150
1593
  if (!dryRun) mkdirSync(dstCodeModeDir, { recursive: true })
1151
1594
  stats.created++
1595
+ // The empty runtime dir is a real create counted by `stats.created`, so
1596
+ // record it too (or the preview under-reports on this fallback path).
1597
+ recordChange({ path: dstCodeModeDir, action: ACTIONS.CREATE, bytes: 0 }, recorder)
1152
1598
  }
1153
1599
 
1154
1600
  // B3-08: explicit installation mode — seed the manifest with SHA-256
1155
1601
  // digests so the server never executes a script implicitly.
1156
- const approvedScripts = writeCodeModeManifest(dstCodeModeDir, dryRun)
1157
- if (approvedScripts > 0) {
1158
- info(`Code-mode manifest: ${approvedScripts} script(s) approved`)
1602
+ const manifest = writeCodeModeManifest(srcCodeModeDir, dstCodeModeDir, dryRun, recorder)
1603
+ if (manifest.status === 'created') stats.created++
1604
+ else if (manifest.status === 'skipped') stats.skipped++
1605
+ if (manifest.approved > 0) {
1606
+ info(`Code-mode manifest: ${manifest.approved} script(s) approved`)
1159
1607
  }
1160
1608
 
1161
1609
  // ── tiers.json ──
@@ -1163,7 +1611,7 @@ export async function installOpenCode(
1163
1611
  const dstTiers = join(runtimeTarget, '.pantheon', 'tiers.json')
1164
1612
  if (existsSync(srcTiers)) {
1165
1613
  const content = readFileSync(srcTiers, 'utf8')
1166
- const status = writeIfChanged(dstTiers, content, dryRun)
1614
+ const status = writeIfChanged(dstTiers, content, dryRun, { recorder })
1167
1615
  if (status === 'created') stats.created++
1168
1616
  else stats.skipped++
1169
1617
  }
@@ -1177,7 +1625,7 @@ export async function installOpenCode(
1177
1625
  // -----------------------------------------------------------------------
1178
1626
  let runtimeHealthy = !componentSet.has('runtime')
1179
1627
  if (componentSet.has('runtime')) {
1180
- runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats)
1628
+ runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal, recorder }, stats)
1181
1629
  }
1182
1630
 
1183
1631
  // -----------------------------------------------------------------------
@@ -1625,12 +2073,13 @@ export async function installOpenCode(
1625
2073
  // Test seam: lets a test simulate a concurrent writer landing between the
1626
2074
  // initial config read and the pre-swap recheck. Absent in production.
1627
2075
  beforeRecheck: opts.deps?.beforeRecheck,
2076
+ recorder,
1628
2077
  })
1629
2078
  if (status === 'conflict') {
1630
2079
  // The user's config changed under the installer between the read and the
1631
2080
  // swap. Treating this as an ordinary skipped write would report a
1632
2081
  // successful install over a file we never actually updated — fail loudly
1633
- // and leave the target and its `.bak` byte-identical instead.
2082
+ // and leave the target byte-identical instead.
1634
2083
  throw new Error(
1635
2084
  `Installation aborted: ${targetConfigPath} changed on disk during install ` +
1636
2085
  '(it no longer matches the content that was read); refusing to overwrite it. ' +
@@ -1646,7 +2095,7 @@ export async function installOpenCode(
1646
2095
  if (componentSet.has('runtime')) {
1647
2096
  const currentVersion = detectVersion(target)
1648
2097
  if (currentVersion) {
1649
- const migration = runMigrations(target, currentVersion, { dryRun })
2098
+ const migration = runMigrations(target, currentVersion, { dryRun, recorder })
1650
2099
  if (migration.applied > 0) {
1651
2100
  success(`Applied ${migration.applied} migration(s)`)
1652
2101
  for (const msg of migration.messages) {
@@ -1656,6 +2105,56 @@ export async function installOpenCode(
1656
2105
  }
1657
2106
  }
1658
2107
 
2108
+ // -----------------------------------------------------------------------
2109
+ // 3.75 Preview summary (Beta 11 Setup phase 1): print the REAL per-file
2110
+ // change set this same pass would apply. Paths + action + byte size
2111
+ // ONLY — never file contents or hashes. Nothing was written.
2112
+ // -----------------------------------------------------------------------
2113
+ if (preview) {
2114
+ // Apply always writes the install-state file (terminal step); record the
2115
+ // planned state-only write here so the printed plan is complete. Unscored:
2116
+ // it is bookkeeping, not part of the install summary.
2117
+ recordChange(
2118
+ {
2119
+ path: join(target, '.pantheon', 'install-state.json'),
2120
+ action: hasState(target) ? ACTIONS.UPDATE : ACTIONS.CREATE,
2121
+ bytes: 0,
2122
+ scored: false,
2123
+ },
2124
+ recorder,
2125
+ )
2126
+ const plan = summarizeChanges(preview.entries)
2127
+ plannedChanges = plan.totals.create + plan.totals.update + plan.totals.remove
2128
+ // Phase 4: snapshot each planned target now, so the apply pass can detect
2129
+ // any change between plan and apply (drift) before the first write.
2130
+ plannedSnapshots = plan.files.map((f) => ({ path: f.path, ...snapshot(f.path) }))
2131
+ const statsDelta = {
2132
+ created: stats.created - statsBefore.created,
2133
+ skipped: stats.skipped - statsBefore.skipped,
2134
+ }
2135
+ const mismatch = planStatsMismatch(plan, statsDelta)
2136
+ if (mismatch) {
2137
+ // Development safety net: a writer that incremented THIS call's install
2138
+ // stats but never recorded a change would otherwise make the preview
2139
+ // silently incomplete. Surface it loudly instead of hiding it.
2140
+ warning(
2141
+ `Change-plan consistency mismatch: plan reports ${mismatch.changed} changed / ` +
2142
+ `${mismatch.unchanged} unchanged vs stats ${mismatch.recordedChanged}/${mismatch.recordedUnchanged}`,
2143
+ )
2144
+ }
2145
+ // Tag the plan like every other dry-run line so the output reads uniformly.
2146
+ // An internal snapshot pass on the apply path (`quietPlan`, e.g. an
2147
+ // authorized `--yes` run) computes the plan and snapshots but must NOT echo
2148
+ // it — a real apply is not a dry run.
2149
+ if (!opts.quietPlan) {
2150
+ console.log('')
2151
+ for (const line of formatChangePlan(target, plan).split('\n')) {
2152
+ console.log(tag(line))
2153
+ }
2154
+ console.log('')
2155
+ }
2156
+ }
2157
+
1659
2158
  // -----------------------------------------------------------------------
1660
2159
  // 4. (runtime setup moved to 2.11 — before the config write — so MCP
1661
2160
  // entries are only added when the Python runtime is actually healthy)
@@ -1695,7 +2194,7 @@ export async function installOpenCode(
1695
2194
  ` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
1696
2195
  )
1697
2196
  process.stdout.write('\n')
1698
- } else {
2197
+ } else if (!opts.planOnly) {
1699
2198
  printSummary(target, ['opencode'], stats)
1700
2199
  }
1701
2200
 
@@ -1757,4 +2256,17 @@ export async function installOpenCode(
1757
2256
  }
1758
2257
  writeState(target, state)
1759
2258
  }
2259
+
2260
+ // Beta 11 Setup phase 3: report whether this call APPLIED, so the caller can
2261
+ // avoid a success banner for a plan-only / unauthorized run. The dry pass also
2262
+ // returns the planned-target snapshots for the apply pass's drift check.
2263
+ return dryRun
2264
+ ? {
2265
+ ...stats,
2266
+ applied: false,
2267
+ reason: 'plan',
2268
+ planned: plannedChanges,
2269
+ snapshots: plannedSnapshots,
2270
+ }
2271
+ : { ...stats, applied: true, reason: 'applied' }
1760
2272
  }