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
@@ -17,11 +17,12 @@
17
17
  */
18
18
 
19
19
  import { createHash } from 'node:crypto'
20
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
20
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } 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 { backupWarning, createBackupSet, previewBackupSet } from './backup.mjs'
25
26
  import {
26
27
  bullet,
27
28
  colors,
@@ -29,10 +30,12 @@ import {
29
30
  error,
30
31
  info,
31
32
  printSummary,
33
+ printWorktreeGuidance,
32
34
  section,
33
35
  spinner,
34
36
  step,
35
37
  success,
38
+ tag,
36
39
  warning,
37
40
  } from './cli-ui.mjs'
38
41
  import {
@@ -40,9 +43,25 @@ import {
40
43
  migrateMcpEntrypointPaths,
41
44
  migrateV1toV2,
42
45
  } from './config-migration.mjs'
46
+ import { componentForInstalledPath, updateFileInventory } from './file-inventory.mjs'
43
47
  import { healthCheck } from './health-check.mjs'
44
48
  import { detectVersion, runMigrations } from './migrate.mjs'
45
49
  import { resolveOpenCodeVersion } from './opencode-version.mjs'
50
+ import {
51
+ ACTIONS,
52
+ createRecorder,
53
+ displayPath,
54
+ fingerprintOutput,
55
+ formatChangePlan,
56
+ isRecording,
57
+ planStatsMismatch,
58
+ recordChange,
59
+ recordRemovalTree,
60
+ removeReviewedTree,
61
+ snapshot,
62
+ summarizeChanges,
63
+ } from './plan.mjs'
64
+ import { findPlanDrift } from './plan-artifact.mjs'
46
65
  import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
47
66
  import {
48
67
  checkRuntimePrerequisites,
@@ -60,6 +79,7 @@ import { createInitialState, hasState, readState, setComponentState, writeState
60
79
  import { strings } from './strings.mjs'
61
80
 
62
81
  import { setupVenv, venvPythonPath } from './venv.mjs'
82
+ import { assertNoSymlinkAncestors, assertPathWithin, assertSafeRemoval } from './verify.mjs'
63
83
 
64
84
  const COMPONENT_NAMES = [
65
85
  'agents',
@@ -71,6 +91,170 @@ const COMPONENT_NAMES = [
71
91
  'runtime',
72
92
  ]
73
93
 
94
+ /**
95
+ * MCP server scripts the runtime component installs. Hoisted so the backup-set
96
+ * enumeration and the runtime writer share one source of truth.
97
+ */
98
+ const MCP_SCRIPTS = [
99
+ 'mcp_resources.py',
100
+ 'code_mode.py',
101
+ 'memory_mcp.py',
102
+ 'scrub-secrets.py',
103
+ '_pantheon_paths.py',
104
+ 'mcp_codemap_module.py',
105
+ 'mcp_persistence.py',
106
+ 'pantheon_vision.py',
107
+ 'eval_store.py',
108
+ 'themis_heuristic_scan.py',
109
+ ]
110
+
111
+ /** Recursively list regular files under `root`, relative to `root`. */
112
+ function walkFiles(root, base = root, out = []) {
113
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
114
+ const full = join(root, entry.name)
115
+ if (entry.isDirectory()) walkFiles(full, base, out)
116
+ else if (entry.isFile()) out.push(relative(base, full))
117
+ }
118
+ return out
119
+ }
120
+
121
+ /**
122
+ * Enumerate what the backup set must preserve, derived from the selected
123
+ * components. It returns:
124
+ * - `entries`: paths (relative to `target`) the run can CREATE/UPDATE/REMOVE.
125
+ * For every managed destination tree we take the UNION of the files that
126
+ * already exist there (so a `--clean`, a stale removal, or an
127
+ * instructions-only refresh is captured) and the source-derived destination
128
+ * paths (so a file that will be created is recorded as absent). The union is
129
+ * a superset of the plan's changes — unchanged files are backed up too.
130
+ * - `notRevertible`: paths the run may delete or write but whose byte-for-byte
131
+ * copy is not feasible or is out of the config dir (bulk `.venv` / plugin
132
+ * `node_modules`, the global `tui.json` copies and the global vision config).
133
+ * These are recorded explicitly in the manifest rather than silently ignored.
134
+ * It never reads file contents.
135
+ *
136
+ * @param {string} target
137
+ * @param {Set<string>} componentSet
138
+ * @param {string} version
139
+ * @param {boolean} isGlobal
140
+ * @returns {{ entries: string[], notRevertible: string[] }}
141
+ */
142
+ function collectInstallBackupEntries(target, componentSet, version, isGlobal) {
143
+ const entries = []
144
+ const notRevertible = []
145
+ const rel = (abs) => relative(target, abs)
146
+ const addFile = (abs) => entries.push(rel(abs))
147
+ // Existing files under an in-target tree (covers removals / clean / refresh).
148
+ // `skipDirNames` prunes bulk subdirectories (e.g. plugin `node_modules`) so the
149
+ // walk stays cheap while everything else — including legacy files a clean pass
150
+ // deletes — is captured.
151
+ const addExisting = (dir, skipDirNames = null) => {
152
+ if (!existsSync(dir)) return
153
+ const walk = (base) => {
154
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
155
+ if (skipDirNames && entry.isDirectory() && skipDirNames.has(entry.name)) continue
156
+ const full = join(base, entry.name)
157
+ if (entry.isDirectory()) walk(full)
158
+ else if (entry.isFile()) addFile(full)
159
+ }
160
+ }
161
+ walk(dir)
162
+ }
163
+ // Destination paths derived from a source tree (covers files to be created).
164
+ const addSource = (srcDir, dstDir, filter = null) => {
165
+ if (!existsSync(srcDir)) return
166
+ for (const r of walkFiles(srcDir)) {
167
+ if (filter && !filter(r)) continue
168
+ addFile(join(dstDir, r))
169
+ }
170
+ }
171
+
172
+ const baseDir = isGlobal ? target : join(target, '.opencode')
173
+ const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
174
+ const agentDir = join(target, agentPrefix)
175
+
176
+ // Always touched.
177
+ addFile(join(target, 'opencode.json'))
178
+ addFile(join(target, '.pantheon', 'install-state.json'))
179
+ addFile(join(baseDir, 'opencode-vision.json'))
180
+ // The active-preset writer (writeActivePreset(target, …)) lives at
181
+ // <target>/.pantheon, NOT under <baseDir>, for both project and global scope.
182
+ addFile(join(target, '.pantheon', 'active-preset.json'))
183
+ addFile(join(target, '.pantheon', 'active-preset.json.bak'))
184
+
185
+ // The agent prompt dir is refreshed (existing-only) by an instructions-only
186
+ // run too, so enumerate it whenever EITHER component can write it.
187
+ if (componentSet.has('agents') || componentSet.has('instructions')) {
188
+ addExisting(agentDir)
189
+ }
190
+ if (componentSet.has('agents')) {
191
+ addFile(join(target, 'routing.yml'))
192
+ addSource(
193
+ join(ROOT, 'src', 'agents'),
194
+ agentDir,
195
+ (r) => r.endsWith('.md') && r.toLowerCase() !== 'readme.md',
196
+ )
197
+ }
198
+ if (componentSet.has('skills')) {
199
+ addExisting(join(baseDir, 'skills'))
200
+ addSource(join(ROOT, 'src', 'skills'), join(baseDir, 'skills'))
201
+ }
202
+ if (componentSet.has('instructions')) addFile(join(target, 'AGENTS.md'))
203
+ if (componentSet.has('prompts')) {
204
+ addExisting(join(target, 'prompts'))
205
+ addSource(join(ROOT, 'prompts'), join(target, 'prompts'))
206
+ }
207
+ if (componentSet.has('commands')) {
208
+ const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
209
+ addExisting(join(target, commandDir))
210
+ addSource(join(ROOT, 'commands'), join(target, commandDir), (r) => r.endsWith('.md'))
211
+ }
212
+ if (componentSet.has('plugins')) {
213
+ const dstPluginDir = isGlobal
214
+ ? join(target, 'plugins', 'pantheon-tui')
215
+ : join(target, '.opencode', 'plugins', 'pantheon-tui')
216
+ // Existing files under the installed plugin dir (legacy `index.tsx` /
217
+ // `dist/tui.tsx` and any extras a `--clean` run deletes) are captured, like
218
+ // every sibling tree — EXCEPT the bulk `node_modules`, which is regenerated
219
+ // by `npm ci` and listed as not revertible.
220
+ addExisting(dstPluginDir, new Set(['node_modules']))
221
+ addSource(join(ROOT, 'src', 'plugins', 'tui', 'dist'), join(dstPluginDir, 'dist'))
222
+ addFile(join(dstPluginDir, 'package.json'))
223
+ addFile(join(dstPluginDir, 'package-lock.json'))
224
+ addFile(join(dstPluginDir, 'tui.ts'))
225
+ addFile(join(dstPluginDir, 'index.ts'))
226
+ notRevertible.push(rel(join(dstPluginDir, 'node_modules')))
227
+ if (version === 'v1') {
228
+ const projectTui = join(baseDir, 'tui.json')
229
+ addFile(projectTui)
230
+ // Cross-location cleanup touches copies OUTSIDE the config dir.
231
+ for (const loc of tuiConfigLocations(target)) {
232
+ if (resolve(loc) !== resolve(projectTui)) notRevertible.push(loc)
233
+ }
234
+ }
235
+ }
236
+ if (componentSet.has('runtime')) {
237
+ const runtimeTarget = isGlobal ? target : join(target, '.opencode')
238
+ addExisting(join(runtimeTarget, 'scripts'))
239
+ for (const script of MCP_SCRIPTS) addFile(join(runtimeTarget, 'scripts', script))
240
+ addFile(join(runtimeTarget, 'requirements-vision.txt'))
241
+ addSource(join(ROOT, '.pantheon', 'code-mode'), join(runtimeTarget, '.pantheon', 'code-mode'))
242
+ addExisting(join(runtimeTarget, '.pantheon', 'code-mode'))
243
+ addFile(join(runtimeTarget, '.pantheon', 'tiers.json'))
244
+ // `--force` recreates the venv from scratch: bulk, not copied.
245
+ notRevertible.push(rel(join(target, '.venv')))
246
+ }
247
+
248
+ // The vision config is written GLOBALLY (outside the config dir).
249
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
250
+ const globalVision = join(xdg, 'opencode', 'opencode-vision.json')
251
+ if (resolve(globalVision) !== resolve(join(baseDir, 'opencode-vision.json'))) {
252
+ notRevertible.push(globalVision)
253
+ }
254
+
255
+ return { entries, notRevertible }
256
+ }
257
+
74
258
  /**
75
259
  * The product defaults an install seeds into the user's `opencode.json`.
76
260
  *
@@ -210,30 +394,40 @@ function deepClone(value) {
210
394
 
211
395
  /**
212
396
  * (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.
397
+ * script, and write it to the destination. This is the explicit installation
398
+ * opt-in: the MCP server only executes scripts listed here whose on-disk hash
399
+ * matches. Missing or empty source approves nothing — execution is never
400
+ * implicit.
401
+ *
402
+ * This function is the SINGLE owner of `manifest.json`: `syncDir` deliberately
403
+ * excludes it (see the runtime block), so exactly one writer and one stats
404
+ * count exist for that path. The payload is derived from the SOURCE scripts
405
+ * (what will be installed), then compared against the CURRENT installed
406
+ * manifest, so the preview action matches what apply writes — including on a
407
+ * script upgrade, where the installed manifest is stale.
408
+ *
409
+ * @returns {{ approved: number, status: 'created'|'skipped'|'conflict'|null }}
216
410
  */
217
- function writeCodeModeManifest(dir, dryRun) {
218
- if (!existsSync(dir)) return 0
411
+ function writeCodeModeManifest(srcDir, dstDir, dryRun, recorder = null) {
412
+ if (!existsSync(srcDir)) return { approved: 0, status: null }
219
413
  let names
220
414
  try {
221
- names = readdirSync(dir)
415
+ names = readdirSync(srcDir)
222
416
  .filter((name) => /\.(sh|py)$/i.test(name) && !name.startsWith('.'))
223
417
  .sort()
224
418
  } catch {
225
- return 0
419
+ return { approved: 0, status: null }
226
420
  }
227
- if (names.length === 0) return 0
421
+ if (names.length === 0) return { approved: 0, status: null }
228
422
  const scripts = {}
229
423
  for (const name of names) {
230
424
  scripts[name] = createHash('sha256')
231
- .update(readFileSync(join(dir, name)))
425
+ .update(readFileSync(join(srcDir, name)))
232
426
  .digest('hex')
233
427
  }
234
428
  const payload = `${JSON.stringify({ version: 1, scripts }, null, 2)}\n`
235
- writeIfChanged(join(dir, 'manifest.json'), payload, dryRun)
236
- return names.length
429
+ const status = writeIfChanged(join(dstDir, 'manifest.json'), payload, dryRun, { recorder })
430
+ return { approved: names.length, status }
237
431
  }
238
432
 
239
433
  function readJsonConfig(filePath) {
@@ -594,11 +788,19 @@ export function tuiConfigLocations(target) {
594
788
  * @param {object} [opts]
595
789
  * @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
596
790
  * @param {boolean} [opts.dryRun=false]
791
+ * @param {(change: object) => void} [opts.recorder] - optional change recorder for preview
597
792
  * @returns {'created'|'skipped'} status for the target registration
598
793
  */
599
794
  export function syncTuiRegistration(
600
795
  target,
601
- { isGlobal = false, dryRun = false, version = 'v1' } = {},
796
+ {
797
+ isGlobal = false,
798
+ dryRun = false,
799
+ version = 'v1',
800
+ recorder = null,
801
+ reviewedRemovals = null,
802
+ reviewedSnapshots = null,
803
+ } = {},
602
804
  ) {
603
805
  const configDir = isGlobal ? target : join(target, '.opencode')
604
806
  const targetTuiConfigPath = join(configDir, 'tui.json')
@@ -608,7 +810,13 @@ export function syncTuiRegistration(
608
810
  const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
609
811
  const tuiCopyDir = resolveTuiCopyTarget(configDir)
610
812
  if (existsSync(tuiSrcDir)) {
611
- copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version })
813
+ copyPluginFiles(tuiSrcDir, tuiCopyDir, {
814
+ dryRun,
815
+ version,
816
+ recorder,
817
+ reviewedRemovals,
818
+ reviewedSnapshots,
819
+ })
612
820
  }
613
821
 
614
822
  // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
@@ -621,6 +829,7 @@ export function syncTuiRegistration(
621
829
  unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', {
622
830
  dryRun,
623
831
  knownPantheonPaths: [ownedCopy, tuiCopyDir],
832
+ recorder,
624
833
  })
625
834
  }
626
835
 
@@ -630,7 +839,7 @@ export function syncTuiRegistration(
630
839
  if (version === 'v2') return 'skipped'
631
840
 
632
841
  // 3. Register the copied directory in the selected target only.
633
- return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
842
+ return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun, recorder })
634
843
  }
635
844
 
636
845
  /**
@@ -639,13 +848,24 @@ export function syncTuiRegistration(
639
848
  * MCP entries from the config (entries pointing at a missing venv would
640
849
  * make every MCP fail to launch). `deps` is injectable for tests.
641
850
  */
642
- export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, deps = {}) {
851
+ export function setupRuntimePhase(
852
+ target,
853
+ { dryRun, clean, isGlobal, recorder = null, reviewedRemovals = null, reviewedSnapshots = null },
854
+ stats,
855
+ deps = {},
856
+ ) {
643
857
  const S = strings()
644
858
  const setup = deps.setupVenv ?? setupVenv
645
859
  const check = deps.healthCheck ?? healthCheck
646
860
  try {
647
861
  const venvSpinner = spinner(S.settingUpRuntime)
648
- setup(target, { dryRun, force: clean })
862
+ setup(target, {
863
+ dryRun,
864
+ force: clean,
865
+ recorder,
866
+ reviewedRemovals,
867
+ reviewedSnapshots,
868
+ })
649
869
  venvSpinner(true)
650
870
  const runtimeTarget = isGlobal ? target : join(target, '.opencode')
651
871
  const health = check(runtimeTarget, { dryRun, pythonTarget: target })
@@ -758,12 +978,80 @@ export function applyMcpServerEntries(config, { runtimeTarget, venvPython }) {
758
978
  return { healed }
759
979
  }
760
980
 
981
+ /**
982
+ * Phase 4 drift detection: the planned targets whose on-disk state changed
983
+ * between the plan pass (snapshots) and the apply pass. A path counts as drifted
984
+ * when its existence, content hash, size, or mode differs — which includes a
985
+ * planned-NEW path that now exists. Returns the absolute paths (empty = no
986
+ * drift); callers print only these paths, never contents or hashes.
987
+ */
988
+ function detectSnapshotDrift(snapshots) {
989
+ if (!Array.isArray(snapshots)) return []
990
+ const changed = []
991
+ for (const before of snapshots) {
992
+ const now = snapshot(before.path)
993
+ if (
994
+ now.exists !== before.exists ||
995
+ now.sha256 !== before.sha256 ||
996
+ now.size !== before.size ||
997
+ now.mode !== before.mode
998
+ ) {
999
+ changed.push(before.path)
1000
+ }
1001
+ }
1002
+ return changed
1003
+ }
1004
+
1005
+ /**
1006
+ * Phase 5 failure semantics. `installPass` is the whole installer; this thin
1007
+ * wrapper reports a mid-apply failure HONESTLY: it aborts the remaining writes
1008
+ * (the throw), relies on each writer to clean up only its own temporary file,
1009
+ * reports the PATHS already applied (never contents), prints the recovery
1010
+ * path, and re-throws so the CLI exits non-zero. There is NO automatic rollback — the
1011
+ * exact backup set + `pantheon-opencode restore` is the recovery.
1012
+ *
1013
+ * Progress is tracked in `_lastRun` (set only by a non-dry pass, so the nested
1014
+ * dry plan pass never clobbers it). The RESOLVED target is read back from
1015
+ * `_lastRun.target` so the report and the sweep work when the caller passed
1016
+ * `undefined` (the default global install resolves to `~/.config/opencode`).
1017
+ */
1018
+ let _lastRun = null
1019
+
761
1020
  export async function installOpenCode(
762
1021
  target,
763
1022
  dryRun = false,
764
1023
  clean = false,
765
1024
  components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
766
1025
  opts = {},
1026
+ ) {
1027
+ // Reset per-call so a stale run can never leak into a fresh failure report.
1028
+ if (!dryRun) _lastRun = null
1029
+ try {
1030
+ return await installPass(target, dryRun, clean, components, opts)
1031
+ } catch (err) {
1032
+ const run = dryRun ? null : _lastRun
1033
+ if (run) {
1034
+ const S = strings()
1035
+ const paths = summarizeChanges(run.entries ?? [])
1036
+ .files.filter((f) => f.action !== ACTIONS.UNCHANGED && f.action !== ACTIONS.CONFLICT)
1037
+ .map((f) => displayPath(run.target, f.path))
1038
+ error(S.applyFailed(err instanceof Error ? err.message : String(err)))
1039
+ if (paths.length > 0) {
1040
+ warning(S.alreadyApplied)
1041
+ for (const p of paths) warning(` ${p}`)
1042
+ }
1043
+ warning(run.backupDir ? S.restoreRecoveryHint(run.backupDir) : S.restoreNoBackup)
1044
+ }
1045
+ throw err
1046
+ }
1047
+ }
1048
+
1049
+ async function installPass(
1050
+ target,
1051
+ dryRun = false,
1052
+ clean = false,
1053
+ components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
1054
+ opts = {},
767
1055
  ) {
768
1056
  // Default target if not provided (global ~/.config/opencode)
769
1057
  if (!target) {
@@ -772,8 +1060,45 @@ export async function installOpenCode(
772
1060
 
773
1061
  const componentSet = new Set(components)
774
1062
  const stats = summary.opencode
1063
+ // Per-call baseline. `stats` is the module-level cumulative summary, so the
1064
+ // consistency guard must compare against THIS call's delta, not the total.
1065
+ const statsBefore = { created: stats.created, skipped: stats.skipped }
1066
+ // Number of planned changes, filled by a dry plan pass (used by the gate).
1067
+ let plannedChanges = 0
1068
+ let planFiles = []
1069
+ let removalPaths = []
1070
+ const planTimestamp = opts.planTimestamp ?? new Date().toISOString()
1071
+ let projectedState = null
1072
+ // Phase 4: planned-target snapshots from the plan pass, used for drift checks.
1073
+ let plannedSnapshots = null
1074
+ let reviewedRemovals = null
1075
+ let reviewedSnapshots = null
775
1076
  const S = strings()
776
1077
 
1078
+ // Beta 11 (Setup phase 1): a dry pass records every writer's intended change
1079
+ // so we can print the REAL per-file plan without writing anything. A recorder
1080
+ // ALWAYS runs now: a dry pass builds the preview/plan from it, and an apply
1081
+ // pass uses it to report which PATHS were already written if a writer throws
1082
+ // (Phase 5 failure semantics). The default is the module-level no-op recorder,
1083
+ // so an uninstrumented caller stays byte-identical.
1084
+ const ownRecorder = opts.recorder ? null : createRecorder()
1085
+ const preview = dryRun && ownRecorder ? ownRecorder : null
1086
+ let recorder = opts.recorder ?? ownRecorder.recorder
1087
+ // Test-only failure seam: throw after N recorded writes so the failure path is
1088
+ // deterministic. Never reachable from production callers (opts.deps is not a
1089
+ // CLI flag) and never applied to the internal dry plan pass.
1090
+ if (!dryRun && typeof opts.deps?.failAfter === 'number') {
1091
+ const base = recorder
1092
+ const limit = opts.deps.failAfter
1093
+ let applied = 0
1094
+ recorder = (change) => {
1095
+ base(change)
1096
+ if (++applied >= limit) throw new Error('injected failure (test-only failAfter seam)')
1097
+ }
1098
+ }
1099
+ // Track this apply so the wrapper can report the paths written on a failure.
1100
+ if (!dryRun) _lastRun = { target, entries: ownRecorder?.entries ?? [], backupDir: null }
1101
+
777
1102
  // V1/V2 dual-version awareness (Phase 3): the installer builds a unified
778
1103
  // config, then applies V2-native migration (migrateV1toV2) when
779
1104
  // --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
@@ -784,7 +1109,16 @@ export async function installOpenCode(
784
1109
  // the V1 branch writes .ts file paths that a 2.x host drops from the config.
785
1110
  // `opts` is forwarded as the resolver's options bag so a caller (and CI) can
786
1111
  // pin env/probe instead of spawning a real host binary.
787
- const version = resolveOpenCodeVersion(opts.version ?? 'auto', opts)
1112
+ const version = resolveOpenCodeVersion(opts.version ?? 'auto', {
1113
+ ...opts,
1114
+ skipProbe: dryRun,
1115
+ })
1116
+ const versionAssumed =
1117
+ dryRun &&
1118
+ (opts.version ?? 'auto') === 'auto' &&
1119
+ process.env.OPENCODE_VERSION !== 'v1' &&
1120
+ process.env.OPENCODE_VERSION !== 'v2' &&
1121
+ !/(?:^|[\\/])opencode2(?:\.exe)?$/i.test(process.env.OPENCODE_BIN ?? '')
788
1122
  if (version === 'v2') {
789
1123
  info(
790
1124
  'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
@@ -822,7 +1156,7 @@ export async function installOpenCode(
822
1156
 
823
1157
  if (!result.confirmed && !autoYes) {
824
1158
  info('Installation canceled.')
825
- return stats
1159
+ return { ...stats, applied: false, reason: 'canceled' }
826
1160
  }
827
1161
 
828
1162
  // Map target selection to actual path
@@ -869,11 +1203,165 @@ export async function installOpenCode(
869
1203
  // active config directory is the project root and its agent loader scans
870
1204
  // <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
871
1205
  // Global installs are flat for both versions.
872
- const isGlobal = isGlobalConfigDir(target)
1206
+ const isGlobal = opts.global ?? isGlobalConfigDir(target)
873
1207
  const _subDir = isGlobal ? '' : '.opencode'
874
1208
  const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
875
1209
 
876
- configure({ dryRun })
1210
+ configure({ dryRun, quiet: opts.quiet === true })
1211
+
1212
+ // -----------------------------------------------------------------------
1213
+ // Plan pass + apply gate + drift check (Beta 11 Setup phases 3-4).
1214
+ // * the plan pass (a dry run — writes nothing) prints the plan and
1215
+ // snapshots every planned target;
1216
+ // * applying requires explicit authorization (Phase 3): a declined or
1217
+ // unauthorized run writes NOTHING;
1218
+ // * drift (Phase 4) aborts BEFORE any write if a planned target changed
1219
+ // between the plan pass and now.
1220
+ // A result with `applied: false` means the caller must not print success.
1221
+ // -----------------------------------------------------------------------
1222
+ const applyRequested = !dryRun
1223
+ if (applyRequested) {
1224
+ const savedStats = { ...stats }
1225
+ let planned
1226
+ try {
1227
+ // Use the LIVE selection: the wizard above may have changed componentSet.
1228
+ // An AUTHORIZED apply (`--yes`) is not a preview: compute the plan +
1229
+ // snapshots but stay silent. The gate path (no `--yes`) still prints the
1230
+ // plan for the review / decline preview.
1231
+ planned = await installOpenCode(target, true, clean, [...componentSet], {
1232
+ ...opts,
1233
+ planTimestamp,
1234
+ planOnly: true,
1235
+ quiet: autoYes,
1236
+ quietPlan: autoYes,
1237
+ })
1238
+ } finally {
1239
+ Object.assign(stats, savedStats)
1240
+ // The plan pass left the UI in dry-run mode; restore this run's mode.
1241
+ configure({ dryRun, quiet: opts.quiet === true })
1242
+ }
1243
+ plannedSnapshots = planned?.snapshots ?? null
1244
+
1245
+ if (opts.expectedPlan) {
1246
+ const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1247
+ const drift = findPlanDrift(opts.expectedPlan, {
1248
+ target,
1249
+ packageRoot: ROOT,
1250
+ packageVersion,
1251
+ files: planned?.files ?? [],
1252
+ snapshots: planned?.snapshots ?? [],
1253
+ })
1254
+ if (drift.length > 0) {
1255
+ warning('Reviewed install plan drifted; no files were written.')
1256
+ for (const item of drift) warning(` ${item}`)
1257
+ return { ...stats, applied: false, reason: 'drift', drift }
1258
+ }
1259
+ }
1260
+
1261
+ if (!autoYes) {
1262
+ const injected = opts.deps?.promptReview
1263
+ const canPrompt = typeof injected === 'function' || interactive
1264
+ if (canPrompt) {
1265
+ const { promptReview } = await import('./interactive.mjs')
1266
+ const ask = typeof injected === 'function' ? injected : promptReview
1267
+ const accepted = await ask(`Apply these ${planned?.planned ?? 0} changes?`, {
1268
+ default: false,
1269
+ })
1270
+ if (!accepted) {
1271
+ info(S.nothingApplied)
1272
+ return { ...stats, applied: false, reason: 'declined' }
1273
+ }
1274
+ } else {
1275
+ info(S.planOnlyNoYes)
1276
+ return { ...stats, applied: false, reason: 'not-authorized' }
1277
+ }
1278
+ }
1279
+
1280
+ // Test seam: mutate a target between the plan/authorization and the drift
1281
+ // check. Absent in production callers.
1282
+ if (typeof opts.deps?.betweenPlanAndApply === 'function') opts.deps.betweenPlanAndApply()
1283
+
1284
+ const drifted = detectSnapshotDrift(plannedSnapshots)
1285
+ if (drifted.length > 0) {
1286
+ warning(S.driftHeader)
1287
+ for (const p of drifted) warning(` ${displayPath(target, p)}`)
1288
+ warning(S.driftAbort)
1289
+ return {
1290
+ ...stats,
1291
+ applied: false,
1292
+ reason: 'drift',
1293
+ drift: drifted.map((p) => displayPath(target, p)),
1294
+ }
1295
+ }
1296
+ reviewedRemovals = new Set((planned?.removals ?? []).map((filePath) => resolve(filePath)))
1297
+ reviewedSnapshots = new Map(
1298
+ (plannedSnapshots ?? []).map((before) => [resolve(before.path), before]),
1299
+ )
1300
+ }
1301
+
1302
+ // -----------------------------------------------------------------------
1303
+ // 0. Exact backup (Beta 11 Setup phase 2): preserve the CURRENT state of
1304
+ // every regular file this run can modify/remove INSIDE the config dir,
1305
+ // byte-for-byte, BEFORE the first modifying write. Bulk directories and
1306
+ // out-of-target global files are recorded in the manifest as
1307
+ // `notRevertible`. A dry run records the planned backup-manifest path only.
1308
+ // -----------------------------------------------------------------------
1309
+ if (componentSet.has('agents')) {
1310
+ const agentTarget = join(target, agentPrefix)
1311
+ assertPathWithin(target, agentTarget)
1312
+ assertNoSymlinkAncestors(agentTarget)
1313
+ }
1314
+ if (componentSet.has('skills')) {
1315
+ const skillsTarget = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
1316
+ assertPathWithin(target, skillsTarget)
1317
+ assertNoSymlinkAncestors(skillsTarget)
1318
+ }
1319
+ const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
1320
+ const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1321
+ if (dryRun) {
1322
+ const preview = previewBackupSet({
1323
+ configDir: target,
1324
+ entries: backupPlan.entries,
1325
+ notRevertible: backupPlan.notRevertible,
1326
+ packageVersion: pkgVersion,
1327
+ clock: () => new Date(planTimestamp),
1328
+ })
1329
+ if (isRecording(recorder)) {
1330
+ for (const file of preview.files) {
1331
+ recordChange(
1332
+ {
1333
+ path: file.path,
1334
+ action: ACTIONS.CREATE,
1335
+ bytes: file.bytes,
1336
+ outputFingerprint: file.outputFingerprint,
1337
+ scored: false,
1338
+ },
1339
+ recorder,
1340
+ )
1341
+ }
1342
+ recordChange(
1343
+ {
1344
+ path: preview.manifestPath,
1345
+ action: ACTIONS.CREATE,
1346
+ bytes: preview.manifestBytes,
1347
+ outputFingerprint: preview.manifestFingerprint,
1348
+ scored: false,
1349
+ },
1350
+ recorder,
1351
+ )
1352
+ }
1353
+ } else {
1354
+ const { dir: backupDir } = createBackupSet({
1355
+ configDir: target,
1356
+ entries: backupPlan.entries,
1357
+ notRevertible: backupPlan.notRevertible,
1358
+ packageVersion: pkgVersion,
1359
+ clock: () => new Date(planTimestamp),
1360
+ })
1361
+ if (_lastRun && _lastRun.target === target) _lastRun.backupDir = backupDir
1362
+ // Secret-safe: the path only. The exact wording is fixed (once per apply).
1363
+ console.warn(backupWarning(backupDir))
1364
+ }
877
1365
 
878
1366
  if (isGlobal) {
879
1367
  info(S.globalLayout)
@@ -891,14 +1379,26 @@ export async function installOpenCode(
891
1379
  } else {
892
1380
  const agentStep = step(S.installingAgents)
893
1381
  const dstDir = join(target, agentPrefix)
894
- if (!dryRun) mkdirSync(dstDir, { recursive: true })
895
- if (clean && existsSync(dstDir) && !dryRun) {
1382
+ assertPathWithin(target, dstDir)
1383
+ assertNoSymlinkAncestors(dstDir)
1384
+ if (!dryRun) {
1385
+ mkdirSync(dstDir, { recursive: true })
1386
+ assertNoSymlinkAncestors(dstDir)
1387
+ }
1388
+ if (clean && existsSync(dstDir)) {
1389
+ assertNoSymlinkAncestors(dstDir)
896
1390
  const existing = readdirSync(dstDir)
897
1391
  for (const f of existing) {
898
- rmSync(join(dstDir, f), { recursive: true, force: true })
1392
+ const agentPath = join(dstDir, f)
1393
+ assertSafeRemoval(dstDir, agentPath)
1394
+ if (dryRun) {
1395
+ recordRemovalTree(agentPath, recorder)
1396
+ } else {
1397
+ removeReviewedTree(agentPath, reviewedRemovals, reviewedSnapshots)
1398
+ }
899
1399
  }
900
1400
  }
901
- const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun)
1401
+ const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun, false, recorder)
902
1402
  stats.created += created
903
1403
  stats.skipped += skipped
904
1404
  agentStep(true)
@@ -912,7 +1412,9 @@ export async function installOpenCode(
912
1412
  const srcRouting = join(ROOT, 'src', 'routing.yml')
913
1413
  const dstRouting = join(target, 'routing.yml')
914
1414
  if (existsSync(srcRouting)) {
915
- const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun)
1415
+ const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun, {
1416
+ recorder,
1417
+ })
916
1418
  if (routingStatus === 'created') stats.created++
917
1419
  else stats.skipped++
918
1420
  }
@@ -927,10 +1429,19 @@ export async function installOpenCode(
927
1429
  if (skillNames.length > 0) {
928
1430
  const skillStep = step(`Installing ${skillNames.length} skills`)
929
1431
  const dstSkillsDir = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
930
- if (clean && existsSync(dstSkillsDir) && !dryRun) {
1432
+ assertPathWithin(target, dstSkillsDir)
1433
+ assertNoSymlinkAncestors(dstSkillsDir)
1434
+ if (clean && existsSync(dstSkillsDir)) {
1435
+ assertNoSymlinkAncestors(dstSkillsDir)
931
1436
  const existing = readdirSync(dstSkillsDir)
932
1437
  for (const s of existing) {
933
- rmSync(join(dstSkillsDir, s), { recursive: true, force: true })
1438
+ const skillPath = join(dstSkillsDir, s)
1439
+ assertSafeRemoval(dstSkillsDir, skillPath)
1440
+ if (dryRun) {
1441
+ recordRemovalTree(skillPath, recorder)
1442
+ } else {
1443
+ removeReviewedTree(skillPath, reviewedRemovals, reviewedSnapshots)
1444
+ }
934
1445
  }
935
1446
  }
936
1447
  const installSubDir = isGlobal ? '' : '.opencode'
@@ -939,6 +1450,10 @@ export async function installOpenCode(
939
1450
  target,
940
1451
  dryRun,
941
1452
  installSubDir,
1453
+ recorder,
1454
+ reviewedRemovals,
1455
+ reviewedSnapshots,
1456
+ clean,
942
1457
  )
943
1458
  stats.created += sCreated
944
1459
  stats.skipped += sSkipped
@@ -960,7 +1475,7 @@ export async function installOpenCode(
960
1475
  const dstAgentsMd = join(target, 'AGENTS.md')
961
1476
  if (existsSync(srcAgentsMd)) {
962
1477
  const content = readFileSync(srcAgentsMd, 'utf8')
963
- const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun)
1478
+ const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun, recorder)
964
1479
  if (status === 'skipped') stats.skipped++
965
1480
  else {
966
1481
  stats.created++
@@ -982,7 +1497,7 @@ export async function installOpenCode(
982
1497
  const installedAgents = join(target, agentPrefix)
983
1498
  const sourceAgents = join(ROOT, 'src', 'agents')
984
1499
  if (existsSync(installedAgents) && existsSync(sourceAgents)) {
985
- const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true)
1500
+ const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true, recorder)
986
1501
  stats.created += refreshed.created
987
1502
  stats.skipped += refreshed.skipped
988
1503
  }
@@ -997,7 +1512,16 @@ export async function installOpenCode(
997
1512
  const srcPrompts = join(ROOT, 'prompts')
998
1513
  const dstPrompts = join(target, 'prompts')
999
1514
  if (existsSync(srcPrompts)) {
1000
- const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean)
1515
+ const promptsResult = syncDir(
1516
+ srcPrompts,
1517
+ dstPrompts,
1518
+ dryRun,
1519
+ clean,
1520
+ null,
1521
+ recorder,
1522
+ reviewedRemovals,
1523
+ reviewedSnapshots,
1524
+ )
1001
1525
  stats.created += promptsResult.created
1002
1526
  stats.skipped += promptsResult.skipped
1003
1527
  }
@@ -1015,7 +1539,16 @@ export async function installOpenCode(
1015
1539
  const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
1016
1540
  const dstCmds = join(target, commandDir)
1017
1541
  if (existsSync(srcCmds)) {
1018
- const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'))
1542
+ const cmdResult = syncDir(
1543
+ srcCmds,
1544
+ dstCmds,
1545
+ dryRun,
1546
+ clean,
1547
+ (f) => f.endsWith('.md'),
1548
+ recorder,
1549
+ reviewedRemovals,
1550
+ reviewedSnapshots,
1551
+ )
1019
1552
  stats.created += cmdResult.created
1020
1553
  stats.skipped += cmdResult.skipped
1021
1554
  }
@@ -1038,7 +1571,14 @@ export async function installOpenCode(
1038
1571
  const dstPluginDir = isGlobal
1039
1572
  ? join(target, 'plugins', 'pantheon-tui')
1040
1573
  : join(target, '.opencode', 'plugins', 'pantheon-tui')
1041
- const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean, version })
1574
+ const pluginStats = installPlugin(srcPluginDir, dstPluginDir, {
1575
+ dryRun,
1576
+ clean,
1577
+ version,
1578
+ recorder,
1579
+ reviewedRemovals,
1580
+ reviewedSnapshots,
1581
+ })
1042
1582
  stats.created += pluginStats.created
1043
1583
  stats.skipped += pluginStats.skipped
1044
1584
  stats.errors += pluginStats.errors
@@ -1050,7 +1590,14 @@ export async function installOpenCode(
1050
1590
  if (componentSet.has('plugins')) {
1051
1591
  let tuiStatus
1052
1592
  try {
1053
- tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version })
1593
+ tuiStatus = syncTuiRegistration(target, {
1594
+ isGlobal,
1595
+ dryRun,
1596
+ version,
1597
+ recorder,
1598
+ reviewedRemovals,
1599
+ reviewedSnapshots,
1600
+ })
1054
1601
  } catch (cause) {
1055
1602
  error(`TUI plugin setup failed: ${cause.message}`)
1056
1603
  stats.errors++
@@ -1069,18 +1616,7 @@ export async function installOpenCode(
1069
1616
  const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1070
1617
 
1071
1618
  // ── 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
- ]
1619
+ const mcpScripts = MCP_SCRIPTS
1084
1620
  const srcScriptsDir = join(ROOT, 'scripts')
1085
1621
  // Canonical MCP server sources live in src/mcp/. Map them explicitly so the
1086
1622
  // sync always copies from the canonical source, never from the stale root
@@ -1108,7 +1644,7 @@ export async function installOpenCode(
1108
1644
  }
1109
1645
  const content = readFileSync(src, 'utf8')
1110
1646
  const dst = join(dstScriptsDir, script)
1111
- const status = writeIfChanged(dst, content, dryRun)
1647
+ const status = writeIfChanged(dst, content, dryRun, { recorder })
1112
1648
  if (status === 'created') {
1113
1649
  stats.created++
1114
1650
  // Make executable
@@ -1126,7 +1662,9 @@ export async function installOpenCode(
1126
1662
  const visionRequirements = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
1127
1663
  if (existsSync(visionRequirements)) {
1128
1664
  const destination = join(runtimeTarget, 'requirements-vision.txt')
1129
- const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun)
1665
+ const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun, {
1666
+ recorder,
1667
+ })
1130
1668
  if (status === 'created') stats.created++
1131
1669
  else stats.skipped++
1132
1670
  } else {
@@ -1138,7 +1676,19 @@ export async function installOpenCode(
1138
1676
  const srcCodeModeDir = join(ROOT, '.pantheon', 'code-mode')
1139
1677
  const dstCodeModeDir = join(runtimeTarget, '.pantheon', 'code-mode')
1140
1678
  if (existsSync(srcCodeModeDir)) {
1141
- const cmResult = syncDir(srcCodeModeDir, dstCodeModeDir, dryRun, clean)
1679
+ // manifest.json is owned by writeCodeModeManifest (below), not by the dir
1680
+ // sync. Excluding it here keeps exactly ONE writer/count for that path,
1681
+ // so the preview action and the install summary can never diverge.
1682
+ const cmResult = syncDir(
1683
+ srcCodeModeDir,
1684
+ dstCodeModeDir,
1685
+ dryRun,
1686
+ clean,
1687
+ (f) => f !== 'manifest.json',
1688
+ recorder,
1689
+ reviewedRemovals,
1690
+ reviewedSnapshots,
1691
+ )
1142
1692
  stats.created += cmResult.created
1143
1693
  stats.skipped += cmResult.skipped
1144
1694
  } else {
@@ -1149,13 +1699,18 @@ export async function installOpenCode(
1149
1699
  warning(`Code-mode source not found: ${srcCodeModeDir} — creating empty ${dstCodeModeDir}`)
1150
1700
  if (!dryRun) mkdirSync(dstCodeModeDir, { recursive: true })
1151
1701
  stats.created++
1702
+ // The empty runtime dir is a real create counted by `stats.created`, so
1703
+ // record it too (or the preview under-reports on this fallback path).
1704
+ recordChange({ path: dstCodeModeDir, action: ACTIONS.CREATE, bytes: 0 }, recorder)
1152
1705
  }
1153
1706
 
1154
1707
  // B3-08: explicit installation mode — seed the manifest with SHA-256
1155
1708
  // 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`)
1709
+ const manifest = writeCodeModeManifest(srcCodeModeDir, dstCodeModeDir, dryRun, recorder)
1710
+ if (manifest.status === 'created') stats.created++
1711
+ else if (manifest.status === 'skipped') stats.skipped++
1712
+ if (manifest.approved > 0) {
1713
+ info(`Code-mode manifest: ${manifest.approved} script(s) approved`)
1159
1714
  }
1160
1715
 
1161
1716
  // ── tiers.json ──
@@ -1163,7 +1718,7 @@ export async function installOpenCode(
1163
1718
  const dstTiers = join(runtimeTarget, '.pantheon', 'tiers.json')
1164
1719
  if (existsSync(srcTiers)) {
1165
1720
  const content = readFileSync(srcTiers, 'utf8')
1166
- const status = writeIfChanged(dstTiers, content, dryRun)
1721
+ const status = writeIfChanged(dstTiers, content, dryRun, { recorder })
1167
1722
  if (status === 'created') stats.created++
1168
1723
  else stats.skipped++
1169
1724
  }
@@ -1177,7 +1732,11 @@ export async function installOpenCode(
1177
1732
  // -----------------------------------------------------------------------
1178
1733
  let runtimeHealthy = !componentSet.has('runtime')
1179
1734
  if (componentSet.has('runtime')) {
1180
- runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats)
1735
+ runtimeHealthy = setupRuntimePhase(
1736
+ target,
1737
+ { dryRun, clean, isGlobal, recorder, reviewedRemovals, reviewedSnapshots },
1738
+ stats,
1739
+ )
1181
1740
  }
1182
1741
 
1183
1742
  // -----------------------------------------------------------------------
@@ -1625,12 +2184,13 @@ export async function installOpenCode(
1625
2184
  // Test seam: lets a test simulate a concurrent writer landing between the
1626
2185
  // initial config read and the pre-swap recheck. Absent in production.
1627
2186
  beforeRecheck: opts.deps?.beforeRecheck,
2187
+ recorder,
1628
2188
  })
1629
2189
  if (status === 'conflict') {
1630
2190
  // The user's config changed under the installer between the read and the
1631
2191
  // swap. Treating this as an ordinary skipped write would report a
1632
2192
  // successful install over a file we never actually updated — fail loudly
1633
- // and leave the target and its `.bak` byte-identical instead.
2193
+ // and leave the target byte-identical instead.
1634
2194
  throw new Error(
1635
2195
  `Installation aborted: ${targetConfigPath} changed on disk during install ` +
1636
2196
  '(it no longer matches the content that was read); refusing to overwrite it. ' +
@@ -1646,7 +2206,12 @@ export async function installOpenCode(
1646
2206
  if (componentSet.has('runtime')) {
1647
2207
  const currentVersion = detectVersion(target)
1648
2208
  if (currentVersion) {
1649
- const migration = runMigrations(target, currentVersion, { dryRun })
2209
+ const migration = runMigrations(target, currentVersion, {
2210
+ dryRun,
2211
+ recorder,
2212
+ updatedAt: planTimestamp,
2213
+ })
2214
+ if (migration.plannedState) projectedState = migration.plannedState
1650
2215
  if (migration.applied > 0) {
1651
2216
  success(`Applied ${migration.applied} migration(s)`)
1652
2217
  for (const msg of migration.messages) {
@@ -1656,6 +2221,100 @@ export async function installOpenCode(
1656
2221
  }
1657
2222
  }
1658
2223
 
2224
+ if (opts.preset && dryRun) {
2225
+ const { writeActivePreset } = await import('./model-picker.mjs')
2226
+ writeActivePreset(target, opts.preset, {
2227
+ dryRun: true,
2228
+ source: 'cli',
2229
+ updatedAt: planTimestamp,
2230
+ recorder,
2231
+ logger: { log() {} },
2232
+ })
2233
+ }
2234
+
2235
+ // -----------------------------------------------------------------------
2236
+ // 3.75 Preview summary (Beta 11 Setup phase 1): print the REAL per-file
2237
+ // change set this same pass would apply. Paths + action + byte size
2238
+ // ONLY — never file contents or hashes. Nothing was written.
2239
+ // -----------------------------------------------------------------------
2240
+ if (preview) {
2241
+ // Project the terminal state write using the plan's stable timestamp so
2242
+ // saved-plan apply can validate its exact output fingerprint.
2243
+ const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
2244
+ let state = projectedState ?? readState(target)
2245
+ if (!state) state = createInitialState(packageVersion, planTimestamp)
2246
+ state.pantheon_version = packageVersion
2247
+ state.updated_at = planTimestamp
2248
+ state.schema_version = 2
2249
+ for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
2250
+ if (!componentSet.has(component)) continue
2251
+ const componentStatus = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
2252
+ const componentVersion = state.components?.[component]?.version ?? 1
2253
+ setComponentState(state, component, componentVersion, componentStatus)
2254
+ }
2255
+ const stateContent = `${JSON.stringify(state, null, 2)}\n`
2256
+ recordChange(
2257
+ {
2258
+ path: join(target, '.pantheon', 'install-state.json'),
2259
+ action: hasState(target) ? ACTIONS.UPDATE : ACTIONS.CREATE,
2260
+ bytes: Buffer.byteLength(stateContent, 'utf8'),
2261
+ outputFingerprint: fingerprintOutput(stateContent),
2262
+ scored: false,
2263
+ },
2264
+ recorder,
2265
+ )
2266
+ const plan = summarizeChanges(preview.entries)
2267
+ planFiles = plan.files
2268
+ plannedChanges = plan.totals.create + plan.totals.update + plan.totals.remove
2269
+ removalPaths = [
2270
+ ...new Set(
2271
+ preview.entries
2272
+ .filter((entry) => entry.action === ACTIONS.REMOVE)
2273
+ .map((entry) => resolve(entry.path)),
2274
+ ),
2275
+ ].sort()
2276
+ // Snapshot both net changes and every raw removal target. A later write can
2277
+ // make a removal path look unchanged in the net plan, but it still needs
2278
+ // review before the clean operation deletes and recreates it.
2279
+ const snapshotPaths = new Set([...plan.files.map((file) => file.path), ...removalPaths])
2280
+ plannedSnapshots = [...snapshotPaths].map((filePath) => ({
2281
+ path: filePath,
2282
+ ...snapshot(filePath),
2283
+ }))
2284
+ const statsDelta = {
2285
+ created: stats.created - statsBefore.created,
2286
+ skipped: stats.skipped - statsBefore.skipped,
2287
+ }
2288
+ const mismatch = planStatsMismatch(plan, statsDelta)
2289
+ if (mismatch) {
2290
+ // Development safety net: a writer that incremented THIS call's install
2291
+ // stats but never recorded a change would otherwise make the preview
2292
+ // silently incomplete. Surface it loudly instead of hiding it.
2293
+ warning(
2294
+ `Change-plan consistency mismatch: plan reports ${mismatch.changed} changed / ` +
2295
+ `${mismatch.unchanged} unchanged vs stats ${mismatch.recordedChanged}/${mismatch.recordedUnchanged}`,
2296
+ )
2297
+ }
2298
+ // Tag the plan like every other dry-run line so the output reads uniformly.
2299
+ // An internal snapshot pass on the apply path (`quietPlan`, e.g. an
2300
+ // authorized `--yes` run) computes the plan and snapshots but must NOT echo
2301
+ // it — a real apply is not a dry run.
2302
+ if (!opts.quietPlan) {
2303
+ console.log('')
2304
+ if (removalPaths.length > 0) {
2305
+ console.log(tag(`Removal review — ${removalPaths.length} path(s):`))
2306
+ for (const filePath of removalPaths) {
2307
+ console.log(tag(` remove ${displayPath(target, filePath)}`))
2308
+ }
2309
+ console.log('')
2310
+ }
2311
+ for (const line of formatChangePlan(target, plan).split('\n')) {
2312
+ console.log(tag(line))
2313
+ }
2314
+ console.log('')
2315
+ }
2316
+ }
2317
+
1659
2318
  // -----------------------------------------------------------------------
1660
2319
  // 4. (runtime setup moved to 2.11 — before the config write — so MCP
1661
2320
  // entries are only added when the Python runtime is actually healthy)
@@ -1695,7 +2354,8 @@ export async function installOpenCode(
1695
2354
  ` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
1696
2355
  )
1697
2356
  process.stdout.write('\n')
1698
- } else {
2357
+ printWorktreeGuidance({ projectRoot: process.cwd(), interactive: true })
2358
+ } else if (!opts.planOnly) {
1699
2359
  printSummary(target, ['opencode'], stats)
1700
2360
  }
1701
2361
 
@@ -1712,7 +2372,7 @@ export async function installOpenCode(
1712
2372
 
1713
2373
  if (opts.preset && !dryRun) {
1714
2374
  const { writeActivePreset } = await import('./model-picker.mjs')
1715
- writeActivePreset(target, opts.preset, { source: 'cli' })
2375
+ writeActivePreset(target, opts.preset, { source: 'cli', updatedAt: planTimestamp, recorder })
1716
2376
  }
1717
2377
 
1718
2378
  // -----------------------------------------------------------------------
@@ -1743,18 +2403,56 @@ export async function installOpenCode(
1743
2403
  // to `doctor` forever. Unselected components retain their previous state.
1744
2404
  if (!dryRun) {
1745
2405
  const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1746
- let state = readState(target)
2406
+ let state = projectedState ?? readState(target)
1747
2407
  if (!state && hasState(target)) {
1748
2408
  throw new Error('Installation state exists but cannot be read; refusing to replace it')
1749
2409
  }
1750
- state ??= createInitialState(packageVersion)
2410
+ state ??= createInitialState(packageVersion, planTimestamp)
1751
2411
  state.pantheon_version = packageVersion
2412
+ state.updated_at = planTimestamp
1752
2413
  for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
1753
2414
  if (!componentSet.has(component)) continue
1754
2415
  const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
1755
2416
  const componentVersion = state.components?.[component]?.version ?? 1
1756
2417
  setComponentState(state, component, componentVersion, status)
1757
2418
  }
1758
- writeState(target, state)
1759
- }
2419
+ const verifiedFiles = summarizeChanges(ownRecorder?.entries ?? []).files.flatMap((file) => {
2420
+ if (!['create', 'update', 'unchanged'].includes(file.action) || !file.outputFingerprint)
2421
+ return []
2422
+ const owner = componentForInstalledPath(target, file.path, { isGlobal, version })
2423
+ if (!owner) return []
2424
+ return [
2425
+ {
2426
+ path: file.path,
2427
+ sha256: file.outputFingerprint,
2428
+ component: owner.component,
2429
+ layout: owner.layout,
2430
+ action: file.action,
2431
+ },
2432
+ ]
2433
+ })
2434
+ updateFileInventory(state, target, {
2435
+ selectedComponents: [...componentSet],
2436
+ verifiedFiles,
2437
+ })
2438
+ writeState(target, state, { updatedAt: planTimestamp })
2439
+ }
2440
+
2441
+ // Beta 11 Setup phase 3: report whether this call APPLIED, so the caller can
2442
+ // avoid a success banner for a plan-only / unauthorized run. The dry pass also
2443
+ // returns the planned-target snapshots for the apply pass's drift check.
2444
+ return dryRun
2445
+ ? {
2446
+ ...stats,
2447
+ applied: false,
2448
+ reason: 'plan',
2449
+ planned: plannedChanges,
2450
+ snapshots: plannedSnapshots,
2451
+ files: planFiles,
2452
+ removals: removalPaths,
2453
+ target,
2454
+ openCodeVersion: version,
2455
+ versionAssumed,
2456
+ }
2457
+ : { ...stats, applied: true, reason: 'applied' }
1760
2458
  }