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.
- package/AGENTS.global.md +3 -3
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +69 -30
- package/README.pt-BR.md +5 -4
- package/bin/pantheon-init.mjs +279 -101
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +77 -13
- package/scripts/check-plugin-v2-coverage.mjs +7 -7
- package/scripts/doctor.mjs +242 -19
- package/scripts/install/backup.mjs +191 -18
- package/scripts/install/cli-ui.mjs +73 -0
- package/scripts/install/file-inventory.mjs +555 -0
- package/scripts/install/migrate.mjs +45 -38
- package/scripts/install/model-picker.mjs +63 -22
- package/scripts/install/opencode-version.mjs +9 -1
- package/scripts/install/opencode.mjs +265 -79
- package/scripts/install/plan-artifact.mjs +517 -0
- package/scripts/install/plan.mjs +165 -3
- package/scripts/install/plugin.mjs +118 -14
- package/scripts/install/shared.mjs +186 -65
- package/scripts/install/state.mjs +63 -8
- package/scripts/install/venv.mjs +15 -6
- package/scripts/install/verify.mjs +128 -0
- package/scripts/install/vision-prompt.mjs +2 -0
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/manifest-inventory.mjs +49 -3
- package/scripts/node-engine-policy.mjs +74 -0
- package/scripts/package-evidence.mjs +6 -0
- package/scripts/postinstall.mjs +3 -4
- package/scripts/reconcile.mjs +325 -0
- package/scripts/uninstall.mjs +258 -34
- package/scripts/update.mjs +164 -0
- package/scripts/validate-node-engine-policy.mjs +13 -0
- package/scripts/version-check.mjs +34 -1
- package/scripts/versioning.mjs +9 -1
- package/skills-lock.json +3 -3
- package/src/agents/mnemosyne.md +7 -2
- package/src/instructions/memory-protocol.instructions.md +2 -2
- package/src/instructions/zeus-memory-operations.instructions.md +4 -3
- package/src/pantheon/v2-unsupported.mjs +2 -3
- package/src/plugins/tui/dist/tui-v2.js +2 -2
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +3 -3
- package/src/plugins/tui/package.json +2 -2
- package/src/skills/artifact-management/SKILL.md +5 -1
- package/src/skills/memory-bank/SKILL.md +5 -1
- 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
|
|
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 {
|
|
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
|
-
{
|
|
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, {
|
|
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, {
|
|
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),
|
|
1021
|
-
* PATHS already applied (never contents), prints the recovery
|
|
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',
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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, {
|
|
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(
|
|
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, {
|
|
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
|
-
//
|
|
2115
|
-
//
|
|
2116
|
-
|
|
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:
|
|
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
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
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
|
-
|
|
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
|
}
|