pantheon-opencode 1.6.0-beta.1 → 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 (133) hide show
  1. package/AGENTS.global.md +206 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +350 -0
  4. package/README.md +561 -52
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +70 -4
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +24 -17
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +428 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/backup.mjs +475 -0
  22. package/scripts/install/cli-ui.mjs +6 -1
  23. package/scripts/install/config-migration.mjs +317 -4
  24. package/scripts/install/exit-codes.mjs +26 -0
  25. package/scripts/install/health-check.mjs +21 -19
  26. package/scripts/install/interactive.mjs +16 -2
  27. package/scripts/install/migrate.mjs +41 -22
  28. package/scripts/install/opencode-version.mjs +246 -4
  29. package/scripts/install/opencode.mjs +1175 -215
  30. package/scripts/install/plan.mjs +287 -0
  31. package/scripts/install/plugin.mjs +62 -14
  32. package/scripts/install/shared.mjs +190 -48
  33. package/scripts/install/strings.mjs +20 -0
  34. package/scripts/install/venv.mjs +13 -3
  35. package/scripts/install/vision-prompt.mjs +171 -0
  36. package/scripts/install-mcp.mjs +28 -9
  37. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  38. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  39. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  40. package/scripts/probe-context-rehydrate.mjs +8 -2
  41. package/scripts/probe-pantheon-cost.mjs +71 -15
  42. package/scripts/prune.mjs +9 -3
  43. package/scripts/restore.mjs +222 -0
  44. package/scripts/run-node-tests.mjs +189 -0
  45. package/scripts/sync-artifacts.mjs +26 -13
  46. package/scripts/sync-tui.mjs +24 -3
  47. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  48. package/scripts/validate-routing.mjs +3 -3
  49. package/scripts/validate-sandbox-install.mjs +138 -0
  50. package/scripts/versioning.mjs +13 -1
  51. package/scripts/vision-status.mjs +682 -0
  52. package/skills-lock.json +8 -8
  53. package/src/agents/aphrodite.md +20 -37
  54. package/src/agents/apollo.md +7 -3
  55. package/src/agents/athena.md +63 -35
  56. package/src/agents/demeter.md +8 -28
  57. package/src/agents/gaia.md +1 -1
  58. package/src/agents/hephaestus.md +10 -21
  59. package/src/agents/hermes.md +15 -38
  60. package/src/agents/iris.md +7 -1
  61. package/src/agents/mnemosyne.md +18 -54
  62. package/src/agents/nyx.md +1 -1
  63. package/src/agents/prometheus.md +16 -20
  64. package/src/agents/talos.md +10 -2
  65. package/src/agents/themis.md +5 -5
  66. package/src/agents/zeus.md +99 -172
  67. package/src/instructions/agent-return-format.instructions.md +34 -17
  68. package/src/instructions/backend-standards.instructions.md +2 -0
  69. package/src/instructions/frontend-standards.instructions.md +3 -1
  70. package/src/instructions/memory-protocol.instructions.md +12 -110
  71. package/src/instructions/test-economy.instructions.md +21 -0
  72. package/src/instructions/zeus-anti-stall.instructions.md +20 -67
  73. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  74. package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
  75. package/src/instructions/zeus-memory-operations.instructions.md +30 -0
  76. package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
  77. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  78. package/src/mcp/eval_store.py +2 -2
  79. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  80. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  81. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  82. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  83. package/src/mcp/requirements-mcp.txt +6 -4
  84. package/src/mcp/requirements-vision.txt +2 -2
  85. package/src/pantheon/cost-command.ts +403 -102
  86. package/src/pantheon/delegation-enforce.ts +10 -1
  87. package/src/pantheon/model-agents.ts +19 -0
  88. package/src/pantheon/model-command.ts +218 -56
  89. package/src/pantheon/presets.d.mts +0 -8
  90. package/src/pantheon/presets.mjs +5 -32
  91. package/src/pantheon/v2-bounded-read.ts +640 -0
  92. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  93. package/src/pantheon/v2-execute-after.ts +186 -0
  94. package/src/pantheon/v2-model-overrides.ts +275 -0
  95. package/src/pantheon/v2-tools.ts +418 -0
  96. package/src/pantheon/v2-unsupported.d.mts +11 -0
  97. package/src/pantheon/v2-unsupported.mjs +90 -0
  98. package/src/pantheon/v2-vision.ts +622 -0
  99. package/src/pantheon/vision-local.ts +531 -0
  100. package/src/pantheon/vision.ts +1160 -115
  101. package/src/plugin-v2.ts +1158 -219
  102. package/src/plugin.ts +12 -9
  103. package/src/plugins/tui/dist/server.d.ts +1 -2
  104. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  105. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  106. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  107. package/src/plugins/tui/dist/tui-v2.js +659 -0
  108. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  109. package/src/plugins/tui/dist/tui.d.ts +85 -82
  110. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  111. package/src/plugins/tui/dist/tui.js +72 -59
  112. package/src/plugins/tui/index-v2.ts +6 -0
  113. package/src/plugins/tui/package-lock.json +5516 -1353
  114. package/src/plugins/tui/package.json +22 -12
  115. package/src/plugins/tui/src/index-v2.tsx +734 -0
  116. package/src/plugins/tui/src/index.tsx +106 -85
  117. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  118. package/src/plugins/tui/tsconfig.build.json +5 -0
  119. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  120. package/src/plugins/tui/tsdown.config.ts +22 -2
  121. package/src/plugins/tui/tui-v2.ts +2 -0
  122. package/src/plugins/tui/tui.ts +3 -0
  123. package/src/routing.yml +11 -21
  124. package/src/skills/README.md +3 -3
  125. package/src/skills/agent-coordination/SKILL.md +7 -2
  126. package/src/skills/artifact-management/SKILL.md +12 -5
  127. package/src/skills/auto-continue/SKILL.md +52 -35
  128. package/src/skills/code-review-checklist/SKILL.md +5 -5
  129. package/src/skills/context-compression/SKILL.md +26 -879
  130. package/src/skills/session-goal/SKILL.md +14 -5
  131. package/src/skills/spec-driven-development/SKILL.md +3 -1
  132. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  133. package/opencode.json +0 -58
@@ -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
+ }
@@ -8,24 +8,29 @@ import { isAbsolute, join, resolve } from 'node:path'
8
8
  import { ROOT, writeIfChanged } from './shared.mjs'
9
9
 
10
10
  /**
11
- * Copy a plugin's runtime payload (the bundled dist/*, package.json and
12
- * package-lock.json) from source to destination. Only the bundle is copied —
13
- * the raw TSX source is never shipped to the destination, so the plugin can
14
- * be modularized without the loader needing relative imports.
11
+ * Copy a plugin's runtime payload (the root TUI loader, bundled dist/*,
12
+ * package.json and package-lock.json) from source to destination. The raw TSX
13
+ * implementation is never shipped to the destination; tui.ts only re-exports
14
+ * the host-specific bundle so the local plugin stays hermetic.
15
15
  * @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
16
16
  * @param {string} dstDir - Destination plugin directory
17
17
  * @param {object} [options]
18
18
  * @param {boolean} [options.dryRun=false] - If true, don't write files
19
+ * @param {'v1'|'v2'} [options.version='v1'] - Host plugin generation
20
+ * @param {(change: object) => void} [options.recorder] - optional change recorder for preview
19
21
  * @returns {{ created: number, skipped: number }}
20
22
  */
21
- export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
23
+ export function copyPluginFiles(
24
+ srcDir,
25
+ dstDir,
26
+ { dryRun = false, version = 'v1', recorder = null } = {},
27
+ ) {
22
28
  const result = { created: 0, skipped: 0 }
23
29
 
24
30
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
25
31
  if (!dryRun) mkdirSync(join(dstDir, 'dist'), { recursive: true })
26
32
 
27
33
  // Drop legacy raw copies from previous installs (root index.tsx + dist/tui.tsx).
28
- // The loader consumes only the bundle, so stale raws must not linger.
29
34
  if (!dryRun) {
30
35
  rmSync(join(dstDir, 'index.tsx'), { force: true })
31
36
  rmSync(join(dstDir, 'dist', 'tui.tsx'), { force: true })
@@ -36,7 +41,7 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
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
  }
@@ -45,15 +50,48 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
45
50
  // Copy package.json
46
51
  const pkgSrc = join(srcDir, 'package.json')
47
52
  if (existsSync(pkgSrc)) {
53
+ const packageJson = JSON.parse(readFileSync(pkgSrc, 'utf8'))
54
+ if (version === 'v2' && packageJson.exports?.['./tui-v2']) {
55
+ packageJson.exports['./tui'] = packageJson.exports['./tui-v2']
56
+ }
48
57
  const pkgStatus = writeIfChanged(
49
58
  join(dstDir, 'package.json'),
50
- readFileSync(pkgSrc, 'utf8'),
59
+ `${JSON.stringify(packageJson, null, 2)}\n`,
51
60
  dryRun,
61
+ { recorder },
52
62
  )
53
63
  if (pkgStatus === 'created') result.created++
54
64
  else result.skipped++
55
65
  }
56
66
 
67
+ // Each host generation receives its own root loader and compiled bundle.
68
+ const tuiEntrySrc = join(srcDir, version === 'v2' ? 'tui-v2.ts' : 'tui.ts')
69
+ if (existsSync(tuiEntrySrc)) {
70
+ const entryStatus = writeIfChanged(
71
+ join(dstDir, 'tui.ts'),
72
+ readFileSync(tuiEntrySrc, 'utf8'),
73
+ dryRun,
74
+ { recorder },
75
+ )
76
+ if (entryStatus === 'created') result.created++
77
+ else result.skipped++
78
+ }
79
+
80
+ const v2ServerEntry = join(srcDir, 'index-v2.ts')
81
+ const v2ServerDestination = join(dstDir, 'index.ts')
82
+ if (version === 'v2' && existsSync(v2ServerEntry)) {
83
+ const entryStatus = writeIfChanged(
84
+ v2ServerDestination,
85
+ readFileSync(v2ServerEntry, 'utf8'),
86
+ dryRun,
87
+ { recorder },
88
+ )
89
+ if (entryStatus === 'created') result.created++
90
+ else result.skipped++
91
+ } else if (!dryRun) {
92
+ rmSync(v2ServerDestination, { force: true })
93
+ }
94
+
57
95
  // Copy the lockfile so dependency installation is reproducible. A missing
58
96
  // lockfile is intentionally not synthesized by the installer.
59
97
  const lockSrc = join(srcDir, 'package-lock.json')
@@ -62,6 +100,7 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
62
100
  join(dstDir, 'package-lock.json'),
63
101
  readFileSync(lockSrc, 'utf8'),
64
102
  dryRun,
103
+ { recorder },
65
104
  )
66
105
  if (lockStatus === 'created') result.created++
67
106
  else result.skipped++
@@ -77,16 +116,21 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
77
116
  * @param {object} [options]
78
117
  * @param {boolean} [options.dryRun=false] - If true, don't write files
79
118
  * @param {boolean} [options.clean=false] - If true, remove existing before install
119
+ * @param {(change: object) => void} [options.recorder] - optional change recorder for preview
80
120
  * @returns {{ created: number, skipped: number, errors: number }}
81
121
  */
82
- export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false } = {}) {
122
+ export function installPlugin(
123
+ srcDir,
124
+ dstDir,
125
+ { dryRun = false, clean = false, version = 'v1', recorder = null } = {},
126
+ ) {
83
127
  const result = { created: 0, skipped: 0, errors: 0 }
84
128
 
85
129
  if (clean && existsSync(dstDir) && !dryRun) {
86
130
  rmSync(dstDir, { recursive: true, force: true })
87
131
  }
88
132
 
89
- const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun })
133
+ const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun, version, recorder })
90
134
  result.created += copyResult.created
91
135
  result.skipped += copyResult.skipped
92
136
 
@@ -116,9 +160,10 @@ export function installPluginDependencies(pluginDir, { dryRun = false } = {}) {
116
160
  * @param {string} pluginId - Plugin identifier string
117
161
  * @param {object} [options]
118
162
  * @param {boolean} [options.dryRun=false]
163
+ * @param {(change: object) => void} [options.recorder] - optional change recorder for preview
119
164
  * @returns {'created'|'skipped'}
120
165
  */
121
- export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
166
+ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false, recorder = null } = {}) {
122
167
  let tuiConfig = { plugin: [] }
123
168
  if (existsSync(tuiJsonPath)) {
124
169
  try {
@@ -134,7 +179,7 @@ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
134
179
  tuiConfig.plugin.push(pluginId)
135
180
  }
136
181
  const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
137
- return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
182
+ return writeIfChanged(tuiJsonPath, tuiContent, dryRun, { recorder })
138
183
  }
139
184
 
140
185
  /**
@@ -222,12 +267,14 @@ export function staleTuiRefs(pluginArray, knownPantheonPaths = []) {
222
267
  * @param {string} pluginId - Plugin to unregister
223
268
  * @param {object} [options]
224
269
  * @param {boolean} [options.dryRun=false]
270
+ * @param {string[]} [options.knownPantheonPaths=[]]
271
+ * @param {(change: object) => void} [options.recorder] - optional change recorder for preview
225
272
  * @returns {'created'|'skipped'}
226
273
  */
227
274
  export function unregisterPlugin(
228
275
  tuiJsonPath,
229
276
  pluginId,
230
- { dryRun = false, knownPantheonPaths = [] } = {},
277
+ { dryRun = false, knownPantheonPaths = [], recorder = null } = {},
231
278
  ) {
232
279
  if (!existsSync(tuiJsonPath)) {
233
280
  // Nothing to clean — and never create an empty tui.json in a location
@@ -258,5 +305,6 @@ export function unregisterPlugin(
258
305
  return !isPantheonTuiRef(ref, knownPantheonPaths)
259
306
  })
260
307
  const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
261
- 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 })
262
310
  }