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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/AGENTS.global.md +3 -3
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +85 -31
  5. package/README.pt-BR.md +7 -4
  6. package/bin/pantheon-init.mjs +278 -102
  7. package/package.json +5 -3
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +77 -13
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +191 -18
  14. package/scripts/install/cli-ui.mjs +73 -0
  15. package/scripts/install/file-inventory.mjs +555 -0
  16. package/scripts/install/interactive.mjs +69 -391
  17. package/scripts/install/migrate.mjs +45 -38
  18. package/scripts/install/model-picker.mjs +63 -22
  19. package/scripts/install/opencode-version.mjs +9 -1
  20. package/scripts/install/opencode.mjs +279 -79
  21. package/scripts/install/plan-artifact.mjs +517 -0
  22. package/scripts/install/plan.mjs +165 -3
  23. package/scripts/install/plugin.mjs +118 -14
  24. package/scripts/install/prompt-result.mjs +2 -0
  25. package/scripts/install/shared.mjs +188 -65
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/venv.mjs +15 -6
  28. package/scripts/install/verify.mjs +128 -0
  29. package/scripts/install/vision-prompt.mjs +2 -0
  30. package/scripts/install-mcp.mjs +1 -1
  31. package/scripts/manifest-inventory.mjs +49 -3
  32. package/scripts/node-engine-policy.mjs +85 -0
  33. package/scripts/package-evidence.mjs +6 -0
  34. package/scripts/postinstall.mjs +26 -26
  35. package/scripts/reconcile.mjs +325 -0
  36. package/scripts/sync-tui.mjs +5 -1
  37. package/scripts/uninstall.mjs +258 -34
  38. package/scripts/update.mjs +164 -0
  39. package/scripts/validate-node-engine-policy.mjs +13 -0
  40. package/scripts/version-check.mjs +34 -1
  41. package/scripts/versioning.mjs +9 -1
  42. package/skills-lock.json +3 -3
  43. package/src/agents/mnemosyne.md +7 -2
  44. package/src/instructions/memory-protocol.instructions.md +2 -2
  45. package/src/instructions/zeus-memory-operations.instructions.md +4 -3
  46. package/src/pantheon/v2-unsupported.mjs +2 -3
  47. package/src/plugins/tui/dist/tui-v2.js +2 -2
  48. package/src/plugins/tui/dist/tui.js +1 -1
  49. package/src/plugins/tui/package-lock.json +3 -3
  50. package/src/plugins/tui/package.json +2 -2
  51. package/src/skills/artifact-management/SKILL.md +5 -1
  52. package/src/skills/memory-bank/SKILL.md +5 -1
  53. package/src/skills/worktrees/SKILL.md +30 -11
@@ -1,420 +1,98 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { emitKeypressEvents } from 'node:readline'
4
- import { colors } from './cli-ui.mjs'
3
+ import * as clack from '@clack/prompts'
4
+ import { PROMPT_CANCELED } from './prompt-result.mjs'
5
5
 
6
- const cursorHide = () => process.stdout.write('\x1b[?25l')
7
- const cursorShow = () => process.stdout.write('\x1b[?25h')
8
- const cursorUp = (n) => process.stdout.write(`\x1b[${n}A`)
9
- const clearDown = () => process.stdout.write('\x1b[J')
6
+ export { PROMPT_CANCELED }
10
7
 
11
- function setupStdin() {
12
- if (!process.stdin.isTTY) return false
13
- if (!process.stdout.isTTY) return false
14
- emitKeypressEvents(process.stdin)
15
- try {
16
- process.stdin.setRawMode(true)
17
- } catch {
18
- return false
19
- }
20
- process.stdin.resume()
21
- return true
8
+ function isTerminal() {
9
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
22
10
  }
23
11
 
24
- function teardownStdin() {
25
- try {
26
- process.stdin.setRawMode(false)
27
- process.stdin.removeAllListeners('keypress')
28
- process.stdin.pause()
29
- } catch {
30
- /* safe to ignore */
31
- }
12
+ function cancelPrompt(prompts, value) {
13
+ if (!prompts.isCancel(value)) return false
14
+ prompts.cancel('Installation canceled.')
15
+ return true
32
16
  }
33
17
 
34
- export async function promptMultiSelect(
35
- options,
36
- { title = '', selected = new Set(), pageSize = 6 } = {},
18
+ /**
19
+ * Prompt for authorization to apply a reviewed installer plan.
20
+ *
21
+ * @param {string} planSummary Summary of the changes that require approval.
22
+ * @param {{ default?: boolean, prompts?: typeof clack, isTTY?: boolean }} [options]
23
+ * @returns {Promise<boolean | typeof PROMPT_CANCELED>} True if authorized, false if declined, or the cancellation sentinel.
24
+ */
25
+ export async function promptReview(
26
+ planSummary,
27
+ { default: defaultValue = false, prompts = clack, isTTY = isTerminal() } = {},
37
28
  ) {
38
- if (options.length === 0) return new Set()
39
-
40
- const preSelected = selected instanceof Set ? selected : new Set(selected)
41
- const items = options.map((o) => ({
42
- name: o.name,
43
- value: o.value,
44
- description: o.description || '',
45
- checked: o.checked || preSelected.has(o.value),
46
- }))
47
-
48
- let cursor = 0
49
- let scroll = 0
50
- const maxVisible = Math.min(pageSize, options.length)
51
- let previousLineCount = 0
52
-
53
- const isTTY = setupStdin()
54
- if (!isTTY) {
55
- return new Set(items.filter((i) => i.checked).map((i) => i.value))
56
- }
57
- cursorHide()
58
-
59
- function render() {
60
- if (cursor < scroll) scroll = cursor
61
- if (cursor >= scroll + maxVisible) scroll = cursor - maxVisible + 1
62
-
63
- const visible = items.slice(scroll, scroll + maxVisible)
64
- const lines = []
65
-
66
- if (title) lines.push(colors.bold(title))
67
-
68
- const hasMoreAbove = scroll > 0
69
- const hasMoreBelow = scroll + maxVisible < options.length
70
-
71
- if (hasMoreAbove) lines.push(colors.dim(' ...'))
72
-
73
- for (let i = 0; i < visible.length; i++) {
74
- const item = visible[i]
75
- const idx = scroll + i
76
- const active = idx === cursor
77
- const box = item.checked ? '[x]' : '[ ]'
78
- const ptr = active ? `${colors.cyan('>')} ` : ' '
79
- const name = active ? colors.cyan(item.name) : item.name
80
- const desc = item.description ? colors.dim(` — ${item.description}`) : ''
81
- lines.push(`${ptr}${colors.bold(box)} ${name}${desc}`)
82
- }
83
-
84
- if (hasMoreBelow) lines.push(colors.dim(' ...'))
85
-
86
- const count = items.filter((i) => i.checked).length
87
- const total = items.length
88
- const noun = count === 1 ? 'selected' : 'selected'
89
- lines.push(
90
- colors.dim(
91
- `(${count}/${total} ${noun}) — ${colors.cyan('↑')}${colors.dim('/')}${colors.cyan('↓')} navigate · ${colors.cyan('Space')}${colors.dim(' toggle · ')}${colors.cyan('Enter')}${colors.dim(' confirm')}`,
92
- ),
93
- )
94
-
95
- const lineCount = lines.length
96
- if (previousLineCount > 0) cursorUp(previousLineCount)
97
- clearDown()
98
- process.stdout.write(`${lines.join('\n')}\n`)
99
- previousLineCount = lineCount
100
- }
101
-
102
- return new Promise((resolve, reject) => {
103
- let settled = false
104
-
105
- function finalize(result) {
106
- if (settled) return
107
- settled = true
108
- cursorShow()
109
- teardownStdin()
110
- if (previousLineCount > 0) cursorUp(previousLineCount)
111
- clearDown()
112
- resolve(result)
113
- }
114
-
115
- function cancel() {
116
- if (settled) return
117
- settled = true
118
- cursorShow()
119
- teardownStdin()
120
- if (previousLineCount > 0) cursorUp(previousLineCount)
121
- clearDown()
122
- reject(new Error('Canceled'))
123
- }
124
-
125
- function handler(_str, key) {
126
- if (!key) return
127
- if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
128
- cancel()
129
- return
130
- }
131
- if (key.name === 'return' || key.name === 'enter') {
132
- const result = new Set(items.filter((i) => i.checked).map((i) => i.value))
133
- finalize(result)
134
- return
135
- }
136
- if (key.name === 'up') {
137
- cursor = (cursor - 1 + items.length) % items.length
138
- render()
139
- return
140
- }
141
- if (key.name === 'down') {
142
- cursor = (cursor + 1) % items.length
143
- render()
144
- return
145
- }
146
- if (key.name === 'space') {
147
- items[cursor].checked = !items[cursor].checked
148
- render()
149
- return
150
- }
151
- }
152
-
153
- process.stdin.on('keypress', handler)
154
- render()
155
- })
156
- }
157
-
158
- export async function promptConfirm(message, { default: def = true } = {}) {
159
- const label = def ? 'Y/n' : 'y/N'
160
- const isTTY = setupStdin()
161
- if (!isTTY) return def
162
-
163
- cursorHide()
164
- process.stdout.write(`${colors.bold('?')} ${message} ${colors.cyan(label)} `)
165
-
166
- return new Promise((resolve, reject) => {
167
- let settled = false
168
-
169
- function finalize(result) {
170
- if (settled) return
171
- settled = true
172
- cursorShow()
173
- teardownStdin()
174
- process.stdout.write(`${result ? 'Yes' : 'No'}\n`)
175
- resolve(result)
176
- }
177
-
178
- function cancel() {
179
- if (settled) return
180
- settled = true
181
- cursorShow()
182
- teardownStdin()
183
- process.stdout.write('\n')
184
- reject(new Error('Canceled'))
185
- }
186
-
187
- function handler(str, key) {
188
- if (!key) return
189
- if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
190
- cancel()
191
- return
192
- }
193
- if (key.name === 'return' || key.name === 'enter') {
194
- finalize(def)
195
- return
196
- }
197
- if (str) {
198
- const ch = str.toLowerCase()
199
- if (ch === 'y' || ch === 'yes') {
200
- finalize(true)
201
- } else if (ch === 'n' || ch === 'no') {
202
- finalize(false)
203
- }
204
- }
205
- }
206
-
207
- process.stdin.on('keypress', handler)
208
- })
29
+ if (!isTTY) return false
30
+ const result = await prompts.confirm({ message: planSummary, initialValue: defaultValue })
31
+ if (cancelPrompt(prompts, result)) return PROMPT_CANCELED
32
+ return result === true
209
33
  }
210
34
 
211
35
  /**
212
- * Apply-gate prompt. Unlike the pre-install confirm this defaults to NO, so a
213
- * bare Enter never authorizes a write. In a non-TTY it returns the default
214
- * (`false`) without blocking — callers that must not prompt at all should branch
215
- * on TTY before calling.
36
+ * Collect interactive setup choices before the installer begins any writes.
216
37
  *
217
- * @param {string} planSummary e.g. "Apply these 12 changes?"
218
- * @param {{ default?: boolean }} [opts]
219
- * @returns {Promise<boolean>}
38
+ * @param {{ components?: string[], defaultComponents?: string[], prompts?: typeof clack, isTTY?: boolean }} [options]
39
+ * @returns {Promise<{ components?: Set<string>, target?: string, confirmed?: boolean, canceled?: boolean }>}
220
40
  */
221
- export async function promptReview(planSummary, { default: def = false } = {}) {
222
- return promptConfirm(planSummary, { default: def })
223
- }
224
-
225
- export async function promptList(options, { title = '' } = {}) {
226
- if (options.length === 0) throw new Error('No options provided')
227
- if (options.length === 1) return options[0].value
228
-
229
- const items = options.map((o) => ({
230
- name: o.name,
231
- value: o.value,
232
- description: o.description || '',
233
- }))
234
-
235
- let cursor = 0
236
- const pageSize = Math.min(8, options.length)
237
- let scroll = 0
238
- const maxVisible = Math.min(pageSize, options.length)
239
- let previousLineCount = 0
240
-
241
- const isTTY = setupStdin()
242
- if (!isTTY) return items[0].value
243
- cursorHide()
244
-
245
- function render() {
246
- if (cursor < scroll) scroll = cursor
247
- if (cursor >= scroll + maxVisible) scroll = cursor - maxVisible + 1
248
-
249
- const visible = items.slice(scroll, scroll + maxVisible)
250
- const lines = []
251
-
252
- if (title) lines.push(colors.bold(title))
253
-
254
- const hasMoreAbove = scroll > 0
255
- const hasMoreBelow = scroll + maxVisible < options.length
256
-
257
- if (hasMoreAbove) lines.push(colors.dim(' ...'))
258
-
259
- for (let i = 0; i < visible.length; i++) {
260
- const item = visible[i]
261
- const idx = scroll + i
262
- const active = idx === cursor
263
- const ptr = active ? `${colors.cyan('>')} ` : ' '
264
- const name = active ? colors.cyan(item.name) : item.name
265
- const desc = item.description ? colors.dim(` — ${item.description}`) : ''
266
- lines.push(`${ptr}${colors.bold('◉')} ${name}${desc}`)
267
- }
268
-
269
- if (hasMoreBelow) lines.push(colors.dim(' ...'))
270
-
271
- lines.push(
272
- colors.dim(
273
- `(${colors.cyan('↑')}${colors.dim('/')}${colors.cyan('↓')} navigate · ${colors.cyan('Enter')}${colors.dim(' select')}`,
274
- ),
275
- )
41
+ export async function runInteractiveInstall({
42
+ components = [],
43
+ defaultComponents = [],
44
+ prompts = clack,
45
+ isTTY = isTerminal(),
46
+ } = {}) {
47
+ const defaults = defaultComponents.length > 0 ? defaultComponents : components
48
+ const defaultSet = new Set(defaults)
276
49
 
277
- const lineCount = lines.length
278
- if (previousLineCount > 0) cursorUp(previousLineCount)
279
- clearDown()
280
- process.stdout.write(`${lines.join('\n')}\n`)
281
- previousLineCount = lineCount
282
- }
283
-
284
- return new Promise((resolve, reject) => {
285
- let settled = false
286
-
287
- function finalize(result) {
288
- if (settled) return
289
- settled = true
290
- cursorShow()
291
- teardownStdin()
292
- if (previousLineCount > 0) cursorUp(previousLineCount)
293
- clearDown()
294
- resolve(result)
295
- }
296
-
297
- function cancel() {
298
- if (settled) return
299
- settled = true
300
- cursorShow()
301
- teardownStdin()
302
- if (previousLineCount > 0) cursorUp(previousLineCount)
303
- clearDown()
304
- reject(new Error('Canceled'))
305
- }
306
-
307
- function handler(_str, key) {
308
- if (!key) return
309
- if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
310
- cancel()
311
- return
312
- }
313
- if (key.name === 'return' || key.name === 'enter') {
314
- finalize(items[cursor].value)
315
- return
316
- }
317
- if (key.name === 'up') {
318
- cursor = (cursor - 1 + items.length) % items.length
319
- render()
320
- return
321
- }
322
- if (key.name === 'down') {
323
- cursor = (cursor + 1) % items.length
324
- render()
325
- return
326
- }
327
- }
328
-
329
- process.stdin.on('keypress', handler)
330
- render()
331
- })
332
- }
50
+ // Preserve the prior no-TTY fallback (defaults, global target, and no apply
51
+ // authorization) without asking a terminal prompt library to access stdin.
52
+ if (!isTTY) return { components: defaultSet, target: 'global', confirmed: true }
333
53
 
334
- export async function runInteractiveInstall({ components = [], defaultComponents = [] } = {}) {
335
- const defaultSet = new Set(defaultComponents.length > 0 ? defaultComponents : components)
54
+ process.stdout.write('\n📦 OpenCode Installer\n\n')
336
55
 
337
56
  const componentOptions = [
338
- {
339
- name: 'agents',
340
- value: 'agents',
341
- description: `Agent configurations and routing (${14} agents)`,
342
- },
343
- {
344
- name: 'skills',
345
- value: 'skills',
346
- description: `Reusable skill workflows (${20} skills)`,
347
- },
348
- {
349
- name: 'instructions',
350
- value: 'instructions',
351
- description: 'AGENTS.md and instructional files',
352
- },
353
- {
354
- name: 'prompts',
355
- value: 'prompts',
356
- description: 'Prompt templates for agents',
357
- },
358
- {
359
- name: 'commands',
360
- value: 'commands',
361
- description: 'OpenCode command shortcuts',
362
- },
363
- {
364
- name: 'plugins',
365
- value: 'plugins',
366
- description: 'TUI sidebar plugin',
367
- },
368
- {
369
- name: 'runtime',
370
- value: 'runtime',
371
- description: 'Python venv + MCP server scripts',
372
- },
57
+ { label: 'agents', value: 'agents', hint: 'Agent configurations and routing (14 agents)' },
58
+ { label: 'skills', value: 'skills', hint: `Reusable skill workflows (${20} skills)` },
59
+ { label: 'instructions', value: 'instructions', hint: 'AGENTS.md and instructional files' },
60
+ { label: 'prompts', value: 'prompts', hint: 'Prompt templates for agents' },
61
+ { label: 'commands', value: 'commands', hint: 'OpenCode command shortcuts' },
62
+ { label: 'plugins', value: 'plugins', hint: 'TUI sidebar plugin' },
63
+ { label: 'runtime', value: 'runtime', hint: 'Python venv + MCP server scripts' },
373
64
  ]
374
-
375
- process.stdout.write('\n')
376
- process.stdout.write(`${colors.bold(colors.cyan('📦 OpenCode Installer'))}\n\n`)
377
-
378
- const selectedComponents = await promptMultiSelect(componentOptions, {
379
- title: 'Select components to install:',
380
- selected: defaultSet,
381
- pageSize: 7,
65
+ const chosen = await prompts.multiselect({
66
+ message: 'Select components to install:',
67
+ options: componentOptions,
68
+ initialValues: [...defaultSet],
69
+ required: false,
382
70
  })
71
+ if (cancelPrompt(prompts, chosen)) return { canceled: true }
383
72
 
384
- process.stdout.write('\n')
385
-
73
+ const selectedComponents = new Set(chosen)
386
74
  if (selectedComponents.size === 0) {
387
- process.stdout.write(`${colors.yellow('No components selected — using defaults\n')}`)
388
- defaultSet.forEach((v) => {
389
- selectedComponents.add(v)
390
- })
75
+ for (const value of defaults) selectedComponents.add(value)
391
76
  }
392
77
 
393
- const targetOptions = [
394
- {
395
- name: 'Global directory',
396
- value: 'global',
397
- description: '~/.config/opencode (shared across projects)',
398
- },
399
- {
400
- name: 'Current project',
401
- value: 'project',
402
- description: './.opencode (scoped to this project)',
403
- },
404
- ]
405
-
406
- const target = await promptList(targetOptions, {
407
- title: 'Install location:',
78
+ const target = await prompts.select({
79
+ message: 'Install location:',
80
+ options: [
81
+ {
82
+ label: 'Global directory',
83
+ value: 'global',
84
+ hint: '~/.config/opencode (shared across projects)',
85
+ },
86
+ { label: 'Current project', value: 'project', hint: './.opencode (scoped to this project)' },
87
+ ],
408
88
  })
89
+ if (cancelPrompt(prompts, target)) return { canceled: true }
409
90
 
410
- process.stdout.write('\n')
411
-
412
- const confirmed = await promptConfirm(
413
- `Install ${selectedComponents.size} component(s) in ${target === 'global' ? 'global config' : 'current project'}?`,
414
- { default: true },
415
- )
416
-
417
- process.stdout.write('\n')
91
+ const confirmed = await prompts.confirm({
92
+ message: `Install ${selectedComponents.size} component(s) in ${target === 'global' ? 'global config' : 'current project'}?`,
93
+ initialValue: true,
94
+ })
95
+ if (cancelPrompt(prompts, confirmed)) return { canceled: true }
418
96
 
419
97
  return { components: selectedComponents, target, confirmed }
420
98
  }
@@ -11,7 +11,7 @@
11
11
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
12
  import { dirname, join } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
- import { ACTIONS, recordChange, snapshot } from './plan.mjs'
14
+ import { ACTIONS, fingerprintOutput, recordChange } from './plan.mjs'
15
15
  import { createInitialState, readState, writeState } from './state.mjs'
16
16
 
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -48,7 +48,11 @@ export function detectVersion(target) {
48
48
  * @param {{ dryRun?: boolean, recorder?: (change: object) => void }} [options]
49
49
  * @returns {{ applied: number, messages: string[] }}
50
50
  */
51
- export function runMigrations(target, currentVersion, { dryRun = false, recorder = null } = {}) {
51
+ export function runMigrations(
52
+ target,
53
+ currentVersion,
54
+ { dryRun = false, recorder = null, updatedAt = new Date().toISOString() } = {},
55
+ ) {
52
56
  const applied = []
53
57
  const semver = parseSemver(currentVersion)
54
58
 
@@ -107,49 +111,52 @@ export function runMigrations(target, currentVersion, { dryRun = false, recorder
107
111
  // ── Write updated state ───────────────────────────────────────────
108
112
  if (applied.length > 0) {
109
113
  const statePath = join(target, '.pantheon', 'install-state.json')
110
- if (dryRun) {
111
- // State-only bookkeeping write: recorded so the preview is complete, but
112
- // unscored because the install summary does not count it. Classified as
113
- // an update — a migration that applies implies a state file existed.
114
- recordChange({ path: statePath, action: ACTIONS.UPDATE, bytes: 0, scored: false }, recorder)
115
- } else {
116
- const newVersion = formatSemver(semver)
117
- let state = readState(target)
118
- const stateExisted = state !== null
119
- if (!state) state = createInitialState(newVersion)
120
- state.pantheon_version = newVersion
121
- for (const m of applied) {
122
- state.applied_migrations.push({
123
- from: m.from,
124
- to: m.to,
125
- applied_at: new Date().toISOString(),
126
- status: 'completed',
127
- })
128
- }
129
- if (!state.components) {
130
- state.components = {
131
- agents: { version: 1, status: 'installed' },
132
- skills: { version: 1, status: 'installed' },
133
- plugins: { version: 1, status: 'installed' },
134
- runtime: { version: 1, status: 'installed' },
135
- }
114
+ const newVersion = formatSemver(semver)
115
+ let state = readState(target)
116
+ const stateExisted = state !== null
117
+ if (!state) state = createInitialState(newVersion, updatedAt)
118
+ state.pantheon_version = newVersion
119
+ for (const m of applied) {
120
+ state.applied_migrations.push({
121
+ from: m.from,
122
+ to: m.to,
123
+ applied_at: updatedAt,
124
+ status: 'completed',
125
+ })
126
+ }
127
+ if (!state.components) {
128
+ state.components = {
129
+ agents: { version: 1, status: 'installed' },
130
+ skills: { version: 1, status: 'installed' },
131
+ plugins: { version: 1, status: 'installed' },
132
+ runtime: { version: 1, status: 'installed' },
136
133
  }
137
- writeState(target, state)
138
- recordChange(
139
- {
140
- path: statePath,
141
- action: stateExisted ? ACTIONS.UPDATE : ACTIONS.CREATE,
142
- bytes: snapshot(statePath).size,
143
- scored: false,
144
- },
145
- recorder,
146
- )
134
+ }
135
+ state.updated_at = updatedAt
136
+ state.schema_version = 2
137
+ const stateContent = `${JSON.stringify(state, null, 2)}\n`
138
+ if (!dryRun) writeState(target, state, { updatedAt })
139
+ recordChange(
140
+ {
141
+ path: statePath,
142
+ action: stateExisted ? ACTIONS.UPDATE : ACTIONS.CREATE,
143
+ bytes: Buffer.byteLength(stateContent, 'utf8'),
144
+ outputFingerprint: fingerprintOutput(stateContent),
145
+ scored: false,
146
+ },
147
+ recorder,
148
+ )
149
+ return {
150
+ applied: applied.length,
151
+ messages: applied.map((m) => `${m.from} → ${m.to}: ${m.description}`),
152
+ plannedState: state,
147
153
  }
148
154
  }
149
155
 
150
156
  return {
151
157
  applied: applied.length,
152
158
  messages: applied.map((m) => `${m.from} → ${m.to}: ${m.description}`),
159
+ plannedState: null,
153
160
  }
154
161
  }
155
162