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.
- package/AGENTS.global.md +42 -18
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +24 -0
- package/README.md +66 -21
- package/bin/pantheon-init.mjs +44 -1
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +60 -6
- package/scripts/install/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/interactive.mjs +15 -1
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode.mjs +562 -50
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +22 -8
- package/scripts/install/shared.mjs +89 -55
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +11 -1
- package/scripts/mcp_persistence.py +50 -15
- package/scripts/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- package/skills-lock.json +1 -1
- package/src/agents/iris.md +6 -0
- package/src/agents/zeus.md +19 -62
- package/src/instructions/agent-return-format.instructions.md +17 -15
- package/src/instructions/memory-protocol.instructions.md +3 -2
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +6 -6
- package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
- package/src/instructions/zeus-memory-operations.instructions.md +10 -6
- package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
- package/src/mcp/mcp_persistence.py +50 -15
- package/src/pantheon/cost-command.ts +5 -2
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +18 -29
- package/src/plugin-v2.ts +13 -0
- 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 +2 -2
- package/src/plugins/tui/package.json +1 -1
- 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(
|
|
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
|
-
|
|
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 {
|
|
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
|
|
306
|
-
*
|
|
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,
|
|
313
|
-
* effects that must not fire on a conflict
|
|
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
|
|
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
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
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
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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:
|
|
386
|
-
|
|
387
|
-
|
|
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
|
}
|