minovative-mind-cli 2.0.0 → 2.1.0

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.
@@ -5,6 +5,20 @@ import { getAuthorizedIdToken } from './auth.js';
5
5
  import { debugLog } from '../utils/logger.js';
6
6
  import { GENERAL_CHAT_INSTRUCTION, PLAN_EXECUTION_INSTRUCTION, PLAN_MODE_INSTRUCTION, CONTEXT_SYSTEM_INSTRUCTION, INTENT_ROUTER_SYSTEM_INSTRUCTION, WEB_SEARCH_SYSTEM_INSTRUCTION, EXECUTION_COMPLEXITY_SYSTEM_INSTRUCTION, INVESTIGATION_COMPLEXITY_SYSTEM_INSTRUCTION, } from '../utils/systemPrompts.js';
7
7
  import { getMetricCollector } from './metrics.js';
8
+ import { workspaceRegistry } from './workspaceRegistry.js';
9
+ function getMultiWorkspaceBlock() {
10
+ const summary = workspaceRegistry.buildPromptSummary();
11
+ if (!summary)
12
+ return '';
13
+ return `<multi_workspace>
14
+ The user has registered external workspaces that you can access using the @alias/ prefix:
15
+ ${summary}
16
+
17
+ To read, modify, or search files in an external workspace, prefix the file path with the alias (e.g., "@backend/src/routes.ts"). To search across ALL workspaces, use grep_search with workspace="all".
18
+
19
+ When the user asks to "transfer", "sync", or "port" features between projects, read the source files from one workspace and apply the changes to the target.
20
+ </multi_workspace>`;
21
+ }
8
22
  // ─── Model Overrides (for Regression Testing) ────────────────────────
9
23
  let contextModelOverride = null;
10
24
  let contextTempOverride = null;
@@ -261,7 +275,7 @@ export function getGeneralChatConfig() {
261
275
  }
262
276
  export function getPlanExecutionConfig() {
263
277
  return {
264
- systemInstruction: PLAN_EXECUTION_INSTRUCTION,
278
+ systemInstruction: PLAN_EXECUTION_INSTRUCTION.replace('{{MULTI_WORKSPACE_BLOCK}}', getMultiWorkspaceBlock()),
265
279
  tools: [{ functionDeclarations: getToolDeclarations() }],
266
280
  };
267
281
  }
@@ -505,7 +519,7 @@ export function createContextAgentSession() {
505
519
  let model = contextModelOverride || getGlobalActiveModel();
506
520
  if (model === 'auto')
507
521
  model = GEMINI_MODELS.FLASH_3_5;
508
- return new ProxyChatSession(model, CONTEXT_SYSTEM_INSTRUCTION, contextTools, {
522
+ return new ProxyChatSession(model, CONTEXT_SYSTEM_INSTRUCTION.replace('{{MULTI_WORKSPACE_BLOCK}}', getMultiWorkspaceBlock()), contextTools, {
509
523
  maxOutputTokens: MAX_OUTPUT_TOKENS,
510
524
  temperature: contextTempOverride !== null ? contextTempOverride : 1,
511
525
  topP: 0.95,
@@ -192,12 +192,24 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
192
192
  if (!needsContext) {
193
193
  return { contextResult: null, targetAgent, chainedMessages: [] };
194
194
  }
195
- const projectTreeResult = await listDirectory(workspaceRoot, '.', 10);
196
- let projectTree = projectTreeResult.output;
197
- if (projectTree.length > 30000) {
198
- projectTree = projectTree.substring(0, 30000) + '\n... (Project tree truncated due to size)';
195
+ const { workspaceRegistry } = await import('./workspaceRegistry.js');
196
+ const allRoots = workspaceRegistry.getAllRoots(workspaceRoot);
197
+ let projectTree = '';
198
+ let primaryProjectType = 'Unknown';
199
+ for (const { alias, root } of allRoots) {
200
+ const label = alias ? `@${alias} (${root})` : `Primary Workspace (${root})`;
201
+ const treeResult = await listDirectory(root, '.', 10);
202
+ let tree = treeResult.output;
203
+ if (tree.length > 30000) {
204
+ tree = tree.substring(0, 30000) + '\\n... (Project tree truncated due to size)';
205
+ }
206
+ const type = await detectProjectType(root);
207
+ if (!alias) {
208
+ primaryProjectType = type;
209
+ }
210
+ projectTree += `=== ${label} ===\\nProject Type: ${type}\\n${tree}\\n\\n`;
199
211
  }
200
- const projectType = await detectProjectType(workspaceRoot);
212
+ const projectType = primaryProjectType;
201
213
  // ─── Parallel Investigation Gate ─────────────────────────────
202
214
  if (isSubAgentsEnabled()) {
203
215
  // Determine complexity and domain breakdown
@@ -11,7 +11,7 @@
11
11
  * components" + "Auth UI styling") and produces an `InvestigationResult` containing
12
12
  * the files it found and a summary of its findings.
13
13
  */
14
- import { ProxyChatSession, getContextToolDeclarations } from '../ai.js';
14
+ import { ProxyChatSession, getContextToolDeclarations, getGlobalActiveModel } from '../ai.js';
15
15
  import { GEMINI_MODELS, MAX_OUTPUT_TOKENS } from '../../utils/config.js';
16
16
  import { CONTEXT_SYSTEM_INSTRUCTION } from '../../utils/systemPrompts.js';
17
17
  import { debugLog } from '../../utils/logger.js';
@@ -47,7 +47,10 @@ export class InvestigationAgentRunner {
47
47
  this.readCache = readCache;
48
48
  this.projectTree = projectTree;
49
49
  this.projectType = projectType;
50
- this.chat = new ProxyChatSession(GEMINI_MODELS.FLASH_3_5, this.buildSystemInstruction(), getContextToolDeclarations(), {
50
+ let model = getGlobalActiveModel();
51
+ if (model === GEMINI_MODELS.AUTO)
52
+ model = GEMINI_MODELS.FLASH_3_5;
53
+ this.chat = new ProxyChatSession(model, this.buildSystemInstruction(), getContextToolDeclarations(), {
51
54
  maxOutputTokens: MAX_OUTPUT_TOKENS,
52
55
  temperature: 1,
53
56
  topP: 0.95,
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import * as p from '@clack/prompts';
12
12
  import pc from 'picocolors';
13
- import { ProxyChatSession } from '../ai.js';
13
+ import { ProxyChatSession, getGlobalActiveModel } from '../ai.js';
14
14
  import { GEMINI_MODELS, MAX_OUTPUT_TOKENS } from '../../utils/config.js';
15
15
  import { debugLog } from '../../utils/logger.js';
16
16
  import { MessageBus } from './messageBus.js';
@@ -61,7 +61,10 @@ export class Orchestrator {
61
61
  this.inputHandler = inputHandler;
62
62
  this.bus = new MessageBus(workspaceRoot, conversationId);
63
63
  this.locks = new FileLockRegistry();
64
- this.pmChat = new ProxyChatSession(GEMINI_MODELS.FLASH_3_5, PM_SYSTEM_INSTRUCTION, [], {
64
+ let model = getGlobalActiveModel();
65
+ if (model === GEMINI_MODELS.AUTO)
66
+ model = GEMINI_MODELS.FLASH_3_5;
67
+ this.pmChat = new ProxyChatSession(model, PM_SYSTEM_INSTRUCTION, [], {
65
68
  maxOutputTokens: MAX_OUTPUT_TOKENS,
66
69
  temperature: 0.1,
67
70
  responseMimeType: 'application/json',
@@ -4,7 +4,7 @@
4
4
  * Implements the lifecycle, health monitoring, and tool-loop execution for a single
5
5
  * parallelized sub-agent.
6
6
  */
7
- import { ProxyChatSession } from '../ai.js';
7
+ import { ProxyChatSession, getGlobalActiveModel } from '../ai.js';
8
8
  import { GEMINI_MODELS, MAX_OUTPUT_TOKENS } from '../../utils/config.js';
9
9
  import { executeScopedTool, getScopedToolDeclarations } from './scopedTools.js';
10
10
  import { debugLog } from '../../utils/logger.js';
@@ -32,8 +32,11 @@ export class SubAgentRunner {
32
32
  this.bus = bus;
33
33
  this.locks = locks;
34
34
  this.globalContext = globalContext;
35
+ let model = getGlobalActiveModel();
36
+ if (model === GEMINI_MODELS.AUTO)
37
+ model = GEMINI_MODELS.FLASH_3_5;
35
38
  // Sub-agents default to flash-3.5 for better reasoning capabilities
36
- this.chat = new ProxyChatSession(GEMINI_MODELS.FLASH_3_5, this.buildSystemInstruction(), [{ functionDeclarations: getScopedToolDeclarations() }], {
39
+ this.chat = new ProxyChatSession(model, this.buildSystemInstruction(), [{ functionDeclarations: getScopedToolDeclarations() }], {
37
40
  maxOutputTokens: MAX_OUTPUT_TOKENS,
38
41
  temperature: 0.3, // Lower temperature for more focused execution
39
42
  topP: 0.95,
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Represents a single registered external workspace entry in the global registry.
3
+ */
4
+ export interface RegisteredWorkspace {
5
+ /** User-chosen short alias (e.g., "backend", "shared-lib"). Used as the `@alias/` prefix. */
6
+ alias: string;
7
+ /** Validated, normalized absolute path on disk. */
8
+ absolutePath: string;
9
+ /** Unix epoch timestamp (ms) when this workspace was registered. */
10
+ registeredAt: number;
11
+ }
12
+ /**
13
+ * Result of resolving an `@alias/relative/path` string against the workspace registry.
14
+ */
15
+ export interface ResolvedWorkspacePath {
16
+ /** The alias that was matched (e.g., "backend"). */
17
+ alias: string;
18
+ /** The absolute root directory of the matched registered workspace. */
19
+ workspaceRoot: string;
20
+ /** The relative path within that workspace (e.g., "src/routes.ts"). */
21
+ relativePath: string;
22
+ /** The fully resolved absolute path to the target file or directory. */
23
+ absolutePath: string;
24
+ }
25
+ /**
26
+ * Service that manages a global registry of external workspace roots.
27
+ *
28
+ * Workspaces are registered with short aliases (e.g., "backend") and referenced
29
+ * in file paths using the `@alias/path` prefix syntax. The registry is persisted
30
+ * globally at `~/.minovative-mind-cli/workspaces.json` so registrations carry
31
+ * across different primary workspaces.
32
+ *
33
+ * @remarks
34
+ * This is a singleton — use the exported `workspaceRegistry` instance.
35
+ * All filesystem access goes through this service to maintain the security
36
+ * boundary: the agent can only touch the primary workspace and explicitly
37
+ * registered secondary workspaces.
38
+ */
39
+ declare class WorkspaceRegistry {
40
+ /** In-memory map of alias → registered workspace. */
41
+ private workspaces;
42
+ /** Whether the registry has been loaded from disk. */
43
+ private initialized;
44
+ /**
45
+ * Initializes the registry by loading persisted workspace entries from disk.
46
+ * Safe to call multiple times — subsequent calls are no-ops.
47
+ */
48
+ init(): void;
49
+ /**
50
+ * Registers a new external workspace root with the given alias.
51
+ *
52
+ * @param alias - Short identifier for the workspace (e.g., "backend").
53
+ * Must be lowercase alphanumeric with hyphens/underscores, 1–30 chars.
54
+ * @param absolutePath - Absolute path to the workspace root directory.
55
+ * Must exist on disk and be a directory.
56
+ * @returns The created `RegisteredWorkspace`, or throws on validation failure.
57
+ * @throws Error if the alias is invalid, the path doesn't exist, or the alias is already taken.
58
+ */
59
+ register(alias: string, absolutePath: string): RegisteredWorkspace;
60
+ /**
61
+ * Removes a registered workspace by alias.
62
+ *
63
+ * @param alias - The alias to unregister.
64
+ * @returns `true` if the workspace was found and removed, `false` otherwise.
65
+ */
66
+ unregister(alias: string): boolean;
67
+ /**
68
+ * Returns all registered workspaces as an array, sorted by alias.
69
+ */
70
+ list(): RegisteredWorkspace[];
71
+ /**
72
+ * Returns the number of registered workspaces.
73
+ */
74
+ get size(): number;
75
+ /**
76
+ * Checks whether any external workspaces are registered.
77
+ */
78
+ hasWorkspaces(): boolean;
79
+ /**
80
+ * Looks up a workspace by alias.
81
+ *
82
+ * @param alias - The alias to find (without the `@` prefix).
83
+ * @returns The registered workspace, or `undefined` if not found.
84
+ */
85
+ get(alias: string): RegisteredWorkspace | undefined;
86
+ /**
87
+ * Returns all workspace roots (primary + registered secondaries).
88
+ *
89
+ * @param primaryRoot - The primary workspace root (from `process.cwd()`).
90
+ * @returns An array of `{ alias: string | null, root: string }` entries.
91
+ * The primary workspace has `alias: null`.
92
+ */
93
+ getAllRoots(primaryRoot: string): Array<{
94
+ alias: string | null;
95
+ root: string;
96
+ }>;
97
+ /**
98
+ * Resolves an `@alias/relative/path` string into its constituent parts.
99
+ *
100
+ * @param filePath - A file path that may or may not start with `@alias/`.
101
+ * @returns A `ResolvedWorkspacePath` if the path has a valid `@alias/` prefix
102
+ * and the alias is registered, or `null` if the path is a standard
103
+ * workspace-relative path (no `@` prefix or unrecognized alias).
104
+ * @throws Error if the path has an `@` prefix but the alias is not registered.
105
+ */
106
+ resolve(filePath: string): ResolvedWorkspacePath | null;
107
+ /**
108
+ * Checks if a given file path uses the `@alias/` prefix syntax.
109
+ *
110
+ * @param filePath - The path to check.
111
+ * @returns `true` if the path starts with `@`.
112
+ */
113
+ isAliasedPath(filePath: string): boolean;
114
+ /**
115
+ * Builds a formatted summary of all registered workspaces for system prompt injection.
116
+ *
117
+ * @returns A human-readable string listing all aliases and their paths,
118
+ * or an empty string if no workspaces are registered.
119
+ */
120
+ buildPromptSummary(): string;
121
+ /**
122
+ * Loads the workspace registry from the global config file.
123
+ * Silently handles missing or corrupted files.
124
+ */
125
+ private loadFromDisk;
126
+ /**
127
+ * Persists the current workspace registry to the global config file.
128
+ * Creates the config directory if it doesn't exist.
129
+ */
130
+ private saveToDisk;
131
+ }
132
+ /**
133
+ * Singleton instance of the WorkspaceRegistry service.
134
+ * Initialize with `workspaceRegistry.init()` during CLI startup.
135
+ */
136
+ export declare const workspaceRegistry: WorkspaceRegistry;
137
+ export {};
@@ -0,0 +1,270 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { debugLog } from '../utils/logger.js';
4
+ /** Regex for valid alias names: lowercase alphanumeric, hyphens, underscores, 1–30 chars. */
5
+ const ALIAS_PATTERN = /^[a-z0-9][a-z0-9_-]{0,29}$/;
6
+ /** Global config directory for cross-project persistence. */
7
+ const GLOBAL_CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '~', '.minovative-mind-cli');
8
+ /** Path to the global workspace registry file. */
9
+ const REGISTRY_FILE = path.join(GLOBAL_CONFIG_DIR, 'workspaces.json');
10
+ /**
11
+ * Service that manages a global registry of external workspace roots.
12
+ *
13
+ * Workspaces are registered with short aliases (e.g., "backend") and referenced
14
+ * in file paths using the `@alias/path` prefix syntax. The registry is persisted
15
+ * globally at `~/.minovative-mind-cli/workspaces.json` so registrations carry
16
+ * across different primary workspaces.
17
+ *
18
+ * @remarks
19
+ * This is a singleton — use the exported `workspaceRegistry` instance.
20
+ * All filesystem access goes through this service to maintain the security
21
+ * boundary: the agent can only touch the primary workspace and explicitly
22
+ * registered secondary workspaces.
23
+ */
24
+ class WorkspaceRegistry {
25
+ /** In-memory map of alias → registered workspace. */
26
+ workspaces = new Map();
27
+ /** Whether the registry has been loaded from disk. */
28
+ initialized = false;
29
+ /**
30
+ * Initializes the registry by loading persisted workspace entries from disk.
31
+ * Safe to call multiple times — subsequent calls are no-ops.
32
+ */
33
+ init() {
34
+ if (this.initialized)
35
+ return;
36
+ this.initialized = true;
37
+ this.loadFromDisk();
38
+ }
39
+ /**
40
+ * Registers a new external workspace root with the given alias.
41
+ *
42
+ * @param alias - Short identifier for the workspace (e.g., "backend").
43
+ * Must be lowercase alphanumeric with hyphens/underscores, 1–30 chars.
44
+ * @param absolutePath - Absolute path to the workspace root directory.
45
+ * Must exist on disk and be a directory.
46
+ * @returns The created `RegisteredWorkspace`, or throws on validation failure.
47
+ * @throws Error if the alias is invalid, the path doesn't exist, or the alias is already taken.
48
+ */
49
+ register(alias, absolutePath) {
50
+ const normalizedAlias = alias.toLowerCase().trim();
51
+ // Validate alias format
52
+ if (!ALIAS_PATTERN.test(normalizedAlias)) {
53
+ throw new Error(`Invalid workspace alias "${normalizedAlias}". Must be 1-30 characters, ` +
54
+ `lowercase alphanumeric, hyphens, or underscores, starting with a letter or digit.`);
55
+ }
56
+ // Check for duplicate aliases
57
+ if (this.workspaces.has(normalizedAlias)) {
58
+ throw new Error(`Workspace alias "${normalizedAlias}" is already registered ` +
59
+ `(→ ${this.workspaces.get(normalizedAlias).absolutePath}). ` +
60
+ `Remove it first with /workspaces → Remove.`);
61
+ }
62
+ // Normalize and validate the path
63
+ const normalizedPath = path.resolve(absolutePath);
64
+ if (!fs.existsSync(normalizedPath)) {
65
+ throw new Error(`Path does not exist: "${normalizedPath}"`);
66
+ }
67
+ const stats = fs.statSync(normalizedPath);
68
+ if (!stats.isDirectory()) {
69
+ throw new Error(`Path is not a directory: "${normalizedPath}"`);
70
+ }
71
+ // Prevent registering sensitive system directories
72
+ const blockedRoots = ['/', '/etc', '/usr', '/bin', '/sbin', '/var', '/tmp', '/System', '/Library'];
73
+ if (blockedRoots.includes(normalizedPath) || normalizedPath === path.parse(normalizedPath).root) {
74
+ throw new Error(`Cannot register system root directory "${normalizedPath}". ` +
75
+ `Only project directories are allowed.`);
76
+ }
77
+ // Prevent duplicate paths under different aliases
78
+ for (const [existingAlias, existing] of this.workspaces) {
79
+ if (existing.absolutePath === normalizedPath) {
80
+ throw new Error(`This path is already registered under alias "${existingAlias}". ` +
81
+ `Remove it first if you want to re-register with a different alias.`);
82
+ }
83
+ }
84
+ const workspace = {
85
+ alias: normalizedAlias,
86
+ absolutePath: normalizedPath,
87
+ registeredAt: Date.now(),
88
+ };
89
+ this.workspaces.set(normalizedAlias, workspace);
90
+ this.saveToDisk();
91
+ debugLog(`Registered workspace: @${normalizedAlias} → ${normalizedPath}`);
92
+ return workspace;
93
+ }
94
+ /**
95
+ * Removes a registered workspace by alias.
96
+ *
97
+ * @param alias - The alias to unregister.
98
+ * @returns `true` if the workspace was found and removed, `false` otherwise.
99
+ */
100
+ unregister(alias) {
101
+ const normalizedAlias = alias.toLowerCase().trim();
102
+ const existed = this.workspaces.delete(normalizedAlias);
103
+ if (existed) {
104
+ this.saveToDisk();
105
+ debugLog(`Unregistered workspace: @${normalizedAlias}`);
106
+ }
107
+ return existed;
108
+ }
109
+ /**
110
+ * Returns all registered workspaces as an array, sorted by alias.
111
+ */
112
+ list() {
113
+ return Array.from(this.workspaces.values()).sort((a, b) => a.alias.localeCompare(b.alias));
114
+ }
115
+ /**
116
+ * Returns the number of registered workspaces.
117
+ */
118
+ get size() {
119
+ return this.workspaces.size;
120
+ }
121
+ /**
122
+ * Checks whether any external workspaces are registered.
123
+ */
124
+ hasWorkspaces() {
125
+ return this.workspaces.size > 0;
126
+ }
127
+ /**
128
+ * Looks up a workspace by alias.
129
+ *
130
+ * @param alias - The alias to find (without the `@` prefix).
131
+ * @returns The registered workspace, or `undefined` if not found.
132
+ */
133
+ get(alias) {
134
+ return this.workspaces.get(alias.toLowerCase().trim());
135
+ }
136
+ /**
137
+ * Returns all workspace roots (primary + registered secondaries).
138
+ *
139
+ * @param primaryRoot - The primary workspace root (from `process.cwd()`).
140
+ * @returns An array of `{ alias: string | null, root: string }` entries.
141
+ * The primary workspace has `alias: null`.
142
+ */
143
+ getAllRoots(primaryRoot) {
144
+ const roots = [{ alias: null, root: primaryRoot }];
145
+ for (const ws of this.workspaces.values()) {
146
+ // Skip if a registered workspace happens to be the same as the primary
147
+ if (ws.absolutePath !== path.resolve(primaryRoot)) {
148
+ roots.push({ alias: ws.alias, root: ws.absolutePath });
149
+ }
150
+ }
151
+ return roots;
152
+ }
153
+ /**
154
+ * Resolves an `@alias/relative/path` string into its constituent parts.
155
+ *
156
+ * @param filePath - A file path that may or may not start with `@alias/`.
157
+ * @returns A `ResolvedWorkspacePath` if the path has a valid `@alias/` prefix
158
+ * and the alias is registered, or `null` if the path is a standard
159
+ * workspace-relative path (no `@` prefix or unrecognized alias).
160
+ * @throws Error if the path has an `@` prefix but the alias is not registered.
161
+ */
162
+ resolve(filePath) {
163
+ if (!filePath.startsWith('@')) {
164
+ return null;
165
+ }
166
+ // Extract alias and relative path from "@alias/relative/path"
167
+ const withoutAt = filePath.substring(1);
168
+ const slashIndex = withoutAt.indexOf('/');
169
+ const alias = slashIndex === -1 ? withoutAt : withoutAt.substring(0, slashIndex);
170
+ const relativePath = slashIndex === -1 ? '.' : withoutAt.substring(slashIndex + 1);
171
+ const workspace = this.workspaces.get(alias.toLowerCase());
172
+ if (!workspace) {
173
+ throw new Error(`Unknown workspace alias "@${alias}". ` +
174
+ `Registered workspaces: ${this.list().map((w) => `@${w.alias}`).join(', ') || '(none)'}. ` +
175
+ `Use /workspaces to add one.`);
176
+ }
177
+ const absolutePath = path.resolve(workspace.absolutePath, relativePath);
178
+ return {
179
+ alias: workspace.alias,
180
+ workspaceRoot: workspace.absolutePath,
181
+ relativePath,
182
+ absolutePath,
183
+ };
184
+ }
185
+ /**
186
+ * Checks if a given file path uses the `@alias/` prefix syntax.
187
+ *
188
+ * @param filePath - The path to check.
189
+ * @returns `true` if the path starts with `@`.
190
+ */
191
+ isAliasedPath(filePath) {
192
+ return filePath.startsWith('@');
193
+ }
194
+ /**
195
+ * Builds a formatted summary of all registered workspaces for system prompt injection.
196
+ *
197
+ * @returns A human-readable string listing all aliases and their paths,
198
+ * or an empty string if no workspaces are registered.
199
+ */
200
+ buildPromptSummary() {
201
+ if (this.workspaces.size === 0)
202
+ return '';
203
+ const lines = this.list().map((ws) => `- @${ws.alias} → ${ws.absolutePath}`);
204
+ return lines.join('\n');
205
+ }
206
+ // ─── Persistence ───────────────────────────────────────────────────
207
+ /**
208
+ * Loads the workspace registry from the global config file.
209
+ * Silently handles missing or corrupted files.
210
+ */
211
+ loadFromDisk() {
212
+ try {
213
+ if (!fs.existsSync(REGISTRY_FILE)) {
214
+ debugLog('Workspace registry file not found, starting fresh.');
215
+ return;
216
+ }
217
+ const raw = fs.readFileSync(REGISTRY_FILE, 'utf-8');
218
+ const data = JSON.parse(raw);
219
+ if (!Array.isArray(data)) {
220
+ debugLog('Workspace registry file has invalid format, starting fresh.');
221
+ return;
222
+ }
223
+ // Validate each entry and skip invalid ones
224
+ for (const entry of data) {
225
+ if (typeof entry.alias === 'string' &&
226
+ typeof entry.absolutePath === 'string' &&
227
+ ALIAS_PATTERN.test(entry.alias)) {
228
+ // Only load if the directory still exists
229
+ if (fs.existsSync(entry.absolutePath)) {
230
+ this.workspaces.set(entry.alias, entry);
231
+ }
232
+ else {
233
+ debugLog(`Skipping stale workspace @${entry.alias} — path no longer exists: ${entry.absolutePath}`);
234
+ }
235
+ }
236
+ }
237
+ debugLog(`Loaded ${this.workspaces.size} workspace(s) from global registry.`);
238
+ }
239
+ catch (err) {
240
+ debugLog(`Failed to load workspace registry: ${err instanceof Error ? err.message : String(err)}`);
241
+ }
242
+ }
243
+ /**
244
+ * Persists the current workspace registry to the global config file.
245
+ * Creates the config directory if it doesn't exist.
246
+ */
247
+ saveToDisk() {
248
+ try {
249
+ // Ensure the global config directory exists
250
+ if (!fs.existsSync(GLOBAL_CONFIG_DIR)) {
251
+ fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
252
+ }
253
+ const data = Array.from(this.workspaces.values());
254
+ const tempPath = `${REGISTRY_FILE}.${Date.now()}.tmp`;
255
+ fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf-8');
256
+ // Atomic rename to prevent corruption
257
+ fs.renameSync(tempPath, REGISTRY_FILE);
258
+ debugLog(`Saved ${data.length} workspace(s) to global registry.`);
259
+ }
260
+ catch (err) {
261
+ debugLog(`Failed to save workspace registry: ${err instanceof Error ? err.message : String(err)}`);
262
+ // Non-fatal: the in-memory state is still correct
263
+ }
264
+ }
265
+ }
266
+ /**
267
+ * Singleton instance of the WorkspaceRegistry service.
268
+ * Initialize with `workspaceRegistry.init()` during CLI startup.
269
+ */
270
+ export const workspaceRegistry = new WorkspaceRegistry();
@@ -43,7 +43,13 @@ ${context.webSearchSummary}
43
43
  injection += `\n## Relevant File Contents\n`;
44
44
  for (const [filePath, contentObj] of context.relevantFiles.entries()) {
45
45
  const contentText = contentObj.text;
46
- injection += `<workspace_file path="${filePath}">
46
+ let aliasAttr = '';
47
+ if (filePath.startsWith('@')) {
48
+ const slashIndex = filePath.indexOf('/');
49
+ const alias = slashIndex === -1 ? filePath.substring(1) : filePath.substring(1, slashIndex);
50
+ aliasAttr = ` workspace="${alias}"`;
51
+ }
52
+ injection += `<workspace_file path="${filePath}"${aliasAttr}>
47
53
  <content_data><![CDATA[
48
54
  ${sanitizeForCDATA(contentText)}
49
55
  ]]\\u200B></content_data>
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Result of resolving a path in a multi-workspace environment.
3
+ * Contains the resolved absolute path along with metadata about which workspace was matched.
4
+ */
5
+ export interface ResolvedPath {
6
+ /** The fully resolved, validated absolute path. */
7
+ absolutePath: string;
8
+ /** The workspace root directory that this path resolved against. */
9
+ workspaceRoot: string;
10
+ /** The relative path within the matched workspace. */
11
+ relativePath: string;
12
+ /** The workspace alias if an external workspace was matched, or `null` for the primary workspace. */
13
+ alias: string | null;
14
+ }
1
15
  /**
2
16
  * Resolves a file path against the workspace root and ensures it does not
3
17
  * break out of the workspace directory (path traversal defense).
@@ -8,3 +22,20 @@
8
22
  * @throws Error if the resolved path is outside the workspace
9
23
  */
10
24
  export declare function resolveAndValidatePath(workspaceRoot: string, filePath: string): string;
25
+ /**
26
+ * Resolves a file path that may use the `@alias/path` multi-workspace prefix syntax.
27
+ *
28
+ * If the path starts with `@`, it is resolved against the matching registered workspace.
29
+ * Otherwise, it falls through to standard single-workspace resolution against `primaryRoot`.
30
+ *
31
+ * Security guarantees are identical to `resolveAndValidatePath`:
32
+ * - Path traversal via `..` is blocked (resolved path must remain within the matched root)
33
+ * - Absolute paths (outside `@alias/` syntax) are rejected
34
+ * - Only explicitly registered workspace roots are accessible
35
+ *
36
+ * @param primaryRoot - The primary workspace root (from `process.cwd()`).
37
+ * @param filePath - A file path that may or may not use `@alias/` prefix syntax.
38
+ * @returns A `ResolvedPath` with the absolute path and workspace metadata.
39
+ * @throws Error on path traversal, unknown alias, or invalid path.
40
+ */
41
+ export declare function resolveAndValidateMultiWorkspacePath(primaryRoot: string, filePath: string): ResolvedPath;
@@ -1,4 +1,5 @@
1
1
  import * as path from 'path';
2
+ import { workspaceRegistry } from '../services/workspaceRegistry.js';
2
3
  /**
3
4
  * Resolves a file path against the workspace root and ensures it does not
4
5
  * break out of the workspace directory (path traversal defense).
@@ -24,3 +25,50 @@ export function resolveAndValidatePath(workspaceRoot, filePath) {
24
25
  }
25
26
  return normalizedResolved;
26
27
  }
28
+ /**
29
+ * Resolves a file path that may use the `@alias/path` multi-workspace prefix syntax.
30
+ *
31
+ * If the path starts with `@`, it is resolved against the matching registered workspace.
32
+ * Otherwise, it falls through to standard single-workspace resolution against `primaryRoot`.
33
+ *
34
+ * Security guarantees are identical to `resolveAndValidatePath`:
35
+ * - Path traversal via `..` is blocked (resolved path must remain within the matched root)
36
+ * - Absolute paths (outside `@alias/` syntax) are rejected
37
+ * - Only explicitly registered workspace roots are accessible
38
+ *
39
+ * @param primaryRoot - The primary workspace root (from `process.cwd()`).
40
+ * @param filePath - A file path that may or may not use `@alias/` prefix syntax.
41
+ * @returns A `ResolvedPath` with the absolute path and workspace metadata.
42
+ * @throws Error on path traversal, unknown alias, or invalid path.
43
+ */
44
+ export function resolveAndValidateMultiWorkspacePath(primaryRoot, filePath) {
45
+ // ─── @alias/ Resolution Path ─────────────────────────────────────
46
+ if (filePath.startsWith('@')) {
47
+ const resolved = workspaceRegistry.resolve(filePath);
48
+ if (!resolved) {
49
+ // resolve() returns null if the alias is not recognized — this shouldn't
50
+ // happen because resolve() throws on unknown aliases, but guard defensively.
51
+ throw new Error(`Failed to resolve workspace path: "${filePath}"`);
52
+ }
53
+ // Apply the same traversal check: resolved path must stay within the workspace root
54
+ const normalizedRoot = path.normalize(resolved.workspaceRoot);
55
+ const normalizedResolved = path.normalize(resolved.absolutePath);
56
+ if (!normalizedResolved.startsWith(normalizedRoot + path.sep) && normalizedResolved !== normalizedRoot) {
57
+ throw new Error(`Path security violation: Path "${filePath}" escapes workspace @${resolved.alias} root.`);
58
+ }
59
+ return {
60
+ absolutePath: normalizedResolved,
61
+ workspaceRoot: resolved.workspaceRoot,
62
+ relativePath: resolved.relativePath,
63
+ alias: resolved.alias,
64
+ };
65
+ }
66
+ // ─── Standard Single-Workspace Path ──────────────────────────────
67
+ const absolutePath = resolveAndValidatePath(primaryRoot, filePath);
68
+ return {
69
+ absolutePath,
70
+ workspaceRoot: primaryRoot,
71
+ relativePath: filePath,
72
+ alias: null,
73
+ };
74
+ }