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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.global.md +3 -3
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +69 -30
  5. package/README.pt-BR.md +5 -4
  6. package/bin/pantheon-init.mjs +279 -101
  7. package/package.json +4 -3
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +77 -13
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +191 -18
  14. package/scripts/install/cli-ui.mjs +73 -0
  15. package/scripts/install/file-inventory.mjs +555 -0
  16. package/scripts/install/migrate.mjs +45 -38
  17. package/scripts/install/model-picker.mjs +63 -22
  18. package/scripts/install/opencode-version.mjs +9 -1
  19. package/scripts/install/opencode.mjs +265 -79
  20. package/scripts/install/plan-artifact.mjs +517 -0
  21. package/scripts/install/plan.mjs +165 -3
  22. package/scripts/install/plugin.mjs +118 -14
  23. package/scripts/install/shared.mjs +186 -65
  24. package/scripts/install/state.mjs +63 -8
  25. package/scripts/install/venv.mjs +15 -6
  26. package/scripts/install/verify.mjs +128 -0
  27. package/scripts/install/vision-prompt.mjs +2 -0
  28. package/scripts/install-mcp.mjs +1 -1
  29. package/scripts/manifest-inventory.mjs +49 -3
  30. package/scripts/node-engine-policy.mjs +74 -0
  31. package/scripts/package-evidence.mjs +6 -0
  32. package/scripts/postinstall.mjs +3 -4
  33. package/scripts/reconcile.mjs +325 -0
  34. package/scripts/uninstall.mjs +258 -34
  35. package/scripts/update.mjs +164 -0
  36. package/scripts/validate-node-engine-policy.mjs +13 -0
  37. package/scripts/version-check.mjs +34 -1
  38. package/scripts/versioning.mjs +9 -1
  39. package/skills-lock.json +3 -3
  40. package/src/agents/mnemosyne.md +7 -2
  41. package/src/instructions/memory-protocol.instructions.md +2 -2
  42. package/src/instructions/zeus-memory-operations.instructions.md +4 -3
  43. package/src/pantheon/v2-unsupported.mjs +2 -3
  44. package/src/plugins/tui/dist/tui-v2.js +2 -2
  45. package/src/plugins/tui/dist/tui.js +1 -1
  46. package/src/plugins/tui/package-lock.json +3 -3
  47. package/src/plugins/tui/package.json +2 -2
  48. package/src/skills/artifact-management/SKILL.md +5 -1
  49. package/src/skills/memory-bank/SKILL.md +5 -1
  50. package/src/skills/worktrees/SKILL.md +30 -11
@@ -0,0 +1,555 @@
1
+ /**
2
+ * Conservative per-file installer ownership tracking and stale-file cleanup.
3
+ * The inventory records paths and hashes only; legacy state never implies
4
+ * ownership of files which predate the inventory.
5
+ */
6
+ import { createHash } from 'node:crypto'
7
+ import {
8
+ closeSync,
9
+ constants,
10
+ fstatSync,
11
+ lstatSync,
12
+ openSync,
13
+ readFileSync,
14
+ unlinkSync,
15
+ } from 'node:fs'
16
+ import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path'
17
+ import { createBackupSet } from './backup.mjs'
18
+ import { writeState } from './state.mjs'
19
+ import { assertNoSymlinkAncestors, verifyPathRemoved } from './verify.mjs'
20
+
21
+ export const FILE_INVENTORY_VERSION = 1
22
+
23
+ /** Map installer-owned copy layouts to their logical component without names heuristics. */
24
+ export function componentForInstalledPath(target, filePath, { isGlobal, version = 'v1' } = {}) {
25
+ let relPath
26
+ try {
27
+ relPath = relativeTargetPath(target, filePath)
28
+ } catch {
29
+ return null
30
+ }
31
+ const layout = isGlobal ? 'global' : version === 'v2' ? 'project-v2' : 'project-v1'
32
+ const agentRoot = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
33
+ const skillRoot = isGlobal ? 'skills' : '.opencode/skills'
34
+ const commandRoot = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
35
+ const pluginRoot = isGlobal ? 'plugins/pantheon-tui' : '.opencode/plugins/pantheon-tui'
36
+ const runtimeRoot = isGlobal ? '' : '.opencode'
37
+ const startsIn = (prefix) => relPath.startsWith(`${prefix}/`)
38
+
39
+ if (startsIn(agentRoot) || relPath === 'routing.yml') return { component: 'agents', layout }
40
+ if (startsIn(skillRoot)) return { component: 'skills', layout }
41
+ // AGENTS.md contains user content outside its managed marker block; the whole
42
+ // file is therefore not safe to claim or remove as a single owned path.
43
+ if (startsIn('instructions')) return { component: 'instructions', layout }
44
+ if (startsIn('prompts')) return { component: 'prompts', layout }
45
+ if (startsIn(commandRoot)) return { component: 'commands', layout }
46
+ if (startsIn(pluginRoot)) return { component: 'plugins', layout }
47
+ const runtimePrefix = runtimeRoot ? `${runtimeRoot}/` : ''
48
+ if (
49
+ startsIn(`${runtimePrefix}scripts`) ||
50
+ startsIn(`${runtimePrefix}code-mode`) ||
51
+ relPath === `${runtimePrefix}tiers.json`
52
+ ) {
53
+ return { component: 'runtime', layout }
54
+ }
55
+ return null
56
+ }
57
+
58
+ /** A new install starts with an empty inventory; `target_root` is bound on write. */
59
+ export function createEmptyFileInventory({ legacyUnknown = false } = {}) {
60
+ return {
61
+ schema_version: FILE_INVENTORY_VERSION,
62
+ target_root: null,
63
+ legacy_unknown: legacyUnknown,
64
+ entries: [],
65
+ }
66
+ }
67
+
68
+ function validHash(value) {
69
+ return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
70
+ }
71
+
72
+ function safeRelativePath(value) {
73
+ if (
74
+ typeof value !== 'string' ||
75
+ value.length === 0 ||
76
+ value.includes('\\') ||
77
+ /^[a-z]:/i.test(value)
78
+ )
79
+ return false
80
+ if (value.startsWith('/') || isAbsolute(value)) return false
81
+ const parts = value.split('/')
82
+ return (
83
+ parts.every((part) => part && part !== '.' && part !== '..') &&
84
+ posix.normalize(value) === value &&
85
+ !value.includes('\0')
86
+ )
87
+ }
88
+
89
+ function rootInfo(target) {
90
+ const root = resolve(target)
91
+ assertNoSymlinkAncestors(root)
92
+ const info = lstatSync(root)
93
+ if (!info.isDirectory() || info.isSymbolicLink()) {
94
+ throw new Error('Installer inventory target must be a regular directory')
95
+ }
96
+ return root
97
+ }
98
+
99
+ function inventoryRootMatches(value, root) {
100
+ return typeof value === 'string' && isAbsolute(value) && resolve(value) === root
101
+ }
102
+
103
+ function relativeTargetPath(target, value) {
104
+ if (typeof value !== 'string' || value.split(/[\\/]/).includes('..')) {
105
+ throw new Error('Installer inventory path contains traversal')
106
+ }
107
+ const root = resolve(target)
108
+ const absolute = resolve(value)
109
+ const rel = relative(root, absolute).split(sep).join('/')
110
+ if (!rel || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !safeRelativePath(rel)) {
111
+ throw new Error('Installer inventory path is outside the selected target')
112
+ }
113
+ return rel
114
+ }
115
+
116
+ function inventoryFingerprint(inventory) {
117
+ return createHash('sha256').update(JSON.stringify(inventory)).digest('hex')
118
+ }
119
+
120
+ function inspectRegularFile(target, relPath) {
121
+ if (!safeRelativePath(relPath)) return { kind: 'ambiguous', reason: 'invalid-path' }
122
+ const filePath = join(target, ...relPath.split('/'))
123
+ try {
124
+ assertNoSymlinkAncestors(filePath)
125
+ const before = lstatSync(filePath)
126
+ if (before.isSymbolicLink()) return { kind: 'ambiguous', reason: 'symlink' }
127
+ if (!before.isFile()) return { kind: 'ambiguous', reason: 'special-file' }
128
+
129
+ let descriptor
130
+ try {
131
+ descriptor = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
132
+ } catch (error) {
133
+ if (error?.code === 'ELOOP') return { kind: 'ambiguous', reason: 'symlink' }
134
+ throw error
135
+ }
136
+ try {
137
+ const opened = fstatSync(descriptor)
138
+ if (!opened.isFile()) return { kind: 'ambiguous', reason: 'special-file' }
139
+ const bytes = readFileSync(descriptor)
140
+ const after = fstatSync(descriptor)
141
+ if (opened.dev !== after.dev || opened.ino !== after.ino || opened.size !== after.size) {
142
+ return { kind: 'ambiguous', reason: 'file-changed-during-read' }
143
+ }
144
+ return {
145
+ kind: 'regular',
146
+ sha256: createHash('sha256').update(bytes).digest('hex'),
147
+ mode: after.mode & 0o777,
148
+ }
149
+ } finally {
150
+ closeSync(descriptor)
151
+ }
152
+ } catch (error) {
153
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
154
+ return { kind: 'missing' }
155
+ }
156
+ return {
157
+ kind: 'ambiguous',
158
+ reason: error?.message?.includes('symlink') ? 'symlink' : 'unreadable',
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Add verified installer writes without discarding entries for unselected
165
+ * components. Existing legacy files are not claimed merely because their
166
+ * bytes happen to equal the current package payload.
167
+ */
168
+ export function updateFileInventory(
169
+ state,
170
+ target,
171
+ { selectedComponents = [], verifiedFiles = [] } = {},
172
+ ) {
173
+ const root = resolve(target)
174
+ let inventory = state.file_inventory
175
+ if (inventory == null) inventory = createEmptyFileInventory({ legacyUnknown: true })
176
+ if (
177
+ !inventory ||
178
+ inventory.schema_version !== FILE_INVENTORY_VERSION ||
179
+ !Array.isArray(inventory.entries) ||
180
+ typeof inventory.legacy_unknown !== 'boolean' ||
181
+ (inventory.target_root == null && inventory.entries.length > 0)
182
+ ) {
183
+ throw new Error('Installer file inventory is invalid; refusing to replace ownership data')
184
+ }
185
+ if (inventory.target_root != null && !inventoryRootMatches(inventory.target_root, root)) {
186
+ // A copied/moved state file must not confer ownership in a new root.
187
+ state.file_inventory = inventory
188
+ return false
189
+ }
190
+ inventory.target_root = root
191
+ const selected = new Set(selectedComponents)
192
+ const entriesByPath = new Map()
193
+ const ambiguousEntries = []
194
+ for (const entry of inventory.entries) {
195
+ if (!entry || !safeRelativePath(entry.path) || !validHash(entry.sha256)) {
196
+ ambiguousEntries.push(entry)
197
+ continue
198
+ }
199
+ if (entriesByPath.has(entry.path)) {
200
+ ambiguousEntries.push(entry)
201
+ continue
202
+ }
203
+ entriesByPath.set(entry.path, entry)
204
+ }
205
+
206
+ for (const file of verifiedFiles) {
207
+ if (!file || !['create', 'update', 'unchanged'].includes(file.action)) continue
208
+ if (
209
+ !['agents', 'skills', 'instructions', 'prompts', 'commands', 'plugins', 'runtime'].includes(
210
+ file.component,
211
+ )
212
+ )
213
+ continue
214
+ if (!selected.has(file.component)) continue
215
+ const relPath = relativeTargetPath(root, file.path)
216
+ const existing = entriesByPath.get(relPath)
217
+ if (existing && existing.component !== file.component) continue
218
+ if (file.action === 'unchanged' && !existing) continue
219
+ if (!validHash(file.sha256)) continue
220
+ const live = inspectRegularFile(root, relPath)
221
+ if (live.kind !== 'regular' || live.sha256 !== file.sha256) continue
222
+ const entry = {
223
+ path: relPath,
224
+ component: file.component,
225
+ layout: String(file.layout),
226
+ sha256: live.sha256,
227
+ mode: live.mode,
228
+ }
229
+ entriesByPath.set(relPath, entry)
230
+ }
231
+
232
+ // The selected set is intentionally not used to filter existing entries:
233
+ // a narrow install must never erase ownership records for other components.
234
+ inventory.entries = [...entriesByPath.values(), ...ambiguousEntries].sort((a, b) =>
235
+ String(a?.path ?? '').localeCompare(String(b?.path ?? '')),
236
+ )
237
+ state.file_inventory = inventory
238
+ return true
239
+ }
240
+
241
+ function normalizeDesiredFiles(target, desiredFiles) {
242
+ const result = new Map()
243
+ const source =
244
+ desiredFiles instanceof Map ? desiredFiles.entries() : Object.entries(desiredFiles ?? {})
245
+ for (const [path, value] of source) {
246
+ if (typeof path !== 'string') continue
247
+ let relPath
248
+ try {
249
+ relPath = path.startsWith('/') ? relativeTargetPath(target, path) : path
250
+ } catch {
251
+ continue
252
+ }
253
+ if (!safeRelativePath(relPath)) continue
254
+ const sha256 = value?.sha256 ?? value?.outputFingerprint
255
+ if (validHash(sha256)) result.set(relPath, { ...value, sha256 })
256
+ }
257
+ return result
258
+ }
259
+
260
+ /** Classify owned paths; only stale-owned regular files become removal candidates. */
261
+ export function classifyFileInventory(
262
+ target,
263
+ state,
264
+ {
265
+ desiredFiles = new Map(),
266
+ desiredUnavailable = false,
267
+ selectedComponents = [],
268
+ selectedLayouts = {},
269
+ } = {},
270
+ ) {
271
+ const root = rootInfo(target)
272
+ const inventory = state?.file_inventory
273
+ if (
274
+ !inventory ||
275
+ inventory.schema_version !== FILE_INVENTORY_VERSION ||
276
+ !Array.isArray(inventory.entries) ||
277
+ typeof inventory.legacy_unknown !== 'boolean'
278
+ ) {
279
+ return {
280
+ target: root,
281
+ manualReview: true,
282
+ inventoryFingerprint: null,
283
+ entries: [
284
+ {
285
+ path: '(legacy installation)',
286
+ classification: 'ambiguous',
287
+ reason: 'inventory-unavailable',
288
+ },
289
+ ],
290
+ operations: [],
291
+ }
292
+ }
293
+
294
+ const rootMismatch = !inventoryRootMatches(inventory.target_root, root)
295
+ const selected = new Set(selectedComponents)
296
+ const layoutFor = (component) =>
297
+ selectedLayouts instanceof Map ? selectedLayouts.get(component) : selectedLayouts?.[component]
298
+ const desired = normalizeDesiredFiles(root, desiredFiles)
299
+ const seen = new Set()
300
+ const entries = []
301
+ const operations = []
302
+ let invalid = false
303
+ for (const owned of inventory.entries) {
304
+ const path = typeof owned?.path === 'string' ? owned.path : '(invalid inventory path)'
305
+ if (
306
+ !owned ||
307
+ !safeRelativePath(owned.path) ||
308
+ typeof owned.component !== 'string' ||
309
+ typeof owned.layout !== 'string' ||
310
+ !validHash(owned.sha256) ||
311
+ !Number.isInteger(owned.mode) ||
312
+ owned.mode < 0 ||
313
+ owned.mode > 0o777 ||
314
+ seen.has(owned.path)
315
+ ) {
316
+ invalid = true
317
+ entries.push({ path, classification: 'ambiguous', reason: 'invalid-or-duplicate-entry' })
318
+ continue
319
+ }
320
+ seen.add(owned.path)
321
+ if (rootMismatch) {
322
+ entries.push({
323
+ path,
324
+ component: owned.component,
325
+ classification: 'ambiguous',
326
+ reason: 'target-root-mismatch',
327
+ })
328
+ continue
329
+ }
330
+ if (!selected.has(owned.component)) {
331
+ entries.push({ path, component: owned.component, classification: 'unselected' })
332
+ continue
333
+ }
334
+ const selectedLayout = layoutFor(owned.component)
335
+ if (selectedLayout && selectedLayout !== owned.layout) {
336
+ entries.push({
337
+ path,
338
+ component: owned.component,
339
+ classification: 'ambiguous',
340
+ reason: 'layout-mismatch',
341
+ })
342
+ invalid = true
343
+ continue
344
+ }
345
+ const live = inspectRegularFile(root, owned.path)
346
+ if (live.kind === 'missing') {
347
+ entries.push({ path: owned.path, component: owned.component, classification: 'missing' })
348
+ continue
349
+ }
350
+ if (live.kind !== 'regular') {
351
+ entries.push({
352
+ path: owned.path,
353
+ component: owned.component,
354
+ classification: 'ambiguous',
355
+ reason: live.reason,
356
+ })
357
+ continue
358
+ }
359
+ if (live.sha256 !== owned.sha256) {
360
+ entries.push({ path: owned.path, component: owned.component, classification: 'modified' })
361
+ continue
362
+ }
363
+ const current = desired.get(owned.path)
364
+ if (!current && desiredUnavailable) {
365
+ entries.push({
366
+ path: owned.path,
367
+ component: owned.component,
368
+ classification: 'recorded-current',
369
+ })
370
+ continue
371
+ }
372
+ if (current) {
373
+ entries.push({
374
+ path: owned.path,
375
+ component: owned.component,
376
+ classification: current.sha256 === live.sha256 ? 'current' : 'update-drift',
377
+ })
378
+ continue
379
+ }
380
+ // Desired payload may be unavailable (e.g. optional runtime output); only
381
+ // a path omitted from the current selected install plan is stale-owned.
382
+ entries.push({ path: owned.path, component: owned.component, classification: 'stale-owned' })
383
+ operations.push({
384
+ path: owned.path,
385
+ sha256: owned.sha256,
386
+ mode: owned.mode,
387
+ component: owned.component,
388
+ })
389
+ }
390
+ operations.sort((a, b) => a.path.localeCompare(b.path))
391
+ return {
392
+ target: root,
393
+ manualReview:
394
+ rootMismatch ||
395
+ invalid ||
396
+ (inventory.legacy_unknown === true && inventory.entries.length === 0),
397
+ legacyAmbiguous: inventory.legacy_unknown === true,
398
+ inventoryFingerprint: inventoryFingerprint(inventory),
399
+ entries,
400
+ operations: rootMismatch || invalid ? [] : operations,
401
+ }
402
+ }
403
+
404
+ function operationSet(operations) {
405
+ return [...operations]
406
+ .map(({ path, sha256, mode, component }) => ({ path, sha256, mode, component }))
407
+ .sort((a, b) => a.path.localeCompare(b.path))
408
+ }
409
+
410
+ function readValidatedStateForApply(root) {
411
+ const stateFile = join(root, '.pantheon', 'install-state.json')
412
+ assertNoSymlinkAncestors(stateFile)
413
+
414
+ const pathInfo = lstatSync(stateFile)
415
+ if (!pathInfo.isFile() || pathInfo.isSymbolicLink()) {
416
+ throw new Error('Installer ownership state must be a regular file; aborting before removal')
417
+ }
418
+
419
+ let descriptor
420
+ try {
421
+ descriptor = openSync(stateFile, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
422
+ const opened = fstatSync(descriptor)
423
+ if (!opened.isFile()) {
424
+ throw new Error('Installer ownership state must be a regular file; aborting before removal')
425
+ }
426
+ const contents = readFileSync(descriptor, 'utf8')
427
+ const after = fstatSync(descriptor)
428
+ if (opened.dev !== after.dev || opened.ino !== after.ino || opened.size !== after.size) {
429
+ throw new Error('Installer ownership state changed while being read; aborting before removal')
430
+ }
431
+
432
+ let state
433
+ try {
434
+ state = JSON.parse(contents)
435
+ } catch {
436
+ throw new Error('Installer ownership state is unreadable; aborting before removal')
437
+ }
438
+ const inventory = state?.file_inventory
439
+ if (
440
+ !state ||
441
+ typeof state !== 'object' ||
442
+ Array.isArray(state) ||
443
+ state.schema_version !== 2 ||
444
+ !inventory ||
445
+ typeof inventory !== 'object' ||
446
+ Array.isArray(inventory) ||
447
+ inventory.schema_version !== FILE_INVENTORY_VERSION ||
448
+ !inventoryRootMatches(inventory.target_root, root) ||
449
+ !Array.isArray(inventory.entries) ||
450
+ typeof inventory.legacy_unknown !== 'boolean'
451
+ ) {
452
+ throw new Error('Installer ownership state or inventory is invalid for this target')
453
+ }
454
+ return state
455
+ } finally {
456
+ if (descriptor !== undefined) closeSync(descriptor)
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Apply only the exact reviewed stale-file set. Hashes and operation set are
462
+ * checked before backup and again after backup; deletion is unlink-only.
463
+ */
464
+ export function applyFileDriftPlan(
465
+ target,
466
+ state,
467
+ reviewedPlan,
468
+ {
469
+ desiredFiles = new Map(),
470
+ selectedComponents = [],
471
+ selectedLayouts = {},
472
+ clock = () => new Date(),
473
+ } = {},
474
+ ) {
475
+ const root = rootInfo(target)
476
+ if (!reviewedPlan || reviewedPlan.target !== root || reviewedPlan.manualReview) {
477
+ throw new Error('Refusing apply: inventory is ambiguous or reviewed target differs')
478
+ }
479
+ const fresh = classifyFileInventory(root, state, {
480
+ desiredFiles,
481
+ selectedComponents,
482
+ selectedLayouts,
483
+ })
484
+ if (
485
+ fresh.manualReview ||
486
+ fresh.inventoryFingerprint !== reviewedPlan.inventoryFingerprint ||
487
+ JSON.stringify(operationSet(fresh.operations)) !==
488
+ JSON.stringify(operationSet(reviewedPlan.operations))
489
+ ) {
490
+ throw new Error('Reviewed installer drift plan changed; no files were removed')
491
+ }
492
+ if (fresh.operations.length === 0) return { removed: [], backupDir: null }
493
+
494
+ const validateOperations = () => {
495
+ const persistedState = readValidatedStateForApply(root)
496
+ const now = classifyFileInventory(root, persistedState, {
497
+ desiredFiles,
498
+ selectedComponents,
499
+ selectedLayouts,
500
+ })
501
+ if (
502
+ now.manualReview ||
503
+ now.inventoryFingerprint !== reviewedPlan.inventoryFingerprint ||
504
+ JSON.stringify(operationSet(now.operations)) !==
505
+ JSON.stringify(operationSet(reviewedPlan.operations))
506
+ ) {
507
+ throw new Error('Reviewed installer drift operation set changed; aborting before removal')
508
+ }
509
+ for (const operation of reviewedPlan.operations) {
510
+ const live = inspectRegularFile(root, operation.path)
511
+ if (
512
+ live.kind !== 'regular' ||
513
+ live.sha256 !== operation.sha256 ||
514
+ live.mode !== operation.mode
515
+ ) {
516
+ throw new Error(
517
+ `Reviewed installer drift target changed; aborting before removal: ${operation.path}`,
518
+ )
519
+ }
520
+ }
521
+ return persistedState
522
+ }
523
+
524
+ const stateBeforeBackup = validateOperations()
525
+ const backup = createBackupSet({
526
+ configDir: root,
527
+ entries: reviewedPlan.operations.map((operation) => operation.path),
528
+ packageVersion: stateBeforeBackup.pantheon_version ?? 'unknown',
529
+ clock,
530
+ keep: 0,
531
+ })
532
+ const persistedState = validateOperations()
533
+
534
+ for (const operation of reviewedPlan.operations) {
535
+ const filePath = join(root, ...operation.path.split('/'))
536
+ assertNoSymlinkAncestors(filePath)
537
+ const live = inspectRegularFile(root, operation.path)
538
+ if (
539
+ live.kind !== 'regular' ||
540
+ live.sha256 !== operation.sha256 ||
541
+ live.mode !== operation.mode
542
+ ) {
543
+ throw new Error(`Reviewed installer drift target changed during apply: ${operation.path}`)
544
+ }
545
+ unlinkSync(filePath)
546
+ verifyPathRemoved(filePath)
547
+ }
548
+
549
+ const removedPaths = new Set(reviewedPlan.operations.map((operation) => operation.path))
550
+ persistedState.file_inventory.entries = persistedState.file_inventory.entries.filter(
551
+ (entry) => !removedPaths.has(entry.path),
552
+ )
553
+ writeState(root, persistedState)
554
+ return { removed: [...removedPaths].sort(), backupDir: backup.dir }
555
+ }
@@ -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