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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/AGENTS.global.md +3 -3
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +85 -31
  5. package/README.pt-BR.md +7 -4
  6. package/bin/pantheon-init.mjs +278 -102
  7. package/package.json +5 -3
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +77 -13
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +191 -18
  14. package/scripts/install/cli-ui.mjs +73 -0
  15. package/scripts/install/file-inventory.mjs +555 -0
  16. package/scripts/install/interactive.mjs +69 -391
  17. package/scripts/install/migrate.mjs +45 -38
  18. package/scripts/install/model-picker.mjs +63 -22
  19. package/scripts/install/opencode-version.mjs +9 -1
  20. package/scripts/install/opencode.mjs +279 -79
  21. package/scripts/install/plan-artifact.mjs +517 -0
  22. package/scripts/install/plan.mjs +165 -3
  23. package/scripts/install/plugin.mjs +118 -14
  24. package/scripts/install/prompt-result.mjs +2 -0
  25. package/scripts/install/shared.mjs +188 -65
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/venv.mjs +15 -6
  28. package/scripts/install/verify.mjs +128 -0
  29. package/scripts/install/vision-prompt.mjs +2 -0
  30. package/scripts/install-mcp.mjs +1 -1
  31. package/scripts/manifest-inventory.mjs +49 -3
  32. package/scripts/node-engine-policy.mjs +85 -0
  33. package/scripts/package-evidence.mjs +6 -0
  34. package/scripts/postinstall.mjs +26 -26
  35. package/scripts/reconcile.mjs +325 -0
  36. package/scripts/sync-tui.mjs +5 -1
  37. package/scripts/uninstall.mjs +258 -34
  38. package/scripts/update.mjs +164 -0
  39. package/scripts/validate-node-engine-policy.mjs +13 -0
  40. package/scripts/version-check.mjs +34 -1
  41. package/scripts/versioning.mjs +9 -1
  42. package/skills-lock.json +3 -3
  43. package/src/agents/mnemosyne.md +7 -2
  44. package/src/instructions/memory-protocol.instructions.md +2 -2
  45. package/src/instructions/zeus-memory-operations.instructions.md +4 -3
  46. package/src/pantheon/v2-unsupported.mjs +2 -3
  47. package/src/plugins/tui/dist/tui-v2.js +2 -2
  48. package/src/plugins/tui/dist/tui.js +1 -1
  49. package/src/plugins/tui/package-lock.json +3 -3
  50. package/src/plugins/tui/package.json +2 -2
  51. package/src/skills/artifact-management/SKILL.md +5 -1
  52. package/src/skills/memory-bank/SKILL.md +5 -1
  53. package/src/skills/worktrees/SKILL.md +30 -11
@@ -17,12 +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
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
+ import { backupWarning, createBackupSet, previewBackupSet } from './backup.mjs'
26
26
  import {
27
27
  bullet,
28
28
  colors,
@@ -30,6 +30,7 @@ import {
30
30
  error,
31
31
  info,
32
32
  printSummary,
33
+ printWorktreeGuidance,
33
34
  section,
34
35
  spinner,
35
36
  step,
@@ -42,6 +43,7 @@ import {
42
43
  migrateMcpEntrypointPaths,
43
44
  migrateV1toV2,
44
45
  } from './config-migration.mjs'
46
+ import { componentForInstalledPath, updateFileInventory } from './file-inventory.mjs'
45
47
  import { healthCheck } from './health-check.mjs'
46
48
  import { detectVersion, runMigrations } from './migrate.mjs'
47
49
  import { resolveOpenCodeVersion } from './opencode-version.mjs'
@@ -49,14 +51,19 @@ import {
49
51
  ACTIONS,
50
52
  createRecorder,
51
53
  displayPath,
54
+ fingerprintOutput,
52
55
  formatChangePlan,
53
56
  isRecording,
54
57
  planStatsMismatch,
55
58
  recordChange,
59
+ recordRemovalTree,
60
+ removeReviewedTree,
56
61
  snapshot,
57
62
  summarizeChanges,
58
63
  } from './plan.mjs'
64
+ import { findPlanDrift } from './plan-artifact.mjs'
59
65
  import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
66
+ import { PROMPT_CANCELED } from './prompt-result.mjs'
60
67
  import {
61
68
  checkRuntimePrerequisites,
62
69
  collectSkillNames,
@@ -73,6 +80,7 @@ import { createInitialState, hasState, readState, setComponentState, writeState
73
80
  import { strings } from './strings.mjs'
74
81
 
75
82
  import { setupVenv, venvPythonPath } from './venv.mjs'
83
+ import { assertNoSymlinkAncestors, assertPathWithin, assertSafeRemoval } from './verify.mjs'
76
84
 
77
85
  const COMPONENT_NAMES = [
78
86
  'agents',
@@ -786,7 +794,14 @@ export function tuiConfigLocations(target) {
786
794
  */
787
795
  export function syncTuiRegistration(
788
796
  target,
789
- { isGlobal = false, dryRun = false, version = 'v1', recorder = null } = {},
797
+ {
798
+ isGlobal = false,
799
+ dryRun = false,
800
+ version = 'v1',
801
+ recorder = null,
802
+ reviewedRemovals = null,
803
+ reviewedSnapshots = null,
804
+ } = {},
790
805
  ) {
791
806
  const configDir = isGlobal ? target : join(target, '.opencode')
792
807
  const targetTuiConfigPath = join(configDir, 'tui.json')
@@ -796,7 +811,13 @@ export function syncTuiRegistration(
796
811
  const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
797
812
  const tuiCopyDir = resolveTuiCopyTarget(configDir)
798
813
  if (existsSync(tuiSrcDir)) {
799
- copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version, recorder })
814
+ copyPluginFiles(tuiSrcDir, tuiCopyDir, {
815
+ dryRun,
816
+ version,
817
+ recorder,
818
+ reviewedRemovals,
819
+ reviewedSnapshots,
820
+ })
800
821
  }
801
822
 
802
823
  // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
@@ -830,7 +851,7 @@ export function syncTuiRegistration(
830
851
  */
831
852
  export function setupRuntimePhase(
832
853
  target,
833
- { dryRun, clean, isGlobal, recorder = null },
854
+ { dryRun, clean, isGlobal, recorder = null, reviewedRemovals = null, reviewedSnapshots = null },
834
855
  stats,
835
856
  deps = {},
836
857
  ) {
@@ -839,7 +860,13 @@ export function setupRuntimePhase(
839
860
  const check = deps.healthCheck ?? healthCheck
840
861
  try {
841
862
  const venvSpinner = spinner(S.settingUpRuntime)
842
- setup(target, { dryRun, force: clean, recorder })
863
+ setup(target, {
864
+ dryRun,
865
+ force: clean,
866
+ recorder,
867
+ reviewedRemovals,
868
+ reviewedSnapshots,
869
+ })
843
870
  venvSpinner(true)
844
871
  const runtimeTarget = isGlobal ? target : join(target, '.opencode')
845
872
  const health = check(runtimeTarget, { dryRun, pythonTarget: target })
@@ -976,50 +1003,12 @@ function detectSnapshotDrift(snapshots) {
976
1003
  return changed
977
1004
  }
978
1005
 
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
1006
  /**
1018
1007
  * Phase 5 failure semantics. `installPass` is the whole installer; this thin
1019
1008
  * 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
1009
+ * (the throw), relies on each writer to clean up only its own temporary file,
1010
+ * reports the PATHS already applied (never contents), prints the recovery
1011
+ * path, and re-throws so the CLI exits non-zero. There is NO automatic rollback — the
1023
1012
  * exact backup set + `pantheon-opencode restore` is the recovery.
1024
1013
  *
1025
1014
  * Progress is tracked in `_lastRun` (set only by a non-dry pass, so the nested
@@ -1053,8 +1042,6 @@ export async function installOpenCode(
1053
1042
  for (const p of paths) warning(` ${p}`)
1054
1043
  }
1055
1044
  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
1045
  }
1059
1046
  throw err
1060
1047
  }
@@ -1079,8 +1066,14 @@ async function installPass(
1079
1066
  const statsBefore = { created: stats.created, skipped: stats.skipped }
1080
1067
  // Number of planned changes, filled by a dry plan pass (used by the gate).
1081
1068
  let plannedChanges = 0
1069
+ let planFiles = []
1070
+ let removalPaths = []
1071
+ const planTimestamp = opts.planTimestamp ?? new Date().toISOString()
1072
+ let projectedState = null
1082
1073
  // Phase 4: planned-target snapshots from the plan pass, used for drift checks.
1083
1074
  let plannedSnapshots = null
1075
+ let reviewedRemovals = null
1076
+ let reviewedSnapshots = null
1084
1077
  const S = strings()
1085
1078
 
1086
1079
  // Beta 11 (Setup phase 1): a dry pass records every writer's intended change
@@ -1117,7 +1110,16 @@ async function installPass(
1117
1110
  // the V1 branch writes .ts file paths that a 2.x host drops from the config.
1118
1111
  // `opts` is forwarded as the resolver's options bag so a caller (and CI) can
1119
1112
  // pin env/probe instead of spawning a real host binary.
1120
- const version = resolveOpenCodeVersion(opts.version ?? 'auto', opts)
1113
+ const version = resolveOpenCodeVersion(opts.version ?? 'auto', {
1114
+ ...opts,
1115
+ skipProbe: dryRun,
1116
+ })
1117
+ const versionAssumed =
1118
+ dryRun &&
1119
+ (opts.version ?? 'auto') === 'auto' &&
1120
+ process.env.OPENCODE_VERSION !== 'v1' &&
1121
+ process.env.OPENCODE_VERSION !== 'v2' &&
1122
+ !/(?:^|[\\/])opencode2(?:\.exe)?$/i.test(process.env.OPENCODE_BIN ?? '')
1121
1123
  if (version === 'v2') {
1122
1124
  info(
1123
1125
  'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
@@ -1151,8 +1153,15 @@ async function installPass(
1151
1153
  const result = await runInteractiveInstall({
1152
1154
  components,
1153
1155
  defaultComponents: COMPONENT_NAMES,
1156
+ prompts: opts.deps?.prompts,
1157
+ isTTY: opts.deps?.isTTY,
1154
1158
  })
1155
1159
 
1160
+ if (result.canceled) {
1161
+ info('Installation canceled.')
1162
+ return { ...stats, applied: false, reason: 'canceled' }
1163
+ }
1164
+
1156
1165
  if (!result.confirmed && !autoYes) {
1157
1166
  info('Installation canceled.')
1158
1167
  return { ...stats, applied: false, reason: 'canceled' }
@@ -1202,7 +1211,7 @@ async function installPass(
1202
1211
  // active config directory is the project root and its agent loader scans
1203
1212
  // <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
1204
1213
  // Global installs are flat for both versions.
1205
- const isGlobal = isGlobalConfigDir(target)
1214
+ const isGlobal = opts.global ?? isGlobalConfigDir(target)
1206
1215
  const _subDir = isGlobal ? '' : '.opencode'
1207
1216
  const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
1208
1217
 
@@ -1229,6 +1238,7 @@ async function installPass(
1229
1238
  // plan for the review / decline preview.
1230
1239
  planned = await installOpenCode(target, true, clean, [...componentSet], {
1231
1240
  ...opts,
1241
+ planTimestamp,
1232
1242
  planOnly: true,
1233
1243
  quiet: autoYes,
1234
1244
  quietPlan: autoYes,
@@ -1240,6 +1250,22 @@ async function installPass(
1240
1250
  }
1241
1251
  plannedSnapshots = planned?.snapshots ?? null
1242
1252
 
1253
+ if (opts.expectedPlan) {
1254
+ const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1255
+ const drift = findPlanDrift(opts.expectedPlan, {
1256
+ target,
1257
+ packageRoot: ROOT,
1258
+ packageVersion,
1259
+ files: planned?.files ?? [],
1260
+ snapshots: planned?.snapshots ?? [],
1261
+ })
1262
+ if (drift.length > 0) {
1263
+ warning('Reviewed install plan drifted; no files were written.')
1264
+ for (const item of drift) warning(` ${item}`)
1265
+ return { ...stats, applied: false, reason: 'drift', drift }
1266
+ }
1267
+ }
1268
+
1243
1269
  if (!autoYes) {
1244
1270
  const injected = opts.deps?.promptReview
1245
1271
  const canPrompt = typeof injected === 'function' || interactive
@@ -1248,7 +1274,13 @@ async function installPass(
1248
1274
  const ask = typeof injected === 'function' ? injected : promptReview
1249
1275
  const accepted = await ask(`Apply these ${planned?.planned ?? 0} changes?`, {
1250
1276
  default: false,
1277
+ prompts: opts.deps?.prompts,
1278
+ isTTY: opts.deps?.isTTY,
1251
1279
  })
1280
+ if (accepted === PROMPT_CANCELED) {
1281
+ info(S.nothingApplied)
1282
+ return { ...stats, applied: false, reason: 'canceled' }
1283
+ }
1252
1284
  if (!accepted) {
1253
1285
  info(S.nothingApplied)
1254
1286
  return { ...stats, applied: false, reason: 'declined' }
@@ -1275,6 +1307,10 @@ async function installPass(
1275
1307
  drift: drifted.map((p) => displayPath(target, p)),
1276
1308
  }
1277
1309
  }
1310
+ reviewedRemovals = new Set((planned?.removals ?? []).map((filePath) => resolve(filePath)))
1311
+ reviewedSnapshots = new Map(
1312
+ (plannedSnapshots ?? []).map((before) => [resolve(before.path), before]),
1313
+ )
1278
1314
  }
1279
1315
 
1280
1316
  // -----------------------------------------------------------------------
@@ -1284,25 +1320,57 @@ async function installPass(
1284
1320
  // out-of-target global files are recorded in the manifest as
1285
1321
  // `notRevertible`. A dry run records the planned backup-manifest path only.
1286
1322
  // -----------------------------------------------------------------------
1287
- const backupManifestPath = join(
1288
- backupSetPath(target, nextBackupName(target, new Date())),
1289
- 'manifest.json',
1290
- )
1323
+ if (componentSet.has('agents')) {
1324
+ const agentTarget = join(target, agentPrefix)
1325
+ assertPathWithin(target, agentTarget)
1326
+ assertNoSymlinkAncestors(agentTarget)
1327
+ }
1328
+ if (componentSet.has('skills')) {
1329
+ const skillsTarget = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
1330
+ assertPathWithin(target, skillsTarget)
1331
+ assertNoSymlinkAncestors(skillsTarget)
1332
+ }
1333
+ const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
1334
+ const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1291
1335
  if (dryRun) {
1336
+ const preview = previewBackupSet({
1337
+ configDir: target,
1338
+ entries: backupPlan.entries,
1339
+ notRevertible: backupPlan.notRevertible,
1340
+ packageVersion: pkgVersion,
1341
+ clock: () => new Date(planTimestamp),
1342
+ })
1292
1343
  if (isRecording(recorder)) {
1344
+ for (const file of preview.files) {
1345
+ recordChange(
1346
+ {
1347
+ path: file.path,
1348
+ action: ACTIONS.CREATE,
1349
+ bytes: file.bytes,
1350
+ outputFingerprint: file.outputFingerprint,
1351
+ scored: false,
1352
+ },
1353
+ recorder,
1354
+ )
1355
+ }
1293
1356
  recordChange(
1294
- { path: backupManifestPath, action: ACTIONS.CREATE, bytes: 0, scored: false },
1357
+ {
1358
+ path: preview.manifestPath,
1359
+ action: ACTIONS.CREATE,
1360
+ bytes: preview.manifestBytes,
1361
+ outputFingerprint: preview.manifestFingerprint,
1362
+ scored: false,
1363
+ },
1295
1364
  recorder,
1296
1365
  )
1297
1366
  }
1298
1367
  } else {
1299
- const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
1300
- const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1301
1368
  const { dir: backupDir } = createBackupSet({
1302
1369
  configDir: target,
1303
1370
  entries: backupPlan.entries,
1304
1371
  notRevertible: backupPlan.notRevertible,
1305
1372
  packageVersion: pkgVersion,
1373
+ clock: () => new Date(planTimestamp),
1306
1374
  })
1307
1375
  if (_lastRun && _lastRun.target === target) _lastRun.backupDir = backupDir
1308
1376
  // Secret-safe: the path only. The exact wording is fixed (once per apply).
@@ -1325,11 +1393,23 @@ async function installPass(
1325
1393
  } else {
1326
1394
  const agentStep = step(S.installingAgents)
1327
1395
  const dstDir = join(target, agentPrefix)
1328
- if (!dryRun) mkdirSync(dstDir, { recursive: true })
1329
- if (clean && existsSync(dstDir) && !dryRun) {
1396
+ assertPathWithin(target, dstDir)
1397
+ assertNoSymlinkAncestors(dstDir)
1398
+ if (!dryRun) {
1399
+ mkdirSync(dstDir, { recursive: true })
1400
+ assertNoSymlinkAncestors(dstDir)
1401
+ }
1402
+ if (clean && existsSync(dstDir)) {
1403
+ assertNoSymlinkAncestors(dstDir)
1330
1404
  const existing = readdirSync(dstDir)
1331
1405
  for (const f of existing) {
1332
- rmSync(join(dstDir, f), { recursive: true, force: true })
1406
+ const agentPath = join(dstDir, f)
1407
+ assertSafeRemoval(dstDir, agentPath)
1408
+ if (dryRun) {
1409
+ recordRemovalTree(agentPath, recorder)
1410
+ } else {
1411
+ removeReviewedTree(agentPath, reviewedRemovals, reviewedSnapshots)
1412
+ }
1333
1413
  }
1334
1414
  }
1335
1415
  const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun, false, recorder)
@@ -1363,10 +1443,19 @@ async function installPass(
1363
1443
  if (skillNames.length > 0) {
1364
1444
  const skillStep = step(`Installing ${skillNames.length} skills`)
1365
1445
  const dstSkillsDir = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
1366
- if (clean && existsSync(dstSkillsDir) && !dryRun) {
1446
+ assertPathWithin(target, dstSkillsDir)
1447
+ assertNoSymlinkAncestors(dstSkillsDir)
1448
+ if (clean && existsSync(dstSkillsDir)) {
1449
+ assertNoSymlinkAncestors(dstSkillsDir)
1367
1450
  const existing = readdirSync(dstSkillsDir)
1368
1451
  for (const s of existing) {
1369
- rmSync(join(dstSkillsDir, s), { recursive: true, force: true })
1452
+ const skillPath = join(dstSkillsDir, s)
1453
+ assertSafeRemoval(dstSkillsDir, skillPath)
1454
+ if (dryRun) {
1455
+ recordRemovalTree(skillPath, recorder)
1456
+ } else {
1457
+ removeReviewedTree(skillPath, reviewedRemovals, reviewedSnapshots)
1458
+ }
1370
1459
  }
1371
1460
  }
1372
1461
  const installSubDir = isGlobal ? '' : '.opencode'
@@ -1376,6 +1465,9 @@ async function installPass(
1376
1465
  dryRun,
1377
1466
  installSubDir,
1378
1467
  recorder,
1468
+ reviewedRemovals,
1469
+ reviewedSnapshots,
1470
+ clean,
1379
1471
  )
1380
1472
  stats.created += sCreated
1381
1473
  stats.skipped += sSkipped
@@ -1434,7 +1526,16 @@ async function installPass(
1434
1526
  const srcPrompts = join(ROOT, 'prompts')
1435
1527
  const dstPrompts = join(target, 'prompts')
1436
1528
  if (existsSync(srcPrompts)) {
1437
- const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean, null, recorder)
1529
+ const promptsResult = syncDir(
1530
+ srcPrompts,
1531
+ dstPrompts,
1532
+ dryRun,
1533
+ clean,
1534
+ null,
1535
+ recorder,
1536
+ reviewedRemovals,
1537
+ reviewedSnapshots,
1538
+ )
1438
1539
  stats.created += promptsResult.created
1439
1540
  stats.skipped += promptsResult.skipped
1440
1541
  }
@@ -1452,7 +1553,16 @@ async function installPass(
1452
1553
  const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
1453
1554
  const dstCmds = join(target, commandDir)
1454
1555
  if (existsSync(srcCmds)) {
1455
- const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'), recorder)
1556
+ const cmdResult = syncDir(
1557
+ srcCmds,
1558
+ dstCmds,
1559
+ dryRun,
1560
+ clean,
1561
+ (f) => f.endsWith('.md'),
1562
+ recorder,
1563
+ reviewedRemovals,
1564
+ reviewedSnapshots,
1565
+ )
1456
1566
  stats.created += cmdResult.created
1457
1567
  stats.skipped += cmdResult.skipped
1458
1568
  }
@@ -1480,6 +1590,8 @@ async function installPass(
1480
1590
  clean,
1481
1591
  version,
1482
1592
  recorder,
1593
+ reviewedRemovals,
1594
+ reviewedSnapshots,
1483
1595
  })
1484
1596
  stats.created += pluginStats.created
1485
1597
  stats.skipped += pluginStats.skipped
@@ -1492,7 +1604,14 @@ async function installPass(
1492
1604
  if (componentSet.has('plugins')) {
1493
1605
  let tuiStatus
1494
1606
  try {
1495
- tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version, recorder })
1607
+ tuiStatus = syncTuiRegistration(target, {
1608
+ isGlobal,
1609
+ dryRun,
1610
+ version,
1611
+ recorder,
1612
+ reviewedRemovals,
1613
+ reviewedSnapshots,
1614
+ })
1496
1615
  } catch (cause) {
1497
1616
  error(`TUI plugin setup failed: ${cause.message}`)
1498
1617
  stats.errors++
@@ -1581,6 +1700,8 @@ async function installPass(
1581
1700
  clean,
1582
1701
  (f) => f !== 'manifest.json',
1583
1702
  recorder,
1703
+ reviewedRemovals,
1704
+ reviewedSnapshots,
1584
1705
  )
1585
1706
  stats.created += cmResult.created
1586
1707
  stats.skipped += cmResult.skipped
@@ -1625,7 +1746,11 @@ async function installPass(
1625
1746
  // -----------------------------------------------------------------------
1626
1747
  let runtimeHealthy = !componentSet.has('runtime')
1627
1748
  if (componentSet.has('runtime')) {
1628
- runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal, recorder }, stats)
1749
+ runtimeHealthy = setupRuntimePhase(
1750
+ target,
1751
+ { dryRun, clean, isGlobal, recorder, reviewedRemovals, reviewedSnapshots },
1752
+ stats,
1753
+ )
1629
1754
  }
1630
1755
 
1631
1756
  // -----------------------------------------------------------------------
@@ -2095,7 +2220,12 @@ async function installPass(
2095
2220
  if (componentSet.has('runtime')) {
2096
2221
  const currentVersion = detectVersion(target)
2097
2222
  if (currentVersion) {
2098
- const migration = runMigrations(target, currentVersion, { dryRun, recorder })
2223
+ const migration = runMigrations(target, currentVersion, {
2224
+ dryRun,
2225
+ recorder,
2226
+ updatedAt: planTimestamp,
2227
+ })
2228
+ if (migration.plannedState) projectedState = migration.plannedState
2099
2229
  if (migration.applied > 0) {
2100
2230
  success(`Applied ${migration.applied} migration(s)`)
2101
2231
  for (const msg of migration.messages) {
@@ -2105,29 +2235,66 @@ async function installPass(
2105
2235
  }
2106
2236
  }
2107
2237
 
2238
+ if (opts.preset && dryRun) {
2239
+ const { writeActivePreset } = await import('./model-picker.mjs')
2240
+ writeActivePreset(target, opts.preset, {
2241
+ dryRun: true,
2242
+ source: 'cli',
2243
+ updatedAt: planTimestamp,
2244
+ recorder,
2245
+ logger: { log() {} },
2246
+ })
2247
+ }
2248
+
2108
2249
  // -----------------------------------------------------------------------
2109
2250
  // 3.75 Preview summary (Beta 11 Setup phase 1): print the REAL per-file
2110
2251
  // change set this same pass would apply. Paths + action + byte size
2111
2252
  // ONLY — never file contents or hashes. Nothing was written.
2112
2253
  // -----------------------------------------------------------------------
2113
2254
  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.
2255
+ // Project the terminal state write using the plan's stable timestamp so
2256
+ // saved-plan apply can validate its exact output fingerprint.
2257
+ const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
2258
+ let state = projectedState ?? readState(target)
2259
+ if (!state) state = createInitialState(packageVersion, planTimestamp)
2260
+ state.pantheon_version = packageVersion
2261
+ state.updated_at = planTimestamp
2262
+ state.schema_version = 2
2263
+ for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
2264
+ if (!componentSet.has(component)) continue
2265
+ const componentStatus = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
2266
+ const componentVersion = state.components?.[component]?.version ?? 1
2267
+ setComponentState(state, component, componentVersion, componentStatus)
2268
+ }
2269
+ const stateContent = `${JSON.stringify(state, null, 2)}\n`
2117
2270
  recordChange(
2118
2271
  {
2119
2272
  path: join(target, '.pantheon', 'install-state.json'),
2120
2273
  action: hasState(target) ? ACTIONS.UPDATE : ACTIONS.CREATE,
2121
- bytes: 0,
2274
+ bytes: Buffer.byteLength(stateContent, 'utf8'),
2275
+ outputFingerprint: fingerprintOutput(stateContent),
2122
2276
  scored: false,
2123
2277
  },
2124
2278
  recorder,
2125
2279
  )
2126
2280
  const plan = summarizeChanges(preview.entries)
2281
+ planFiles = plan.files
2127
2282
  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) }))
2283
+ removalPaths = [
2284
+ ...new Set(
2285
+ preview.entries
2286
+ .filter((entry) => entry.action === ACTIONS.REMOVE)
2287
+ .map((entry) => resolve(entry.path)),
2288
+ ),
2289
+ ].sort()
2290
+ // Snapshot both net changes and every raw removal target. A later write can
2291
+ // make a removal path look unchanged in the net plan, but it still needs
2292
+ // review before the clean operation deletes and recreates it.
2293
+ const snapshotPaths = new Set([...plan.files.map((file) => file.path), ...removalPaths])
2294
+ plannedSnapshots = [...snapshotPaths].map((filePath) => ({
2295
+ path: filePath,
2296
+ ...snapshot(filePath),
2297
+ }))
2131
2298
  const statsDelta = {
2132
2299
  created: stats.created - statsBefore.created,
2133
2300
  skipped: stats.skipped - statsBefore.skipped,
@@ -2148,6 +2315,13 @@ async function installPass(
2148
2315
  // it — a real apply is not a dry run.
2149
2316
  if (!opts.quietPlan) {
2150
2317
  console.log('')
2318
+ if (removalPaths.length > 0) {
2319
+ console.log(tag(`Removal review — ${removalPaths.length} path(s):`))
2320
+ for (const filePath of removalPaths) {
2321
+ console.log(tag(` remove ${displayPath(target, filePath)}`))
2322
+ }
2323
+ console.log('')
2324
+ }
2151
2325
  for (const line of formatChangePlan(target, plan).split('\n')) {
2152
2326
  console.log(tag(line))
2153
2327
  }
@@ -2194,6 +2368,7 @@ async function installPass(
2194
2368
  ` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
2195
2369
  )
2196
2370
  process.stdout.write('\n')
2371
+ printWorktreeGuidance({ projectRoot: process.cwd(), interactive: true })
2197
2372
  } else if (!opts.planOnly) {
2198
2373
  printSummary(target, ['opencode'], stats)
2199
2374
  }
@@ -2211,7 +2386,7 @@ async function installPass(
2211
2386
 
2212
2387
  if (opts.preset && !dryRun) {
2213
2388
  const { writeActivePreset } = await import('./model-picker.mjs')
2214
- writeActivePreset(target, opts.preset, { source: 'cli' })
2389
+ writeActivePreset(target, opts.preset, { source: 'cli', updatedAt: planTimestamp, recorder })
2215
2390
  }
2216
2391
 
2217
2392
  // -----------------------------------------------------------------------
@@ -2242,19 +2417,39 @@ async function installPass(
2242
2417
  // to `doctor` forever. Unselected components retain their previous state.
2243
2418
  if (!dryRun) {
2244
2419
  const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
2245
- let state = readState(target)
2420
+ let state = projectedState ?? readState(target)
2246
2421
  if (!state && hasState(target)) {
2247
2422
  throw new Error('Installation state exists but cannot be read; refusing to replace it')
2248
2423
  }
2249
- state ??= createInitialState(packageVersion)
2424
+ state ??= createInitialState(packageVersion, planTimestamp)
2250
2425
  state.pantheon_version = packageVersion
2426
+ state.updated_at = planTimestamp
2251
2427
  for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
2252
2428
  if (!componentSet.has(component)) continue
2253
2429
  const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
2254
2430
  const componentVersion = state.components?.[component]?.version ?? 1
2255
2431
  setComponentState(state, component, componentVersion, status)
2256
2432
  }
2257
- writeState(target, state)
2433
+ const verifiedFiles = summarizeChanges(ownRecorder?.entries ?? []).files.flatMap((file) => {
2434
+ if (!['create', 'update', 'unchanged'].includes(file.action) || !file.outputFingerprint)
2435
+ return []
2436
+ const owner = componentForInstalledPath(target, file.path, { isGlobal, version })
2437
+ if (!owner) return []
2438
+ return [
2439
+ {
2440
+ path: file.path,
2441
+ sha256: file.outputFingerprint,
2442
+ component: owner.component,
2443
+ layout: owner.layout,
2444
+ action: file.action,
2445
+ },
2446
+ ]
2447
+ })
2448
+ updateFileInventory(state, target, {
2449
+ selectedComponents: [...componentSet],
2450
+ verifiedFiles,
2451
+ })
2452
+ writeState(target, state, { updatedAt: planTimestamp })
2258
2453
  }
2259
2454
 
2260
2455
  // Beta 11 Setup phase 3: report whether this call APPLIED, so the caller can
@@ -2267,6 +2462,11 @@ async function installPass(
2267
2462
  reason: 'plan',
2268
2463
  planned: plannedChanges,
2269
2464
  snapshots: plannedSnapshots,
2465
+ files: planFiles,
2466
+ removals: removalPaths,
2467
+ target,
2468
+ openCodeVersion: version,
2469
+ versionAssumed,
2270
2470
  }
2271
2471
  : { ...stats, applied: true, reason: 'applied' }
2272
2472
  }