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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/AGENTS.global.md +42 -18
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +24 -0
  4. package/README.md +66 -21
  5. package/bin/pantheon-init.mjs +44 -1
  6. package/package.json +4 -4
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +1 -1
  9. package/scripts/build-agents-md.mjs +60 -6
  10. package/scripts/install/backup.mjs +475 -0
  11. package/scripts/install/cli-ui.mjs +6 -1
  12. package/scripts/install/exit-codes.mjs +26 -0
  13. package/scripts/install/interactive.mjs +15 -1
  14. package/scripts/install/migrate.mjs +41 -22
  15. package/scripts/install/opencode.mjs +562 -50
  16. package/scripts/install/plan.mjs +287 -0
  17. package/scripts/install/plugin.mjs +22 -8
  18. package/scripts/install/shared.mjs +89 -55
  19. package/scripts/install/strings.mjs +20 -0
  20. package/scripts/install/venv.mjs +13 -3
  21. package/scripts/install/vision-prompt.mjs +11 -1
  22. package/scripts/mcp_persistence.py +50 -15
  23. package/scripts/prune.mjs +9 -3
  24. package/scripts/restore.mjs +222 -0
  25. package/skills-lock.json +1 -1
  26. package/src/agents/iris.md +6 -0
  27. package/src/agents/zeus.md +19 -62
  28. package/src/instructions/agent-return-format.instructions.md +17 -15
  29. package/src/instructions/memory-protocol.instructions.md +3 -2
  30. package/src/instructions/test-economy.instructions.md +21 -0
  31. package/src/instructions/zeus-anti-stall.instructions.md +6 -6
  32. package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
  33. package/src/instructions/zeus-memory-operations.instructions.md +10 -6
  34. package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
  35. package/src/mcp/mcp_persistence.py +50 -15
  36. package/src/pantheon/cost-command.ts +5 -2
  37. package/src/pantheon/model-agents.ts +19 -0
  38. package/src/pantheon/model-command.ts +218 -56
  39. package/src/pantheon/v2-model-overrides.ts +275 -0
  40. package/src/pantheon/v2-tools.ts +18 -29
  41. package/src/plugin-v2.ts +13 -0
  42. package/src/plugins/tui/dist/tui-v2.js +2 -2
  43. package/src/plugins/tui/dist/tui.js +1 -1
  44. package/src/plugins/tui/package-lock.json +2 -2
  45. package/src/plugins/tui/package.json +1 -1
  46. package/src/skills/auto-continue/SKILL.md +14 -6
@@ -0,0 +1,287 @@
1
+ /**
2
+ * plan.mjs — Per-file change planning for the Pantheon installer.
3
+ *
4
+ * Beta 11, Setup phase 1: `pantheon-opencode init ... --dry-run` must print the
5
+ * REAL per-file change set (create / update / remove / unchanged) that the SAME
6
+ * code path would produce, and write nothing.
7
+ *
8
+ * The plan is built by a change recorder threaded through the writer helpers as
9
+ * an OPTIONAL argument whose default is a module-level no-op singleton, so any
10
+ * caller (or writer) that is not instrumented keeps byte-identical behavior.
11
+ *
12
+ * NET-OUTCOME semantics: several writers can touch the same path in one pass
13
+ * (the TUI `unregister`→`register` toggle; the code-mode `syncDir` payload and
14
+ * its generated `manifest.json`). The LAST writer decides the net disk result,
15
+ * so a later no-write (skipped/unchanged) leaves the path UNCHANGED even when an
16
+ * earlier writer computed a would-be update. A no-write never promotes a path to
17
+ * a scored change.
18
+ *
19
+ * "Scored" vs "unscored": writers that increment the install summary
20
+ * (`stats.created`) alongside a genuine change are SCORED. Artifacts that are
21
+ * not part of that summary (the synthetic `.venv` entry, the state-only
22
+ * `.pantheon/install-state.json` write, the backup-set manifest, and the TUI
23
+ * stale-ref cleanup) are recorded UNSCORED so they still appear in the printed
24
+ * plan but never affect the consistency guard.
25
+ *
26
+ * Security: the recorder, the summary and the formatter deal in paths, actions
27
+ * and byte sizes ONLY. File contents and hashes stay internal — hashes are kept
28
+ * so a later phase can detect drift between a preview and an apply, and are
29
+ * never printed.
30
+ */
31
+
32
+ import { createHash } from 'node:crypto'
33
+ import { lstatSync, readFileSync } from 'node:fs'
34
+ import { isAbsolute, relative } from 'node:path'
35
+
36
+ /** The action vocabulary shared by the recorder, the summary and the formatter. */
37
+ export const ACTIONS = Object.freeze({
38
+ CREATE: 'create',
39
+ UPDATE: 'update',
40
+ REMOVE: 'remove',
41
+ UNCHANGED: 'unchanged',
42
+ CONFLICT: 'conflict',
43
+ })
44
+
45
+ const ACTION_VALUES = new Set(Object.values(ACTIONS))
46
+
47
+ /** Whether an action represents a real change to the target (not a no-write). */
48
+ function isChange(action) {
49
+ return action === ACTIONS.CREATE || action === ACTIONS.UPDATE || action === ACTIONS.REMOVE
50
+ }
51
+
52
+ // Display order for the formatted plan.
53
+ const DISPLAY_ORDER = Object.freeze([
54
+ ACTIONS.CREATE,
55
+ ACTIONS.UPDATE,
56
+ ACTIONS.REMOVE,
57
+ ACTIONS.CONFLICT,
58
+ ACTIONS.UNCHANGED,
59
+ ])
60
+
61
+ /**
62
+ * The module-level no-op recorder. Instrumented writers whose caller did not
63
+ * pass a recorder fall back to this, so their behavior is byte-identical to an
64
+ * uninstrumented run (no allocation, no side effects).
65
+ */
66
+ export function noopRecorder() {}
67
+
68
+ /** The default recorder instance — the no-op singleton. */
69
+ export const NOOP_RECORDER = noopRecorder
70
+
71
+ /**
72
+ * Whether a recorder will actually collect anything. Writers in hot loops use
73
+ * this to skip `Buffer.byteLength(content)` on the common (uninstrumented) path.
74
+ *
75
+ * @param {unknown} recorder
76
+ * @returns {boolean}
77
+ */
78
+ export function isRecording(recorder) {
79
+ return typeof recorder === 'function' && recorder !== NOOP_RECORDER
80
+ }
81
+
82
+ /** Normalize an arbitrary change into the `{ path, action, bytes, scored }` shape. */
83
+ function normalizeChange(change) {
84
+ const action = change && ACTION_VALUES.has(change.action) ? change.action : ACTIONS.UNCHANGED
85
+ return {
86
+ path: String(change?.path ?? ''),
87
+ action,
88
+ bytes: Number.isFinite(change?.bytes) ? change.bytes : 0,
89
+ // Scored by default: most writers feed the install summary. Synthetic and
90
+ // state-only entries opt out explicitly with `scored: false`.
91
+ scored: !(change && change.scored === false),
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Create a recorder that collects changes in memory. `recorder` is the function
97
+ * to thread into the writer helpers; `entries` holds the raw recorded changes.
98
+ *
99
+ * @returns {{ recorder: (change: object) => void, entries: object[] }}
100
+ */
101
+ export function createRecorder() {
102
+ const entries = []
103
+ const recorder = (change) => {
104
+ entries.push(normalizeChange(change))
105
+ }
106
+ return { recorder, entries }
107
+ }
108
+
109
+ /**
110
+ * Record a single change. Writers call this instead of touching fs bookkeeping.
111
+ * With no explicit recorder the module-level no-op singleton is used.
112
+ *
113
+ * @param {{ path: string, action: string, bytes?: number, scored?: boolean }} change
114
+ * @param {(change: object) => void} [recorder]
115
+ */
116
+ export function recordChange(change, recorder = NOOP_RECORDER) {
117
+ if (!isRecording(recorder)) return
118
+ recorder(normalizeChange(change))
119
+ }
120
+
121
+ /** Sort helper: display order first, then path for determinism. */
122
+ function compareFiles(a, b) {
123
+ const order = DISPLAY_ORDER.indexOf(a.action) - DISPLAY_ORDER.indexOf(b.action)
124
+ if (order !== 0) return order
125
+ return a.path.localeCompare(b.path)
126
+ }
127
+
128
+ function zeroTotals() {
129
+ return { create: 0, update: 0, remove: 0, unchanged: 0, conflict: 0 }
130
+ }
131
+
132
+ /** Tally a change into one of the five action buckets. */
133
+ function tally(totals, action) {
134
+ if (Object.hasOwn(totals, action)) totals[action] += 1
135
+ }
136
+
137
+ /**
138
+ * Collapse recorded changes into the final change set: one entry per path,
139
+ * reflecting the NET disk outcome, plus totals.
140
+ *
141
+ * The last record for a path decides the action, so a later no-write wins over
142
+ * an earlier would-be write. A path is scored only if at least one of its
143
+ * CHANGE records was scored AND the net action is still a change — a no-write
144
+ * can never turn a path into a scored change.
145
+ *
146
+ * @param {object[]} entries
147
+ * @returns {{
148
+ * files: object[],
149
+ * totals: { create: number, update: number, remove: number, unchanged: number, conflict: number },
150
+ * scored: { create: number, update: number, remove: number, unchanged: number, conflict: number },
151
+ * }}
152
+ */
153
+ export function summarizeChanges(entries = []) {
154
+ const byPath = new Map()
155
+ for (const entry of entries ?? []) {
156
+ const change = normalizeChange(entry)
157
+ let record = byPath.get(change.path)
158
+ if (!record) {
159
+ record = { path: change.path, action: change.action, bytes: change.bytes, scored: false }
160
+ byPath.set(change.path, record)
161
+ }
162
+ // The LAST writer decides the net on-disk outcome. A later no-write
163
+ // (skipped/unchanged) leaves the path unchanged even if an earlier writer
164
+ // computed a would-be update (e.g. unregister→register toggling).
165
+ record.action = change.action
166
+ record.bytes = change.bytes
167
+ // Only a genuine change can score a path; a no-write never does.
168
+ if (isChange(change.action) && change.scored) record.scored = true
169
+ }
170
+
171
+ const files = [...byPath.values()].sort(compareFiles)
172
+ for (const file of files) {
173
+ if (!isChange(file.action)) file.scored = false
174
+ }
175
+
176
+ const totals = zeroTotals()
177
+ const scored = zeroTotals()
178
+ for (const file of files) {
179
+ tally(totals, file.action)
180
+ if (file.scored) tally(scored, file.action)
181
+ }
182
+ return { files, totals, scored }
183
+ }
184
+
185
+ /** Human-readable byte size. The caller never sees raw bytes or hashes. */
186
+ function formatBytes(bytes) {
187
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
188
+ if (bytes < 1024) return `${bytes} B`
189
+ const kb = bytes / 1024
190
+ if (kb < 1024) return `${kb.toFixed(1)} KB`
191
+ return `${(kb / 1024).toFixed(1)} MB`
192
+ }
193
+
194
+ /** Path shown in the plan: relative to the target when it lives inside it. */
195
+ export function displayPath(target, filePath) {
196
+ if (!target) return filePath
197
+ const rel = relative(target, filePath)
198
+ if (rel === '') return '.'
199
+ return rel.startsWith('..') || isAbsolute(rel) ? filePath : rel
200
+ }
201
+
202
+ /**
203
+ * Render the secret-safe plan: paths + action + byte size ONLY. No contents,
204
+ * no hashes.
205
+ *
206
+ * @param {string} target - install target (used to relativize paths)
207
+ * @param {{ files: object[], totals: object }} plan
208
+ * @returns {string}
209
+ */
210
+ export function formatChangePlan(target, plan) {
211
+ const { files, totals } = plan
212
+ const lines = []
213
+ lines.push(`Change plan — ${target}`)
214
+ for (const file of files) {
215
+ const isWrite = file.action === ACTIONS.CREATE || file.action === ACTIONS.UPDATE
216
+ const size = isWrite ? ` (${formatBytes(file.bytes)})` : ''
217
+ lines.push(` ${file.action.padEnd(9)} ${displayPath(target, file.path)}${size}`)
218
+ }
219
+ lines.push(` ${'─'.repeat(28)}`)
220
+ lines.push(
221
+ ` ${totals.create} create · ${totals.update} update · ${totals.remove} remove · ` +
222
+ `${totals.unchanged} unchanged · ${totals.conflict} conflict`,
223
+ )
224
+ lines.push(' No files were written.')
225
+ return lines.join('\n')
226
+ }
227
+
228
+ /**
229
+ * Snapshot a path for later drift comparison. Hashes stay internal.
230
+ *
231
+ * @param {string} filePath
232
+ * @returns {{ exists: boolean, sha256: string|null, size: number, mode: number|null }}
233
+ */
234
+ export function snapshot(filePath) {
235
+ let stat
236
+ try {
237
+ stat = lstatSync(filePath)
238
+ } catch {
239
+ return { exists: false, sha256: null, size: 0, mode: null }
240
+ }
241
+ if (!stat.isFile()) {
242
+ return { exists: true, sha256: null, size: stat.size, mode: stat.mode }
243
+ }
244
+ let buffer
245
+ try {
246
+ buffer = readFileSync(filePath)
247
+ } catch {
248
+ return { exists: true, sha256: null, size: stat.size, mode: stat.mode }
249
+ }
250
+ return {
251
+ exists: true,
252
+ sha256: createHash('sha256').update(buffer).digest('hex'),
253
+ size: buffer.length,
254
+ mode: stat.mode,
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Compare the preview's SCORED change count against this call's install
260
+ * summary delta. Returns a descriptor on mismatch, or `null` when they agree.
261
+ *
262
+ * Only the CHANGED count is gated (`create + update + remove` vs
263
+ * `stats.created`). `stats.skipped` deliberately mixes genuine file-unchanged
264
+ * outcomes with logical no-op skips that never touch a file (e.g. the V2 TUI
265
+ * registration skip), so it cannot be reconciled against `plan.scored.unchanged`
266
+ * without making the guard cry wolf. The meaningful invariant is: every change
267
+ * the summary counted must have been recorded — a preview that silently missed
268
+ * a writer is exactly a `stats.created` with no recorded change.
269
+ *
270
+ * Both sides must be scoped to a SINGLE `installOpenCode` call.
271
+ *
272
+ * @param {{ scored: object }} plan
273
+ * @param {{ created: number, skipped: number }} stats - per-call delta
274
+ * @returns {{ changed: number, unchanged: number, recordedChanged: number, recordedUnchanged: number }|null}
275
+ */
276
+ export function planStatsMismatch(plan, stats) {
277
+ const changed = plan.scored.create + plan.scored.update + plan.scored.remove
278
+ if (changed !== stats.created) {
279
+ return {
280
+ changed,
281
+ unchanged: plan.scored.unchanged,
282
+ recordedChanged: stats.created,
283
+ recordedUnchanged: stats.skipped,
284
+ }
285
+ }
286
+ return null
287
+ }
@@ -17,9 +17,14 @@ import { ROOT, writeIfChanged } from './shared.mjs'
17
17
  * @param {object} [options]
18
18
  * @param {boolean} [options.dryRun=false] - If true, don't write files
19
19
  * @param {'v1'|'v2'} [options.version='v1'] - Host plugin generation
20
+ * @param {(change: object) => void} [options.recorder] - optional change recorder for preview
20
21
  * @returns {{ created: number, skipped: number }}
21
22
  */
22
- export function copyPluginFiles(srcDir, dstDir, { dryRun = false, version = 'v1' } = {}) {
23
+ export function copyPluginFiles(
24
+ srcDir,
25
+ dstDir,
26
+ { dryRun = false, version = 'v1', recorder = null } = {},
27
+ ) {
23
28
  const result = { created: 0, skipped: 0 }
24
29
 
25
30
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
@@ -36,7 +41,7 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false, version = 'v1'
36
41
  if (existsSync(distSrc)) {
37
42
  for (const f of readdirSync(distSrc)) {
38
43
  const c = readFileSync(join(distSrc, f), 'utf8')
39
- const status = writeIfChanged(join(dstDir, 'dist', f), c, dryRun)
44
+ const status = writeIfChanged(join(dstDir, 'dist', f), c, dryRun, { recorder })
40
45
  if (status === 'created') result.created++
41
46
  else result.skipped++
42
47
  }
@@ -53,6 +58,7 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false, version = 'v1'
53
58
  join(dstDir, 'package.json'),
54
59
  `${JSON.stringify(packageJson, null, 2)}\n`,
55
60
  dryRun,
61
+ { recorder },
56
62
  )
57
63
  if (pkgStatus === 'created') result.created++
58
64
  else result.skipped++
@@ -65,6 +71,7 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false, version = 'v1'
65
71
  join(dstDir, 'tui.ts'),
66
72
  readFileSync(tuiEntrySrc, 'utf8'),
67
73
  dryRun,
74
+ { recorder },
68
75
  )
69
76
  if (entryStatus === 'created') result.created++
70
77
  else result.skipped++
@@ -77,6 +84,7 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false, version = 'v1'
77
84
  v2ServerDestination,
78
85
  readFileSync(v2ServerEntry, 'utf8'),
79
86
  dryRun,
87
+ { recorder },
80
88
  )
81
89
  if (entryStatus === 'created') result.created++
82
90
  else result.skipped++
@@ -92,6 +100,7 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false, version = 'v1'
92
100
  join(dstDir, 'package-lock.json'),
93
101
  readFileSync(lockSrc, 'utf8'),
94
102
  dryRun,
103
+ { recorder },
95
104
  )
96
105
  if (lockStatus === 'created') result.created++
97
106
  else result.skipped++
@@ -107,12 +116,13 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false, version = 'v1'
107
116
  * @param {object} [options]
108
117
  * @param {boolean} [options.dryRun=false] - If true, don't write files
109
118
  * @param {boolean} [options.clean=false] - If true, remove existing before install
119
+ * @param {(change: object) => void} [options.recorder] - optional change recorder for preview
110
120
  * @returns {{ created: number, skipped: number, errors: number }}
111
121
  */
112
122
  export function installPlugin(
113
123
  srcDir,
114
124
  dstDir,
115
- { dryRun = false, clean = false, version = 'v1' } = {},
125
+ { dryRun = false, clean = false, version = 'v1', recorder = null } = {},
116
126
  ) {
117
127
  const result = { created: 0, skipped: 0, errors: 0 }
118
128
 
@@ -120,7 +130,7 @@ export function installPlugin(
120
130
  rmSync(dstDir, { recursive: true, force: true })
121
131
  }
122
132
 
123
- const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun, version })
133
+ const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun, version, recorder })
124
134
  result.created += copyResult.created
125
135
  result.skipped += copyResult.skipped
126
136
 
@@ -150,9 +160,10 @@ export function installPluginDependencies(pluginDir, { dryRun = false } = {}) {
150
160
  * @param {string} pluginId - Plugin identifier string
151
161
  * @param {object} [options]
152
162
  * @param {boolean} [options.dryRun=false]
163
+ * @param {(change: object) => void} [options.recorder] - optional change recorder for preview
153
164
  * @returns {'created'|'skipped'}
154
165
  */
155
- export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
166
+ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false, recorder = null } = {}) {
156
167
  let tuiConfig = { plugin: [] }
157
168
  if (existsSync(tuiJsonPath)) {
158
169
  try {
@@ -168,7 +179,7 @@ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
168
179
  tuiConfig.plugin.push(pluginId)
169
180
  }
170
181
  const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
171
- return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
182
+ return writeIfChanged(tuiJsonPath, tuiContent, dryRun, { recorder })
172
183
  }
173
184
 
174
185
  /**
@@ -256,12 +267,14 @@ export function staleTuiRefs(pluginArray, knownPantheonPaths = []) {
256
267
  * @param {string} pluginId - Plugin to unregister
257
268
  * @param {object} [options]
258
269
  * @param {boolean} [options.dryRun=false]
270
+ * @param {string[]} [options.knownPantheonPaths=[]]
271
+ * @param {(change: object) => void} [options.recorder] - optional change recorder for preview
259
272
  * @returns {'created'|'skipped'}
260
273
  */
261
274
  export function unregisterPlugin(
262
275
  tuiJsonPath,
263
276
  pluginId,
264
- { dryRun = false, knownPantheonPaths = [] } = {},
277
+ { dryRun = false, knownPantheonPaths = [], recorder = null } = {},
265
278
  ) {
266
279
  if (!existsSync(tuiJsonPath)) {
267
280
  // Nothing to clean — and never create an empty tui.json in a location
@@ -292,5 +305,6 @@ export function unregisterPlugin(
292
305
  return !isPantheonTuiRef(ref, knownPantheonPaths)
293
306
  })
294
307
  const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
295
- return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
308
+ // Stale-ref cleanup is not part of the install summary, so record it unscored.
309
+ return writeIfChanged(tuiJsonPath, tuiContent, dryRun, { recorder, scored: false })
296
310
  }
@@ -4,8 +4,6 @@
4
4
 
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import {
7
- copyFileSync,
8
- cpSync,
9
7
  existsSync,
10
8
  lstatSync,
11
9
  mkdirSync,
@@ -16,7 +14,7 @@ import {
16
14
  statSync,
17
15
  writeFileSync,
18
16
  } from 'node:fs'
19
- import { basename, dirname, join } from 'node:path'
17
+ import { dirname, join } from 'node:path'
20
18
  import { fileURLToPath } from 'node:url'
21
19
 
22
20
  const REQUIRED_NODE_MAJOR = 18
@@ -27,6 +25,8 @@ if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
27
25
 
28
26
  import { dump, load } from 'js-yaml'
29
27
 
28
+ import { ACTIONS, isRecording, recordChange } from './plan.mjs'
29
+
30
30
  export const __dirname = dirname(fileURLToPath(import.meta.url))
31
31
  export const ROOT = join(__dirname, '..', '..')
32
32
  export const AGENTS_DIR = join(ROOT, 'src', 'agents')
@@ -141,33 +141,6 @@ export function resolveTarget(target) {
141
141
  return join(process.cwd(), target)
142
142
  }
143
143
 
144
- export function createBackup(target) {
145
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
146
- const backupDir = join(target, '..', `.pantheon-bak-${basename(target)}-${timestamp}`)
147
-
148
- console.log(`\n 💾 Creating backup: ${backupDir}`)
149
- mkdirSync(backupDir, { recursive: true })
150
-
151
- // Copy only relevant Pantheon directories/files
152
- const items = ['agents', 'skills', 'commands', 'instructions', 'opencode.json', 'tui.json']
153
- let count = 0
154
- for (const item of items) {
155
- const src = join(target, item)
156
- try {
157
- cpSync(src, join(backupDir, item), {
158
- recursive: true,
159
- errorOnExist: false,
160
- })
161
- count++
162
- } catch {
163
- // File or directory doesn't exist in target — skip silently
164
- }
165
- }
166
-
167
- console.log(` ✅ Backed up ${count} items to: ${backupDir}`)
168
- return backupDir
169
- }
170
-
171
144
  export function detectAndReport(target) {
172
145
  const found = detectPlatforms(target).length > 0
173
146
 
@@ -302,19 +275,27 @@ function readFileOrNull(filePath) {
302
275
  * filesystem CAS: the final re-read and the rename are still two syscalls, so
303
276
  * a writer that lands between them is not prevented. What it does close is
304
277
  * the window the installer actually races with — a config the caller parsed
305
- * going stale during the same run — and it keeps the target and the `.bak`
306
- * consistent with the bytes that were checked.
278
+ * going stale during the same run — and it keeps the target consistent with
279
+ * the bytes that were checked.
307
280
  * @param {(existing: string|null) => void} [options.beforeRecheck] - runs after
308
281
  * the initial content check and before the last-moment re-read. Injection
309
282
  * point so tests can simulate a concurrent writer landing in the window;
310
283
  * production callers omit it (undefined is a no-op).
311
284
  * @param {(existing: string|null) => void} [options.beforeSwap] - runs after the
312
- * expected-content checks pass and immediately before the rename, so side
313
- * effects that must not fire on a conflict (e.g. the `.bak` copy) live here.
285
+ * expected-content checks pass and immediately before the rename, for side
286
+ * effects that must not fire on a conflict.
287
+ * @param {(change: object) => void} [options.recorder] - optional change
288
+ * recorder. When supplied, the outcome is recorded (path + action + byte
289
+ * size only) so `--dry-run` can print the real per-file plan. Defaults to
290
+ * the module-level no-op singleton, so an uninstrumented caller is
291
+ * byte-identical to before.
292
+ * @param {boolean} [options.scored] - set `false` when this write is not part
293
+ * of the install summary (e.g. stale-ref cleanup), so it is shown in the plan
294
+ * but excluded from the consistency guard.
314
295
  * @returns {'created'|'skipped'|'conflict'}
315
296
  */
316
297
  export function writeIfChanged(filePath, content, dryRun, options = {}) {
317
- const { expectedContent, beforeSwap, beforeRecheck } = options
298
+ const { expectedContent, beforeSwap, beforeRecheck, recorder, scored } = options
318
299
 
319
300
  // Refuse to write through/over a symlink. A dangling symlink is invisible to
320
301
  // existsSync, and rename-over-link would replace the link rather than its
@@ -325,12 +306,29 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
325
306
  }
326
307
 
327
308
  const existing = stat === null ? null : readFileOrNull(filePath)
309
+ const record = (outcome) => {
310
+ if (!isRecording(recorder)) return
311
+ const action =
312
+ outcome === 'conflict'
313
+ ? ACTIONS.CONFLICT
314
+ : outcome === 'skipped'
315
+ ? ACTIONS.UNCHANGED
316
+ : existing === null
317
+ ? ACTIONS.CREATE
318
+ : ACTIONS.UPDATE
319
+ recordChange(
320
+ { path: filePath, action, bytes: Buffer.byteLength(content, 'utf8'), scored },
321
+ recorder,
322
+ )
323
+ }
328
324
  if (existing === content) {
325
+ record('skipped')
329
326
  return 'skipped'
330
327
  }
331
328
  if (expectedContent !== undefined && existing !== expectedContent) {
332
329
  // The file changed since the caller read it (or appeared/disappeared).
333
330
  // Do not clobber the newer content; the caller decides how to react.
331
+ record('conflict')
334
332
  return 'conflict'
335
333
  }
336
334
  if (!dryRun) {
@@ -347,11 +345,12 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
347
345
  // Last-moment re-read. The initial compare and this rename are separate
348
346
  // syscalls, so a concurrent writer can land in between; re-check the
349
347
  // expected bytes immediately before the swap. On a mismatch, drop the temp
350
- // and leave the target AND any existing `.bak` byte-identical.
348
+ // and leave the target byte-identical.
351
349
  if (expectedContent !== undefined) {
352
350
  const current = readFileOrNull(filePath)
353
351
  if (current !== expectedContent) {
354
352
  rmSync(tmpPath, { force: true })
353
+ record('conflict')
355
354
  return 'conflict'
356
355
  }
357
356
  }
@@ -359,19 +358,20 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
359
358
  if (typeof beforeSwap === 'function') beforeSwap(existing)
360
359
  renameSync(tmpPath, filePath)
361
360
  }
361
+ record('created')
362
362
  return 'created'
363
363
  }
364
364
 
365
365
  /**
366
- * beta.5: write the user's opencode.json with a same-directory backup of the
367
- * previous content. The installer rewrites the whole config; a `.bak` gives
368
- * the user a one-step recovery if a merge ever goes wrong.
366
+ * Write a config file atomically, honoring the compare-before-write guard.
367
+ * This used to always keep a `<config>.bak` sidecar; the main `opencode.json`
368
+ * no longer does — the target-scoped exact backup set
369
+ * (scripts/install/backup.mjs) preserves it byte-for-byte and
370
+ * `pantheon-opencode restore` reverts it.
369
371
  *
370
- * The backup is created INSIDE the `beforeSwap` hook, so it fires only after
371
- * both expected-content checks pass (the initial compare and the last-moment
372
- * re-read before the rename). A conflict observed at either point leaves the
373
- * target AND any pre-existing `.bak` untouched, rather than overwriting the
374
- * user's last known-good backup with a stale copy on a no-op write.
372
+ * A caller may still supply `beforeSwap` for a file the exact set does NOT
373
+ * cover: the GLOBAL `opencode-vision.json` lives outside the config dir, so
374
+ * vision-prompt passes a `beforeSwap` that keeps its `<file>.bak` recovery.
375
375
  *
376
376
  * @param {string} filePath
377
377
  * @param {string} content
@@ -382,14 +382,9 @@ export function writeConfigWithBackup(filePath, content, dryRun, options = {}) {
382
382
  return writeIfChanged(filePath, content, dryRun, {
383
383
  expectedContent: options.expectedContent,
384
384
  beforeRecheck: options.beforeRecheck,
385
- beforeSwap: (existing) => {
386
- if (existing === null) return
387
- try {
388
- copyFileSync(filePath, `${filePath}.bak`)
389
- } catch {
390
- // Best-effort: a backup failure must not block the install.
391
- }
392
- },
385
+ beforeSwap: options.beforeSwap,
386
+ recorder: options.recorder,
387
+ scored: options.scored,
393
388
  })
394
389
  }
395
390
 
@@ -455,7 +450,7 @@ export function serializeFm(fm) {
455
450
  })
456
451
  }
457
452
 
458
- export function installSkills(skills, target, dryRun, subDir = '.opencode') {
453
+ export function installSkills(skills, target, dryRun, subDir = '.opencode', recorder = null) {
459
454
  const srcSkillsDir = join(ROOT, 'src', 'skills')
460
455
  const dstSkillsDir = join(target, subDir, 'skills')
461
456
 
@@ -481,9 +476,29 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
481
476
  const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf8') : null
482
477
  if (existing === content) {
483
478
  skipped++
479
+ if (isRecording(recorder)) {
480
+ recordChange(
481
+ {
482
+ path: dstPath,
483
+ action: ACTIONS.UNCHANGED,
484
+ bytes: Buffer.byteLength(content, 'utf8'),
485
+ },
486
+ recorder,
487
+ )
488
+ }
484
489
  } else {
485
490
  if (!dryRun) writeFileSync(dstPath, content, 'utf8')
486
491
  created++
492
+ if (isRecording(recorder)) {
493
+ recordChange(
494
+ {
495
+ path: dstPath,
496
+ action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
497
+ bytes: Buffer.byteLength(content, 'utf8'),
498
+ },
499
+ recorder,
500
+ )
501
+ }
487
502
  }
488
503
  }
489
504
  }
@@ -504,6 +519,9 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
504
519
  console.log(` 🗑️ Removed stale skill: ${entry}`)
505
520
  }
506
521
  created++
522
+ // A stale removal increments `created` (a change), so it must also be
523
+ // recorded or the preview would under-report on a non-fresh target.
524
+ recordChange({ path: dstPath, action: ACTIONS.REMOVE, bytes: 0 }, recorder)
507
525
  }
508
526
  }
509
527
  }
@@ -511,7 +529,7 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
511
529
  return { created, skipped }
512
530
  }
513
531
 
514
- export function syncDir(src, dst, dryRun, clean = false, filter = null) {
532
+ export function syncDir(src, dst, dryRun, clean = false, filter = null, recorder = null) {
515
533
  if (!existsSync(src)) return { created: 0, skipped: 0 }
516
534
  let created = 0
517
535
  let skipped = 0
@@ -530,7 +548,7 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null) {
530
548
  const srcPath = join(src, entry)
531
549
  const dstPath = join(dst, entry)
532
550
  if (statSync(srcPath).isDirectory()) {
533
- const sub = syncDir(srcPath, dstPath, dryRun, false, filter)
551
+ const sub = syncDir(srcPath, dstPath, dryRun, false, filter, recorder)
534
552
  created += sub.created
535
553
  skipped += sub.skipped
536
554
  } else {
@@ -538,9 +556,25 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null) {
538
556
  const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf8') : null
539
557
  if (existing === content) {
540
558
  skipped++
559
+ if (isRecording(recorder)) {
560
+ recordChange(
561
+ { path: dstPath, action: ACTIONS.UNCHANGED, bytes: Buffer.byteLength(content, 'utf8') },
562
+ recorder,
563
+ )
564
+ }
541
565
  } else {
542
566
  if (!dryRun) writeFileSync(dstPath, content, 'utf8')
543
567
  created++
568
+ if (isRecording(recorder)) {
569
+ recordChange(
570
+ {
571
+ path: dstPath,
572
+ action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
573
+ bytes: Buffer.byteLength(content, 'utf8'),
574
+ },
575
+ recorder,
576
+ )
577
+ }
544
578
  }
545
579
  }
546
580
  }