@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.
@@ -0,0 +1,132 @@
1
+ import { stat } from 'node:fs/promises'
2
+
3
+ import type { Config, WorkspaceConfigEntry } from '@vibe-forge/types'
4
+ import { glob } from 'fast-glob'
5
+
6
+ const DEFAULT_WORKSPACE_IGNORES = [
7
+ '**/.git/**',
8
+ '**/.ai/**',
9
+ '**/node_modules/**'
10
+ ]
11
+
12
+ export interface NormalizedWorkspaceEntry {
13
+ enabled?: boolean
14
+ name?: string
15
+ description?: string
16
+ path?: string
17
+ include?: string[]
18
+ exclude?: string[]
19
+ }
20
+
21
+ const isRecord = (value: unknown): value is Record<string, unknown> => (
22
+ value != null && typeof value === 'object' && !Array.isArray(value)
23
+ )
24
+
25
+ const toStringList = (value: unknown): string[] => {
26
+ if (typeof value === 'string' && value.trim() !== '') {
27
+ return [value.trim()]
28
+ }
29
+ if (!Array.isArray(value)) return []
30
+
31
+ return value
32
+ .filter((item): item is string => typeof item === 'string' && item.trim() !== '')
33
+ .map(item => item.trim())
34
+ }
35
+
36
+ const normalizeWorkspaceEntry = (
37
+ id: string,
38
+ value: string | WorkspaceConfigEntry
39
+ ): NormalizedWorkspaceEntry | undefined => {
40
+ if (typeof value === 'string') {
41
+ return { path: value }
42
+ }
43
+
44
+ if (!isRecord(value)) return undefined
45
+ const enabled = typeof value.enabled === 'boolean' ? value.enabled : undefined
46
+ if (enabled === false) return { enabled: false }
47
+
48
+ return {
49
+ enabled,
50
+ name: typeof value.name === 'string' && value.name.trim() !== '' ? value.name.trim() : id,
51
+ description: typeof value.description === 'string' && value.description.trim() !== ''
52
+ ? value.description.trim()
53
+ : undefined,
54
+ path: typeof value.path === 'string' && value.path.trim() !== '' ? value.path.trim() : undefined,
55
+ include: [
56
+ ...toStringList(value.include),
57
+ ...toStringList(value.glob),
58
+ ...toStringList(value.globs)
59
+ ],
60
+ exclude: toStringList(value.exclude)
61
+ }
62
+ }
63
+
64
+ export const normalizeWorkspaceConfig = (config: Config['workspaces'] | undefined) => {
65
+ if (config == null) {
66
+ return {
67
+ include: [] as string[],
68
+ exclude: [] as string[],
69
+ entries: {} as Record<string, NormalizedWorkspaceEntry>
70
+ }
71
+ }
72
+
73
+ if (typeof config === 'string' || Array.isArray(config)) {
74
+ return {
75
+ include: toStringList(config),
76
+ exclude: [] as string[],
77
+ entries: {} as Record<string, NormalizedWorkspaceEntry>
78
+ }
79
+ }
80
+
81
+ if (!isRecord(config)) {
82
+ return {
83
+ include: [] as string[],
84
+ exclude: [] as string[],
85
+ entries: {} as Record<string, NormalizedWorkspaceEntry>
86
+ }
87
+ }
88
+
89
+ const entries = Object.fromEntries(
90
+ Object.entries(config.entries ?? {})
91
+ .map(([id, value]) => [id, normalizeWorkspaceEntry(id, value)])
92
+ .filter((entry): entry is [string, NormalizedWorkspaceEntry] => entry[1] != null)
93
+ )
94
+
95
+ return {
96
+ include: [
97
+ ...toStringList(config.include),
98
+ ...toStringList(config.glob),
99
+ ...toStringList(config.globs)
100
+ ],
101
+ exclude: toStringList(config.exclude),
102
+ entries
103
+ }
104
+ }
105
+
106
+ export const isDirectory = async (path: string) => {
107
+ try {
108
+ return (await stat(path)).isDirectory()
109
+ } catch {
110
+ return false
111
+ }
112
+ }
113
+
114
+ export const scanWorkspacePatterns = async (
115
+ cwd: string,
116
+ patterns: string[],
117
+ exclude: string[]
118
+ ) => {
119
+ if (patterns.length === 0) return []
120
+
121
+ return await glob(patterns, {
122
+ cwd,
123
+ absolute: true,
124
+ onlyDirectories: true,
125
+ unique: true,
126
+ followSymbolicLinks: true,
127
+ ignore: [
128
+ ...DEFAULT_WORKSPACE_IGNORES,
129
+ ...exclude
130
+ ]
131
+ })
132
+ }
@@ -0,0 +1,33 @@
1
+ import type { WorkspaceDefinitionPayload } from '@vibe-forge/types'
2
+ import { CANONICAL_VIBE_FORGE_MCP_SERVER_NAME, resolvePromptPath } from '@vibe-forge/utils'
3
+
4
+ import { buildManagedTaskToolGuidance } from './task-tool-guidance'
5
+
6
+ export const generateWorkspaceRoutePrompt = (
7
+ cwd: string,
8
+ workspaces: WorkspaceDefinitionPayload[]
9
+ ) => {
10
+ if (workspaces.length === 0) return ''
11
+ const taskToolGuidance = buildManagedTaskToolGuidance(CANONICAL_VIBE_FORGE_MCP_SERVER_NAME)
12
+
13
+ const workspaceList = workspaces
14
+ .map((workspace) => {
15
+ const description = workspace.description?.trim() || workspace.name?.trim() || workspace.path
16
+ return (
17
+ ` - Identifier: ${workspace.id}\n` +
18
+ ` - Path: ${resolvePromptPath(cwd, workspace.cwd)}\n` +
19
+ ` - Description: ${description}\n`
20
+ )
21
+ })
22
+ .join('')
23
+
24
+ return (
25
+ '<system-prompt>\n' +
26
+ 'The project includes the following registered workspaces:\n' +
27
+ `${workspaceList}\n` +
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. ` +
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` +
31
+ '</system-prompt>\n'
32
+ )
33
+ }
@@ -0,0 +1,188 @@
1
+ import { basename, resolve } from 'node:path'
2
+
3
+ import { mergeConfigs } from '@vibe-forge/config'
4
+ import type { Config, WorkspaceAsset } from '@vibe-forge/types'
5
+ import { normalizePath, resolveRelativePath } from '@vibe-forge/utils'
6
+
7
+ import { isDirectory, normalizeWorkspaceConfig, scanWorkspacePatterns } from './workspace-config'
8
+
9
+ interface WorkspaceCandidate {
10
+ preferredId?: string
11
+ name?: string
12
+ description?: string
13
+ cwd: string
14
+ path: string
15
+ pattern?: string
16
+ sourcePath: string
17
+ }
18
+
19
+ const createWorkspaceAsset = (params: {
20
+ id: string
21
+ name?: string
22
+ description?: string
23
+ cwd: string
24
+ path: string
25
+ pattern?: string
26
+ sourcePath: string
27
+ }) => ({
28
+ id: `workspace:workspace:workspace:${params.id}:${params.path}`,
29
+ kind: 'workspace',
30
+ name: params.id,
31
+ displayName: params.id,
32
+ origin: 'workspace',
33
+ sourcePath: params.sourcePath,
34
+ payload: {
35
+ id: params.id,
36
+ name: params.name,
37
+ description: params.description,
38
+ path: params.path,
39
+ cwd: params.cwd,
40
+ pattern: params.pattern
41
+ }
42
+ } satisfies Extract<WorkspaceAsset, { kind: 'workspace' }>)
43
+
44
+ const addCandidate = (
45
+ candidates: Map<string, WorkspaceCandidate>,
46
+ candidate: WorkspaceCandidate
47
+ ) => {
48
+ const key = normalizePath(candidate.cwd)
49
+ const existing = candidates.get(key)
50
+ if (existing == null) {
51
+ candidates.set(key, candidate)
52
+ return
53
+ }
54
+
55
+ candidates.set(key, {
56
+ ...existing,
57
+ ...candidate,
58
+ preferredId: candidate.preferredId ?? existing.preferredId,
59
+ name: candidate.name ?? existing.name,
60
+ description: candidate.description ?? existing.description
61
+ })
62
+ }
63
+
64
+ const assignWorkspaceIds = (candidates: WorkspaceCandidate[]) => {
65
+ const preferredIds = new Set(
66
+ candidates
67
+ .map(candidate => candidate.preferredId)
68
+ .filter((value): value is string => value != null && value.trim() !== '')
69
+ )
70
+ const basenameCounts = candidates.reduce<Map<string, number>>((counts, candidate) => {
71
+ if (candidate.preferredId != null) return counts
72
+ const id = basename(candidate.path)
73
+ counts.set(id, (counts.get(id) ?? 0) + 1)
74
+ return counts
75
+ }, new Map())
76
+ const usedIds = new Set<string>()
77
+
78
+ return candidates.map((candidate) => {
79
+ const preferredId = candidate.preferredId?.trim()
80
+ if (preferredId != null && preferredId !== '' && !usedIds.has(preferredId)) {
81
+ usedIds.add(preferredId)
82
+ return [preferredId, candidate] as const
83
+ }
84
+
85
+ const basenameId = basename(candidate.path)
86
+ const shouldUsePathId = preferredIds.has(basenameId) || (basenameCounts.get(basenameId) ?? 0) > 1
87
+ const id = shouldUsePathId ? candidate.path : basenameId
88
+ const uniqueId = usedIds.has(id) ? candidate.path : id
89
+ usedIds.add(uniqueId)
90
+ return [uniqueId, candidate] as const
91
+ })
92
+ }
93
+
94
+ export const resolveConfiguredWorkspaceAssets = async (params: {
95
+ cwd: string
96
+ configs?: [Config?, Config?]
97
+ }): Promise<Array<Extract<WorkspaceAsset, { kind: 'workspace' }>>> => {
98
+ const [config, userConfig] = params.configs ?? [undefined, undefined]
99
+ const mergedConfig = mergeConfigs(config, userConfig)
100
+ const workspaceConfig = normalizeWorkspaceConfig(mergedConfig?.workspaces)
101
+ const sourcePath = resolve(params.cwd, '.ai.config.json')
102
+ const candidates = new Map<string, WorkspaceCandidate>()
103
+
104
+ for (const path of await scanWorkspacePatterns(params.cwd, workspaceConfig.include, workspaceConfig.exclude)) {
105
+ const relativePath = resolveRelativePath(params.cwd, path)
106
+ if (relativePath === '') continue
107
+ addCandidate(candidates, {
108
+ cwd: path,
109
+ path: relativePath,
110
+ sourcePath
111
+ })
112
+ }
113
+
114
+ for (const [id, entry] of Object.entries(workspaceConfig.entries)) {
115
+ if (entry.enabled === false) continue
116
+
117
+ if (entry.path != null) {
118
+ const workspaceCwd = resolve(params.cwd, entry.path)
119
+ if (await isDirectory(workspaceCwd)) {
120
+ addCandidate(candidates, {
121
+ preferredId: id,
122
+ name: entry.name,
123
+ description: entry.description,
124
+ cwd: workspaceCwd,
125
+ path: resolveRelativePath(params.cwd, workspaceCwd),
126
+ sourcePath
127
+ })
128
+ }
129
+ }
130
+
131
+ for (
132
+ const path of await scanWorkspacePatterns(
133
+ params.cwd,
134
+ entry.include ?? [],
135
+ [
136
+ ...workspaceConfig.exclude,
137
+ ...(entry.exclude ?? [])
138
+ ]
139
+ )
140
+ ) {
141
+ const relativePath = resolveRelativePath(params.cwd, path)
142
+ if (relativePath === '') continue
143
+ addCandidate(candidates, {
144
+ name: entry.name,
145
+ description: entry.description,
146
+ cwd: path,
147
+ path: relativePath,
148
+ pattern: entry.include?.join(', '),
149
+ sourcePath
150
+ })
151
+ }
152
+ }
153
+
154
+ return assignWorkspaceIds(Array.from(candidates.values()))
155
+ .map(([id, candidate]) =>
156
+ createWorkspaceAsset({
157
+ id,
158
+ name: candidate.name,
159
+ description: candidate.description,
160
+ cwd: candidate.cwd,
161
+ path: candidate.path,
162
+ pattern: candidate.pattern,
163
+ sourcePath: candidate.sourcePath
164
+ })
165
+ )
166
+ }
167
+
168
+ export const findWorkspaceAsset = (
169
+ workspaces: Array<Extract<WorkspaceAsset, { kind: 'workspace' }>>,
170
+ ref: string
171
+ ) => {
172
+ const normalizedRef = normalizePath(ref.trim())
173
+ const matches = workspaces.filter(workspace =>
174
+ workspace.displayName === normalizedRef ||
175
+ workspace.name === normalizedRef ||
176
+ workspace.payload.id === normalizedRef ||
177
+ workspace.payload.path === normalizedRef ||
178
+ workspace.payload.name === normalizedRef
179
+ )
180
+
181
+ if (matches.length === 0) return undefined
182
+ if (matches.length > 1) {
183
+ throw new Error(
184
+ `Ambiguous workspace reference ${ref}. Candidates: ${matches.map(match => match.displayName).join(', ')}`
185
+ )
186
+ }
187
+ return matches[0]
188
+ }