@vibe-forge/workspace-assets 0.8.0 → 0.8.4

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.
@@ -0,0 +1,388 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { resolvePromptAssetSelection, resolveWorkspaceAssetBundle } from '#~/index.js'
6
+
7
+ import { createWorkspace, installPluginPackage, writeDocument } from './test-helpers'
8
+
9
+ describe('resolvePromptAssetSelection', () => {
10
+ it('embeds only alwaysApply rules and keeps optional rules as summaries', async () => {
11
+ const workspace = await createWorkspace()
12
+
13
+ await writeDocument(
14
+ join(workspace, '.ai/rules/base.md'),
15
+ [
16
+ '---',
17
+ 'alwaysApply: true',
18
+ 'description: 基础约束',
19
+ '---',
20
+ '始终检查公共边界。'
21
+ ].join('\n')
22
+ )
23
+ await writeDocument(
24
+ join(workspace, '.ai/rules/optional.md'),
25
+ [
26
+ '---',
27
+ 'alwaysApply: false',
28
+ 'description: 按需参考规则',
29
+ '---',
30
+ '只有在特定场景才需要展开。'
31
+ ].join('\n')
32
+ )
33
+
34
+ const bundle = await resolveWorkspaceAssetBundle({
35
+ cwd: workspace,
36
+ useDefaultVibeForgeMcpServer: false
37
+ })
38
+ const [, options] = await resolvePromptAssetSelection({
39
+ bundle,
40
+ type: undefined
41
+ })
42
+
43
+ expect(options.systemPrompt).toContain('# base')
44
+ expect(options.systemPrompt).toContain('> 始终检查公共边界。')
45
+ expect(options.systemPrompt).toContain('# optional')
46
+ expect(options.systemPrompt).toContain('> 适用场景:按需参考规则')
47
+ expect(options.systemPrompt).toContain('> 规则文件路径:.ai/rules/optional.md')
48
+ expect(options.systemPrompt).toContain('> 仅在任务满足上述场景时,再阅读该规则文件。')
49
+ expect(options.systemPrompt).not.toContain('> 只有在特定场景才需要展开。')
50
+ })
51
+
52
+ it('selects local assets by short name and scoped plugin assets explicitly', async () => {
53
+ const workspace = await createWorkspace()
54
+
55
+ await installPluginPackage(workspace, '@vibe-forge/plugin-demo', {
56
+ 'package.json': JSON.stringify(
57
+ {
58
+ name: '@vibe-forge/plugin-demo',
59
+ version: '1.0.0'
60
+ },
61
+ null,
62
+ 2
63
+ ),
64
+ 'specs/release/index.md': '---\ndescription: 插件发布流程\n---\n执行插件发布'
65
+ })
66
+ await writeDocument(
67
+ join(workspace, '.ai/specs/release.md'),
68
+ '---\ndescription: 项目发布流程\n---\n执行项目发布'
69
+ )
70
+
71
+ const bundle = await resolveWorkspaceAssetBundle({
72
+ cwd: workspace,
73
+ configs: [{
74
+ plugins: [
75
+ { id: 'demo', scope: 'demo' }
76
+ ]
77
+ }, undefined],
78
+ useDefaultVibeForgeMcpServer: false
79
+ })
80
+ const [localData, localOptions] = await resolvePromptAssetSelection({
81
+ bundle,
82
+ type: 'spec',
83
+ name: 'release'
84
+ })
85
+ const [pluginData, pluginOptions] = await resolvePromptAssetSelection({
86
+ bundle,
87
+ type: 'spec',
88
+ name: 'demo/release'
89
+ })
90
+ const localSpecId = bundle.specs.find(asset => asset.origin === 'workspace' && asset.name === 'release')?.id
91
+ const pluginSpecId = bundle.specs.find(asset => asset.origin === 'plugin' && asset.displayName === 'demo/release')
92
+ ?.id
93
+
94
+ expect(localData.targetBody).toContain('执行项目发布')
95
+ expect(localData.targetBody).not.toContain('执行插件发布')
96
+ expect(localOptions.systemPrompt).toContain('项目发布流程')
97
+ expect(localOptions.systemPrompt).toContain('demo/release')
98
+ expect(localOptions.promptAssetIds).toEqual(expect.arrayContaining([localSpecId]))
99
+
100
+ expect(pluginData.targetBody).toContain('执行插件发布')
101
+ expect(pluginOptions.systemPrompt).toContain('插件发布流程')
102
+ expect(pluginOptions.promptAssetIds).toEqual(expect.arrayContaining([pluginSpecId]))
103
+ })
104
+
105
+ it('formats rules as markdown headings and blockquotes', async () => {
106
+ const workspace = await createWorkspace()
107
+
108
+ await writeDocument(
109
+ join(workspace, '.ai/rules/required.md'),
110
+ [
111
+ '---',
112
+ 'description: 必须执行的规则',
113
+ 'alwaysApply: true',
114
+ '---',
115
+ '# 标题',
116
+ '',
117
+ '正文第一行',
118
+ '正文第二行'
119
+ ].join('\n')
120
+ )
121
+ await writeDocument(
122
+ join(workspace, '.ai/rules/summary-only.md'),
123
+ [
124
+ '---',
125
+ 'description: 只展示摘要',
126
+ 'alwaysApply: false',
127
+ '---',
128
+ '不应该出现在引用正文里'
129
+ ].join('\n')
130
+ )
131
+
132
+ const bundle = await resolveWorkspaceAssetBundle({
133
+ cwd: workspace,
134
+ configs: [undefined, undefined],
135
+ useDefaultVibeForgeMcpServer: false
136
+ })
137
+ const [, resolvedOptions] = await resolvePromptAssetSelection({
138
+ bundle,
139
+ type: undefined
140
+ })
141
+
142
+ expect(resolvedOptions.systemPrompt).toContain('# required')
143
+ expect(resolvedOptions.systemPrompt).toContain('> # 标题')
144
+ expect(resolvedOptions.systemPrompt).toContain('> 正文第一行')
145
+ expect(resolvedOptions.systemPrompt).toContain('> 正文第二行')
146
+ expect(resolvedOptions.systemPrompt).toContain('# summary-only')
147
+ expect(resolvedOptions.systemPrompt).toContain('> 适用场景:只展示摘要')
148
+ expect(resolvedOptions.systemPrompt).toContain('> 规则文件路径:.ai/rules/summary-only.md')
149
+ expect(resolvedOptions.systemPrompt).not.toContain('> 不应该出现在引用正文里')
150
+ expect(resolvedOptions.systemPrompt).not.toContain('--------------------')
151
+ })
152
+
153
+ it('keeps skills as route-only guidance unless the target spec references them', async () => {
154
+ const workspace = await createWorkspace()
155
+
156
+ await writeDocument(
157
+ join(workspace, '.ai/skills/research/SKILL.md'),
158
+ [
159
+ '---',
160
+ 'description: 检索项目信息',
161
+ '---',
162
+ '先读 README.md'
163
+ ].join('\n')
164
+ )
165
+ await writeDocument(
166
+ join(workspace, '.ai/skills/review/SKILL.md'),
167
+ [
168
+ '---',
169
+ 'description: 评审代码改动',
170
+ '---',
171
+ '检查回归风险'
172
+ ].join('\n')
173
+ )
174
+ await writeDocument(
175
+ join(workspace, '.ai/specs/release/index.md'),
176
+ [
177
+ '---',
178
+ 'description: 发布流程',
179
+ 'skills:',
180
+ ' - research',
181
+ '---',
182
+ '执行发布'
183
+ ].join('\n')
184
+ )
185
+
186
+ const bundle = await resolveWorkspaceAssetBundle({
187
+ cwd: workspace,
188
+ useDefaultVibeForgeMcpServer: false
189
+ })
190
+ const [data, options] = await resolvePromptAssetSelection({
191
+ bundle,
192
+ type: 'spec',
193
+ name: 'release'
194
+ })
195
+
196
+ expect(data.targetSkills.map(skill => skill.resolvedName ?? skill.attributes.name)).toEqual(['research'])
197
+ expect(options.systemPrompt).toContain('项目已加载如下技能模块')
198
+ expect(options.systemPrompt).toContain('# research')
199
+ expect(options.systemPrompt).toContain('> 技能文件路径:.ai/skills/research/SKILL.md')
200
+ expect(options.systemPrompt).toContain('<skill-content>')
201
+ expect(options.systemPrompt).toContain('先读 README.md')
202
+ expect(options.systemPrompt).toContain('# review')
203
+ expect(options.systemPrompt).toContain('> 技能文件路径:.ai/skills/review/SKILL.md')
204
+ expect(options.systemPrompt).toContain('> 默认无需预先加载正文;仅在任务明确需要该技能时,再读取对应技能文件。')
205
+ expect(options.systemPrompt).not.toContain('<skill-content>\n检查回归风险\n</skill-content>')
206
+ })
207
+
208
+ it('keeps skills as route-only guidance in normal mode', async () => {
209
+ const workspace = await createWorkspace()
210
+
211
+ await writeDocument(
212
+ join(workspace, '.ai/skills/research/SKILL.md'),
213
+ [
214
+ '---',
215
+ 'description: 检索项目信息',
216
+ '---',
217
+ '先读 README.md'
218
+ ].join('\n')
219
+ )
220
+
221
+ const bundle = await resolveWorkspaceAssetBundle({
222
+ cwd: workspace,
223
+ useDefaultVibeForgeMcpServer: false
224
+ })
225
+ const [data, options] = await resolvePromptAssetSelection({
226
+ bundle,
227
+ type: undefined
228
+ })
229
+
230
+ expect(data.targetSkills).toEqual([])
231
+ expect(options.systemPrompt).not.toContain('项目已加载如下技能模块')
232
+ expect(options.systemPrompt).toContain('<skills>')
233
+ expect(options.systemPrompt).toContain('# research')
234
+ expect(options.systemPrompt).toContain('> 技能文件路径:.ai/skills/research/SKILL.md')
235
+ expect(options.systemPrompt).toContain('> 默认无需预先加载正文;仅在任务明确需要该技能时,再读取对应技能文件。')
236
+ expect(options.systemPrompt).not.toContain('<skill-content>')
237
+ expect(options.systemPrompt).not.toContain('先读 README.md')
238
+ })
239
+
240
+ it('keeps spec route guidance without default identity in normal mode', async () => {
241
+ const workspace = await createWorkspace()
242
+
243
+ await writeDocument(
244
+ join(workspace, '.ai/specs/release/index.md'),
245
+ [
246
+ '---',
247
+ 'description: 发布流程',
248
+ '---',
249
+ '执行发布'
250
+ ].join('\n')
251
+ )
252
+
253
+ const bundle = await resolveWorkspaceAssetBundle({
254
+ cwd: workspace,
255
+ useDefaultVibeForgeMcpServer: false
256
+ })
257
+ const [, options] = await resolvePromptAssetSelection({
258
+ bundle,
259
+ type: undefined
260
+ })
261
+
262
+ expect(options.systemPrompt).toContain('项目存在如下工作流程')
263
+ expect(options.systemPrompt).toContain('流程名称:release')
264
+ expect(options.systemPrompt).not.toContain('项目推进管理大师')
265
+ })
266
+
267
+ it('injects spec identity guidance when a spec is actively selected', async () => {
268
+ const workspace = await createWorkspace()
269
+
270
+ await writeDocument(
271
+ join(workspace, '.ai/specs/release/index.md'),
272
+ [
273
+ '---',
274
+ 'description: 发布流程',
275
+ '---',
276
+ '执行发布'
277
+ ].join('\n')
278
+ )
279
+
280
+ const bundle = await resolveWorkspaceAssetBundle({
281
+ cwd: workspace,
282
+ useDefaultVibeForgeMcpServer: false
283
+ })
284
+ const [, options] = await resolvePromptAssetSelection({
285
+ bundle,
286
+ type: 'spec',
287
+ name: 'release'
288
+ })
289
+
290
+ expect(options.systemPrompt).toContain('项目推进管理大师')
291
+ expect(options.systemPrompt).toContain('永远不要单独完成代码开发工作')
292
+ expect(options.systemPrompt).toContain('流程名称:release')
293
+ })
294
+
295
+ it('embeds referenced skills for entity mode and removes them from route guidance', async () => {
296
+ const workspace = await createWorkspace()
297
+
298
+ await writeDocument(
299
+ join(workspace, '.ai/skills/research/SKILL.md'),
300
+ [
301
+ '---',
302
+ 'description: 检索项目信息',
303
+ '---',
304
+ '先读 README.md'
305
+ ].join('\n')
306
+ )
307
+ await writeDocument(
308
+ join(workspace, '.ai/skills/review/SKILL.md'),
309
+ [
310
+ '---',
311
+ 'description: 评审代码改动',
312
+ '---',
313
+ '检查回归风险'
314
+ ].join('\n')
315
+ )
316
+ await writeDocument(
317
+ join(workspace, '.ai/entities/reviewer/README.md'),
318
+ [
319
+ '---',
320
+ 'description: 代码评审实体',
321
+ 'skills:',
322
+ ' - review',
323
+ '---',
324
+ '负责代码评审'
325
+ ].join('\n')
326
+ )
327
+
328
+ const bundle = await resolveWorkspaceAssetBundle({
329
+ cwd: workspace,
330
+ useDefaultVibeForgeMcpServer: false
331
+ })
332
+ const [data, options] = await resolvePromptAssetSelection({
333
+ bundle,
334
+ type: 'entity',
335
+ name: 'reviewer'
336
+ })
337
+
338
+ expect(data.targetSkills.map(skill => skill.resolvedName ?? skill.attributes.name)).toEqual(['review'])
339
+ expect(options.systemPrompt).toContain('项目已加载如下技能模块')
340
+ expect(options.systemPrompt).toContain('# review')
341
+ expect(options.systemPrompt).toContain('<skill-content>')
342
+ expect(options.systemPrompt).toContain('检查回归风险')
343
+ expect(options.systemPrompt).not.toContain('<skills>\n# review')
344
+ expect(options.systemPrompt).toContain('<skills>')
345
+ expect(options.systemPrompt).toContain('# research')
346
+ expect(options.systemPrompt).toContain('> 技能文件路径:.ai/skills/research/SKILL.md')
347
+ expect(options.systemPrompt).not.toContain('<skill-content>\n先读 README.md\n</skill-content>')
348
+ })
349
+
350
+ it('does not preload all skills when the target entity omits skill references', async () => {
351
+ const workspace = await createWorkspace()
352
+
353
+ await writeDocument(
354
+ join(workspace, '.ai/skills/research/SKILL.md'),
355
+ [
356
+ '---',
357
+ 'description: 检索项目信息',
358
+ '---',
359
+ '先读 README.md'
360
+ ].join('\n')
361
+ )
362
+ await writeDocument(
363
+ join(workspace, '.ai/entities/reviewer/README.md'),
364
+ [
365
+ '---',
366
+ 'description: 代码评审实体',
367
+ '---',
368
+ '负责代码评审'
369
+ ].join('\n')
370
+ )
371
+
372
+ const bundle = await resolveWorkspaceAssetBundle({
373
+ cwd: workspace,
374
+ useDefaultVibeForgeMcpServer: false
375
+ })
376
+ const [data, options] = await resolvePromptAssetSelection({
377
+ bundle,
378
+ type: 'entity',
379
+ name: 'reviewer'
380
+ })
381
+
382
+ expect(data.targetSkills).toEqual([])
383
+ expect(options.systemPrompt).not.toContain('项目已加载如下技能模块')
384
+ expect(options.systemPrompt).toContain('# research')
385
+ expect(options.systemPrompt).toContain('> 技能文件路径:.ai/skills/research/SKILL.md')
386
+ expect(options.systemPrompt).not.toContain('先读 README.md')
387
+ })
388
+ })
@@ -0,0 +1,276 @@
1
+ import process from 'node:process'
2
+
3
+ import type {
4
+ AdapterAssetPlan,
5
+ AssetDiagnostic,
6
+ Definition,
7
+ Entity,
8
+ Filter,
9
+ PromptAssetResolution,
10
+ Rule,
11
+ Skill,
12
+ Spec,
13
+ WorkspaceAsset,
14
+ WorkspaceAssetBundle,
15
+ WorkspaceAssetKind
16
+ } from '@vibe-forge/types'
17
+ import { resolveDocumentName, resolveSpecIdentifier } from '@vibe-forge/utils'
18
+
19
+ const sortStrings = (values: string[]) => [...values].sort((left, right) => left.localeCompare(right))
20
+
21
+ const sanitizeValue = (
22
+ value: string,
23
+ cwd: string
24
+ ) => (
25
+ value
26
+ .replaceAll(`/private${cwd}`, '<workspace>')
27
+ .replaceAll(cwd, '<workspace>')
28
+ .replaceAll(`/private${process.execPath}`, '<node-path>')
29
+ .replaceAll(process.execPath, '<node-path>')
30
+ )
31
+
32
+ const normalizeValue = (
33
+ value: unknown,
34
+ cwd: string
35
+ ): unknown => {
36
+ if (typeof value === 'string') {
37
+ return sanitizeValue(value, cwd)
38
+ }
39
+
40
+ if (Array.isArray(value)) {
41
+ return value.map(item => normalizeValue(item, cwd))
42
+ }
43
+
44
+ if (value != null && typeof value === 'object') {
45
+ return Object.fromEntries(
46
+ Object.entries(value)
47
+ .sort(([left], [right]) => left.localeCompare(right))
48
+ .map(([key, entry]) => [key, normalizeValue(entry, cwd)])
49
+ )
50
+ }
51
+
52
+ return value
53
+ }
54
+
55
+ const sortFilter = (filter: Filter | undefined) => {
56
+ if (filter == null) return undefined
57
+ return {
58
+ include: filter.include == null ? undefined : sortStrings(filter.include),
59
+ exclude: filter.exclude == null ? undefined : sortStrings(filter.exclude)
60
+ }
61
+ }
62
+
63
+ const resolveDefinitionIdentifier = (
64
+ kind: Extract<WorkspaceAssetKind, 'rule' | 'spec' | 'entity' | 'skill'>,
65
+ definition: Definition<Rule | Spec | Entity | Skill>
66
+ ) => {
67
+ if (kind === 'spec') {
68
+ return resolveSpecIdentifier(definition.path, definition.attributes.name)
69
+ }
70
+
71
+ if (kind === 'entity') {
72
+ return resolveDocumentName(definition.path, definition.attributes.name, ['readme.md', 'index.json'])
73
+ }
74
+
75
+ if (kind === 'skill') {
76
+ return resolveDocumentName(definition.path, definition.attributes.name, ['skill.md'])
77
+ }
78
+
79
+ return resolveDocumentName(definition.path, definition.attributes.name)
80
+ }
81
+
82
+ const summarizeDefinition = (
83
+ kind: Extract<WorkspaceAssetKind, 'rule' | 'spec' | 'entity' | 'skill'>,
84
+ definition: Definition<Rule | Spec | Entity | Skill>,
85
+ cwd: string
86
+ ) => ({
87
+ identifier: resolveDefinitionIdentifier(kind, definition),
88
+ path: sanitizeValue(definition.path, cwd),
89
+ attributes: normalizeValue(definition.attributes, cwd),
90
+ body: definition.body.trim()
91
+ })
92
+
93
+ const buildSnapshotAssetId = (
94
+ asset: WorkspaceAsset,
95
+ cwd: string
96
+ ) => (
97
+ `${asset.kind}:${asset.origin}:${asset.instancePath ?? 'workspace'}:${asset.displayName}:${sanitizeValue(asset.sourcePath, cwd)}`
98
+ )
99
+
100
+ const summarizeBaseAsset = (
101
+ asset: WorkspaceAsset,
102
+ cwd: string,
103
+ assetIdMap?: Map<string, string>
104
+ ) => ({
105
+ id: assetIdMap?.get(asset.id) ?? buildSnapshotAssetId(asset, cwd),
106
+ kind: asset.kind,
107
+ name: asset.name,
108
+ displayName: asset.displayName,
109
+ origin: asset.origin,
110
+ scope: asset.scope,
111
+ sourcePath: sanitizeValue(asset.sourcePath, cwd),
112
+ instancePath: asset.instancePath,
113
+ packageId: asset.packageId,
114
+ resolvedBy: asset.resolvedBy,
115
+ taskOverlaySource: asset.taskOverlaySource
116
+ })
117
+
118
+ const summarizeDocumentAsset = (
119
+ asset: Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>,
120
+ cwd: string,
121
+ assetIdMap?: Map<string, string>
122
+ ) => ({
123
+ ...summarizeBaseAsset(asset, cwd, assetIdMap),
124
+ definition: summarizeDefinition(asset.kind, asset.payload.definition, cwd)
125
+ })
126
+
127
+ const summarizeMcpServer = (
128
+ asset: Extract<WorkspaceAsset, { kind: 'mcpServer' }>,
129
+ cwd: string,
130
+ assetIdMap?: Map<string, string>
131
+ ) => ({
132
+ ...summarizeBaseAsset(asset, cwd, assetIdMap),
133
+ config: normalizeValue(asset.payload.config, cwd)
134
+ })
135
+
136
+ const summarizeHookPlugin = (
137
+ asset: Extract<WorkspaceAsset, { kind: 'hookPlugin' }>,
138
+ cwd: string,
139
+ assetIdMap?: Map<string, string>
140
+ ) => ({
141
+ ...summarizeBaseAsset(asset, cwd, assetIdMap),
142
+ packageName: asset.payload.packageName,
143
+ config: normalizeValue(asset.payload.config, cwd)
144
+ })
145
+
146
+ const summarizeOpenCodeOverlayAsset = (
147
+ asset: Extract<WorkspaceAsset, { kind: 'agent' | 'command' | 'mode' | 'nativePlugin' }>,
148
+ cwd: string,
149
+ assetIdMap?: Map<string, string>
150
+ ) => ({
151
+ ...summarizeBaseAsset(asset, cwd, assetIdMap),
152
+ entryName: asset.payload.entryName,
153
+ targetSubpath: asset.payload.targetSubpath
154
+ })
155
+
156
+ const summarizeDiagnostics = (
157
+ diagnostics: AssetDiagnostic[],
158
+ assetIdMap: Map<string, string>
159
+ ) => (
160
+ [...diagnostics]
161
+ .sort((left, right) => {
162
+ const assetIdDiff = left.assetId.localeCompare(right.assetId)
163
+ if (assetIdDiff !== 0) return assetIdDiff
164
+ return left.status.localeCompare(right.status)
165
+ })
166
+ .map(diagnostic => ({
167
+ assetId: assetIdMap.get(diagnostic.assetId) ?? diagnostic.assetId,
168
+ adapter: diagnostic.adapter,
169
+ status: diagnostic.status,
170
+ reason: diagnostic.reason,
171
+ scope: diagnostic.scope,
172
+ packageId: diagnostic.packageId
173
+ }))
174
+ )
175
+
176
+ const summarizePlan = (
177
+ plan: AdapterAssetPlan,
178
+ cwd: string,
179
+ assetIdMap: Map<string, string>
180
+ ) => ({
181
+ adapter: plan.adapter,
182
+ mcpServers: normalizeValue(plan.mcpServers, cwd),
183
+ overlays: [...plan.overlays]
184
+ .sort((left, right) => left.assetId.localeCompare(right.assetId))
185
+ .map(entry => ({
186
+ assetId: assetIdMap.get(entry.assetId) ?? entry.assetId,
187
+ kind: entry.kind,
188
+ sourcePath: sanitizeValue(entry.sourcePath, cwd),
189
+ targetPath: entry.targetPath
190
+ })),
191
+ diagnostics: summarizeDiagnostics(plan.diagnostics, assetIdMap)
192
+ })
193
+
194
+ const summarizeSelection = (
195
+ resolution: PromptAssetResolution,
196
+ options: {
197
+ systemPrompt?: string
198
+ tools?: Filter
199
+ mcpServers?: Filter
200
+ promptAssetIds?: string[]
201
+ },
202
+ cwd: string,
203
+ assetIdMap: Map<string, string>
204
+ ) => ({
205
+ resolution: {
206
+ rules: resolution.rules.map(rule => summarizeDefinition('rule', rule, cwd)),
207
+ targetSkills: resolution.targetSkills.map(skill => summarizeDefinition('skill', skill, cwd)),
208
+ entities: resolution.entities.map(entity => summarizeDefinition('entity', entity, cwd)),
209
+ skills: resolution.skills.map(skill => summarizeDefinition('skill', skill, cwd)),
210
+ specs: resolution.specs.map(spec => summarizeDefinition('spec', spec, cwd)),
211
+ targetBody: resolution.targetBody.trim(),
212
+ promptAssetIds: sortStrings(resolution.promptAssetIds.map(assetId => assetIdMap.get(assetId) ?? assetId))
213
+ },
214
+ options: {
215
+ systemPrompt: options.systemPrompt?.trim(),
216
+ tools: sortFilter(options.tools),
217
+ mcpServers: sortFilter(options.mcpServers),
218
+ promptAssetIds: options.promptAssetIds == null
219
+ ? undefined
220
+ : sortStrings(options.promptAssetIds.map(assetId => assetIdMap.get(assetId) ?? assetId))
221
+ }
222
+ })
223
+
224
+ export const serializeWorkspaceAssetsSnapshot = (params: {
225
+ cwd: string
226
+ bundle: WorkspaceAssetBundle
227
+ selection: {
228
+ resolution: PromptAssetResolution
229
+ options: {
230
+ systemPrompt?: string
231
+ tools?: Filter
232
+ mcpServers?: Filter
233
+ promptAssetIds?: string[]
234
+ }
235
+ }
236
+ plans: AdapterAssetPlan[]
237
+ }) => {
238
+ const { bundle, cwd } = params
239
+ const assetIdMap = new Map(bundle.assets.map(asset => [asset.id, buildSnapshotAssetId(asset, cwd)]))
240
+
241
+ const snapshot = {
242
+ bundle: {
243
+ cwd: '<workspace>',
244
+ pluginConfigs: normalizeValue(bundle.pluginConfigs, cwd),
245
+ pluginInstances: normalizeValue(bundle.pluginInstances, cwd),
246
+ defaultIncludeMcpServers: sortStrings(bundle.defaultIncludeMcpServers),
247
+ defaultExcludeMcpServers: sortStrings(bundle.defaultExcludeMcpServers),
248
+ rules: bundle.rules.map(rule => summarizeDocumentAsset(rule, cwd, assetIdMap)),
249
+ specs: bundle.specs.map(spec => summarizeDocumentAsset(spec, cwd, assetIdMap)),
250
+ entities: bundle.entities.map(entity => summarizeDocumentAsset(entity, cwd, assetIdMap)),
251
+ skills: bundle.skills.map(skill => summarizeDocumentAsset(skill, cwd, assetIdMap)),
252
+ mcpServers: Object.values(bundle.mcpServers)
253
+ .sort((left, right) => left.payload.name.localeCompare(right.payload.name))
254
+ .map(server => summarizeMcpServer(server, cwd, assetIdMap)),
255
+ hookPlugins: bundle.hookPlugins
256
+ .sort((left, right) => left.id.localeCompare(right.id))
257
+ .map(plugin => summarizeHookPlugin(plugin, cwd, assetIdMap)),
258
+ opencodeOverlayAssets: bundle.opencodeOverlayAssets
259
+ .sort((left, right) => left.id.localeCompare(right.id))
260
+ .map(asset => summarizeOpenCodeOverlayAsset(asset, cwd, assetIdMap))
261
+ },
262
+ selection: summarizeSelection(
263
+ params.selection.resolution,
264
+ params.selection.options,
265
+ cwd,
266
+ assetIdMap
267
+ ),
268
+ plans: Object.fromEntries(
269
+ [...params.plans]
270
+ .sort((left, right) => left.adapter.localeCompare(right.adapter))
271
+ .map(plan => [plan.adapter, summarizePlan(plan, cwd, assetIdMap)])
272
+ )
273
+ }
274
+
275
+ return `${JSON.stringify(snapshot, null, 2)}\n`
276
+ }
@@ -0,0 +1,33 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import { afterEach } from 'vitest'
6
+
7
+ const tempDirs: string[] = []
8
+
9
+ export const createWorkspace = async () => {
10
+ const dir = await mkdtemp(join(tmpdir(), 'workspace-assets-'))
11
+ tempDirs.push(dir)
12
+ return dir
13
+ }
14
+
15
+ export const writeDocument = async (filePath: string, content: string) => {
16
+ await mkdir(dirname(filePath), { recursive: true })
17
+ await writeFile(filePath, content)
18
+ }
19
+
20
+ export const installPluginPackage = async (
21
+ workspace: string,
22
+ packageName: string,
23
+ files: Record<string, string>
24
+ ) => {
25
+ const packageDir = join(workspace, 'node_modules', ...packageName.split('/'))
26
+ await Promise.all(Object.entries(files).map(async ([relativePath, content]) => {
27
+ await writeDocument(join(packageDir, relativePath), content)
28
+ }))
29
+ }
30
+
31
+ afterEach(async () => {
32
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
33
+ })