@vibe-forge/workspace-assets 2.0.0 → 2.0.2

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.
@@ -1,12 +1,18 @@
1
+ /* eslint-disable max-lines -- bundle coverage keeps related fixture scenarios in one file */
1
2
  import { join } from 'node:path'
2
3
  import process from 'node:process'
3
4
 
4
- import { describe, expect, it } from 'vitest'
5
+ import { readFile } from 'node:fs/promises'
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
7
 
6
- import { resolveWorkspaceAssetBundle } from '#~/index.js'
8
+ import { buildAdapterAssetPlan, resolveWorkspaceAssetBundle } from '#~/index.js'
7
9
 
8
10
  import { createWorkspace, installPluginPackage, writeDocument } from './test-helpers'
9
11
 
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals()
14
+ })
15
+
10
16
  describe('resolveWorkspaceAssetBundle', () => {
11
17
  it('loads npm plugin assets via the package-id fallback and exposes OpenCode overlays', async () => {
12
18
  const workspace = await createWorkspace()
@@ -97,6 +103,562 @@ describe('resolveWorkspaceAssetBundle', () => {
97
103
  }
98
104
  })
99
105
 
106
+ it('loads local and dev rule files as workspace rules', async () => {
107
+ const workspace = await createWorkspace()
108
+
109
+ await writeDocument(
110
+ join(workspace, '.ai/rules/team.md'),
111
+ '---\ndescription: 团队规则\n---\n团队共享约束'
112
+ )
113
+ await writeDocument(
114
+ join(workspace, '.ai/rules/preference.local.md'),
115
+ '---\ndescription: 本地偏好\nalwaysApply: true\n---\n使用当前用户偏好的输出风格'
116
+ )
117
+ await writeDocument(
118
+ join(workspace, '.ai/rules/debug.dev.md'),
119
+ '---\ndescription: 本地调试\nalwaysApply: true\n---\n优先保留调试证据'
120
+ )
121
+
122
+ const bundle = await resolveWorkspaceAssetBundle({
123
+ cwd: workspace,
124
+ configs: [undefined, undefined],
125
+ useDefaultVibeForgeMcpServer: false
126
+ })
127
+
128
+ expect(bundle.rules.map(asset => asset.displayName).sort()).toEqual(['debug.dev', 'preference.local', 'team'])
129
+ expect(bundle.rules.find(asset => asset.displayName === 'preference.local')?.payload.definition.body)
130
+ .toContain('当前用户偏好')
131
+ expect(bundle.rules.find(asset => asset.displayName === 'debug.dev')?.payload.definition.attributes.alwaysApply)
132
+ .toBe(true)
133
+ })
134
+
135
+ it('bridges supported home skill roots by default and keeps the first duplicate root', async () => {
136
+ const workspace = await createWorkspace()
137
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__
138
+
139
+ await writeDocument(
140
+ join(realHome!, '.agents/skills/research/SKILL.md'),
141
+ '---\ndescription: 来自 agents root\n---\n阅读 README.md'
142
+ )
143
+ await writeDocument(
144
+ join(realHome!, '.claude/skills/research/SKILL.md'),
145
+ '---\ndescription: 来自 claude root\n---\n这份定义应被后面的 root 覆盖掉'
146
+ )
147
+ await writeDocument(
148
+ join(realHome!, '.config/opencode/skills/release/SKILL.md'),
149
+ '---\ndescription: 来自 opencode root\n---\n整理发布材料'
150
+ )
151
+
152
+ const bundle = await resolveWorkspaceAssetBundle({
153
+ cwd: workspace,
154
+ configs: [undefined, undefined],
155
+ useDefaultVibeForgeMcpServer: false
156
+ })
157
+
158
+ expect(bundle.skills.map(asset => asset.displayName)).toEqual(['research', 'release'])
159
+ expect(bundle.skills.find(asset => asset.name === 'research')).toEqual(expect.objectContaining({
160
+ origin: 'workspace',
161
+ resolvedBy: 'home-bridge',
162
+ sourcePath: join(realHome!, '.agents/skills/research/SKILL.md')
163
+ }))
164
+ expect(bundle.skills.find(asset => asset.name === 'release')).toEqual(expect.objectContaining({
165
+ origin: 'workspace',
166
+ resolvedBy: 'home-bridge',
167
+ sourcePath: join(realHome!, '.config/opencode/skills/release/SKILL.md')
168
+ }))
169
+ })
170
+
171
+ it('can disable the home skill bridge entirely', async () => {
172
+ const workspace = await createWorkspace()
173
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__
174
+
175
+ await writeDocument(
176
+ join(realHome!, '.agents/skills/research/SKILL.md'),
177
+ '---\ndescription: 检索资料\n---\n阅读 README.md'
178
+ )
179
+
180
+ const bundle = await resolveWorkspaceAssetBundle({
181
+ cwd: workspace,
182
+ configs: [{
183
+ skills: {
184
+ homeBridge: {
185
+ enabled: false
186
+ }
187
+ }
188
+ }, undefined],
189
+ useDefaultVibeForgeMcpServer: false
190
+ })
191
+
192
+ expect(bundle.skills).toEqual([])
193
+ })
194
+
195
+ it('supports custom home skill roots with tilde expansion', async () => {
196
+ const workspace = await createWorkspace()
197
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__
198
+
199
+ await writeDocument(
200
+ join(realHome!, '.agents/skills/ignored/SKILL.md'),
201
+ '---\ndescription: 默认目录\n---\n这份定义不应被加载'
202
+ )
203
+ await writeDocument(
204
+ join(realHome!, 'custom-skills/writer/SKILL.md'),
205
+ '---\ndescription: 自定义目录\n---\n产出说明文档'
206
+ )
207
+
208
+ const bundle = await resolveWorkspaceAssetBundle({
209
+ cwd: workspace,
210
+ configs: [{
211
+ skills: {
212
+ homeBridge: {
213
+ roots: '~/custom-skills'
214
+ }
215
+ }
216
+ }, undefined],
217
+ useDefaultVibeForgeMcpServer: false
218
+ })
219
+
220
+ expect(bundle.skills.map(asset => asset.displayName)).toEqual(['writer'])
221
+ expect(bundle.skills[0]?.sourcePath).toBe(join(realHome!, 'custom-skills/writer/SKILL.md'))
222
+ expect(bundle.skills[0]?.resolvedBy).toBe('home-bridge')
223
+ })
224
+
225
+ it('keeps the first matching skill when multiple homeBridge roots contain the same name', async () => {
226
+ const workspace = await createWorkspace()
227
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__
228
+
229
+ await writeDocument(
230
+ join(realHome!, '.claude/skills/research/SKILL.md'),
231
+ '---\ndescription: 来自 claude root\n---\n优先保留这份定义'
232
+ )
233
+ await writeDocument(
234
+ join(realHome!, '.agents/skills/research/SKILL.md'),
235
+ '---\ndescription: 来自 agents root\n---\n这份定义应被后面的 root 跳过'
236
+ )
237
+
238
+ const bundle = await resolveWorkspaceAssetBundle({
239
+ cwd: workspace,
240
+ configs: [{
241
+ skills: {
242
+ homeBridge: {
243
+ roots: ['~/.claude/skills', '~/.agents/skills']
244
+ }
245
+ }
246
+ }, undefined],
247
+ useDefaultVibeForgeMcpServer: false
248
+ })
249
+
250
+ expect(bundle.skills.map(asset => asset.displayName)).toEqual(['research'])
251
+ expect(bundle.skills[0]).toEqual(expect.objectContaining({
252
+ origin: 'workspace',
253
+ resolvedBy: 'home-bridge',
254
+ sourcePath: join(realHome!, '.claude/skills/research/SKILL.md')
255
+ }))
256
+ })
257
+
258
+ it('warns once when a custom homeBridge root uses an unsupported relative path', async () => {
259
+ const workspace = await createWorkspace()
260
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
261
+
262
+ try {
263
+ const bundle = await resolveWorkspaceAssetBundle({
264
+ cwd: workspace,
265
+ configs: [{
266
+ skills: {
267
+ homeBridge: {
268
+ roots: ['./team-skills']
269
+ }
270
+ }
271
+ }, undefined],
272
+ useDefaultVibeForgeMcpServer: false
273
+ })
274
+
275
+ expect(bundle.skills).toEqual([])
276
+ expect(warnSpy).toHaveBeenCalledWith(
277
+ expect.stringContaining('Ignoring invalid skills.homeBridge root "./team-skills"')
278
+ )
279
+ } finally {
280
+ warnSpy.mockRestore()
281
+ }
282
+ })
283
+
284
+ it('lets project and plugin skills override matching home-bridged skills', async () => {
285
+ const workspace = await createWorkspace()
286
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__
287
+
288
+ await writeDocument(
289
+ join(realHome!, '.agents/skills/research/SKILL.md'),
290
+ '---\ndescription: home research\n---\nhome research body'
291
+ )
292
+ await writeDocument(
293
+ join(realHome!, '.agents/skills/review/SKILL.md'),
294
+ '---\ndescription: home review\n---\nhome review body'
295
+ )
296
+ await writeDocument(
297
+ join(workspace, '.ai/skills/research/SKILL.md'),
298
+ '---\ndescription: project research\n---\nproject research body'
299
+ )
300
+ await installPluginPackage(workspace, '@vibe-forge/plugin-review', {
301
+ 'package.json': JSON.stringify(
302
+ {
303
+ name: '@vibe-forge/plugin-review',
304
+ version: '1.0.0'
305
+ },
306
+ null,
307
+ 2
308
+ ),
309
+ 'skills/review/SKILL.md': '---\ndescription: plugin review\n---\nplugin review body'
310
+ })
311
+
312
+ const bundle = await resolveWorkspaceAssetBundle({
313
+ cwd: workspace,
314
+ configs: [{
315
+ plugins: [
316
+ { id: 'review' }
317
+ ]
318
+ }, undefined],
319
+ useDefaultVibeForgeMcpServer: false
320
+ })
321
+
322
+ expect(bundle.skills.map(asset => asset.displayName).sort()).toEqual(['research', 'review'])
323
+ expect(bundle.skills.find(asset => asset.name === 'research')).toEqual(expect.objectContaining({
324
+ sourcePath: join(workspace, '.ai/skills/research/SKILL.md'),
325
+ resolvedBy: undefined
326
+ }))
327
+ expect(bundle.skills.find(asset => asset.name === 'review')).toEqual(expect.objectContaining({
328
+ origin: 'plugin',
329
+ sourcePath: expect.stringContaining('/node_modules/@vibe-forge/plugin-review/skills/review/SKILL.md')
330
+ }))
331
+ })
332
+
333
+ it('keeps scoped project skills alongside unscoped home skills with the same base name', async () => {
334
+ const workspace = await createWorkspace()
335
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__
336
+
337
+ await writeDocument(
338
+ join(realHome!, '.agents/skills/research/SKILL.md'),
339
+ '---\ndescription: home research\n---\nhome research body'
340
+ )
341
+ await installPluginPackage(workspace, '@vibe-forge/plugin-team', {
342
+ 'package.json': JSON.stringify(
343
+ {
344
+ name: '@vibe-forge/plugin-team',
345
+ version: '1.0.0'
346
+ },
347
+ null,
348
+ 2
349
+ ),
350
+ 'skills/research/SKILL.md': '---\ndescription: scoped research\n---\nscoped research body'
351
+ })
352
+
353
+ const bundle = await resolveWorkspaceAssetBundle({
354
+ cwd: workspace,
355
+ configs: [{
356
+ plugins: [
357
+ { id: 'team', scope: 'team' }
358
+ ]
359
+ }, undefined],
360
+ useDefaultVibeForgeMcpServer: false
361
+ })
362
+
363
+ expect(bundle.skills.map(asset => asset.displayName).sort()).toEqual(['research', 'team/research'])
364
+ expect(bundle.skills.find(asset => asset.displayName === 'research')).toEqual(expect.objectContaining({
365
+ resolvedBy: 'home-bridge'
366
+ }))
367
+ expect(bundle.skills.find(asset => asset.displayName === 'team/research')).toEqual(expect.objectContaining({
368
+ origin: 'plugin'
369
+ }))
370
+ })
371
+
372
+ it('installs selected missing skill dependencies from an API-compatible registry cache', async () => {
373
+ const workspace = await createWorkspace()
374
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__
375
+ const fetchMock = vi.fn(async (url: string) => {
376
+ if (url === 'https://registry.example.test/api/search?q=frontend-design&limit=10') {
377
+ return new Response(JSON.stringify({
378
+ skills: [{
379
+ id: 'anthropics/skills/frontend-design',
380
+ skillId: 'frontend-design',
381
+ name: 'frontend-design',
382
+ source: 'anthropics/skills'
383
+ }]
384
+ }))
385
+ }
386
+ if (url === 'https://registry.example.test/api/download/anthropics/skills/frontend-design') {
387
+ return new Response(JSON.stringify({
388
+ files: [{
389
+ path: 'SKILL.md',
390
+ contents: '---\nname: frontend-design\ndescription: UI design guidance\n---\nUse strong visual hierarchy.\n'
391
+ }]
392
+ }))
393
+ }
394
+ return new Response('not found', { status: 404 })
395
+ })
396
+ vi.stubGlobal('fetch', fetchMock)
397
+
398
+ await writeDocument(
399
+ join(realHome!, '.agents/skills/frontend-design/SKILL.md'),
400
+ '---\ndescription: home frontend design\n---\nUse the home definition.'
401
+ )
402
+ await writeDocument(
403
+ join(workspace, '.ai/skills/app-builder/SKILL.md'),
404
+ [
405
+ '---',
406
+ 'name: app-builder',
407
+ 'description: Build apps',
408
+ 'dependencies:',
409
+ ' - anthropics/skills@frontend-design',
410
+ '---',
411
+ 'Build the app.'
412
+ ].join('\n')
413
+ )
414
+
415
+ const bundle = await resolveWorkspaceAssetBundle({
416
+ cwd: workspace,
417
+ configs: [{
418
+ skills: {
419
+ registry: 'https://registry.example.test'
420
+ }
421
+ }, undefined],
422
+ useDefaultVibeForgeMcpServer: false
423
+ })
424
+
425
+ expect(bundle.skills.map(asset => asset.name).sort()).toEqual(['app-builder', 'frontend-design'])
426
+ expect(bundle.skills.find(asset => asset.name === 'frontend-design')).toEqual(expect.objectContaining({
427
+ resolvedBy: 'home-bridge',
428
+ sourcePath: join(realHome!, '.agents/skills/frontend-design/SKILL.md')
429
+ }))
430
+ expect(fetchMock).not.toHaveBeenCalled()
431
+
432
+ await buildAdapterAssetPlan({
433
+ adapter: 'opencode',
434
+ bundle,
435
+ options: {
436
+ skills: {
437
+ include: ['app-builder']
438
+ }
439
+ }
440
+ })
441
+
442
+ const dependency = bundle.skills.find(asset => asset.name === 'frontend-design')
443
+ expect(bundle.skills.map(asset => asset.name).sort()).toEqual(['app-builder', 'frontend-design'])
444
+ expect(dependency?.sourcePath).toContain('/.ai/caches/skill-dependencies/registry.example.test/')
445
+ expect(bundle.skills.find(asset => (
446
+ asset.name === 'frontend-design' && asset.resolvedBy === 'home-bridge'
447
+ ))).toBeUndefined()
448
+ expect(fetchMock).toHaveBeenCalledWith(
449
+ 'https://registry.example.test/api/download/anthropics/skills/frontend-design',
450
+ expect.any(Object)
451
+ )
452
+ })
453
+
454
+ it('installs skill dependencies into the primary workspace shared cache', async () => {
455
+ const primary = await createWorkspace()
456
+ const worktree = await createWorkspace()
457
+ const previousPrimaryWorkspace = process.env.__VF_PROJECT_PRIMARY_WORKSPACE_FOLDER__
458
+ const fetchMock = vi.fn(async (url: string) => {
459
+ if (url === 'https://registry.example.test/api/search?q=frontend-design&limit=10') {
460
+ return new Response(JSON.stringify({
461
+ skills: [{
462
+ id: 'anthropics/skills/frontend-design',
463
+ skillId: 'frontend-design',
464
+ name: 'frontend-design',
465
+ source: 'anthropics/skills'
466
+ }]
467
+ }))
468
+ }
469
+ if (url === 'https://registry.example.test/api/download/anthropics/skills/frontend-design') {
470
+ return new Response(JSON.stringify({
471
+ files: [{
472
+ path: 'SKILL.md',
473
+ contents: '---\nname: frontend-design\ndescription: UI design guidance\n---\nUse primary cache.\n'
474
+ }]
475
+ }))
476
+ }
477
+ return new Response('not found', { status: 404 })
478
+ })
479
+ vi.stubGlobal('fetch', fetchMock)
480
+
481
+ try {
482
+ process.env.__VF_PROJECT_PRIMARY_WORKSPACE_FOLDER__ = primary
483
+ await writeDocument(
484
+ join(worktree, '.ai/skills/app-builder/SKILL.md'),
485
+ [
486
+ '---',
487
+ 'name: app-builder',
488
+ 'description: Build apps',
489
+ 'dependencies:',
490
+ ' - frontend-design',
491
+ '---',
492
+ 'Build the app.'
493
+ ].join('\n')
494
+ )
495
+
496
+ const bundle = await resolveWorkspaceAssetBundle({
497
+ cwd: worktree,
498
+ configs: [{
499
+ skills: {
500
+ registry: 'https://registry.example.test'
501
+ }
502
+ }, undefined],
503
+ useDefaultVibeForgeMcpServer: false
504
+ })
505
+
506
+ await buildAdapterAssetPlan({
507
+ adapter: 'opencode',
508
+ bundle,
509
+ options: {
510
+ skills: {
511
+ include: ['app-builder']
512
+ }
513
+ }
514
+ })
515
+
516
+ const dependency = bundle.skills.find(asset => asset.name === 'frontend-design')
517
+ expect(dependency?.sourcePath).toContain(join(
518
+ primary,
519
+ '.ai/caches/skill-dependencies/registry.example.test/'
520
+ ))
521
+ expect(dependency?.sourcePath).not.toContain(join(worktree, '.ai/caches'))
522
+ } finally {
523
+ if (previousPrimaryWorkspace == null) {
524
+ delete process.env.__VF_PROJECT_PRIMARY_WORKSPACE_FOLDER__
525
+ } else {
526
+ process.env.__VF_PROJECT_PRIMARY_WORKSPACE_FOLDER__ = previousPrimaryWorkspace
527
+ }
528
+ }
529
+ })
530
+
531
+ it('reuses complete skill dependency caches without deleting or downloading them again', async () => {
532
+ const workspace = await createWorkspace()
533
+ const fetchMock = vi.fn(async () => new Response('not found', { status: 404 }))
534
+ vi.stubGlobal('fetch', fetchMock)
535
+
536
+ const cachedSkillPath = join(
537
+ workspace,
538
+ '.ai/caches/skill-dependencies/registry.example.test/anthropics/skills/frontend-design/SKILL.md'
539
+ )
540
+ await writeDocument(
541
+ cachedSkillPath,
542
+ '---\nname: frontend-design\ndescription: Cached UI guidance\n---\nUse the cached copy.\n'
543
+ )
544
+ await writeDocument(
545
+ join(workspace, '.ai/skills/app-builder/SKILL.md'),
546
+ [
547
+ '---',
548
+ 'name: app-builder',
549
+ 'description: Build apps',
550
+ 'dependencies:',
551
+ ' - anthropics/skills@frontend-design',
552
+ '---',
553
+ 'Build the app.'
554
+ ].join('\n')
555
+ )
556
+
557
+ const bundle = await resolveWorkspaceAssetBundle({
558
+ cwd: workspace,
559
+ configs: [{
560
+ skills: {
561
+ registry: 'https://registry.example.test'
562
+ }
563
+ }, undefined],
564
+ useDefaultVibeForgeMcpServer: false
565
+ })
566
+
567
+ await buildAdapterAssetPlan({
568
+ adapter: 'opencode',
569
+ bundle,
570
+ options: {
571
+ skills: {
572
+ include: ['app-builder']
573
+ }
574
+ }
575
+ })
576
+
577
+ expect(fetchMock).not.toHaveBeenCalled()
578
+ expect(await readFile(cachedSkillPath, 'utf8')).toContain('Use the cached copy.')
579
+ expect(bundle.skills.map(asset => asset.name).sort()).toEqual(['app-builder', 'frontend-design'])
580
+ })
581
+
582
+ it('keeps configured registry url search and download endpoints together when env overrides exist', async () => {
583
+ const workspace = await createWorkspace()
584
+ const previousDownloadUrl = process.env.SKILLS_DOWNLOAD_URL
585
+ const fetchMock = vi.fn(async (url: string) => {
586
+ if (url === 'https://private-registry.example.test/api/search?q=frontend-design&limit=10') {
587
+ return new Response(JSON.stringify({
588
+ skills: [{
589
+ id: 'anthropics/skills/frontend-design',
590
+ skillId: 'frontend-design',
591
+ name: 'frontend-design',
592
+ source: 'anthropics/skills'
593
+ }]
594
+ }))
595
+ }
596
+ if (url === 'https://private-registry.example.test/api/download/anthropics/skills/frontend-design') {
597
+ return new Response(JSON.stringify({
598
+ files: [{
599
+ path: 'SKILL.md',
600
+ contents: '---\nname: frontend-design\ndescription: UI design guidance\n---\nUse strong visual hierarchy.\n'
601
+ }]
602
+ }))
603
+ }
604
+ return new Response('not found', { status: 404 })
605
+ })
606
+ vi.stubGlobal('fetch', fetchMock)
607
+
608
+ try {
609
+ process.env.SKILLS_DOWNLOAD_URL = 'https://env-download.example.test'
610
+ await writeDocument(
611
+ join(workspace, '.ai/skills/app-builder/SKILL.md'),
612
+ [
613
+ '---',
614
+ 'name: app-builder',
615
+ 'description: Build apps',
616
+ 'dependencies:',
617
+ ' - frontend-design',
618
+ '---',
619
+ 'Build the app.'
620
+ ].join('\n')
621
+ )
622
+
623
+ const bundle = await resolveWorkspaceAssetBundle({
624
+ cwd: workspace,
625
+ configs: [{
626
+ skills: {
627
+ registry: {
628
+ url: 'https://private-registry.example.test'
629
+ }
630
+ }
631
+ }, undefined],
632
+ useDefaultVibeForgeMcpServer: false
633
+ })
634
+
635
+ await buildAdapterAssetPlan({
636
+ adapter: 'opencode',
637
+ bundle,
638
+ options: {
639
+ skills: {
640
+ include: ['app-builder']
641
+ }
642
+ }
643
+ })
644
+
645
+ expect(fetchMock).toHaveBeenCalledWith(
646
+ 'https://private-registry.example.test/api/download/anthropics/skills/frontend-design',
647
+ expect.any(Object)
648
+ )
649
+ expect(fetchMock).not.toHaveBeenCalledWith(
650
+ 'https://env-download.example.test/api/download/anthropics/skills/frontend-design',
651
+ expect.any(Object)
652
+ )
653
+ } finally {
654
+ if (previousDownloadUrl == null) {
655
+ delete process.env.SKILLS_DOWNLOAD_URL
656
+ } else {
657
+ process.env.SKILLS_DOWNLOAD_URL = previousDownloadUrl
658
+ }
659
+ }
660
+ })
661
+
100
662
  it('loads workspace entities from the env-configured entities dir', async () => {
101
663
  const workspace = await createWorkspace()
102
664
  const previousEntitiesDir = process.env.__VF_PROJECT_AI_ENTITIES_DIR__
@@ -201,6 +763,44 @@ describe('resolveWorkspaceAssetBundle', () => {
201
763
  expect(disabledBundle.mcpServers).not.toHaveProperty('VibeForge')
202
764
  })
203
765
 
766
+ it('discovers configured workspaces from glob patterns and entries', async () => {
767
+ const workspace = await createWorkspace()
768
+
769
+ await writeDocument(join(workspace, 'services/billing/README.md'), '# billing\n')
770
+ await writeDocument(join(workspace, 'services/legacy/README.md'), '# legacy\n')
771
+ await writeDocument(join(workspace, 'docs/README.md'), '# docs\n')
772
+
773
+ const bundle = await resolveWorkspaceAssetBundle({
774
+ cwd: workspace,
775
+ configs: [{
776
+ workspaces: {
777
+ include: ['services/*'],
778
+ exclude: ['services/legacy'],
779
+ entries: {
780
+ docs: {
781
+ path: 'docs',
782
+ description: 'Documentation workspace'
783
+ }
784
+ }
785
+ }
786
+ }, undefined],
787
+ useDefaultVibeForgeMcpServer: false
788
+ })
789
+
790
+ expect(bundle.workspaces.map(asset => asset.displayName)).toEqual(['billing', 'docs'])
791
+ expect(bundle.workspaces.map(asset => asset.payload)).toEqual([
792
+ expect.objectContaining({
793
+ id: 'billing',
794
+ path: 'services/billing'
795
+ }),
796
+ expect.objectContaining({
797
+ id: 'docs',
798
+ path: 'docs',
799
+ description: 'Documentation workspace'
800
+ })
801
+ ])
802
+ })
803
+
204
804
  it('skips disabled plugin instances and lets disabled child overrides suppress default child activation', async () => {
205
805
  const workspace = await createWorkspace()
206
806
 
@@ -9,6 +9,7 @@ import {
9
9
  generateSkillsRoutePrompt,
10
10
  generateSpecRoutePrompt
11
11
  } from '#~/prompt-builders.js'
12
+ import { generateWorkspaceRoutePrompt } from '#~/workspace-prompt.js'
12
13
 
13
14
  describe('workspace asset prompt builders', () => {
14
15
  it('builds skill prompts with stable names, descriptions, and relative paths', () => {
@@ -175,12 +176,50 @@ describe('workspace asset prompt builders', () => {
175
176
  expect(prompt).toContain('reviewer: 负责代码审查')
176
177
  expect(prompt).toContain('`VibeForge.StartTasks`')
177
178
  expect(prompt).toContain('`VibeForge.GetTaskInfo`')
179
+ expect(prompt).toContain('Task tool guide:')
180
+ expect(prompt).toContain('After starting a task')
181
+ expect(prompt).toContain('10 most recent log entries')
182
+ expect(prompt).toContain('`logLimit`')
183
+ expect(prompt).toContain('`logOrder`')
184
+ expect(prompt).toContain('`VibeForge.SendTaskMessage`')
185
+ expect(prompt).toContain('`{ taskId, message, mode }`')
186
+ expect(prompt).toContain('Choose `mode: "direct"`')
187
+ expect(prompt).toContain('Choose `mode: "steer"`')
188
+ expect(prompt).toContain('`VibeForge.SubmitTaskInput`')
189
+ expect(prompt).toContain('Do not use it for ordinary follow-up instructions or queued steer turns')
190
+ expect(prompt).toContain('If a task is `completed` or `failed`')
178
191
  expect(prompt).toContain('`wait`')
179
192
  expect(prompt).not.toContain('run-tasks')
180
193
  expect(prompt).not.toContain('需要关注变更风险')
181
194
  expect(prompt).not.toContain('hidden')
182
195
  })
183
196
 
197
+ it('builds workspace routes with managed task guidance', () => {
198
+ const cwd = '/tmp/project'
199
+
200
+ const prompt = generateWorkspaceRoutePrompt(cwd, [
201
+ {
202
+ id: 'billing',
203
+ cwd: join(cwd, 'packages/billing'),
204
+ path: join(cwd, '.ai/workspaces/billing.md'),
205
+ description: 'Billing workspace'
206
+ }
207
+ ])
208
+
209
+ expect(prompt).toContain('The project includes the following registered workspaces')
210
+ expect(prompt).toContain('Identifier: billing')
211
+ expect(prompt).toContain('`VibeForge.StartTasks`')
212
+ expect(prompt).toContain('type: "workspace"')
213
+ expect(prompt).toContain('Task tool guide:')
214
+ expect(prompt).toContain('`VibeForge.GetTaskInfo`')
215
+ expect(prompt).toContain('`VibeForge.ListTasks`')
216
+ expect(prompt).toContain('`VibeForge.SendTaskMessage`')
217
+ expect(prompt).toContain('`VibeForge.SubmitTaskInput`')
218
+ expect(prompt).toContain('Choose `mode: "direct"`')
219
+ expect(prompt).toContain('Choose `mode: "steer"`')
220
+ expect(prompt).toContain('Do not directly edit files inside a registered workspace')
221
+ })
222
+
184
223
  it('builds skill route prompts without preloading content', () => {
185
224
  const cwd = '/tmp/project'
186
225