pantheon-opencode 1.6.0-beta.11 → 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 (50) hide show
  1. package/AGENTS.global.md +3 -3
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +69 -30
  5. package/README.pt-BR.md +5 -4
  6. package/bin/pantheon-init.mjs +279 -101
  7. package/package.json +4 -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/migrate.mjs +45 -38
  17. package/scripts/install/model-picker.mjs +63 -22
  18. package/scripts/install/opencode-version.mjs +9 -1
  19. package/scripts/install/opencode.mjs +265 -79
  20. package/scripts/install/plan-artifact.mjs +517 -0
  21. package/scripts/install/plan.mjs +165 -3
  22. package/scripts/install/plugin.mjs +118 -14
  23. package/scripts/install/shared.mjs +186 -65
  24. package/scripts/install/state.mjs +63 -8
  25. package/scripts/install/venv.mjs +15 -6
  26. package/scripts/install/verify.mjs +128 -0
  27. package/scripts/install/vision-prompt.mjs +2 -0
  28. package/scripts/install-mcp.mjs +1 -1
  29. package/scripts/manifest-inventory.mjs +49 -3
  30. package/scripts/node-engine-policy.mjs +74 -0
  31. package/scripts/package-evidence.mjs +6 -0
  32. package/scripts/postinstall.mjs +3 -4
  33. package/scripts/reconcile.mjs +325 -0
  34. package/scripts/uninstall.mjs +258 -34
  35. package/scripts/update.mjs +164 -0
  36. package/scripts/validate-node-engine-policy.mjs +13 -0
  37. package/scripts/version-check.mjs +34 -1
  38. package/scripts/versioning.mjs +9 -1
  39. package/skills-lock.json +3 -3
  40. package/src/agents/mnemosyne.md +7 -2
  41. package/src/instructions/memory-protocol.instructions.md +2 -2
  42. package/src/instructions/zeus-memory-operations.instructions.md +4 -3
  43. package/src/pantheon/v2-unsupported.mjs +2 -3
  44. package/src/plugins/tui/dist/tui-v2.js +2 -2
  45. package/src/plugins/tui/dist/tui.js +1 -1
  46. package/src/plugins/tui/package-lock.json +3 -3
  47. package/src/plugins/tui/package.json +2 -2
  48. package/src/skills/artifact-management/SKILL.md +5 -1
  49. package/src/skills/memory-bank/SKILL.md +5 -1
  50. 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,13 +51,17 @@ 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'
60
66
  import {
61
67
  checkRuntimePrerequisites,
@@ -73,6 +79,7 @@ import { createInitialState, hasState, readState, setComponentState, writeState
73
79
  import { strings } from './strings.mjs'
74
80
 
75
81
  import { setupVenv, venvPythonPath } from './venv.mjs'
82
+ import { assertNoSymlinkAncestors, assertPathWithin, assertSafeRemoval } from './verify.mjs'
76
83
 
77
84
  const COMPONENT_NAMES = [
78
85
  'agents',
@@ -786,7 +793,14 @@ export function tuiConfigLocations(target) {
786
793
  */
787
794
  export function syncTuiRegistration(
788
795
  target,
789
- { isGlobal = false, dryRun = false, version = 'v1', recorder = null } = {},
796
+ {
797
+ isGlobal = false,
798
+ dryRun = false,
799
+ version = 'v1',
800
+ recorder = null,
801
+ reviewedRemovals = null,
802
+ reviewedSnapshots = null,
803
+ } = {},
790
804
  ) {
791
805
  const configDir = isGlobal ? target : join(target, '.opencode')
792
806
  const targetTuiConfigPath = join(configDir, 'tui.json')
@@ -796,7 +810,13 @@ export function syncTuiRegistration(
796
810
  const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
797
811
  const tuiCopyDir = resolveTuiCopyTarget(configDir)
798
812
  if (existsSync(tuiSrcDir)) {
799
- copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version, recorder })
813
+ copyPluginFiles(tuiSrcDir, tuiCopyDir, {
814
+ dryRun,
815
+ version,
816
+ recorder,
817
+ reviewedRemovals,
818
+ reviewedSnapshots,
819
+ })
800
820
  }
801
821
 
802
822
  // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
@@ -830,7 +850,7 @@ export function syncTuiRegistration(
830
850
  */
831
851
  export function setupRuntimePhase(
832
852
  target,
833
- { dryRun, clean, isGlobal, recorder = null },
853
+ { dryRun, clean, isGlobal, recorder = null, reviewedRemovals = null, reviewedSnapshots = null },
834
854
  stats,
835
855
  deps = {},
836
856
  ) {
@@ -839,7 +859,13 @@ export function setupRuntimePhase(
839
859
  const check = deps.healthCheck ?? healthCheck
840
860
  try {
841
861
  const venvSpinner = spinner(S.settingUpRuntime)
842
- setup(target, { dryRun, force: clean, recorder })
862
+ setup(target, {
863
+ dryRun,
864
+ force: clean,
865
+ recorder,
866
+ reviewedRemovals,
867
+ reviewedSnapshots,
868
+ })
843
869
  venvSpinner(true)
844
870
  const runtimeTarget = isGlobal ? target : join(target, '.opencode')
845
871
  const health = check(runtimeTarget, { dryRun, pythonTarget: target })
@@ -976,50 +1002,12 @@ function detectSnapshotDrift(snapshots) {
976
1002
  return changed
977
1003
  }
978
1004
 
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
1005
  /**
1018
1006
  * Phase 5 failure semantics. `installPass` is the whole installer; this thin
1019
1007
  * 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
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
1023
1011
  * exact backup set + `pantheon-opencode restore` is the recovery.
1024
1012
  *
1025
1013
  * Progress is tracked in `_lastRun` (set only by a non-dry pass, so the nested
@@ -1053,8 +1041,6 @@ export async function installOpenCode(
1053
1041
  for (const p of paths) warning(` ${p}`)
1054
1042
  }
1055
1043
  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
1044
  }
1059
1045
  throw err
1060
1046
  }
@@ -1079,8 +1065,14 @@ async function installPass(
1079
1065
  const statsBefore = { created: stats.created, skipped: stats.skipped }
1080
1066
  // Number of planned changes, filled by a dry plan pass (used by the gate).
1081
1067
  let plannedChanges = 0
1068
+ let planFiles = []
1069
+ let removalPaths = []
1070
+ const planTimestamp = opts.planTimestamp ?? new Date().toISOString()
1071
+ let projectedState = null
1082
1072
  // Phase 4: planned-target snapshots from the plan pass, used for drift checks.
1083
1073
  let plannedSnapshots = null
1074
+ let reviewedRemovals = null
1075
+ let reviewedSnapshots = null
1084
1076
  const S = strings()
1085
1077
 
1086
1078
  // Beta 11 (Setup phase 1): a dry pass records every writer's intended change
@@ -1117,7 +1109,16 @@ async function installPass(
1117
1109
  // the V1 branch writes .ts file paths that a 2.x host drops from the config.
1118
1110
  // `opts` is forwarded as the resolver's options bag so a caller (and CI) can
1119
1111
  // pin env/probe instead of spawning a real host binary.
1120
- 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 ?? '')
1121
1122
  if (version === 'v2') {
1122
1123
  info(
1123
1124
  'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
@@ -1202,7 +1203,7 @@ async function installPass(
1202
1203
  // active config directory is the project root and its agent loader scans
1203
1204
  // <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
1204
1205
  // Global installs are flat for both versions.
1205
- const isGlobal = isGlobalConfigDir(target)
1206
+ const isGlobal = opts.global ?? isGlobalConfigDir(target)
1206
1207
  const _subDir = isGlobal ? '' : '.opencode'
1207
1208
  const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
1208
1209
 
@@ -1229,6 +1230,7 @@ async function installPass(
1229
1230
  // plan for the review / decline preview.
1230
1231
  planned = await installOpenCode(target, true, clean, [...componentSet], {
1231
1232
  ...opts,
1233
+ planTimestamp,
1232
1234
  planOnly: true,
1233
1235
  quiet: autoYes,
1234
1236
  quietPlan: autoYes,
@@ -1240,6 +1242,22 @@ async function installPass(
1240
1242
  }
1241
1243
  plannedSnapshots = planned?.snapshots ?? null
1242
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
+
1243
1261
  if (!autoYes) {
1244
1262
  const injected = opts.deps?.promptReview
1245
1263
  const canPrompt = typeof injected === 'function' || interactive
@@ -1275,6 +1293,10 @@ async function installPass(
1275
1293
  drift: drifted.map((p) => displayPath(target, p)),
1276
1294
  }
1277
1295
  }
1296
+ reviewedRemovals = new Set((planned?.removals ?? []).map((filePath) => resolve(filePath)))
1297
+ reviewedSnapshots = new Map(
1298
+ (plannedSnapshots ?? []).map((before) => [resolve(before.path), before]),
1299
+ )
1278
1300
  }
1279
1301
 
1280
1302
  // -----------------------------------------------------------------------
@@ -1284,25 +1306,57 @@ async function installPass(
1284
1306
  // out-of-target global files are recorded in the manifest as
1285
1307
  // `notRevertible`. A dry run records the planned backup-manifest path only.
1286
1308
  // -----------------------------------------------------------------------
1287
- const backupManifestPath = join(
1288
- backupSetPath(target, nextBackupName(target, new Date())),
1289
- 'manifest.json',
1290
- )
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
1291
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
+ })
1292
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
+ }
1293
1342
  recordChange(
1294
- { path: backupManifestPath, action: ACTIONS.CREATE, bytes: 0, scored: false },
1343
+ {
1344
+ path: preview.manifestPath,
1345
+ action: ACTIONS.CREATE,
1346
+ bytes: preview.manifestBytes,
1347
+ outputFingerprint: preview.manifestFingerprint,
1348
+ scored: false,
1349
+ },
1295
1350
  recorder,
1296
1351
  )
1297
1352
  }
1298
1353
  } else {
1299
- const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
1300
- const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1301
1354
  const { dir: backupDir } = createBackupSet({
1302
1355
  configDir: target,
1303
1356
  entries: backupPlan.entries,
1304
1357
  notRevertible: backupPlan.notRevertible,
1305
1358
  packageVersion: pkgVersion,
1359
+ clock: () => new Date(planTimestamp),
1306
1360
  })
1307
1361
  if (_lastRun && _lastRun.target === target) _lastRun.backupDir = backupDir
1308
1362
  // Secret-safe: the path only. The exact wording is fixed (once per apply).
@@ -1325,11 +1379,23 @@ async function installPass(
1325
1379
  } else {
1326
1380
  const agentStep = step(S.installingAgents)
1327
1381
  const dstDir = join(target, agentPrefix)
1328
- if (!dryRun) mkdirSync(dstDir, { recursive: true })
1329
- 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)
1330
1390
  const existing = readdirSync(dstDir)
1331
1391
  for (const f of existing) {
1332
- 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
+ }
1333
1399
  }
1334
1400
  }
1335
1401
  const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun, false, recorder)
@@ -1363,10 +1429,19 @@ async function installPass(
1363
1429
  if (skillNames.length > 0) {
1364
1430
  const skillStep = step(`Installing ${skillNames.length} skills`)
1365
1431
  const dstSkillsDir = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
1366
- if (clean && existsSync(dstSkillsDir) && !dryRun) {
1432
+ assertPathWithin(target, dstSkillsDir)
1433
+ assertNoSymlinkAncestors(dstSkillsDir)
1434
+ if (clean && existsSync(dstSkillsDir)) {
1435
+ assertNoSymlinkAncestors(dstSkillsDir)
1367
1436
  const existing = readdirSync(dstSkillsDir)
1368
1437
  for (const s of existing) {
1369
- 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
+ }
1370
1445
  }
1371
1446
  }
1372
1447
  const installSubDir = isGlobal ? '' : '.opencode'
@@ -1376,6 +1451,9 @@ async function installPass(
1376
1451
  dryRun,
1377
1452
  installSubDir,
1378
1453
  recorder,
1454
+ reviewedRemovals,
1455
+ reviewedSnapshots,
1456
+ clean,
1379
1457
  )
1380
1458
  stats.created += sCreated
1381
1459
  stats.skipped += sSkipped
@@ -1434,7 +1512,16 @@ async function installPass(
1434
1512
  const srcPrompts = join(ROOT, 'prompts')
1435
1513
  const dstPrompts = join(target, 'prompts')
1436
1514
  if (existsSync(srcPrompts)) {
1437
- const promptsResult = syncDir(srcPrompts, dstPrompts, dryRun, clean, null, recorder)
1515
+ const promptsResult = syncDir(
1516
+ srcPrompts,
1517
+ dstPrompts,
1518
+ dryRun,
1519
+ clean,
1520
+ null,
1521
+ recorder,
1522
+ reviewedRemovals,
1523
+ reviewedSnapshots,
1524
+ )
1438
1525
  stats.created += promptsResult.created
1439
1526
  stats.skipped += promptsResult.skipped
1440
1527
  }
@@ -1452,7 +1539,16 @@ async function installPass(
1452
1539
  const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
1453
1540
  const dstCmds = join(target, commandDir)
1454
1541
  if (existsSync(srcCmds)) {
1455
- const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'), recorder)
1542
+ const cmdResult = syncDir(
1543
+ srcCmds,
1544
+ dstCmds,
1545
+ dryRun,
1546
+ clean,
1547
+ (f) => f.endsWith('.md'),
1548
+ recorder,
1549
+ reviewedRemovals,
1550
+ reviewedSnapshots,
1551
+ )
1456
1552
  stats.created += cmdResult.created
1457
1553
  stats.skipped += cmdResult.skipped
1458
1554
  }
@@ -1480,6 +1576,8 @@ async function installPass(
1480
1576
  clean,
1481
1577
  version,
1482
1578
  recorder,
1579
+ reviewedRemovals,
1580
+ reviewedSnapshots,
1483
1581
  })
1484
1582
  stats.created += pluginStats.created
1485
1583
  stats.skipped += pluginStats.skipped
@@ -1492,7 +1590,14 @@ async function installPass(
1492
1590
  if (componentSet.has('plugins')) {
1493
1591
  let tuiStatus
1494
1592
  try {
1495
- tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version, recorder })
1593
+ tuiStatus = syncTuiRegistration(target, {
1594
+ isGlobal,
1595
+ dryRun,
1596
+ version,
1597
+ recorder,
1598
+ reviewedRemovals,
1599
+ reviewedSnapshots,
1600
+ })
1496
1601
  } catch (cause) {
1497
1602
  error(`TUI plugin setup failed: ${cause.message}`)
1498
1603
  stats.errors++
@@ -1581,6 +1686,8 @@ async function installPass(
1581
1686
  clean,
1582
1687
  (f) => f !== 'manifest.json',
1583
1688
  recorder,
1689
+ reviewedRemovals,
1690
+ reviewedSnapshots,
1584
1691
  )
1585
1692
  stats.created += cmResult.created
1586
1693
  stats.skipped += cmResult.skipped
@@ -1625,7 +1732,11 @@ async function installPass(
1625
1732
  // -----------------------------------------------------------------------
1626
1733
  let runtimeHealthy = !componentSet.has('runtime')
1627
1734
  if (componentSet.has('runtime')) {
1628
- runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal, recorder }, stats)
1735
+ runtimeHealthy = setupRuntimePhase(
1736
+ target,
1737
+ { dryRun, clean, isGlobal, recorder, reviewedRemovals, reviewedSnapshots },
1738
+ stats,
1739
+ )
1629
1740
  }
1630
1741
 
1631
1742
  // -----------------------------------------------------------------------
@@ -2095,7 +2206,12 @@ async function installPass(
2095
2206
  if (componentSet.has('runtime')) {
2096
2207
  const currentVersion = detectVersion(target)
2097
2208
  if (currentVersion) {
2098
- const migration = runMigrations(target, currentVersion, { dryRun, recorder })
2209
+ const migration = runMigrations(target, currentVersion, {
2210
+ dryRun,
2211
+ recorder,
2212
+ updatedAt: planTimestamp,
2213
+ })
2214
+ if (migration.plannedState) projectedState = migration.plannedState
2099
2215
  if (migration.applied > 0) {
2100
2216
  success(`Applied ${migration.applied} migration(s)`)
2101
2217
  for (const msg of migration.messages) {
@@ -2105,29 +2221,66 @@ async function installPass(
2105
2221
  }
2106
2222
  }
2107
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
+
2108
2235
  // -----------------------------------------------------------------------
2109
2236
  // 3.75 Preview summary (Beta 11 Setup phase 1): print the REAL per-file
2110
2237
  // change set this same pass would apply. Paths + action + byte size
2111
2238
  // ONLY — never file contents or hashes. Nothing was written.
2112
2239
  // -----------------------------------------------------------------------
2113
2240
  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.
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`
2117
2256
  recordChange(
2118
2257
  {
2119
2258
  path: join(target, '.pantheon', 'install-state.json'),
2120
2259
  action: hasState(target) ? ACTIONS.UPDATE : ACTIONS.CREATE,
2121
- bytes: 0,
2260
+ bytes: Buffer.byteLength(stateContent, 'utf8'),
2261
+ outputFingerprint: fingerprintOutput(stateContent),
2122
2262
  scored: false,
2123
2263
  },
2124
2264
  recorder,
2125
2265
  )
2126
2266
  const plan = summarizeChanges(preview.entries)
2267
+ planFiles = plan.files
2127
2268
  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) }))
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
+ }))
2131
2284
  const statsDelta = {
2132
2285
  created: stats.created - statsBefore.created,
2133
2286
  skipped: stats.skipped - statsBefore.skipped,
@@ -2148,6 +2301,13 @@ async function installPass(
2148
2301
  // it — a real apply is not a dry run.
2149
2302
  if (!opts.quietPlan) {
2150
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
+ }
2151
2311
  for (const line of formatChangePlan(target, plan).split('\n')) {
2152
2312
  console.log(tag(line))
2153
2313
  }
@@ -2194,6 +2354,7 @@ async function installPass(
2194
2354
  ` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
2195
2355
  )
2196
2356
  process.stdout.write('\n')
2357
+ printWorktreeGuidance({ projectRoot: process.cwd(), interactive: true })
2197
2358
  } else if (!opts.planOnly) {
2198
2359
  printSummary(target, ['opencode'], stats)
2199
2360
  }
@@ -2211,7 +2372,7 @@ async function installPass(
2211
2372
 
2212
2373
  if (opts.preset && !dryRun) {
2213
2374
  const { writeActivePreset } = await import('./model-picker.mjs')
2214
- writeActivePreset(target, opts.preset, { source: 'cli' })
2375
+ writeActivePreset(target, opts.preset, { source: 'cli', updatedAt: planTimestamp, recorder })
2215
2376
  }
2216
2377
 
2217
2378
  // -----------------------------------------------------------------------
@@ -2242,19 +2403,39 @@ async function installPass(
2242
2403
  // to `doctor` forever. Unselected components retain their previous state.
2243
2404
  if (!dryRun) {
2244
2405
  const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
2245
- let state = readState(target)
2406
+ let state = projectedState ?? readState(target)
2246
2407
  if (!state && hasState(target)) {
2247
2408
  throw new Error('Installation state exists but cannot be read; refusing to replace it')
2248
2409
  }
2249
- state ??= createInitialState(packageVersion)
2410
+ state ??= createInitialState(packageVersion, planTimestamp)
2250
2411
  state.pantheon_version = packageVersion
2412
+ state.updated_at = planTimestamp
2251
2413
  for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
2252
2414
  if (!componentSet.has(component)) continue
2253
2415
  const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
2254
2416
  const componentVersion = state.components?.[component]?.version ?? 1
2255
2417
  setComponentState(state, component, componentVersion, status)
2256
2418
  }
2257
- writeState(target, state)
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 })
2258
2439
  }
2259
2440
 
2260
2441
  // Beta 11 Setup phase 3: report whether this call APPLIED, so the caller can
@@ -2267,6 +2448,11 @@ async function installPass(
2267
2448
  reason: 'plan',
2268
2449
  planned: plannedChanges,
2269
2450
  snapshots: plannedSnapshots,
2451
+ files: planFiles,
2452
+ removals: removalPaths,
2453
+ target,
2454
+ openCodeVersion: version,
2455
+ versionAssumed,
2270
2456
  }
2271
2457
  : { ...stats, applied: true, reason: 'applied' }
2272
2458
  }