@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.
package/src/index.ts CHANGED
@@ -1,3 +1,1368 @@
1
- export { buildAdapterAssetPlan } from './adapter-asset-plan'
2
- export { resolveWorkspaceAssetBundle } from './bundle'
3
- export { resolvePromptAssetSelection } from './prompt-selection'
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, dirname, extname, resolve } from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import {
6
+ DEFAULT_VIBE_FORGE_MCP_SERVER_NAME,
7
+ buildConfigJsonVariables,
8
+ loadConfig,
9
+ resolveDefaultVibeForgeMcpServerConfig
10
+ } from '@vibe-forge/config'
11
+ import type {
12
+ AdapterAssetPlan,
13
+ AdapterOverlayEntry,
14
+ AssetDiagnostic,
15
+ Config,
16
+ Definition,
17
+ Entity,
18
+ Filter,
19
+ PluginConfig,
20
+ PluginOverlayConfig,
21
+ Rule,
22
+ RuleReference,
23
+ Skill,
24
+ SkillSelection,
25
+ Spec,
26
+ WorkspaceAsset,
27
+ WorkspaceAssetAdapter,
28
+ WorkspaceAssetBundle,
29
+ WorkspaceAssetKind,
30
+ WorkspaceMcpSelection,
31
+ WorkspaceSkillSelection
32
+ } from '@vibe-forge/types'
33
+ import {
34
+ normalizePath,
35
+ resolveDocumentName,
36
+ resolvePromptPath,
37
+ resolveRelativePath,
38
+ resolveSpecIdentifier
39
+ } from '@vibe-forge/utils'
40
+ import {
41
+ flattenPluginInstances,
42
+ mergePluginConfigs,
43
+ normalizePluginConfig,
44
+ resolveConfiguredPluginInstances,
45
+ resolvePluginHooksEntryPath
46
+ } from '@vibe-forge/utils/plugin-resolver'
47
+ import type { ResolvedPluginInstance } from '@vibe-forge/utils/plugin-resolver'
48
+ import { glob } from 'fast-glob'
49
+ import fm from 'front-matter'
50
+ import yaml from 'js-yaml'
51
+
52
+ type DocumentAssetKind = Extract<WorkspaceAssetKind, 'rule' | 'spec' | 'entity' | 'skill'>
53
+ type OpenCodeOverlayKind = Extract<WorkspaceAssetKind, 'agent' | 'command' | 'mode' | 'nativePlugin'>
54
+ type DocumentAsset<TDefinition> = Extract<WorkspaceAsset, { kind: DocumentAssetKind }> & {
55
+ payload: {
56
+ definition: TDefinition & { path: string }
57
+ }
58
+ }
59
+ interface OpenCodeOverlayAssetEntry {
60
+ kind: OpenCodeOverlayKind
61
+ sourcePath: string
62
+ entryName: string
63
+ targetSubpath: string
64
+ }
65
+
66
+ type OpenCodeOverlayAsset<TKind extends OpenCodeOverlayKind> = Extract<WorkspaceAsset, { kind: TKind }>
67
+
68
+ const isRecord = (value: unknown): value is Record<string, unknown> => (
69
+ value != null && typeof value === 'object' && !Array.isArray(value)
70
+ )
71
+
72
+ const getFirstNonEmptyLine = (text: string) =>
73
+ text
74
+ .split('\n')
75
+ .map(line => line.trim())
76
+ .find(Boolean)
77
+
78
+ const resolveDisplayName = (name: string, scope?: string) => (
79
+ scope != null && scope.trim() !== '' ? `${scope}/${name}` : name
80
+ )
81
+
82
+ const resolveDocumentDescription = (
83
+ body: string,
84
+ explicitDescription?: string,
85
+ fallbackName?: string
86
+ ) => {
87
+ const trimmedDescription = explicitDescription?.trim()
88
+ return trimmedDescription || getFirstNonEmptyLine(body) || fallbackName || ''
89
+ }
90
+
91
+ const isAlwaysRule = (attributes: Pick<Rule, 'always' | 'alwaysApply'>) => (
92
+ attributes.always ?? attributes.alwaysApply ?? false
93
+ )
94
+
95
+ const resolveDefinitionName = <T extends { name?: string }>(
96
+ definition: Definition<T>,
97
+ indexFileNames: string[] = []
98
+ ) => definition.resolvedName?.trim() || resolveDocumentName(definition.path, definition.attributes.name, indexFileNames)
99
+
100
+ const toMarkdownBlockquote = (content: string) => (
101
+ content
102
+ .trim()
103
+ .split('\n')
104
+ .map(line => line === '' ? '>' : `> ${line}`)
105
+ .join('\n')
106
+ )
107
+
108
+ const buildOptionalRuleGuidance = (cwd: string, rule: Definition<Rule>) => {
109
+ const name = resolveDefinitionName(rule)
110
+ const desc = resolveDocumentDescription(rule.body, rule.attributes.description, name)
111
+ return [
112
+ `适用场景:${desc}`,
113
+ `规则文件路径:${resolvePromptPath(cwd, rule.path)}`,
114
+ '仅在任务满足上述场景时,再阅读该规则文件。'
115
+ ].join('\n')
116
+ }
117
+
118
+ const buildSkillSummary = (
119
+ cwd: string,
120
+ skill: Definition<Skill>,
121
+ guidance: string
122
+ ) => {
123
+ const name = resolveDefinitionName(skill, ['skill.md'])
124
+ const desc = resolveDocumentDescription(skill.body, skill.attributes.description, name)
125
+ return toMarkdownBlockquote(
126
+ [
127
+ `技能介绍:${desc}`,
128
+ `技能文件路径:${resolvePromptPath(cwd, skill.path)}`,
129
+ guidance
130
+ ].join('\n')
131
+ )
132
+ }
133
+
134
+ const resolveEntityIdentifier = (path: string, explicitName?: string) => (
135
+ resolveDocumentName(path, explicitName, ['readme.md', 'index.json'])
136
+ )
137
+
138
+ const resolveSkillIdentifier = (path: string, explicitName?: string) => (
139
+ resolveDocumentName(path, explicitName, ['skill.md'])
140
+ )
141
+
142
+ const parseScopedReference = (value: string) => {
143
+ if (
144
+ value.startsWith('./') ||
145
+ value.startsWith('../') ||
146
+ value.startsWith('/') ||
147
+ value.endsWith('.md') ||
148
+ value.endsWith('.json') ||
149
+ value.endsWith('.yaml') ||
150
+ value.endsWith('.yml')
151
+ ) {
152
+ return undefined
153
+ }
154
+ const separatorIndex = value.indexOf('/')
155
+ if (separatorIndex <= 0) return undefined
156
+ return {
157
+ scope: value.slice(0, separatorIndex),
158
+ name: value.slice(separatorIndex + 1)
159
+ }
160
+ }
161
+
162
+ const isPathLikeReference = (value: string) => (
163
+ value.startsWith('./') ||
164
+ value.startsWith('../') ||
165
+ value.startsWith('/') ||
166
+ value.includes('*') ||
167
+ value.endsWith('.md') ||
168
+ value.endsWith('.json') ||
169
+ value.endsWith('.yaml') ||
170
+ value.endsWith('.yml')
171
+ )
172
+
173
+ const loadWorkspaceConfig = async (cwd: string) => (
174
+ loadConfig({
175
+ cwd,
176
+ jsonVariables: buildConfigJsonVariables(cwd, process.env)
177
+ })
178
+ )
179
+
180
+ const parseFrontmatterDocument = async <TDefinition extends object>(
181
+ path: string
182
+ ): Promise<Definition<TDefinition>> => {
183
+ const content = await readFile(path, 'utf-8')
184
+ const { body, attributes } = fm<TDefinition>(content)
185
+ return {
186
+ path,
187
+ body,
188
+ attributes
189
+ }
190
+ }
191
+
192
+ const parseEntityIndexJson = async (path: string): Promise<Definition<Entity>> => {
193
+ const raw = JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>
194
+ const promptPath = typeof raw.promptPath === 'string'
195
+ ? (raw.promptPath.startsWith('/') ? raw.promptPath : resolve(dirname(path), raw.promptPath))
196
+ : undefined
197
+ const prompt = typeof raw.prompt === 'string'
198
+ ? raw.prompt
199
+ : promptPath != null
200
+ ? await readFile(promptPath, 'utf-8')
201
+ : ''
202
+
203
+ return {
204
+ path,
205
+ body: prompt,
206
+ attributes: raw as Entity
207
+ }
208
+ }
209
+
210
+ const parseStructuredMcpFile = async (path: string) => {
211
+ const raw = await readFile(path, 'utf8')
212
+ const extension = extname(path).toLowerCase()
213
+ return extension === '.yaml' || extension === '.yml'
214
+ ? yaml.load(raw)
215
+ : JSON.parse(raw)
216
+ }
217
+
218
+ const createDocumentAsset = <
219
+ TKind extends DocumentAssetKind,
220
+ TDefinition extends { path: string; attributes: { name?: string } },
221
+ >(params: {
222
+ cwd: string
223
+ kind: TKind
224
+ definition: TDefinition
225
+ origin: 'workspace' | 'plugin'
226
+ scope?: string
227
+ instance?: ResolvedPluginInstance
228
+ }) => {
229
+ const name = ({
230
+ rule: resolveDocumentName,
231
+ spec: resolveSpecIdentifier,
232
+ entity: resolveEntityIdentifier,
233
+ skill: resolveSkillIdentifier
234
+ }[params.kind])(params.definition.path, params.definition.attributes.name)
235
+ const displayName = resolveDisplayName(name, params.scope)
236
+
237
+ return {
238
+ id: `${params.kind}:${params.origin}:${params.instance?.instancePath ?? 'workspace'}:${displayName}:${
239
+ resolveRelativePath(params.cwd, params.definition.path)
240
+ }`,
241
+ kind: params.kind,
242
+ name,
243
+ displayName,
244
+ scope: params.scope,
245
+ origin: params.origin,
246
+ sourcePath: params.definition.path,
247
+ instancePath: params.instance?.instancePath,
248
+ packageId: params.instance?.packageId,
249
+ resolvedBy: params.instance?.resolvedBy,
250
+ taskOverlaySource: params.instance?.overlaySource,
251
+ payload: {
252
+ definition: params.definition
253
+ }
254
+ } as Extract<WorkspaceAsset, { kind: TKind }>
255
+ }
256
+
257
+ const createMcpAsset = (params: {
258
+ cwd: string
259
+ name: string
260
+ config: NonNullable<Config['mcpServers']>[string]
261
+ origin: 'workspace' | 'plugin'
262
+ scope?: string
263
+ sourcePath: string
264
+ instance?: ResolvedPluginInstance
265
+ }) => {
266
+ const displayName = resolveDisplayName(params.name, params.scope)
267
+ return {
268
+ id: `mcpServer:${params.origin}:${params.instance?.instancePath ?? 'workspace'}:${displayName}:${
269
+ resolveRelativePath(params.cwd, params.sourcePath)
270
+ }`,
271
+ kind: 'mcpServer',
272
+ name: params.name,
273
+ displayName,
274
+ scope: params.scope,
275
+ origin: params.origin,
276
+ sourcePath: params.sourcePath,
277
+ instancePath: params.instance?.instancePath,
278
+ packageId: params.instance?.packageId,
279
+ resolvedBy: params.instance?.resolvedBy,
280
+ taskOverlaySource: params.instance?.overlaySource,
281
+ payload: {
282
+ name: displayName,
283
+ config: params.config
284
+ }
285
+ } satisfies Extract<WorkspaceAsset, { kind: 'mcpServer' }>
286
+ }
287
+
288
+ const createHookPluginAsset = (
289
+ instance: ResolvedPluginInstance
290
+ ) => ({
291
+ id: `hookPlugin:${instance.instancePath}:${instance.packageId ?? instance.requestId}`,
292
+ kind: 'hookPlugin',
293
+ name: instance.requestId,
294
+ displayName: resolveDisplayName(instance.requestId, instance.scope),
295
+ scope: instance.scope,
296
+ origin: 'plugin' as const,
297
+ sourcePath: instance.rootDir,
298
+ instancePath: instance.instancePath,
299
+ packageId: instance.packageId,
300
+ resolvedBy: instance.resolvedBy,
301
+ taskOverlaySource: instance.overlaySource,
302
+ payload: {
303
+ packageName: instance.packageId,
304
+ config: instance.options
305
+ }
306
+ } satisfies Extract<WorkspaceAsset, { kind: 'hookPlugin' }>)
307
+
308
+ const createOpenCodeOverlayAsset = <TKind extends OpenCodeOverlayKind>(params: {
309
+ cwd: string
310
+ kind: TKind
311
+ sourcePath: string
312
+ entryName: string
313
+ targetSubpath: string
314
+ instance: ResolvedPluginInstance
315
+ }): OpenCodeOverlayAsset<TKind> => ({
316
+ id: `${params.kind}:plugin:${params.instance.instancePath}:${
317
+ resolveDisplayName(params.entryName, params.instance.scope)
318
+ }:${resolveRelativePath(params.cwd, params.sourcePath)}`,
319
+ kind: params.kind,
320
+ name: params.entryName,
321
+ displayName: resolveDisplayName(params.entryName, params.instance.scope),
322
+ scope: params.instance.scope,
323
+ origin: 'plugin' as const,
324
+ sourcePath: params.sourcePath,
325
+ instancePath: params.instance.instancePath,
326
+ packageId: params.instance.packageId,
327
+ resolvedBy: params.instance.resolvedBy,
328
+ taskOverlaySource: params.instance.overlaySource,
329
+ payload: {
330
+ entryName: params.entryName,
331
+ targetSubpath: params.targetSubpath
332
+ }
333
+ } as OpenCodeOverlayAsset<TKind>)
334
+
335
+ const scanWorkspaceDocuments = async (cwd: string) => {
336
+ const [rulePaths, skillPaths, specPaths, entityDocPaths, entityJsonPaths, mcpPaths] = await Promise.all([
337
+ glob(['.ai/rules/*.md'], { cwd, absolute: true }),
338
+ glob(['.ai/skills/*/SKILL.md'], { cwd, absolute: true }),
339
+ glob(['.ai/specs/*.md', '.ai/specs/*/index.md'], { cwd, absolute: true }),
340
+ glob(['.ai/entities/*.md', '.ai/entities/*/README.md'], { cwd, absolute: true }),
341
+ glob(['.ai/entities/*/index.json'], { cwd, absolute: true }),
342
+ glob(['.ai/mcp/*.json', '.ai/mcp/*.yaml', '.ai/mcp/*.yml'], { cwd, absolute: true })
343
+ ])
344
+
345
+ return {
346
+ rulePaths,
347
+ skillPaths,
348
+ specPaths,
349
+ entityDocPaths,
350
+ entityJsonPaths,
351
+ mcpPaths
352
+ }
353
+ }
354
+
355
+ const scanInstanceDocuments = async (instance: ResolvedPluginInstance) => {
356
+ const rootDir = instance.rootDir
357
+ const assets = instance.manifest?.assets
358
+ const resolveAssetRoot = (dir: string | undefined, fallback: string) => resolve(rootDir, dir ?? fallback)
359
+
360
+ const [rulePaths, skillPaths, specPaths, entityDocPaths, entityJsonPaths, mcpPaths] = await Promise.all([
361
+ glob(['*.md'], { cwd: resolveAssetRoot(assets?.rules, 'rules'), absolute: true }).catch(() => [] as string[]),
362
+ glob(['*/SKILL.md'], { cwd: resolveAssetRoot(assets?.skills, 'skills'), absolute: true }).catch(() =>
363
+ [] as string[]
364
+ ),
365
+ glob(['*.md', '*/index.md'], { cwd: resolveAssetRoot(assets?.specs, 'specs'), absolute: true }).catch(() =>
366
+ [] as string[]
367
+ ),
368
+ glob(['*.md', '*/README.md'], { cwd: resolveAssetRoot(assets?.entities, 'entities'), absolute: true }).catch(() =>
369
+ [] as string[]
370
+ ),
371
+ glob(['*/index.json'], { cwd: resolveAssetRoot(assets?.entities, 'entities'), absolute: true }).catch(() =>
372
+ [] as string[]
373
+ ),
374
+ glob(['*.json', '*.yaml', '*.yml'], { cwd: resolveAssetRoot(assets?.mcp, 'mcp'), absolute: true }).catch(() =>
375
+ [] as string[]
376
+ )
377
+ ])
378
+
379
+ return {
380
+ rulePaths,
381
+ skillPaths,
382
+ specPaths,
383
+ entityDocPaths,
384
+ entityJsonPaths,
385
+ mcpPaths
386
+ }
387
+ }
388
+
389
+ const toOpenCodeOverlayEntries = (
390
+ kind: OpenCodeOverlayKind,
391
+ targetDir: 'agents' | 'commands' | 'modes' | 'plugins',
392
+ paths: string[]
393
+ ): OpenCodeOverlayAssetEntry[] =>
394
+ paths.map((sourcePath) => ({
395
+ kind,
396
+ sourcePath,
397
+ entryName: basename(sourcePath, extname(sourcePath)),
398
+ targetSubpath: `${targetDir}/${basename(sourcePath)}`
399
+ }))
400
+
401
+ const scanInstanceOpenCodeOverlays = async (
402
+ instance: ResolvedPluginInstance
403
+ ) => {
404
+ const opencodeRoot = resolve(instance.rootDir, 'opencode')
405
+ const [agentPaths, commandPaths, modePaths, nativePluginPaths] = await Promise.all([
406
+ glob(['*.md'], { cwd: resolve(opencodeRoot, 'agents'), absolute: true, onlyFiles: true }).catch(() =>
407
+ [] as string[]
408
+ ),
409
+ glob(['*.md'], { cwd: resolve(opencodeRoot, 'commands'), absolute: true, onlyFiles: true }).catch(() =>
410
+ [] as string[]
411
+ ),
412
+ glob(['*.md'], { cwd: resolve(opencodeRoot, 'modes'), absolute: true, onlyFiles: true }).catch(() =>
413
+ [] as string[]
414
+ ),
415
+ glob(['**/*'], { cwd: resolve(opencodeRoot, 'plugins'), absolute: true, onlyFiles: true }).catch(() =>
416
+ [] as string[]
417
+ )
418
+ ])
419
+
420
+ return [
421
+ ...toOpenCodeOverlayEntries('agent', 'agents', agentPaths),
422
+ ...toOpenCodeOverlayEntries('command', 'commands', commandPaths),
423
+ ...toOpenCodeOverlayEntries('mode', 'modes', modePaths),
424
+ ...toOpenCodeOverlayEntries('nativePlugin', 'plugins', nativePluginPaths)
425
+ ]
426
+ }
427
+
428
+ const definitionWithResolvedName = <TDefinition>(
429
+ definition: Definition<TDefinition>,
430
+ resolvedName: string,
431
+ instancePath?: string
432
+ ) => ({
433
+ ...definition,
434
+ resolvedName,
435
+ resolvedInstancePath: instancePath
436
+ })
437
+
438
+ const toDocumentDefinitions = <TDefinition>(
439
+ assets: Array<DocumentAsset<TDefinition>>
440
+ ) =>
441
+ assets.map(asset =>
442
+ definitionWithResolvedName(
443
+ asset.payload.definition,
444
+ asset.displayName,
445
+ asset.instancePath
446
+ )
447
+ )
448
+
449
+ const assertNoDocumentConflicts = (
450
+ assets: Array<Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>>
451
+ ) => {
452
+ const seen = new Map<string, WorkspaceAsset>()
453
+ for (const asset of assets) {
454
+ const key = `${asset.kind}:${asset.displayName}`
455
+ const existing = seen.get(key)
456
+ if (existing != null) {
457
+ throw new Error(
458
+ `Duplicate ${asset.kind} asset ${asset.displayName} from ${existing.sourcePath} and ${asset.sourcePath}`
459
+ )
460
+ }
461
+ seen.set(key, asset)
462
+ }
463
+ }
464
+
465
+ const assertNoMcpConflicts = (
466
+ assets: Array<Extract<WorkspaceAsset, { kind: 'mcpServer' }>>
467
+ ) => {
468
+ const seen = new Map<string, WorkspaceAsset>()
469
+ for (const asset of assets) {
470
+ const existing = seen.get(asset.displayName)
471
+ if (existing != null) {
472
+ throw new Error(`Duplicate MCP server ${asset.displayName} from ${existing.sourcePath} and ${asset.sourcePath}`)
473
+ }
474
+ seen.set(asset.displayName, asset)
475
+ }
476
+ }
477
+
478
+ const resolveUniqueAssetByName = <TAsset extends Extract<WorkspaceAsset, { kind: DocumentAssetKind }>>(
479
+ assets: TAsset[],
480
+ name: string
481
+ ) => {
482
+ const matches = assets.filter(asset => asset.name === name)
483
+ if (matches.length === 0) return undefined
484
+ const unscopedMatches = matches.filter(asset => asset.scope == null)
485
+ if (unscopedMatches.length === 1) {
486
+ return unscopedMatches[0]
487
+ }
488
+ if (matches.length > 1) {
489
+ throw new Error(
490
+ `Ambiguous asset reference ${name}. Candidates: ${matches.map(match => match.displayName).join(', ')}`
491
+ )
492
+ }
493
+ return matches[0]
494
+ }
495
+
496
+ const resolveScopedAsset = <TAsset extends Extract<WorkspaceAsset, { kind: DocumentAssetKind }>>(
497
+ assets: TAsset[],
498
+ scope: string,
499
+ name: string
500
+ ) => assets.find(asset => asset.scope === scope && asset.name === name)
501
+
502
+ const resolveNamedAssets = <TAsset extends Extract<WorkspaceAsset, { kind: DocumentAssetKind }>>(
503
+ assets: TAsset[],
504
+ refs: string[] | undefined,
505
+ currentInstancePath?: string
506
+ ) => {
507
+ if (refs == null || refs.length === 0) return [] as TAsset[]
508
+
509
+ const selected: TAsset[] = []
510
+ const seen = new Set<string>()
511
+
512
+ const add = (asset: TAsset) => {
513
+ if (seen.has(asset.id)) return
514
+ seen.add(asset.id)
515
+ selected.push(asset)
516
+ }
517
+
518
+ for (const ref of refs) {
519
+ const scoped = parseScopedReference(ref)
520
+ if (scoped != null) {
521
+ const asset = resolveScopedAsset(assets, scoped.scope, scoped.name)
522
+ if (asset == null) throw new Error(`Failed to resolve asset ${ref}`)
523
+ add(asset)
524
+ continue
525
+ }
526
+
527
+ if (currentInstancePath != null) {
528
+ const local = assets.find(asset => asset.instancePath === currentInstancePath && asset.name === ref)
529
+ if (local != null) {
530
+ add(local)
531
+ continue
532
+ }
533
+ }
534
+
535
+ const asset = resolveUniqueAssetByName(assets, ref)
536
+ if (asset == null) throw new Error(`Failed to resolve asset ${ref}`)
537
+ add(asset)
538
+ }
539
+
540
+ return selected
541
+ }
542
+
543
+ const toRuleSelectionRefs = (
544
+ refs: RuleReference[] | string[] | undefined
545
+ ) =>
546
+ (refs ?? []).flatMap((ref) => {
547
+ if (typeof ref === 'string') return [ref]
548
+ if (ref.type === 'remote') return []
549
+ return [ref.path]
550
+ })
551
+
552
+ const createRemoteRuleDefinition = (
553
+ rule: Extract<RuleReference, { type: 'remote' }>,
554
+ index: number
555
+ ): Definition<Rule> => {
556
+ const tags = Array.isArray(rule.tags)
557
+ ? rule.tags.filter((value): value is string => typeof value === 'string' && value.trim() !== '').map(value =>
558
+ value.trim()
559
+ )
560
+ : []
561
+ const description = rule.desc?.trim() || (
562
+ tags.length > 0
563
+ ? `远程知识库标签:${tags.join(', ')}`
564
+ : '远程知识库规则引用'
565
+ )
566
+
567
+ return {
568
+ path: `remote-rule-${index + 1}.md`,
569
+ body: [
570
+ description,
571
+ tags.length > 0 ? `知识库标签:${tags.join(', ')}` : undefined,
572
+ '该规则来自远程知识库引用,不对应本地文件。'
573
+ ].filter((value): value is string => value != null && value !== '').join('\n'),
574
+ attributes: {
575
+ name: tags.length > 0 ? `remote:${tags.join(',')}` : `remote-rule-${index + 1}`,
576
+ description
577
+ }
578
+ }
579
+ }
580
+
581
+ const resolvePathMatchedRules = async (
582
+ bundle: WorkspaceAssetBundle,
583
+ ref: string
584
+ ) => {
585
+ const matchedPaths = new Set(
586
+ (await glob(ref, {
587
+ cwd: bundle.cwd,
588
+ absolute: true
589
+ })).map(normalizePath)
590
+ )
591
+ return bundle.rules.filter(rule => matchedPaths.has(normalizePath(rule.sourcePath)))
592
+ }
593
+
594
+ const resolveRuleSelection = async (
595
+ bundle: WorkspaceAssetBundle,
596
+ refs: RuleReference[] | string[] | undefined,
597
+ currentInstancePath?: string
598
+ ) => {
599
+ const assets: Array<Extract<WorkspaceAsset, { kind: 'rule' }>> = []
600
+ const remoteDefinitions: Definition<Rule>[] = []
601
+ const seen = new Set<string>()
602
+
603
+ const addAsset = (asset: Extract<WorkspaceAsset, { kind: 'rule' }>) => {
604
+ if (seen.has(asset.id)) return
605
+ seen.add(asset.id)
606
+ assets.push(asset)
607
+ }
608
+
609
+ let remoteIndex = 0
610
+ for (const ref of refs ?? []) {
611
+ if (typeof ref === 'object' && ref != null && ref.type === 'remote') {
612
+ remoteDefinitions.push(createRemoteRuleDefinition(ref, remoteIndex++))
613
+ continue
614
+ }
615
+
616
+ const value = typeof ref === 'string' ? ref : ref.path
617
+ if (isPathLikeReference(value)) {
618
+ const matched = await resolvePathMatchedRules(bundle, value)
619
+ matched.forEach(addAsset)
620
+ continue
621
+ }
622
+
623
+ const scoped = parseScopedReference(value)
624
+ if (scoped != null) {
625
+ const asset = resolveScopedAsset(bundle.rules, scoped.scope, scoped.name)
626
+ if (asset == null) throw new Error(`Failed to resolve rule ${value}`)
627
+ addAsset(asset)
628
+ continue
629
+ }
630
+
631
+ if (currentInstancePath != null) {
632
+ const local = bundle.rules.find(rule => rule.instancePath === currentInstancePath && rule.name === value)
633
+ if (local != null) {
634
+ addAsset(local)
635
+ continue
636
+ }
637
+ }
638
+
639
+ const asset = resolveUniqueAssetByName(bundle.rules, value)
640
+ if (asset == null) throw new Error(`Failed to resolve rule ${value}`)
641
+ addAsset(asset)
642
+ }
643
+
644
+ return {
645
+ assets,
646
+ remoteDefinitions
647
+ }
648
+ }
649
+
650
+ const resolveIncludedSkillRefs = (selection: string[] | SkillSelection | undefined) => {
651
+ if (selection == null) return undefined
652
+ if (Array.isArray(selection)) return selection
653
+ return selection.type === 'include' ? selection.list : undefined
654
+ }
655
+
656
+ const resolveExcludedSkillRefs = (selection: string[] | SkillSelection | undefined) => {
657
+ if (selection == null || Array.isArray(selection)) return undefined
658
+ return selection.type === 'exclude' ? selection.list : undefined
659
+ }
660
+
661
+ const resolveSelectedSkillAssets = (
662
+ assets: Array<Extract<WorkspaceAsset, { kind: 'skill' }>>,
663
+ selection?: WorkspaceSkillSelection
664
+ ) => {
665
+ if (selection == null) return assets
666
+
667
+ const included = selection.include != null && selection.include.length > 0
668
+ ? resolveNamedAssets(assets, selection.include)
669
+ : assets
670
+ const excluded = new Set(
671
+ resolveNamedAssets(assets, selection.exclude).map(asset => asset.id)
672
+ )
673
+ return included.filter(asset => !excluded.has(asset.id))
674
+ }
675
+
676
+ const resolveSelectedMcpNames = (
677
+ bundle: WorkspaceAssetBundle,
678
+ selection: WorkspaceMcpSelection | undefined
679
+ ) => {
680
+ const allAssets = Object.values(bundle.mcpServers)
681
+ const includeRefs = selection?.include ??
682
+ (bundle.defaultIncludeMcpServers.length > 0 ? bundle.defaultIncludeMcpServers : undefined)
683
+ const excludeRefs = selection?.exclude ??
684
+ (bundle.defaultExcludeMcpServers.length > 0 ? bundle.defaultExcludeMcpServers : undefined)
685
+
686
+ const resolveRefs = (refs: string[] | undefined) => {
687
+ if (refs == null || refs.length === 0) return undefined
688
+ return new Set(refs.map((ref) => {
689
+ const scoped = parseScopedReference(ref)
690
+ if (scoped != null) {
691
+ const asset = allAssets.find(item => item.scope === scoped.scope && item.name === scoped.name)
692
+ if (asset == null) throw new Error(`Failed to resolve MCP server ${ref}`)
693
+ return asset.displayName
694
+ }
695
+
696
+ const matches = allAssets.filter(item => item.name === ref || item.displayName === ref)
697
+ if (matches.length === 0) throw new Error(`Failed to resolve MCP server ${ref}`)
698
+ if (matches.length > 1) {
699
+ throw new Error(
700
+ `Ambiguous MCP server reference ${ref}. Candidates: ${matches.map(match => match.displayName).join(', ')}`
701
+ )
702
+ }
703
+ return matches[0].displayName
704
+ }))
705
+ }
706
+
707
+ const include = resolveRefs(includeRefs)
708
+ const exclude = resolveRefs(excludeRefs) ?? new Set<string>()
709
+
710
+ return allAssets
711
+ .map(asset => asset.displayName)
712
+ .filter(name => (include == null || include.has(name)) && !exclude.has(name))
713
+ }
714
+
715
+ const resolvePluginOverlay = (
716
+ basePlugins: PluginConfig | undefined,
717
+ overlay: PluginOverlayConfig | undefined
718
+ ) => {
719
+ if (overlay == null) return basePlugins
720
+ if (overlay.mode !== 'override' && overlay.mode !== 'extend') {
721
+ throw new Error('Invalid plugins overlay. "mode" must be "extend" or "override".')
722
+ }
723
+
724
+ const overlayList = normalizePluginConfig(overlay.list, 'plugins overlay list') ?? []
725
+ return overlay.mode === 'override'
726
+ ? overlayList
727
+ : mergePluginConfigs(basePlugins, overlayList)
728
+ }
729
+
730
+ const generateRulesPrompt = (cwd: string, rules: Definition<Rule>[]) => {
731
+ const rulesPrompt = rules
732
+ .map((rule) => {
733
+ const name = resolveDefinitionName(rule)
734
+ const content = isAlwaysRule(rule.attributes) && rule.body.trim()
735
+ ? rule.body.trim()
736
+ : buildOptionalRuleGuidance(cwd, rule)
737
+ return `# ${name}\n\n${toMarkdownBlockquote(content)}`
738
+ })
739
+ .filter(Boolean)
740
+ .join('\n\n')
741
+
742
+ return `<system-prompt>\n项目系统规则如下:\n${rulesPrompt}\n</system-prompt>\n`
743
+ }
744
+
745
+ const generateSkillsPrompt = (cwd: string, skills: Definition<Skill>[]) => {
746
+ const modules = skills
747
+ .map((skill) => {
748
+ const name = resolveDefinitionName(skill, ['skill.md'])
749
+ return [
750
+ `# ${name}`,
751
+ '',
752
+ buildSkillSummary(
753
+ cwd,
754
+ skill,
755
+ '资源内容中的相对路径相对该技能文件所在目录解析。'
756
+ ),
757
+ '',
758
+ '<skill-content>',
759
+ skill.body.trim(),
760
+ '</skill-content>'
761
+ ].join('\n')
762
+ })
763
+ .filter(Boolean)
764
+ .join('\n\n')
765
+
766
+ if (modules === '') return ''
767
+
768
+ return `<system-prompt>\n项目已加载如下技能模块:\n${modules}\n</system-prompt>\n`
769
+ }
770
+
771
+ const generateSkillsRoutePrompt = (cwd: string, skills: Definition<Skill>[]) => {
772
+ const modules = skills
773
+ .filter(({ attributes: { always } }) => always !== false)
774
+ .map((skill) => {
775
+ const name = resolveDefinitionName(skill, ['skill.md'])
776
+ return [
777
+ `# ${name}`,
778
+ '',
779
+ buildSkillSummary(
780
+ cwd,
781
+ skill,
782
+ '默认无需预先加载正文;仅在任务明确需要该技能时,再读取对应技能文件。'
783
+ )
784
+ ].join('\n')
785
+ })
786
+ .filter(Boolean)
787
+ .join('\n\n')
788
+
789
+ if (modules === '') return ''
790
+
791
+ return `<skills>\n${modules}\n</skills>\n`
792
+ }
793
+
794
+ const generateSpecRoutePrompt = (specs: Definition<Spec>[], options?: { active?: boolean }) => {
795
+ const specsRouteStr = specs
796
+ .filter(({ attributes }) => attributes.always !== false)
797
+ .map((spec) => {
798
+ const name = resolveDefinitionName(spec, ['index.md'])
799
+ const desc = resolveDocumentDescription(spec.body, spec.attributes.description, name)
800
+ const identifier = spec.resolvedName?.trim() || resolveSpecIdentifier(spec.path, spec.attributes.name)
801
+ const params = spec.attributes.params ?? []
802
+ const paramsPrompt = params.length > 0
803
+ ? params.map(({ name: paramName, description }) => ` - ${paramName}:${description ?? '无'}\n`).join('')
804
+ : ' - 无\n'
805
+
806
+ return (
807
+ `- 流程名称:${name}\n` +
808
+ ` - 介绍:${desc}\n` +
809
+ ` - 标识:${identifier}\n` +
810
+ ' - 参数:\n' +
811
+ `${paramsPrompt}`
812
+ )
813
+ })
814
+ .join('\n')
815
+
816
+ const activeIdentityPrompt = options?.active
817
+ ? (
818
+ '你是一个专业的项目推进管理大师,能够熟练指导其他实体来为你的目标工作。对你的预期是:\n' +
819
+ '\n' +
820
+ '- 永远不要单独完成代码开发工作\n' +
821
+ '- 必须要协调其他的开发人员来完成任务\n' +
822
+ '- 必须让他们按照目标进行完成,不要偏离目标,检查他们任务完成后的汇报内容是否符合要求\n' +
823
+ '\n'
824
+ )
825
+ : ''
826
+
827
+ return `<system-prompt>
828
+ ${activeIdentityPrompt}根据用户需要以及实际的开发目标来决定使用不同的工作流程,调用 \`load-spec\` mcp tool 完成工作流程的加载。
829
+ - 根据实际需求传入标识,这不是路径,只能使用工具进行加载
830
+ - 通过参数的描述以及实际应用场景决定怎么传入参数
831
+ 项目存在如下工作流程:
832
+ ${specsRouteStr}
833
+ </system-prompt>
834
+ `
835
+ }
836
+
837
+ const generateEntitiesRoutePrompt = (entities: Definition<Entity>[]) => (
838
+ '<system-prompt>\n' +
839
+ '项目存在如下实体:\n' +
840
+ `${
841
+ entities
842
+ .filter(({ attributes }) => attributes.always !== false)
843
+ .map((entity) => {
844
+ const name = resolveDefinitionName(entity, ['readme.md', 'index.json'])
845
+ const desc = resolveDocumentDescription(entity.body, entity.attributes.description, name)
846
+ return ` - ${name}:${desc}\n`
847
+ })
848
+ .join('')
849
+ }\n` +
850
+ '解决用户问题时,需根据用户需求可以通过 run-tasks 工具指定为实体后,自行调度多个不同类型的实体来完成工作。\n' +
851
+ '</system-prompt>\n'
852
+ )
853
+
854
+ const pickSpecAsset = (bundle: WorkspaceAssetBundle, ref: string) => {
855
+ const scoped = parseScopedReference(ref)
856
+ if (scoped != null) {
857
+ return resolveScopedAsset(bundle.specs, scoped.scope, scoped.name)
858
+ }
859
+ return resolveUniqueAssetByName(bundle.specs, ref)
860
+ }
861
+
862
+ const pickEntityAsset = (bundle: WorkspaceAssetBundle, ref: string) => {
863
+ const scoped = parseScopedReference(ref)
864
+ if (scoped != null) {
865
+ return resolveScopedAsset(bundle.entities, scoped.scope, scoped.name)
866
+ }
867
+ return resolveUniqueAssetByName(bundle.entities, ref)
868
+ }
869
+
870
+ export async function resolveWorkspaceAssetBundle(params: {
871
+ cwd: string
872
+ configs?: [Config?, Config?]
873
+ plugins?: PluginConfig
874
+ overlaySource?: string
875
+ useDefaultVibeForgeMcpServer?: boolean
876
+ }): Promise<WorkspaceAssetBundle> {
877
+ const [config, userConfig] = params.configs ?? await loadWorkspaceConfig(params.cwd)
878
+ const pluginConfigs = params.plugins ?? mergePluginConfigs(config?.plugins, userConfig?.plugins)
879
+ const pluginInstances = await resolveConfiguredPluginInstances({
880
+ cwd: params.cwd,
881
+ plugins: pluginConfigs,
882
+ overlaySource: params.overlaySource
883
+ })
884
+
885
+ const localScan = await scanWorkspaceDocuments(params.cwd)
886
+ const flattenedPluginInstances = flattenPluginInstances(pluginInstances)
887
+ const pluginScans = await Promise.all(flattenedPluginInstances.map(instance => scanInstanceDocuments(instance)))
888
+ const pluginOverlayScans = await Promise.all(
889
+ flattenedPluginInstances.map(instance => scanInstanceOpenCodeOverlays(instance))
890
+ )
891
+
892
+ const assets: WorkspaceAsset[] = []
893
+
894
+ const pushDocumentAssets = async <TKind extends DocumentAssetKind>(
895
+ kind: TKind,
896
+ paths: string[],
897
+ origin: 'workspace' | 'plugin',
898
+ instance?: ResolvedPluginInstance,
899
+ parser?: (path: string) => Promise<any>
900
+ ) => {
901
+ const definitions = await Promise.all(paths.map(path => (
902
+ parser != null ? parser(path) : parseFrontmatterDocument(path)
903
+ )))
904
+ assets.push(
905
+ ...definitions.map(definition =>
906
+ createDocumentAsset({
907
+ cwd: params.cwd,
908
+ kind,
909
+ definition,
910
+ origin,
911
+ scope: instance?.scope,
912
+ instance
913
+ })
914
+ )
915
+ )
916
+ }
917
+
918
+ await pushDocumentAssets('rule', localScan.rulePaths, 'workspace')
919
+ await pushDocumentAssets('skill', localScan.skillPaths, 'workspace')
920
+ await pushDocumentAssets('spec', localScan.specPaths, 'workspace')
921
+ await pushDocumentAssets('entity', localScan.entityDocPaths, 'workspace')
922
+ await pushDocumentAssets('entity', localScan.entityJsonPaths, 'workspace', undefined, parseEntityIndexJson)
923
+
924
+ for (let index = 0; index < flattenedPluginInstances.length; index++) {
925
+ const instance = flattenedPluginInstances[index]
926
+ const scan = pluginScans[index]
927
+ await pushDocumentAssets('rule', scan.rulePaths, 'plugin', instance)
928
+ await pushDocumentAssets('skill', scan.skillPaths, 'plugin', instance)
929
+ await pushDocumentAssets('spec', scan.specPaths, 'plugin', instance)
930
+ await pushDocumentAssets('entity', scan.entityDocPaths, 'plugin', instance)
931
+ await pushDocumentAssets('entity', scan.entityJsonPaths, 'plugin', instance, parseEntityIndexJson)
932
+ }
933
+
934
+ const mcpAssets = new Map<string, Extract<WorkspaceAsset, { kind: 'mcpServer' }>>()
935
+ const addMcpAsset = (
936
+ asset: Extract<WorkspaceAsset, { kind: 'mcpServer' }>,
937
+ options?: { overwrite?: boolean }
938
+ ) => {
939
+ const existing = mcpAssets.get(asset.displayName)
940
+ if (existing != null && options?.overwrite !== true) {
941
+ throw new Error(`Duplicate MCP server ${asset.displayName} from ${existing.sourcePath} and ${asset.sourcePath}`)
942
+ }
943
+ mcpAssets.set(asset.displayName, asset)
944
+ }
945
+
946
+ if (params.useDefaultVibeForgeMcpServer !== false) {
947
+ const defaultVibeForgeMcpServer = resolveDefaultVibeForgeMcpServerConfig()
948
+ if (defaultVibeForgeMcpServer != null) {
949
+ addMcpAsset(createMcpAsset({
950
+ cwd: params.cwd,
951
+ name: DEFAULT_VIBE_FORGE_MCP_SERVER_NAME,
952
+ config: defaultVibeForgeMcpServer,
953
+ origin: 'workspace',
954
+ sourcePath: resolve(params.cwd, '.ai')
955
+ }))
956
+ }
957
+ }
958
+
959
+ for (const [name, configValue] of Object.entries(config?.mcpServers ?? {})) {
960
+ if (configValue.enabled === false) continue
961
+ const { enabled: _enabled, ...nextConfig } = configValue
962
+ addMcpAsset(
963
+ createMcpAsset({
964
+ cwd: params.cwd,
965
+ name,
966
+ config: nextConfig as NonNullable<Config['mcpServers']>[string],
967
+ origin: 'workspace',
968
+ sourcePath: resolve(params.cwd, '.ai.config.json')
969
+ }),
970
+ { overwrite: true }
971
+ )
972
+ }
973
+
974
+ for (const [name, configValue] of Object.entries(userConfig?.mcpServers ?? {})) {
975
+ if (configValue.enabled === false) continue
976
+ const { enabled: _enabled, ...nextConfig } = configValue
977
+ addMcpAsset(
978
+ createMcpAsset({
979
+ cwd: params.cwd,
980
+ name,
981
+ config: nextConfig as NonNullable<Config['mcpServers']>[string],
982
+ origin: 'workspace',
983
+ sourcePath: resolve(params.cwd, '.ai.dev.config.json')
984
+ }),
985
+ { overwrite: true }
986
+ )
987
+ }
988
+
989
+ for (let index = 0; index < flattenedPluginInstances.length; index++) {
990
+ const instance = flattenedPluginInstances[index]
991
+ const scan = pluginScans[index]
992
+ for (const path of scan.mcpPaths) {
993
+ const parsed = await parseStructuredMcpFile(path)
994
+ if (!isRecord(parsed)) continue
995
+ const fileName = basename(path, extname(path))
996
+ const name = typeof parsed.name === 'string' && parsed.name.trim() !== ''
997
+ ? parsed.name.trim()
998
+ : fileName
999
+ const { name: _name, enabled, ...configValue } = parsed
1000
+ if (enabled === false) continue
1001
+ addMcpAsset(createMcpAsset({
1002
+ cwd: params.cwd,
1003
+ name,
1004
+ config: configValue as NonNullable<Config['mcpServers']>[string],
1005
+ origin: 'plugin',
1006
+ scope: instance.scope,
1007
+ sourcePath: path,
1008
+ instance
1009
+ }))
1010
+ }
1011
+ }
1012
+
1013
+ const hookPlugins = flattenedPluginInstances
1014
+ .filter(instance =>
1015
+ instance.packageId != null && resolvePluginHooksEntryPath(params.cwd, instance.packageId) != null
1016
+ )
1017
+ .map(instance => createHookPluginAsset(instance))
1018
+ assets.push(...hookPlugins)
1019
+
1020
+ const opencodeOverlayAssets = flattenedPluginInstances.flatMap((instance, index) => (
1021
+ pluginOverlayScans[index].map((entry) =>
1022
+ createOpenCodeOverlayAsset({
1023
+ cwd: params.cwd,
1024
+ kind: entry.kind,
1025
+ sourcePath: entry.sourcePath,
1026
+ entryName: entry.entryName,
1027
+ targetSubpath: entry.targetSubpath,
1028
+ instance
1029
+ })
1030
+ )
1031
+ ))
1032
+ assets.push(...opencodeOverlayAssets)
1033
+
1034
+ assets.push(...mcpAssets.values())
1035
+
1036
+ const rules = assets.filter((asset): asset is Extract<WorkspaceAsset, { kind: 'rule' }> => asset.kind === 'rule')
1037
+ const specs = assets.filter((asset): asset is Extract<WorkspaceAsset, { kind: 'spec' }> => asset.kind === 'spec')
1038
+ const entities = assets.filter((asset): asset is Extract<WorkspaceAsset, { kind: 'entity' }> =>
1039
+ asset.kind === 'entity'
1040
+ )
1041
+ const skills = assets.filter((asset): asset is Extract<WorkspaceAsset, { kind: 'skill' }> => asset.kind === 'skill')
1042
+
1043
+ assertNoDocumentConflicts([...rules, ...specs, ...entities, ...skills])
1044
+ assertNoMcpConflicts(Array.from(mcpAssets.values()))
1045
+
1046
+ return {
1047
+ cwd: params.cwd,
1048
+ pluginConfigs,
1049
+ pluginInstances,
1050
+ assets,
1051
+ rules,
1052
+ specs,
1053
+ entities,
1054
+ skills,
1055
+ mcpServers: Object.fromEntries(Array.from(mcpAssets.values()).map(asset => [asset.displayName, asset])),
1056
+ hookPlugins,
1057
+ opencodeOverlayAssets,
1058
+ defaultIncludeMcpServers: [
1059
+ ...(config?.defaultIncludeMcpServers ?? []),
1060
+ ...(userConfig?.defaultIncludeMcpServers ?? [])
1061
+ ],
1062
+ defaultExcludeMcpServers: [
1063
+ ...(config?.defaultExcludeMcpServers ?? []),
1064
+ ...(userConfig?.defaultExcludeMcpServers ?? [])
1065
+ ]
1066
+ }
1067
+ }
1068
+
1069
+ export async function resolvePromptAssetSelection(params: {
1070
+ bundle: WorkspaceAssetBundle
1071
+ type: 'spec' | 'entity' | undefined
1072
+ name?: string
1073
+ input?: {
1074
+ skills?: WorkspaceSkillSelection
1075
+ }
1076
+ }) {
1077
+ const options: {
1078
+ systemPrompt?: string
1079
+ tools?: Filter
1080
+ mcpServers?: WorkspaceMcpSelection
1081
+ promptAssetIds?: string[]
1082
+ assetBundle?: WorkspaceAssetBundle
1083
+ } = {
1084
+ assetBundle: params.bundle
1085
+ }
1086
+
1087
+ let effectiveBundle = params.bundle
1088
+ let pinnedTargetAsset: Extract<WorkspaceAsset, { kind: 'spec' | 'entity' }> | undefined
1089
+ let targetBody = ''
1090
+ let targetToolsFilter: Filter | undefined
1091
+ let targetMcpServersFilter: Filter | undefined
1092
+ let targetInstancePath: string | undefined
1093
+
1094
+ if (params.type && params.name) {
1095
+ const baseTarget = params.type === 'spec'
1096
+ ? pickSpecAsset(params.bundle, params.name)
1097
+ : pickEntityAsset(params.bundle, params.name)
1098
+ if (baseTarget == null) {
1099
+ throw new Error(`Failed to load ${params.type} ${params.name}`)
1100
+ }
1101
+
1102
+ const pluginOverlay = baseTarget.payload.definition.attributes.plugins as PluginOverlayConfig | undefined
1103
+ if (pluginOverlay != null) {
1104
+ effectiveBundle = await resolveWorkspaceAssetBundle({
1105
+ cwd: params.bundle.cwd,
1106
+ plugins: resolvePluginOverlay(params.bundle.pluginConfigs, pluginOverlay),
1107
+ overlaySource: `${params.type}:${baseTarget.displayName}`
1108
+ })
1109
+ }
1110
+
1111
+ pinnedTargetAsset = baseTarget
1112
+ targetBody = baseTarget.payload.definition.body
1113
+ targetToolsFilter = baseTarget.payload.definition.attributes.tools
1114
+ targetMcpServersFilter = baseTarget.payload.definition.attributes.mcpServers
1115
+ targetInstancePath = baseTarget.instancePath
1116
+ options.assetBundle = effectiveBundle
1117
+ }
1118
+
1119
+ const selectedSkillAssets = resolveSelectedSkillAssets(effectiveBundle.skills, params.input?.skills)
1120
+ const promptAssetIds = new Set<string>([
1121
+ ...effectiveBundle.rules.map(asset => asset.id),
1122
+ ...effectiveBundle.specs.map(asset => asset.id),
1123
+ ...selectedSkillAssets.map(asset => asset.id),
1124
+ ...(params.type !== 'entity' ? effectiveBundle.entities.map(asset => asset.id) : [])
1125
+ ])
1126
+ const ruleDefinitions = new Map<string, Definition<Rule>>(
1127
+ effectiveBundle.rules.map(asset => [
1128
+ asset.id,
1129
+ definitionWithResolvedName(asset.payload.definition, asset.displayName, asset.instancePath)
1130
+ ])
1131
+ )
1132
+ const targetSkillsAssets: Array<Extract<WorkspaceAsset, { kind: 'skill' }>> = []
1133
+
1134
+ if (pinnedTargetAsset != null) {
1135
+ promptAssetIds.add(pinnedTargetAsset.id)
1136
+ const attributes = pinnedTargetAsset.payload.definition.attributes
1137
+
1138
+ if (attributes.rules != null) {
1139
+ const selection = await resolveRuleSelection(
1140
+ effectiveBundle,
1141
+ attributes.rules as RuleReference[] | string[],
1142
+ targetInstancePath
1143
+ )
1144
+ for (const asset of selection.assets) {
1145
+ promptAssetIds.add(asset.id)
1146
+ ruleDefinitions.set(
1147
+ asset.id,
1148
+ definitionWithResolvedName(
1149
+ {
1150
+ ...asset.payload.definition,
1151
+ attributes: {
1152
+ ...asset.payload.definition.attributes,
1153
+ always: true
1154
+ }
1155
+ },
1156
+ asset.displayName,
1157
+ asset.instancePath
1158
+ )
1159
+ )
1160
+ }
1161
+ selection.remoteDefinitions.forEach((definition) => {
1162
+ ruleDefinitions.set(definition.path, definition)
1163
+ })
1164
+ }
1165
+
1166
+ const skillSelection = attributes.skills as string[] | SkillSelection | undefined
1167
+ const includedRefs = resolveIncludedSkillRefs(skillSelection)
1168
+ const excludedRefs = resolveExcludedSkillRefs(skillSelection)
1169
+ const includedAssets = skillSelection == null
1170
+ ? []
1171
+ : includedRefs != null
1172
+ ? (includedRefs.length > 0 ? resolveNamedAssets(effectiveBundle.skills, includedRefs, targetInstancePath) : [])
1173
+ : effectiveBundle.skills
1174
+ const excludedIds = new Set(
1175
+ resolveNamedAssets(effectiveBundle.skills, excludedRefs, targetInstancePath).map(asset => asset.id)
1176
+ )
1177
+
1178
+ includedAssets
1179
+ .filter(asset => !excludedIds.has(asset.id))
1180
+ .forEach((asset) => {
1181
+ targetSkillsAssets.push(asset)
1182
+ promptAssetIds.add(asset.id)
1183
+ })
1184
+ }
1185
+
1186
+ const rules = Array.from(ruleDefinitions.values())
1187
+ const targetSkills = toDocumentDefinitions(targetSkillsAssets)
1188
+ const routedSkills = toDocumentDefinitions(
1189
+ selectedSkillAssets.filter(skill => !targetSkillsAssets.some(target => target.id === skill.id))
1190
+ )
1191
+ const entities = params.type !== 'entity'
1192
+ ? toDocumentDefinitions(effectiveBundle.entities)
1193
+ : []
1194
+ const skills = toDocumentDefinitions(selectedSkillAssets)
1195
+ const specs = toDocumentDefinitions(effectiveBundle.specs)
1196
+
1197
+ options.systemPrompt = [
1198
+ generateRulesPrompt(effectiveBundle.cwd, rules),
1199
+ generateSkillsPrompt(effectiveBundle.cwd, targetSkills),
1200
+ generateEntitiesRoutePrompt(entities),
1201
+ generateSkillsRoutePrompt(effectiveBundle.cwd, routedSkills),
1202
+ generateSpecRoutePrompt(specs, { active: params.type === 'spec' }),
1203
+ targetBody
1204
+ ].join('\n\n')
1205
+
1206
+ if (targetToolsFilter != null) {
1207
+ options.tools = targetToolsFilter
1208
+ }
1209
+ if (targetMcpServersFilter != null) {
1210
+ options.mcpServers = targetMcpServersFilter
1211
+ }
1212
+ options.promptAssetIds = Array.from(promptAssetIds)
1213
+
1214
+ return [
1215
+ {
1216
+ rules,
1217
+ targetSkills,
1218
+ entities,
1219
+ skills,
1220
+ specs,
1221
+ targetBody,
1222
+ promptAssetIds: Array.from(promptAssetIds)
1223
+ },
1224
+ options
1225
+ ] as const
1226
+ }
1227
+
1228
+ export function buildAdapterAssetPlan(params: {
1229
+ adapter: WorkspaceAssetAdapter
1230
+ bundle: WorkspaceAssetBundle
1231
+ options: {
1232
+ mcpServers?: WorkspaceMcpSelection
1233
+ skills?: WorkspaceSkillSelection
1234
+ promptAssetIds?: string[]
1235
+ }
1236
+ }): AdapterAssetPlan {
1237
+ const diagnostics: AssetDiagnostic[] = []
1238
+
1239
+ for (const assetId of params.options.promptAssetIds ?? []) {
1240
+ const asset = params.bundle.assets.find(item => item.id === assetId)
1241
+ if (asset == null || asset.kind === 'mcpServer') continue
1242
+ diagnostics.push({
1243
+ assetId,
1244
+ adapter: params.adapter,
1245
+ status: 'prompt',
1246
+ reason: 'Mapped into the generated system prompt.',
1247
+ packageId: asset.packageId,
1248
+ scope: asset.scope,
1249
+ instancePath: asset.instancePath,
1250
+ origin: asset.origin,
1251
+ resolvedBy: asset.resolvedBy,
1252
+ taskOverlaySource: asset.taskOverlaySource
1253
+ })
1254
+ }
1255
+
1256
+ const selectedMcpNames = resolveSelectedMcpNames(params.bundle, params.options.mcpServers)
1257
+ const mcpServers = Object.fromEntries(
1258
+ selectedMcpNames.map(name => [name, params.bundle.mcpServers[name].payload.config])
1259
+ )
1260
+
1261
+ selectedMcpNames.forEach((name) => {
1262
+ const asset = params.bundle.mcpServers[name]
1263
+ diagnostics.push({
1264
+ assetId: asset.id,
1265
+ adapter: params.adapter,
1266
+ status: params.adapter === 'claude-code' ? 'native' : 'translated',
1267
+ reason: params.adapter === 'claude-code'
1268
+ ? 'Mapped into adapter MCP settings.'
1269
+ : 'Translated into adapter-specific MCP configuration.',
1270
+ packageId: asset.packageId,
1271
+ scope: asset.scope,
1272
+ instancePath: asset.instancePath,
1273
+ origin: asset.origin,
1274
+ resolvedBy: asset.resolvedBy,
1275
+ taskOverlaySource: asset.taskOverlaySource
1276
+ })
1277
+ })
1278
+
1279
+ params.bundle.hookPlugins.forEach((asset) => {
1280
+ diagnostics.push({
1281
+ assetId: asset.id,
1282
+ adapter: params.adapter,
1283
+ status: 'native',
1284
+ reason: params.adapter === 'claude-code'
1285
+ ? 'Mapped into the Claude Code native hooks bridge.'
1286
+ : params.adapter === 'codex'
1287
+ ? 'Mapped into the Codex native hooks bridge.'
1288
+ : 'Mapped into the OpenCode native hooks bridge.',
1289
+ packageId: asset.packageId,
1290
+ scope: asset.scope,
1291
+ instancePath: asset.instancePath,
1292
+ origin: asset.origin,
1293
+ resolvedBy: asset.resolvedBy,
1294
+ taskOverlaySource: asset.taskOverlaySource
1295
+ })
1296
+ })
1297
+
1298
+ const selectedSkillAssets = resolveSelectedSkillAssets(params.bundle.skills, params.options.skills)
1299
+ if (params.adapter === 'opencode') {
1300
+ selectedSkillAssets.forEach((asset) => {
1301
+ diagnostics.push({
1302
+ assetId: asset.id,
1303
+ adapter: params.adapter,
1304
+ status: 'native',
1305
+ reason: 'Mirrored into OPENCODE_CONFIG_DIR as a native skill.',
1306
+ packageId: asset.packageId,
1307
+ scope: asset.scope,
1308
+ instancePath: asset.instancePath,
1309
+ origin: asset.origin,
1310
+ resolvedBy: asset.resolvedBy,
1311
+ taskOverlaySource: asset.taskOverlaySource
1312
+ })
1313
+ })
1314
+ params.bundle.opencodeOverlayAssets.forEach((asset) => {
1315
+ diagnostics.push({
1316
+ assetId: asset.id,
1317
+ adapter: params.adapter,
1318
+ status: 'native',
1319
+ reason: 'Mirrored into OPENCODE_CONFIG_DIR as a native OpenCode asset.',
1320
+ packageId: asset.packageId,
1321
+ scope: asset.scope,
1322
+ instancePath: asset.instancePath,
1323
+ origin: asset.origin,
1324
+ resolvedBy: asset.resolvedBy,
1325
+ taskOverlaySource: asset.taskOverlaySource
1326
+ })
1327
+ })
1328
+ } else if (params.adapter === 'codex') {
1329
+ params.bundle.opencodeOverlayAssets.forEach((asset) => {
1330
+ diagnostics.push({
1331
+ assetId: asset.id,
1332
+ adapter: params.adapter,
1333
+ status: 'skipped',
1334
+ reason: 'No stable native Codex mapping exists for this asset kind in V1.',
1335
+ packageId: asset.packageId,
1336
+ scope: asset.scope,
1337
+ instancePath: asset.instancePath,
1338
+ origin: asset.origin,
1339
+ resolvedBy: asset.resolvedBy,
1340
+ taskOverlaySource: asset.taskOverlaySource
1341
+ })
1342
+ })
1343
+ }
1344
+
1345
+ const overlays: AdapterOverlayEntry[] = params.adapter === 'opencode'
1346
+ ? [
1347
+ ...selectedSkillAssets.map((asset): AdapterOverlayEntry => ({
1348
+ assetId: asset.id,
1349
+ kind: 'skill',
1350
+ sourcePath: dirname(asset.sourcePath),
1351
+ targetPath: `skills/${asset.displayName.replaceAll('/', '__')}`
1352
+ })),
1353
+ ...params.bundle.opencodeOverlayAssets.map((asset): AdapterOverlayEntry => ({
1354
+ assetId: asset.id,
1355
+ kind: asset.kind,
1356
+ sourcePath: asset.sourcePath,
1357
+ targetPath: asset.payload.targetSubpath
1358
+ }))
1359
+ ]
1360
+ : []
1361
+
1362
+ return {
1363
+ adapter: params.adapter,
1364
+ diagnostics,
1365
+ mcpServers,
1366
+ overlays
1367
+ }
1368
+ }