@vibe-forge/workspace-assets 2.0.1 → 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.
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-forge/workspace-assets",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Workspace asset resolution and adapter asset planning for Vibe Forge",
5
5
  "imports": {
6
6
  "#~/*.js": {
@@ -30,9 +30,9 @@
30
30
  "fast-glob": "^3.3.3",
31
31
  "front-matter": "^4.0.2",
32
32
  "js-yaml": "^4.1.1",
33
- "@vibe-forge/utils": "^2.0.1",
34
- "@vibe-forge/types": "^2.0.1",
35
- "@vibe-forge/definition-core": "^2.0.1"
33
+ "@vibe-forge/definition-core": "^2.0.0",
34
+ "@vibe-forge/types": "^2.0.2",
35
+ "@vibe-forge/utils": "^2.0.3"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/js-yaml": "^4.0.9"
@@ -9,12 +9,7 @@ import {
9
9
  resolveDefaultVibeForgeMcpServerConfig
10
10
  } from '@vibe-forge/config'
11
11
  import type { Config, Definition, Entity, PluginConfig, WorkspaceAsset, WorkspaceAssetKind } from '@vibe-forge/types'
12
- import {
13
- isLegacySkillsConfig,
14
- resolveProjectAiBaseDir,
15
- resolveProjectAiEntitiesDir,
16
- resolveRelativePath
17
- } from '@vibe-forge/utils'
12
+ import { resolveProjectAiBaseDir, resolveProjectAiEntitiesDir, resolveRelativePath } from '@vibe-forge/utils'
18
13
  import { listManagedPluginInstalls, toManagedPluginConfig } from '@vibe-forge/utils/managed-plugin'
19
14
  import {
20
15
  flattenPluginInstances,
@@ -33,7 +28,6 @@ import {
33
28
  resolveSkillIdentifier,
34
29
  resolveSpecIdentifier
35
30
  } from '@vibe-forge/definition-core'
36
- import { ensureConfiguredProjectSkills } from './configured-skills'
37
31
  import { HOME_BRIDGE_RESOLVED_BY } from './home-bridge'
38
32
  import { resolveConfiguredWorkspaceAssets } from './workspaces'
39
33
 
@@ -159,8 +153,8 @@ const warnInvalidHomeSkillRoot = (root: string) => {
159
153
 
160
154
  const resolveHomeBridgeConfig = (configs: [Config?, Config?]) => {
161
155
  const [config, userConfig] = configs
162
- const projectHomeBridge = isLegacySkillsConfig(config?.skills) ? config.skills.homeBridge : undefined
163
- const userHomeBridge = isLegacySkillsConfig(userConfig?.skills) ? userConfig.skills.homeBridge : undefined
156
+ const projectHomeBridge = config?.skills?.homeBridge
157
+ const userHomeBridge = userConfig?.skills?.homeBridge
164
158
 
165
159
  return {
166
160
  enabled: userHomeBridge?.enabled ?? projectHomeBridge?.enabled ?? true,
@@ -532,8 +526,6 @@ export async function collectWorkspaceAssets(params: {
532
526
  plugins?: PluginConfig
533
527
  overlaySource?: string
534
528
  includeManagedPlugins?: boolean
535
- syncConfiguredSkills?: boolean
536
- updateConfiguredSkills?: boolean
537
529
  useDefaultVibeForgeMcpServer?: boolean
538
530
  }): Promise<{
539
531
  assets: WorkspaceAsset[]
@@ -552,13 +544,6 @@ export async function collectWorkspaceAssets(params: {
552
544
  workspaces: Array<Extract<WorkspaceAsset, { kind: 'workspace' }>>
553
545
  }> {
554
546
  const [config, userConfig] = params.configs ?? await loadWorkspaceConfig(params.cwd)
555
- if (params.syncConfiguredSkills === true) {
556
- await ensureConfiguredProjectSkills({
557
- configs: [config, userConfig],
558
- updateInstalledSkills: params.updateConfiguredSkills,
559
- workspaceFolder: params.cwd
560
- })
561
- }
562
547
  const managedPluginConfigs = params.includeManagedPlugins === false
563
548
  ? undefined
564
549
  : toManagedPluginConfig(await listManagedPluginInstalls(params.cwd))
package/src/bundle.ts CHANGED
@@ -8,8 +8,6 @@ export async function resolveWorkspaceAssetBundle(params: {
8
8
  plugins?: PluginConfig
9
9
  overlaySource?: string
10
10
  includeManagedPlugins?: boolean
11
- syncConfiguredSkills?: boolean
12
- updateConfiguredSkills?: boolean
13
11
  useDefaultVibeForgeMcpServer?: boolean
14
12
  }): Promise<WorkspaceAssetBundle> {
15
13
  const collected = await collectWorkspaceAssets(params)
@@ -7,6 +7,8 @@ import {
7
7
  import type { Definition, Entity, Rule, Skill, Spec } from '@vibe-forge/types'
8
8
  import { CANONICAL_VIBE_FORGE_MCP_SERVER_NAME, resolvePromptPath } from '@vibe-forge/utils'
9
9
 
10
+ import { buildManagedTaskToolGuidance } from './task-tool-guidance'
11
+
10
12
  const toMarkdownBlockquote = (content: string) => (
11
13
  content
12
14
  .trim()
@@ -165,6 +167,7 @@ export const generateSpecRoutePrompt = (
165
167
  }
166
168
 
167
169
  export const generateEntitiesRoutePrompt = (entities: Definition<Entity>[]) => {
170
+ const taskToolGuidance = buildManagedTaskToolGuidance(CANONICAL_VIBE_FORGE_MCP_SERVER_NAME)
168
171
  return (
169
172
  '<system-prompt>\n' +
170
173
  'The project includes the following entities:\n' +
@@ -179,6 +182,7 @@ export const generateEntitiesRoutePrompt = (entities: Definition<Entity>[]) => {
179
182
  .join('')
180
183
  }\n` +
181
184
  `When solving user problems, you may specify entities through \`${CANONICAL_VIBE_FORGE_MCP_SERVER_NAME}.StartTasks\` as needed and have them coordinate multiple entity types to complete the work; use \`${CANONICAL_VIBE_FORGE_MCP_SERVER_NAME}.GetTaskInfo\` and \`wait\` to track progress.\n` +
185
+ `${taskToolGuidance}\n` +
182
186
  '</system-prompt>\n'
183
187
  )
184
188
  }
@@ -35,7 +35,7 @@ import {
35
35
  resolveSelectedSkillAssetsWithDependencies,
36
36
  toDocumentDefinitions
37
37
  } from './selection-internal'
38
- import { expandSkillAssetDependenciesWithRemoteResolution } from './skill-dependencies'
38
+ import { expandSkillAssetDependenciesWithRegistry } from './skill-dependencies'
39
39
  import { generateWorkspaceRoutePrompt } from './workspace-prompt'
40
40
 
41
41
  export async function resolvePromptAssetSelection(params: {
@@ -164,7 +164,7 @@ export async function resolvePromptAssetSelection(params: {
164
164
  resolveNamedAssets(effectiveBundle.skills, excludedRefs, targetInstancePath).map(asset => asset.id)
165
165
  )
166
166
 
167
- const expandedTargetSkills = await expandSkillAssetDependenciesWithRemoteResolution({
167
+ const expandedTargetSkills = await expandSkillAssetDependenciesWithRegistry({
168
168
  allAssets: effectiveBundle.assets,
169
169
  configs: effectiveBundle.configs ?? [undefined, undefined],
170
170
  cwd: effectiveBundle.cwd,
@@ -26,7 +26,7 @@ import {
26
26
  isRemoteRuleReference,
27
27
  parseScopedReference
28
28
  } from '@vibe-forge/definition-core'
29
- import { expandSkillAssetDependencies, expandSkillAssetDependenciesWithRemoteResolution } from './skill-dependencies'
29
+ import { expandSkillAssetDependencies, expandSkillAssetDependenciesWithRegistry } from './skill-dependencies'
30
30
 
31
31
  type DocumentAssetKind = Extract<WorkspaceAssetKind, 'rule' | 'spec' | 'entity' | 'skill'>
32
32
  type DocumentAsset<TDefinition> = Extract<WorkspaceAsset, { kind: DocumentAssetKind }> & {
@@ -538,7 +538,7 @@ export const resolveSelectedSkillAssetsWithDependencies = async (
538
538
  const excluded = new Set(
539
539
  resolveNamedAssets(bundle.skills, selection?.exclude).map(asset => asset.id)
540
540
  )
541
- return await expandSkillAssetDependenciesWithRemoteResolution({
541
+ return await expandSkillAssetDependenciesWithRegistry({
542
542
  allAssets: bundle.assets,
543
543
  configs: bundle.configs ?? [undefined, undefined],
544
544
  cwd: bundle.cwd,
@@ -8,7 +8,7 @@ import type { Config, Definition, Skill, WorkspaceAsset } from '@vibe-forge/type
8
8
  import { resolveRelativePath } from '@vibe-forge/utils'
9
9
 
10
10
  import { HOME_BRIDGE_RESOLVED_BY } from './home-bridge'
11
- import { installSkillsCliDependency } from './skills-cli-dependency'
11
+ import { installRegistrySkillDependency } from './skill-registry'
12
12
 
13
13
  type SkillAsset = Extract<WorkspaceAsset, { kind: 'skill' }>
14
14
 
@@ -16,6 +16,7 @@ export interface NormalizedSkillDependency {
16
16
  ref: string
17
17
  name: string
18
18
  source?: string
19
+ registry?: string
19
20
  }
20
21
 
21
22
  interface DependencyExpansionParams {
@@ -118,7 +119,7 @@ const parseFrontmatterSkill = async (path: string): Promise<Definition<Skill>> =
118
119
  }
119
120
  }
120
121
 
121
- const createResolvedSkillAsset = (params: {
122
+ const createRegistrySkillAsset = (params: {
122
123
  cwd: string
123
124
  definition: Definition<Skill>
124
125
  }) => {
@@ -148,15 +149,12 @@ const parseStringDependency = (value: string): NormalizedSkillDependency => {
148
149
  }
149
150
  }
150
151
 
151
- const sourcePathSegments = ref.split('/').filter(segment => segment.trim() !== '')
152
- if (
153
- sourcePathSegments.length >= 3 &&
154
- sourcePathSegments.every(segment => !segment.includes(' '))
155
- ) {
152
+ const sourcePathMatch = ref.match(/^([^/\s]+\/[^/\s]+)\/([^/\s]+)$/)
153
+ if (sourcePathMatch != null) {
156
154
  return {
157
155
  ref,
158
- source: sourcePathSegments.slice(0, -1).join('/'),
159
- name: sourcePathSegments[sourcePathSegments.length - 1]
156
+ source: sourcePathMatch[1],
157
+ name: sourcePathMatch[2]
160
158
  }
161
159
  }
162
160
 
@@ -179,7 +177,8 @@ export const normalizeSkillDependency = (value: unknown): NormalizedSkillDepende
179
177
  return {
180
178
  ref: source == null ? name : `${source}@${name}`,
181
179
  name,
182
- ...(source == null ? {} : { source })
180
+ ...(source == null ? {} : { source }),
181
+ ...(asNonEmptyString(record.registry) == null ? {} : { registry: asNonEmptyString(record.registry) })
183
182
  }
184
183
  }
185
184
 
@@ -243,7 +242,7 @@ export const expandSkillAssetDependencies = (
243
242
  return selected
244
243
  }
245
244
 
246
- export const expandSkillAssetDependenciesWithRemoteResolution = async (
245
+ export const expandSkillAssetDependenciesWithRegistry = async (
247
246
  params: DependencyExpansionParams
248
247
  ) => {
249
248
  const selected: SkillAsset[] = []
@@ -261,16 +260,16 @@ export const expandSkillAssetDependenciesWithRemoteResolution = async (
261
260
  dependency: NormalizedSkillDependency,
262
261
  currentInstancePath?: string
263
262
  ) => {
264
- const fetchKey = dependency.ref
263
+ const fetchKey = `${dependency.registry ?? ''}:${dependency.ref}`
265
264
  if (!fetchedDependencyRefs.has(fetchKey)) {
266
265
  fetchedDependencyRefs.add(fetchKey)
267
- const installed = await installSkillsCliDependency({
266
+ const installed = await installRegistrySkillDependency({
268
267
  cwd: params.cwd,
269
268
  configs: params.configs,
270
269
  dependency
271
270
  })
272
271
  const definition = await parseFrontmatterSkill(installed.skillPath)
273
- const dependencyAsset = createResolvedSkillAsset({
272
+ const dependencyAsset = createRegistrySkillAsset({
274
273
  cwd: params.cwd,
275
274
  definition
276
275
  })
@@ -335,13 +334,14 @@ export const expandSkillAssetDependenciesWithRemoteResolution = async (
335
334
  const dependencyAsset = await installDependencyAsset(dependency, asset.instancePath).catch((error: unknown) => {
336
335
  if (
337
336
  localOrBridgedDependency != null &&
338
- dependency.source == null
337
+ dependency.source == null &&
338
+ dependency.registry == null
339
339
  ) {
340
340
  return localOrBridgedDependency
341
341
  }
342
342
  throw error
343
343
  }) ?? (
344
- dependency.source == null
344
+ dependency.source == null && dependency.registry == null
345
345
  ? localOrBridgedDependency
346
346
  : undefined
347
347
  )
@@ -0,0 +1,329 @@
1
+ /* eslint-disable max-lines -- registry install logic keeps URL resolution, locking, and cache writes together */
2
+ import { access, mkdir, rename, rm, writeFile } from 'node:fs/promises'
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
4
+ import process from 'node:process'
5
+ import { setTimeout as delay } from 'node:timers/promises'
6
+
7
+ import type { Config } from '@vibe-forge/types'
8
+ import { resolveProjectSharedCachePath } from '@vibe-forge/utils/project-cache-path'
9
+
10
+ import type { NormalizedSkillDependency } from './skill-dependencies'
11
+
12
+ interface RegistryOptions {
13
+ enabled: boolean
14
+ searchBaseUrl: string
15
+ downloadBaseUrl: string
16
+ }
17
+
18
+ interface RegistrySearchSkill {
19
+ id?: string
20
+ name?: string
21
+ skillId?: string
22
+ source?: string
23
+ }
24
+
25
+ interface RegistryDownloadFile {
26
+ path?: string
27
+ contents?: string
28
+ }
29
+
30
+ interface RegistryDownloadResponse {
31
+ files?: RegistryDownloadFile[]
32
+ }
33
+
34
+ const DEFAULT_SKILL_REGISTRY_URL = 'https://skills.sh'
35
+ const FETCH_TIMEOUT_MS = 10_000
36
+ const INSTALL_LOCK_TIMEOUT_MS = 30_000
37
+ const INSTALL_LOCK_RETRY_MS = 100
38
+
39
+ const asNonEmptyString = (value: unknown) => (
40
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
41
+ )
42
+
43
+ const normalizeBaseUrl = (value: string) => value.replace(/\/+$/, '')
44
+
45
+ const toSkillSlug = (value: string) => (
46
+ value
47
+ .trim()
48
+ .toLowerCase()
49
+ .replace(/[\s_]+/g, '-')
50
+ .replace(/[^a-z0-9-]/g, '')
51
+ .replace(/-+/g, '-')
52
+ .replace(/^-|-$/g, '')
53
+ )
54
+
55
+ const toCacheSegment = (value: string) => (
56
+ value
57
+ .trim()
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9._-]+/g, '-')
60
+ .replace(/^-+|-+$/g, '') || 'registry'
61
+ )
62
+
63
+ const resolveConfiguredRegistry = (
64
+ projectConfig: Config | undefined,
65
+ userConfig: Config | undefined
66
+ ) => userConfig?.skills?.registry ?? projectConfig?.skills?.registry
67
+
68
+ const resolveRegistryOptions = (params: {
69
+ configs: [Config?, Config?]
70
+ registry?: string
71
+ }): RegistryOptions => {
72
+ const [projectConfig, userConfig] = params.configs
73
+ const configuredRegistry = params.registry ?? resolveConfiguredRegistry(projectConfig, userConfig)
74
+ const envSearchBaseUrl = asNonEmptyString(process.env.SKILLS_API_URL)
75
+ const envDownloadBaseUrl = asNonEmptyString(process.env.SKILLS_DOWNLOAD_URL)
76
+
77
+ if (typeof configuredRegistry === 'string' && configuredRegistry.trim() !== '') {
78
+ const baseUrl = normalizeBaseUrl(configuredRegistry.trim())
79
+ return {
80
+ enabled: true,
81
+ searchBaseUrl: baseUrl,
82
+ downloadBaseUrl: baseUrl
83
+ }
84
+ }
85
+
86
+ if (configuredRegistry != null && typeof configuredRegistry === 'object') {
87
+ const baseUrl = normalizeBaseUrl(
88
+ asNonEmptyString(configuredRegistry.url) ?? DEFAULT_SKILL_REGISTRY_URL
89
+ )
90
+ return {
91
+ enabled: configuredRegistry.enabled !== false,
92
+ searchBaseUrl: normalizeBaseUrl(asNonEmptyString(configuredRegistry.searchUrl) ?? baseUrl),
93
+ downloadBaseUrl: normalizeBaseUrl(asNonEmptyString(configuredRegistry.downloadUrl) ?? baseUrl)
94
+ }
95
+ }
96
+
97
+ const baseUrl = normalizeBaseUrl(envSearchBaseUrl ?? DEFAULT_SKILL_REGISTRY_URL)
98
+ return {
99
+ enabled: true,
100
+ searchBaseUrl: baseUrl,
101
+ downloadBaseUrl: normalizeBaseUrl(envDownloadBaseUrl ?? baseUrl)
102
+ }
103
+ }
104
+
105
+ const fetchJson = async <T>(url: string): Promise<T> => {
106
+ const response = await fetch(url, {
107
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
108
+ })
109
+ if (!response.ok) {
110
+ throw new Error(`Request failed with status ${response.status}`)
111
+ }
112
+ return await response.json() as T
113
+ }
114
+
115
+ const parseSourceAndSlugFromId = (id: string | undefined) => {
116
+ if (id == null) return undefined
117
+ const segments = id.split('/').filter(Boolean)
118
+ if (segments.length < 3) return undefined
119
+ return {
120
+ source: `${segments[0]}/${segments[1]}`,
121
+ slug: segments.slice(2).join('/')
122
+ }
123
+ }
124
+
125
+ const pickSearchResult = (
126
+ skills: RegistrySearchSkill[],
127
+ name: string
128
+ ) => {
129
+ const slug = toSkillSlug(name)
130
+ return skills.find(skill => (
131
+ skill.skillId === slug ||
132
+ skill.name === name ||
133
+ toSkillSlug(skill.name ?? '') === slug ||
134
+ parseSourceAndSlugFromId(skill.id)?.slug === slug
135
+ )) ?? skills[0]
136
+ }
137
+
138
+ const resolveRegistrySkillTarget = async (
139
+ dependency: NormalizedSkillDependency,
140
+ registry: RegistryOptions
141
+ ) => {
142
+ if (dependency.source != null) {
143
+ return {
144
+ source: dependency.source,
145
+ slug: toSkillSlug(dependency.name)
146
+ }
147
+ }
148
+
149
+ const searchUrl = `${registry.searchBaseUrl}/api/search?q=${encodeURIComponent(dependency.name)}&limit=10`
150
+ const searchResult = await fetchJson<{
151
+ skills?: RegistrySearchSkill[]
152
+ }>(searchUrl)
153
+ const skills = Array.isArray(searchResult.skills) ? searchResult.skills : []
154
+ const picked = pickSearchResult(skills, dependency.name)
155
+ if (picked == null) {
156
+ throw new Error(`Skill ${dependency.name} was not found in ${registry.searchBaseUrl}`)
157
+ }
158
+
159
+ const parsed = parseSourceAndSlugFromId(picked.id)
160
+ const source = asNonEmptyString(picked.source) ?? parsed?.source
161
+ const slug = asNonEmptyString(picked.skillId) ?? parsed?.slug ?? toSkillSlug(picked.name ?? dependency.name)
162
+ if (source == null || slug === '') {
163
+ throw new Error(`Registry search result for ${dependency.name} did not include a source and skill id`)
164
+ }
165
+
166
+ return {
167
+ source,
168
+ slug
169
+ }
170
+ }
171
+
172
+ const toSafeRelativePath = (filePath: string) => {
173
+ const normalized = filePath.replace(/\\/g, '/')
174
+ if (normalized.startsWith('/') || normalized.split('/').includes('..')) {
175
+ throw new Error(`Unsafe skill dependency file path ${filePath}`)
176
+ }
177
+ return normalized
178
+ }
179
+
180
+ const assertInside = (root: string, target: string) => {
181
+ const relativePath = relative(root, target)
182
+ if (
183
+ relativePath === '' ||
184
+ (
185
+ relativePath !== '..' &&
186
+ !relativePath.startsWith(`..${sep}`) &&
187
+ !isAbsolute(relativePath)
188
+ )
189
+ ) {
190
+ return
191
+ }
192
+ throw new Error(`Skill dependency file resolves outside ${root}`)
193
+ }
194
+
195
+ const pathExists = async (path: string) => {
196
+ try {
197
+ await access(path)
198
+ return true
199
+ } catch {
200
+ return false
201
+ }
202
+ }
203
+
204
+ const withInstallLock = async <T>(lockDir: string, callback: () => Promise<T>) => {
205
+ const start = Date.now()
206
+ await mkdir(dirname(lockDir), { recursive: true })
207
+
208
+ while (true) {
209
+ try {
210
+ await mkdir(lockDir)
211
+ break
212
+ } catch (error) {
213
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
214
+ if (Date.now() - start > INSTALL_LOCK_TIMEOUT_MS) {
215
+ throw new Error(`Timed out waiting for skill dependency install lock ${lockDir}`)
216
+ }
217
+ await delay(INSTALL_LOCK_RETRY_MS)
218
+ }
219
+ }
220
+
221
+ try {
222
+ return await callback()
223
+ } finally {
224
+ await rm(lockDir, { recursive: true, force: true })
225
+ }
226
+ }
227
+
228
+ const buildInstallDir = (params: {
229
+ cwd: string
230
+ registry: RegistryOptions
231
+ source: string
232
+ slug: string
233
+ }) => {
234
+ let registryKey = params.registry.downloadBaseUrl
235
+ try {
236
+ const parsed = new URL(params.registry.downloadBaseUrl)
237
+ registryKey = `${parsed.hostname}${parsed.pathname}`
238
+ } catch {
239
+ }
240
+
241
+ return resolveProjectSharedCachePath(
242
+ params.cwd,
243
+ process.env,
244
+ 'skill-dependencies',
245
+ toCacheSegment(registryKey),
246
+ ...params.source.split('/').map(toCacheSegment),
247
+ toCacheSegment(params.slug)
248
+ )
249
+ }
250
+
251
+ export const installRegistrySkillDependency = async (params: {
252
+ cwd: string
253
+ configs: [Config?, Config?]
254
+ dependency: NormalizedSkillDependency
255
+ }) => {
256
+ const registry = resolveRegistryOptions({
257
+ configs: params.configs,
258
+ registry: params.dependency.registry
259
+ })
260
+ if (!registry.enabled) {
261
+ throw new Error(`Skill dependency registry is disabled; cannot install ${params.dependency.ref}`)
262
+ }
263
+
264
+ const target = await resolveRegistrySkillTarget(params.dependency, registry)
265
+ const [owner, repo] = target.source.split('/')
266
+ if (owner == null || repo == null || owner === '' || repo === '') {
267
+ throw new Error(`Skill dependency source ${target.source} must use owner/repo format`)
268
+ }
269
+
270
+ const downloadUrl = `${registry.downloadBaseUrl}/api/download/${encodeURIComponent(owner)}/${
271
+ encodeURIComponent(repo)
272
+ }/${encodeURIComponent(target.slug)}`
273
+ const installDir = buildInstallDir({
274
+ cwd: params.cwd,
275
+ registry,
276
+ source: target.source,
277
+ slug: target.slug
278
+ })
279
+ const skillPath = resolve(installDir, 'SKILL.md')
280
+
281
+ return await withInstallLock(`${installDir}.lock`, async () => {
282
+ if (await pathExists(skillPath)) {
283
+ return {
284
+ installDir,
285
+ skillPath
286
+ }
287
+ }
288
+
289
+ const response = await fetchJson<RegistryDownloadResponse>(downloadUrl)
290
+ const files = Array.isArray(response.files) ? response.files : []
291
+ if (files.length === 0) {
292
+ throw new Error(`Skill dependency ${params.dependency.ref} did not include any files`)
293
+ }
294
+
295
+ const tempDir = `${installDir}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
296
+ await rm(tempDir, { recursive: true, force: true })
297
+ await mkdir(tempDir, { recursive: true })
298
+
299
+ try {
300
+ let hasSkillMd = false
301
+ for (const file of files) {
302
+ const filePath = asNonEmptyString(file.path)
303
+ if (filePath == null || typeof file.contents !== 'string') continue
304
+ const relativePath = toSafeRelativePath(filePath)
305
+ if (relativePath.toLowerCase() === 'skill.md') hasSkillMd = true
306
+
307
+ const targetPath = resolve(tempDir, relativePath)
308
+ assertInside(tempDir, targetPath)
309
+ await mkdir(dirname(targetPath), { recursive: true })
310
+ await writeFile(targetPath, file.contents, 'utf8')
311
+ }
312
+
313
+ if (!hasSkillMd) {
314
+ throw new Error(`Skill dependency ${params.dependency.ref} did not include SKILL.md`)
315
+ }
316
+
317
+ await rm(installDir, { recursive: true, force: true })
318
+ await rename(tempDir, installDir)
319
+ } catch (error) {
320
+ await rm(tempDir, { recursive: true, force: true })
321
+ throw error
322
+ }
323
+
324
+ return {
325
+ installDir,
326
+ skillPath
327
+ }
328
+ })
329
+ }
@@ -0,0 +1,15 @@
1
+ export const buildManagedTaskToolGuidance = (serverName: string) => {
2
+ return [
3
+ 'Task tool guide:',
4
+ `- Use \`${serverName}.StartTasks\` to start a new child task when the work should run in a separate entity or workspace, or when it needs to continue independently from the current turn.`,
5
+ `- After starting a task, use \`${serverName}.GetTaskInfo\` with \`{ taskId }\` to inspect one task. It is also the right tool when a task seems stalled, failed, or might be waiting for input.`,
6
+ '- By default, `GetTaskInfo` returns the 10 most recent log entries in descending order, so newer entries appear earlier in the `logs` array. Pass `logLimit` to inspect a different number of recent logs, and set `logOrder` to `"asc"` when you want the selected log window in oldest-to-newest order.',
7
+ `- Use \`${serverName}.ListTasks\` with the same \`logLimit\` and \`logOrder\` fields when you need to find a taskId or inspect multiple tasks at once.`,
8
+ `- Use \`${serverName}.SendTaskMessage\` with \`{ taskId, message, mode }\` when you want to continue the same task without starting a replacement task.`,
9
+ '- Choose `mode: "direct"` when the task is `running` and the new instruction should be delivered immediately into the current task.',
10
+ '- Choose `mode: "steer"` when the task should finish its current run naturally first, and the follow-up should be queued for the same task afterward.',
11
+ `- Use \`${serverName}.SubmitTaskInput\` only when \`${serverName}.GetTaskInfo\` or \`${serverName}.ListTasks\` shows \`pendingInput\` or \`pendingInteraction\`, or the task status is \`waiting_input\`. Do not use it for ordinary follow-up instructions or queued steer turns.`,
12
+ '- If a task is `completed` or `failed`, start a new task instead of trying to continue the old one.',
13
+ '- When a task is still making progress, use `wait` between checks instead of repeatedly restarting it.'
14
+ ].join('\n')
15
+ }
@@ -1,11 +1,14 @@
1
1
  import type { WorkspaceDefinitionPayload } from '@vibe-forge/types'
2
2
  import { CANONICAL_VIBE_FORGE_MCP_SERVER_NAME, resolvePromptPath } from '@vibe-forge/utils'
3
3
 
4
+ import { buildManagedTaskToolGuidance } from './task-tool-guidance'
5
+
4
6
  export const generateWorkspaceRoutePrompt = (
5
7
  cwd: string,
6
8
  workspaces: WorkspaceDefinitionPayload[]
7
9
  ) => {
8
10
  if (workspaces.length === 0) return ''
11
+ const taskToolGuidance = buildManagedTaskToolGuidance(CANONICAL_VIBE_FORGE_MCP_SERVER_NAME)
9
12
 
10
13
  const workspaceList = workspaces
11
14
  .map((workspace) => {
@@ -24,6 +27,7 @@ export const generateWorkspaceRoutePrompt = (
24
27
  `${workspaceList}\n` +
25
28
  `When a user request targets one of these workspaces, start a child task with \`${CANONICAL_VIBE_FORGE_MCP_SERVER_NAME}.StartTasks\` using \`type: "workspace"\` and \`name\` set to the workspace identifier. ` +
26
29
  'Do not directly edit files inside a registered workspace from the current session unless the user explicitly asks this session to work in that directory.\n' +
30
+ `${taskToolGuidance}\n` +
27
31
  '</system-prompt>\n'
28
32
  )
29
33
  }