vexp-cli 2.2.1 → 2.2.3

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.
@@ -58,6 +58,12 @@ esac
58
58
  * Error aborts the tool call. Written to `.opencode/plugins/vexp-guard.js` by
59
59
  * `vexp setup-agents` (opencode auto-loads that directory at startup).
60
60
  *
61
+ * The SAME file is installed for Kilo Code at `.kilo/plugins/vexp-guard.js`
62
+ * (installKiloPlugin), because Kilo v7 is opencode vendored — its plugin loader
63
+ * lives at packages/opencode/src/config/plugin.ts inside Kilo-Org/kilocode, so
64
+ * the hook name, the `input.tool` ids and the grep/glob arg schemas are the same
65
+ * code and not merely a compatible API.
66
+ *
61
67
  * It blocks the native `grep`/`glob` tools AND shelled-out tree search
62
68
  * (grep/rg/find/... via bash) WHILE the vexp daemon is healthy, and fails OPEN
63
69
  * when the daemon is down so search still works without an index — mirroring
@@ -66,16 +72,31 @@ esac
66
72
  * The literal body is deliberately ASCII-only and free of backticks, `${...}`
67
73
  * and backslash escapes so it can be embedded verbatim in a TS template literal
68
74
  * (the VS Code extension keeps a byte-identical copy, enforced by the lockstep
69
- * test in packages/vexp-cli/test/hook-template.test.ts). Do NOT introduce any
75
+ * test in packages/vexp-cli/test/guard-lockstep.test.ts — which is where that
76
+ * guarantee actually lives; this comment used to point at hook-template.test.ts,
77
+ * which only ever compared the CLI constant with itself). Do NOT introduce any
70
78
  * of those characters here without updating that test's un-escape logic.
71
79
  */
72
- export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode plugin (generated by 'vexp setup-agents').
73
- // Forces the agent onto vexp's run_pipeline by blocking opencode's native
74
- // grep/glob (and shelled-out tree search) WHILE the vexp daemon is healthy.
80
+ export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode / Kilo Code plugin (generated by 'vexp setup-agents'; it is
81
+ // rewritten on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak
82
+ // first). Kilo v7 vendors opencode, so one plugin serves both unchanged.
83
+ //
84
+ // Blocks opencode's native grep/glob ONLY where vexp has a better answer: indexed
85
+ // source inside the workspace, while the daemon is healthy. Two deliberate limits:
86
+ //
87
+ // 1. It never touches bash. Shelling out is the escape hatch for everything vexp
88
+ // cannot index - runtime logs, build output, files outside the repo. vexp's
89
+ // Claude Code guard has always matched Grep|Glob|Regex only, and it works
90
+ // precisely because that valve stays open. Blocking shell search instead
91
+ // teaches the agent to evade (any prefix defeats it) and strands it when the
92
+ // answer genuinely is not in the index.
93
+ // 2. It only blocks targets vexp actually indexed. A search aimed at a log file,
94
+ // at dist/ or node_modules, or outside the workspace has no run_pipeline
95
+ // answer, so blocking it would be a dead end rather than a redirect.
96
+ //
75
97
  // Fails OPEN when the daemon is down, so native search still works with no index.
76
- // Mirrors vexp's Claude Code PreToolUse guard (.claude/hooks/vexp-guard.sh).
77
98
  import { existsSync, readFileSync } from "node:fs";
78
- import { join, dirname } from "node:path";
99
+ import { join, dirname, relative, isAbsolute, sep } from "node:path";
79
100
 
80
101
  // Walk up from start to the first ancestor that owns a .vexp dir.
81
102
  function findVexpDir(start) {
@@ -108,36 +129,248 @@ function daemonHealthy(vexpDir) {
108
129
  }
109
130
 
110
131
  const SEARCH_TOOLS = new Set(["grep", "glob"]);
111
- const SHELL_SEARCH_BINS = ["rg", "grep", "egrep", "fgrep", "ag", "ack", "find", "fd", "fdfind"];
112
132
 
113
- // True when a bash command's first token is a tree-search binary (grep/rg/find).
114
- function isShellSearch(command) {
115
- const t = (command || "").trim();
116
- for (const b of SHELL_SEARCH_BINS) {
117
- if (t === b || t.indexOf(b + " ") === 0) return true;
133
+ // Directory names vexp never indexes: build output and vendored dependencies.
134
+ const NON_INDEXED_DIRS = [
135
+ "node_modules", "dist", "build", "out", "coverage", "target", "vendor",
136
+ ".vite", ".next", ".nuxt", ".output", ".turbo", ".cache", ".git", ".vexp"
137
+ ];
138
+
139
+ // File kinds vexp never indexes. Logs are the case agents legitimately grep the
140
+ // most while debugging a running app. "-lock." is not redundant with ".lock":
141
+ // the two commonest JS lockfiles (package-lock.json, pnpm-lock.yaml) have no dot
142
+ // before "lock", so ".lock" alone let yarn.lock and Cargo.lock through while
143
+ // blocking them.
144
+ const NON_INDEXED_HINTS = [".log", ".lock", "-lock.", ".map", ".min.js"];
145
+
146
+ // Is this search target inside what vexp indexed? Only then is "call
147
+ // run_pipeline instead" real advice rather than a dead end. Anything that cannot
148
+ // be resolved confidently returns false (allow) - under-blocking just leaves the
149
+ // agent on grep, over-blocking strands it with no tool at all.
150
+ function isIndexedTarget(root, target) {
151
+ if (!target) return true; // no path given: a whole-workspace search is vexp's job
152
+ const s = String(target);
153
+ const low = s.toLowerCase();
154
+ for (const h of NON_INDEXED_HINTS) if (low.indexOf(h) !== -1) return false;
155
+ let rel;
156
+ try {
157
+ rel = relative(root, isAbsolute(s) ? s : join(root, s));
158
+ } catch (e) {
159
+ return false;
118
160
  }
119
- return false;
161
+ if (rel === "..") return false;
162
+ if (rel.indexOf(".." + sep) === 0) return false; // escapes the workspace
163
+ if (isAbsolute(rel)) return false; // different root or drive
164
+ const segs = rel.split(sep);
165
+ for (const g of segs) if (NON_INDEXED_DIRS.indexOf(g) !== -1) return false;
166
+ return true;
167
+ }
168
+
169
+ // Where a search is aimed. grep carries the regex in its pattern arg (never a
170
+ // path), so only path and the include filter describe its target; for glob the
171
+ // pattern IS the path.
172
+ function searchTargets(tool, args) {
173
+ if (!args) return [];
174
+ const out = [];
175
+ if (args.path) out.push(args.path);
176
+ if (tool === "glob" && args.pattern) out.push(args.pattern);
177
+ if (tool === "grep" && args.include) out.push(args.include);
178
+ return out;
120
179
  }
121
180
 
122
181
  export const VexpGuard = async ({ directory, worktree }) => {
123
- const root = worktree || directory || ".";
182
+ const start = worktree || directory || ".";
124
183
  let vexpDir = null;
125
184
  return {
126
185
  "tool.execute.before": async (input, output) => {
127
- if (!vexpDir) vexpDir = findVexpDir(root);
128
- if (!daemonHealthy(vexpDir)) return;
129
- if (SEARCH_TOOLS.has(input.tool)) {
130
- throw new Error(
131
- "vexp daemon is running - call run_pipeline instead of " + input.tool +
132
- " (pre-indexed, graph-ranked, ~60% fewer tokens)."
133
- );
134
- }
135
- if (input.tool === "bash" && isShellSearch(output && output.args && output.args.command)) {
136
- throw new Error(
137
- "vexp daemon is running - call run_pipeline instead of shell search (grep/rg/find)."
138
- );
186
+ if (!vexpDir) vexpDir = findVexpDir(start);
187
+ if (!daemonHealthy(vexpDir)) return; // no live index -> allow
188
+ if (!SEARCH_TOOLS.has(input.tool)) return; // bash and everything else: never blocked
189
+ const root = dirname(vexpDir);
190
+ const targets = searchTargets(input.tool, output && output.args);
191
+ for (const t of targets) {
192
+ if (!isIndexedTarget(root, t)) return; // vexp has no answer here -> allow
139
193
  }
194
+ throw new Error(
195
+ "vexp daemon is running. AGENT INSTRUCTION: call run_pipeline instead of " +
196
+ input.tool + " - it returns pre-indexed, graph-ranked context in one call." +
197
+ " This is policy, not a transient failure: do NOT work around it with shell" +
198
+ " search or by writing a script."
199
+ );
140
200
  },
141
201
  };
142
202
  };
143
203
  `;
204
+ /**
205
+ * Cursor `preToolUse` hook. Same policy as the Claude Code hook and the
206
+ * opencode/Kilo plugin — block text search while the vexp daemon is healthy,
207
+ * never touch the shell, never block a target vexp did not index — expressed in
208
+ * the third dialect we now have to speak.
209
+ *
210
+ * Written in NODE, not bash, and that is deliberate. The Claude Code guard is a
211
+ * bash script and it silently enforced NOTHING on Windows for several releases
212
+ * (`[ -S "$SOCK" ]` can never match a named pipe), which is the single most
213
+ * expensive bug this file has produced. Cursor documents no PowerShell variant
214
+ * of `command` and does not say which shell runs it on Windows, so a bash guard
215
+ * here would be that bug again by construction. Anyone who installed vexp has
216
+ * node on PATH; a node script is the one interpreter we can count on everywhere.
217
+ *
218
+ * Denial is emitted on BOTH documented channels — the stdout verdict object and
219
+ * exit code 2 — because Cursor's docs describe both and we cannot test which one
220
+ * this build actually honours. Belt and braces on a schema we are trusting on
221
+ * documentation alone.
222
+ *
223
+ * Fails OPEN in every uncertain case: daemon down, unparseable input, an unknown
224
+ * payload shape. An over-eager guard strands the agent with no way to look at
225
+ * anything; an under-eager one costs tokens. Those are not symmetric.
226
+ *
227
+ * ASCII-only, no backticks and no ${...}: it is embedded verbatim in a TS
228
+ * template literal in two packages (lockstep test in test/guard-lockstep.test.ts).
229
+ */
230
+ export const VEXP_CURSOR_GUARD = `#!/usr/bin/env node
231
+ // vexp-guard - Cursor preToolUse hook (generated by 'vexp setup-agents'; rewritten
232
+ // on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak first).
233
+ //
234
+ // Blocks Cursor's Grep tool ONLY where vexp has a better answer: indexed source
235
+ // inside the workspace, while the daemon is healthy. Never blocks Shell - that
236
+ // valve is what keeps the agent able to reach logs, build output and anything
237
+ // outside the repo, and blocking it teaches evasion instead of redirection.
238
+ //
239
+ // NOTE: Cursor's native semantic codebase search is NOT exposed to hooks, so it
240
+ // cannot be redirected. This guard covers text search only.
241
+ const fs = require("node:fs");
242
+ const path = require("node:path");
243
+
244
+ function findVexpDir(start) {
245
+ let dir = start;
246
+ for (;;) {
247
+ if (fs.existsSync(path.join(dir, ".vexp"))) return path.join(dir, ".vexp");
248
+ const parent = path.dirname(dir);
249
+ if (parent === dir) return null;
250
+ dir = parent;
251
+ }
252
+ }
253
+
254
+ function pidAlive(pidFile) {
255
+ let pid = 0;
256
+ try { pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); } catch (e) { return false; }
257
+ if (!pid) return false;
258
+ try { process.kill(pid, 0); return true; } catch (e) { return !!(e && e.code === "EPERM"); }
259
+ }
260
+
261
+ // The daemon writes .vexp/healthy when up and removes it on graceful shutdown.
262
+ // Unix also requires a live socket AND a live pid: healthy + socket both linger
263
+ // after a kill -9, so the pid check is what makes fail-open actually work.
264
+ // Windows has no socket file, so the daemon drops .vexp/daemon.pipe instead.
265
+ function daemonHealthy(vexpDir) {
266
+ if (!vexpDir || !fs.existsSync(path.join(vexpDir, "healthy"))) return false;
267
+ if (process.platform === "win32") return fs.existsSync(path.join(vexpDir, "daemon.pipe"));
268
+ return fs.existsSync(path.join(vexpDir, "daemon.sock")) && pidAlive(path.join(vexpDir, "daemon.pid"));
269
+ }
270
+
271
+ const NON_INDEXED_DIRS = [
272
+ "node_modules", "dist", "build", "out", "coverage", "target", "vendor",
273
+ ".vite", ".next", ".nuxt", ".output", ".turbo", ".cache", ".git", ".vexp"
274
+ ];
275
+ const NON_INDEXED_HINTS = [".log", ".lock", "-lock.", ".map", ".min.js"];
276
+
277
+ // Is this search target inside what vexp indexed? Only then is "call
278
+ // run_pipeline instead" real advice rather than a dead end. Anything that cannot
279
+ // be resolved confidently returns false (allow).
280
+ function isIndexedTarget(root, target) {
281
+ if (!target) return true; // no path given: a whole-workspace search is vexp's job
282
+ const s = String(target);
283
+ const low = s.toLowerCase();
284
+ for (const h of NON_INDEXED_HINTS) if (low.indexOf(h) !== -1) return false;
285
+ let rel;
286
+ try {
287
+ rel = path.relative(root, path.isAbsolute(s) ? s : path.join(root, s));
288
+ } catch (e) {
289
+ return false;
290
+ }
291
+ if (rel === "..") return false;
292
+ if (rel.indexOf(".." + path.sep) === 0) return false; // escapes the workspace
293
+ if (path.isAbsolute(rel)) return false; // different root or drive
294
+ const segs = rel.split(path.sep);
295
+ for (const g of segs) if (NON_INDEXED_DIRS.indexOf(g) !== -1) return false;
296
+ return true;
297
+ }
298
+
299
+ function allow() {
300
+ process.stdout.write(JSON.stringify({ permission: "allow" }));
301
+ process.exit(0);
302
+ }
303
+
304
+ function deny(toolName) {
305
+ const reason =
306
+ "vexp daemon is running. AGENT INSTRUCTION: call run_pipeline instead of " +
307
+ toolName + " - it returns pre-indexed, graph-ranked context in one call." +
308
+ " This is policy, not a transient failure: do NOT work around it with shell" +
309
+ " search or by writing a script.";
310
+ // Both documented channels: the verdict object AND exit 2.
311
+ process.stdout.write(JSON.stringify({
312
+ permission: "deny",
313
+ agent_message: reason,
314
+ user_message: "vexp blocked a text search; the agent was told to use run_pipeline."
315
+ }));
316
+ process.stderr.write(reason);
317
+ process.exit(2);
318
+ }
319
+
320
+ // Cursor documents tool_input for Shell only; the payload shape for a Grep call
321
+ // appears NOWHERE in its docs. Rather than guess key names - a guess that fires,
322
+ // reads undefined and then blocks everything or nothing - we do not read keys at
323
+ // all. We scan the VALUES of tool_input for anything path-shaped. That is immune
324
+ // to whatever the keys turn out to be called, and to them being renamed later.
325
+ //
326
+ // The search pattern itself is one of those values and may look like a path. It
327
+ // can only ever push us toward ALLOW (a pattern mentioning node_modules reads as
328
+ // a non-indexed target), never toward a wrongful block - which is the direction
329
+ // we want to be wrong in.
330
+ function candidateTargets(toolInput) {
331
+ if (!toolInput) return [];
332
+ // MCP hooks receive tool_input as a JSON *string*, so the type is not uniform.
333
+ let obj = toolInput;
334
+ if (typeof obj === "string") {
335
+ try { obj = JSON.parse(obj); } catch (e) { return []; }
336
+ }
337
+ if (typeof obj !== "object") return [];
338
+ // EVERY string value, with no "does this look like a path" pre-filter. An
339
+ // earlier version required a separator or a dot and therefore missed bare
340
+ // directory names - "dist", "build", "node_modules" - which are exactly the
341
+ // targets the carve-out exists to protect. Handing the search pattern itself
342
+ // to isIndexedTarget is harmless: a pattern that reads as non-indexed only
343
+ // ever produces an ALLOW, and a pattern that reads as indexed changes nothing.
344
+ const out = [];
345
+ for (const v of Object.values(obj)) {
346
+ if (typeof v === "string" && v) out.push(v);
347
+ }
348
+ return out;
349
+ }
350
+
351
+ function main(input) {
352
+ const toolName = input && input.tool_name;
353
+ // Grep only. Shell is never blocked - it is the valve for everything vexp
354
+ // cannot index - and Read is never blocked because an agent must read to edit.
355
+ if (toolName !== "Grep") return allow();
356
+
357
+ const roots = (input && input.workspace_roots) || [];
358
+ const start = roots[0] || (input && input.cwd) || process.cwd();
359
+ const vexpDir = findVexpDir(start);
360
+ if (!daemonHealthy(vexpDir)) return allow(); // no live index -> allow
361
+ const root = path.dirname(vexpDir);
362
+
363
+ for (const t of candidateTargets(input.tool_input)) {
364
+ if (!isIndexedTarget(root, t)) return allow(); // vexp has no answer here
365
+ }
366
+ deny(toolName);
367
+ }
368
+
369
+ let raw = "";
370
+ process.stdin.on("data", (c) => { raw += c; });
371
+ process.stdin.on("end", () => {
372
+ let input;
373
+ try { input = JSON.parse(raw); } catch (e) { return allow(); }
374
+ try { main(input); } catch (e) { allow(); } // any surprise -> fail open
375
+ });
376
+ `;
@@ -0,0 +1,166 @@
1
+ import * as path from "path";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ /** Expand a leading `~` — readline input arrives unexpanded, unlike a shell arg. */
5
+ export function expandHome(p) {
6
+ if (p === "~")
7
+ return os.homedir();
8
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
9
+ return path.join(os.homedir(), p.slice(2));
10
+ }
11
+ return p;
12
+ }
13
+ /**
14
+ * Resolve a `repos[].path` entry like vexp-core's `resolve_repo_path`:
15
+ * absolute paths are preserved, relative paths resolve against the workspace
16
+ * ROOT. Configs written before 2.2.1 stored paths relative to `.vexp/`, so
17
+ * when the root-relative answer is not a directory we fall back to the legacy
18
+ * base — same order as the Rust resolver, so list/add agree with the daemon.
19
+ */
20
+ export function resolveRepoEntryPath(workspaceRoot, raw) {
21
+ if (path.isAbsolute(raw))
22
+ return path.normalize(raw);
23
+ const fromRoot = path.resolve(workspaceRoot, raw);
24
+ if (fs.existsSync(fromRoot))
25
+ return fromRoot;
26
+ const fromLegacy = path.resolve(workspaceRoot, ".vexp", raw);
27
+ if (fs.existsSync(fromLegacy))
28
+ return fromLegacy;
29
+ return fromRoot;
30
+ }
31
+ function readWorkspaceConfig(configPath) {
32
+ try {
33
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
34
+ if (!parsed || !Array.isArray(parsed.repos))
35
+ return null;
36
+ return parsed;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * If `childRoot` is a secondary repo of a multi-repo workspace, return the
44
+ * parent workspace info. The link is `.vexp/parent_workspace.json` (written on
45
+ * add, both by the VS Code plugin and by `addRepoToWorkspace` below).
46
+ *
47
+ * Returns null — i.e. "treat this repo as standalone" — unless the parent
48
+ * config still exists AND still lists this repo: a stale parent_workspace.json
49
+ * left behind after the repo was disconnected must not hijack the CLI.
50
+ */
51
+ export function resolveParentWorkspace(childRoot) {
52
+ let configPath;
53
+ try {
54
+ const raw = JSON.parse(fs.readFileSync(path.join(childRoot, ".vexp", "parent_workspace.json"), "utf-8"));
55
+ if (typeof raw?.workspace_config !== "string")
56
+ return null;
57
+ configPath = raw.workspace_config;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ if (!fs.existsSync(configPath))
63
+ return null;
64
+ // workspace.json lives in <parentRoot>/.vexp/
65
+ const parentRoot = path.dirname(path.dirname(configPath));
66
+ if (path.resolve(parentRoot) === path.resolve(childRoot))
67
+ return null;
68
+ const config = readWorkspaceConfig(configPath);
69
+ if (!config)
70
+ return null;
71
+ const childResolved = path.resolve(childRoot);
72
+ const listed = config.repos.some((r) => {
73
+ if (typeof r?.path !== "string")
74
+ return false;
75
+ return path.resolve(resolveRepoEntryPath(parentRoot, r.path)) === childResolved;
76
+ });
77
+ if (!listed)
78
+ return null;
79
+ return {
80
+ parentRoot,
81
+ workspaceName: config.name ?? path.basename(parentRoot),
82
+ configPath,
83
+ };
84
+ }
85
+ /**
86
+ * Repos connected to the workspace at `workspaceRoot`. Empty array when there
87
+ * is no workspace.json (single-repo workspace).
88
+ */
89
+ export function listWorkspaceRepos(workspaceRoot) {
90
+ const configPath = path.join(workspaceRoot, ".vexp", "workspace.json");
91
+ const config = readWorkspaceConfig(configPath);
92
+ if (!config)
93
+ return [];
94
+ const rootResolved = path.resolve(workspaceRoot);
95
+ return config.repos
96
+ .filter((r) => typeof r?.path === "string" && typeof r?.alias === "string")
97
+ .map((r) => {
98
+ const resolvedPath = resolveRepoEntryPath(workspaceRoot, r.path);
99
+ return {
100
+ alias: r.alias,
101
+ rawPath: r.path,
102
+ resolvedPath,
103
+ isPrimary: path.resolve(resolvedPath) === rootResolved,
104
+ exists: fs.existsSync(resolvedPath),
105
+ };
106
+ });
107
+ }
108
+ /**
109
+ * Connect `newRepoPath` to the workspace at `workspaceRoot` — the exact write
110
+ * sequence of the VS Code `vexp.setupMultiRepo` command:
111
+ * 1. plan-limit guard BEFORE writing anything (maxRepos, 0 = unlimited)
112
+ * 2. read-or-create workspace.json (primary entry is `"."` — the 2.2.1+
113
+ * root-relative convention)
114
+ * 3. append `{ alias, path: <absolute> }` unless already present
115
+ * 4. write `.vexp/parent_workspace.json` in the child so opening the child
116
+ * resolves back to this workspace
117
+ *
118
+ * Indexing is NOT triggered here — the caller decides whether to restart the
119
+ * daemon (mirrors the plugin's "Restart / Later" prompt).
120
+ */
121
+ export function addRepoToWorkspace(workspaceRoot, newRepoPath, maxRepos) {
122
+ const resolved = path.resolve(expandHome(newRepoPath.trim()));
123
+ let stat;
124
+ try {
125
+ stat = fs.statSync(resolved);
126
+ }
127
+ catch {
128
+ return { status: "invalid", reason: `Directory not found: ${resolved}` };
129
+ }
130
+ if (!stat.isDirectory()) {
131
+ return { status: "invalid", reason: `Not a directory: ${resolved}` };
132
+ }
133
+ if (path.resolve(workspaceRoot) === resolved) {
134
+ return { status: "invalid", reason: "That is the workspace root itself — it is already the primary repo." };
135
+ }
136
+ const configPath = path.join(workspaceRoot, ".vexp", "workspace.json");
137
+ const alias = path.basename(resolved);
138
+ // Plan-limit guard: same semantics as the plugin — count existing entries,
139
+ // block only genuinely-new repos, never after a partial write.
140
+ let config = readWorkspaceConfig(configPath);
141
+ const currentCount = config ? config.repos.length : 1; // primary always exists
142
+ const alreadyExists = config?.repos.some((r) => r.alias === alias ||
143
+ r.path === resolved ||
144
+ path.resolve(resolveRepoEntryPath(workspaceRoot, r.path)) === resolved) ?? false;
145
+ if (alreadyExists) {
146
+ return { status: "exists", alias };
147
+ }
148
+ if (maxRepos > 0 && currentCount >= maxRepos) {
149
+ return { status: "limit", maxRepos, current: currentCount };
150
+ }
151
+ if (!config) {
152
+ config = {
153
+ name: path.basename(workspaceRoot),
154
+ repos: [{ alias: path.basename(workspaceRoot), path: "." }],
155
+ };
156
+ }
157
+ config.repos.push({ alias, path: resolved });
158
+ fs.mkdirSync(path.join(workspaceRoot, ".vexp"), { recursive: true });
159
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
160
+ // Bidirectional link in the child, so `vexp` run there targets this
161
+ // workspace's daemon instead of spawning a conflicting one.
162
+ const childVexpDir = path.join(resolved, ".vexp");
163
+ fs.mkdirSync(childVexpDir, { recursive: true });
164
+ fs.writeFileSync(path.join(childVexpDir, "parent_workspace.json"), JSON.stringify({ workspace_config: configPath }, null, 2));
165
+ return { status: "added", alias, configPath };
166
+ }