@haus-tech/haus-workflow 1.4.1 → 1.5.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.
package/dist/cli.js CHANGED
@@ -59,12 +59,12 @@ var init_exec = __esm({
59
59
  });
60
60
 
61
61
  // src/cli.ts
62
- import { readFileSync as readFileSync4 } from "node:fs";
63
- import path53 from "node:path";
62
+ import { readFileSync as readFileSync5 } from "node:fs";
63
+ import path70 from "node:path";
64
64
  import { Command } from "commander";
65
65
 
66
66
  // src/commands/apply.ts
67
- import path22 from "node:path";
67
+ import path23 from "node:path";
68
68
  import checkbox from "@inquirer/checkbox";
69
69
 
70
70
  // src/catalog/load-catalog.ts
@@ -141,13 +141,44 @@ async function atomicWriteText(file, value) {
141
141
  await fs.writeFile(tempPath, value, "utf8");
142
142
  await fs.move(tempPath, file, { overwrite: true });
143
143
  }
144
+ async function findNestedRepoDirs(root) {
145
+ const matches = await fg("**/.git", {
146
+ cwd: root,
147
+ dot: true,
148
+ onlyFiles: false,
149
+ followSymbolicLinks: false,
150
+ suppressErrors: true,
151
+ ignore: ["**/node_modules/**", "**/dist/**"]
152
+ });
153
+ const owners = /* @__PURE__ */ new Set();
154
+ for (const match of matches) {
155
+ const owner = path.posix.dirname(match);
156
+ if (owner === "." || owner === "") continue;
157
+ owners.add(owner);
158
+ }
159
+ const shallowestFirst = [...owners].sort((a, b) => a.split("/").length - b.split("/").length);
160
+ const nested = [];
161
+ for (const dir of shallowestFirst) {
162
+ if (nested.some((kept) => dir === kept || dir.startsWith(`${kept}/`))) continue;
163
+ nested.push(dir);
164
+ }
165
+ return nested;
166
+ }
144
167
  async function listFiles(root, patterns) {
168
+ const nestedRepoDirs = await findNestedRepoDirs(root);
169
+ const ignore = [
170
+ "**/node_modules/**",
171
+ "**/.git/**",
172
+ "**/dist/**",
173
+ "**/tests/fixtures/**",
174
+ ...nestedRepoDirs.map((d) => `${d}/**`)
175
+ ];
145
176
  const files = await fg(patterns, {
146
177
  cwd: root,
147
178
  dot: true,
148
179
  onlyFiles: true,
149
180
  suppressErrors: true,
150
- ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/tests/fixtures/**"]
181
+ ignore
151
182
  });
152
183
  return files.sort((a, b) => a.localeCompare(b));
153
184
  }
@@ -478,12 +509,12 @@ async function resolveCatalogRef(opts) {
478
509
  const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
479
510
  const tag = await fetchLatest();
480
511
  if (tag !== null) return tag;
481
- const fallback = opts?.fallbackRef;
482
- if (fallback) {
512
+ const fallback2 = opts?.fallbackRef;
513
+ if (fallback2) {
483
514
  warn(
484
- `Tag resolution failed \u2014 using cached ref ${fallback}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
515
+ `Tag resolution failed \u2014 using cached ref ${fallback2}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
485
516
  );
486
- return fallback;
517
+ return fallback2;
487
518
  }
488
519
  const bundled = getBundledCatalogRef();
489
520
  if (bundled) {
@@ -1489,6 +1520,37 @@ function catalogItemContentPath(contentRoot, item) {
1489
1520
  return path9.join(contentRoot, item.path);
1490
1521
  }
1491
1522
 
1523
+ // src/claude/gitignore-owned-check.ts
1524
+ init_exec();
1525
+ var HAUS_OWNED_TRACKED_PATHS = [
1526
+ ".claude",
1527
+ ".claude/skills",
1528
+ ".claude/agents",
1529
+ ".claude/commands",
1530
+ ".haus-workflow"
1531
+ ];
1532
+ async function findGitignoredHausPaths(root) {
1533
+ try {
1534
+ const result = await runGit(["check-ignore", "--", ...HAUS_OWNED_TRACKED_PATHS], {
1535
+ cwd: root,
1536
+ timeout: 5e3
1537
+ });
1538
+ if (result.exitCode !== 0 && result.exitCode !== 1) return [];
1539
+ return [
1540
+ ...new Set(
1541
+ result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean)
1542
+ )
1543
+ ];
1544
+ } catch {
1545
+ return [];
1546
+ }
1547
+ }
1548
+ function summarizeGitignoredHausDirs(paths) {
1549
+ const dirs = /* @__PURE__ */ new Set();
1550
+ for (const p of paths) dirs.add(p === ".haus-workflow" ? ".haus-workflow/" : ".claude/");
1551
+ return [...dirs];
1552
+ }
1553
+
1492
1554
  // src/claude/write-claude-files.ts
1493
1555
  import path19 from "node:path";
1494
1556
  import fs18 from "fs-extra";
@@ -1596,7 +1658,8 @@ function digestFileContent(buf) {
1596
1658
  }
1597
1659
  return hashText(buf);
1598
1660
  }
1599
- async function hashInstalledPaths(root, relPaths) {
1661
+ async function hashInstalledPaths(root, relPaths, opts = {}) {
1662
+ const followSymlinks = opts.followSymlinks ?? true;
1600
1663
  if (relPaths.length === 0) {
1601
1664
  return hashText(EMPTY_LOCK_PATHS_TOKEN);
1602
1665
  }
@@ -1605,17 +1668,24 @@ async function hashInstalledPaths(root, relPaths) {
1605
1668
  for (const rel of normalized) {
1606
1669
  const abs = path10.join(root, rel);
1607
1670
  if (!await fs7.pathExists(abs)) continue;
1608
- const stat = await fs7.stat(abs);
1671
+ const stat = followSymlinks ? await fs7.stat(abs) : await fs7.lstat(abs);
1672
+ if (!followSymlinks && stat.isSymbolicLink()) continue;
1609
1673
  if (stat.isFile()) {
1610
1674
  const body = await fs7.readFile(abs);
1611
1675
  fileDigests.push({ rel, digest: digestFileContent(body) });
1612
1676
  continue;
1613
1677
  }
1614
1678
  if (!stat.isDirectory()) continue;
1615
- const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
1679
+ const inner = await fg2("**/*", {
1680
+ cwd: abs,
1681
+ onlyFiles: true,
1682
+ dot: true,
1683
+ followSymbolicLinks: followSymlinks
1684
+ });
1616
1685
  for (const sub of inner.sort()) {
1617
- const relFile = path10.join(rel, sub).replace(/\\/g, "/");
1618
1686
  const absFile = path10.join(abs, sub);
1687
+ if (!followSymlinks && (await fs7.lstat(absFile)).isSymbolicLink()) continue;
1688
+ const relFile = path10.join(rel, sub).replace(/\\/g, "/");
1619
1689
  const body = await fs7.readFile(absFile);
1620
1690
  fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
1621
1691
  }
@@ -2033,8 +2103,8 @@ function buildDenyRules() {
2033
2103
  for (const command of DENY_COMMANDS) {
2034
2104
  rules.push(`Bash(${command}:*)`);
2035
2105
  }
2036
- for (const path54 of DENY_PATHS) {
2037
- const pattern = DENY_DIRS.has(path54) ? `${path54}/**` : path54;
2106
+ for (const path71 of DENY_PATHS) {
2107
+ const pattern = DENY_DIRS.has(path71) ? `${path71}/**` : path71;
2038
2108
  for (const tool of FILE_TOOLS) {
2039
2109
  rules.push(`${tool}(${pattern})`);
2040
2110
  }
@@ -2078,6 +2148,13 @@ var PROJECT_HOOK_FRAGMENTS = [
2078
2148
  event: "SessionStart",
2079
2149
  matcher: "*",
2080
2150
  command: "haus update --from-hook"
2151
+ },
2152
+ {
2153
+ id: "hook.workspace.worktree-check",
2154
+ gate: "keep",
2155
+ event: "SessionStart",
2156
+ matcher: "*",
2157
+ command: "haus workspace worktree doctor --from-hook"
2081
2158
  }
2082
2159
  ];
2083
2160
  async function readProjectSettings(root) {
@@ -2186,6 +2263,10 @@ var CANONICAL_HOOKS = {
2186
2263
  {
2187
2264
  matcher: "*",
2188
2265
  hooks: [{ type: "command", command: "haus update --from-hook" }]
2266
+ },
2267
+ {
2268
+ matcher: "*",
2269
+ hooks: [{ type: "command", command: "haus workspace worktree doctor --from-hook" }]
2189
2270
  }
2190
2271
  ]
2191
2272
  }
@@ -2555,7 +2636,6 @@ function refillContent(existing, v) {
2555
2636
  }
2556
2637
  var FALLBACK_CONTEXT = {
2557
2638
  generatedAt: "",
2558
- root: "",
2559
2639
  repoName: "",
2560
2640
  packageManager: "unknown",
2561
2641
  repoRoles: [],
@@ -3247,27 +3327,192 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3247
3327
  }
3248
3328
  }
3249
3329
 
3330
+ // src/claude/write-gitignore.ts
3331
+ init_exec();
3332
+ import path20 from "node:path";
3333
+ import fs19 from "fs-extra";
3334
+ var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
3335
+ var GITIGNORE_END = "# HAUS:END haus-managed";
3336
+ var GITIGNORED_ARTIFACT_PATHS = [
3337
+ ".haus-workflow/context-map.json",
3338
+ ".haus-workflow/recommendation.json",
3339
+ ".haus-workflow/sources-report.json",
3340
+ ".haus-workflow/deep-context.json"
3341
+ ];
3342
+ function findLineMarker3(content2, marker, from = 0) {
3343
+ let idx = content2.indexOf(marker, from);
3344
+ while (idx !== -1) {
3345
+ const lineStart = idx === 0 || content2[idx - 1] === "\n";
3346
+ const after = idx + marker.length;
3347
+ const lineEnd = after === content2.length || content2[after] === "\n" || content2[after] === "\r";
3348
+ if (lineStart && lineEnd) return { start: idx, end: after };
3349
+ idx = content2.indexOf(marker, idx + marker.length);
3350
+ }
3351
+ return null;
3352
+ }
3353
+ function findBlockRangeFor(content2, beginMarker, endMarker) {
3354
+ const begin = findLineMarker3(content2, beginMarker);
3355
+ if (!begin) return null;
3356
+ const end = findLineMarker3(content2, endMarker, begin.end);
3357
+ if (!end) return null;
3358
+ return { start: begin.start, end: end.end };
3359
+ }
3360
+ function gitignoreCovers(lines, entry) {
3361
+ const bare = entry.replace(/^\/+/, "");
3362
+ const exactEquivalents = /* @__PURE__ */ new Set([bare, `/${bare}`]);
3363
+ for (const raw of lines) {
3364
+ const line2 = raw.trim();
3365
+ if (!line2 || line2.startsWith("#")) continue;
3366
+ if (exactEquivalents.has(line2)) return true;
3367
+ const dirCandidate = line2.replace(/^\/+/, "").replace(/\/\*\*$/, "").replace(/\/$/, "");
3368
+ if (dirCandidate && !/[*?[\]!]/.test(dirCandidate)) {
3369
+ if (bare === dirCandidate || bare.startsWith(`${dirCandidate}/`)) return true;
3370
+ }
3371
+ }
3372
+ return false;
3373
+ }
3374
+ function linesOutsideBlockFor(content2, beginMarker, endMarker) {
3375
+ const range = findBlockRangeFor(content2, beginMarker, endMarker);
3376
+ const outside = range ? content2.slice(0, range.start) + content2.slice(range.end) : content2;
3377
+ return outside.split("\n");
3378
+ }
3379
+ function buildBlockFor(existing, beginMarker, endMarker, comment, patterns) {
3380
+ const outside = linesOutsideBlockFor(existing, beginMarker, endMarker);
3381
+ const needed = patterns.filter((p) => !gitignoreCovers(outside, p));
3382
+ if (needed.length === 0) return null;
3383
+ return [beginMarker, comment, ...needed, endMarker].join("\n");
3384
+ }
3385
+ var ARTIFACT_BLOCK_COMMENT = "# haus scan artifacts \u2014 machine-local, must never be committed (see ADR-0025)";
3386
+ function injectBlockFor(existing, beginMarker, endMarker, comment, patterns) {
3387
+ const block = buildBlockFor(existing, beginMarker, endMarker, comment, patterns);
3388
+ const range = findBlockRangeFor(existing, beginMarker, endMarker);
3389
+ if (block === null) {
3390
+ if (!range) return existing;
3391
+ const before = existing.slice(0, range.start);
3392
+ const after = existing.slice(range.end);
3393
+ const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
3394
+ return merged.length > 0 ? `${merged}
3395
+ ` : "";
3396
+ }
3397
+ if (range) {
3398
+ const before = existing.slice(0, range.start);
3399
+ const after = existing.slice(range.end);
3400
+ return `${before}${block}${after}`.replace(/\n*$/, "\n");
3401
+ }
3402
+ const loneBegin = findLineMarker3(existing, beginMarker);
3403
+ if (loneBegin) {
3404
+ const before = existing.slice(0, loneBegin.start).trimEnd();
3405
+ if (before.length === 0) return `${block}
3406
+ `;
3407
+ return `${before}
3408
+
3409
+ ${block}
3410
+ `;
3411
+ }
3412
+ const trimmed = existing.trimEnd();
3413
+ if (trimmed.length === 0) return `${block}
3414
+ `;
3415
+ return `${trimmed}
3416
+
3417
+ ${block}
3418
+ `;
3419
+ }
3420
+ function injectGitignoreBlock(existing) {
3421
+ return injectBlockFor(
3422
+ existing,
3423
+ GITIGNORE_BEGIN,
3424
+ GITIGNORE_END,
3425
+ ARTIFACT_BLOCK_COMMENT,
3426
+ GITIGNORED_ARTIFACT_PATHS
3427
+ );
3428
+ }
3429
+ var LINK_CONTEXT_GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed-link-context v=1";
3430
+ var LINK_CONTEXT_GITIGNORE_END = "# HAUS:END haus-managed-link-context";
3431
+ var LINKED_CONTEXT_GITIGNORE_PATTERNS = [
3432
+ ".claude/skills/*--*/",
3433
+ ".claude/agents/*--*.md",
3434
+ ".claude/commands/*--*.md"
3435
+ ];
3436
+ var LINK_CONTEXT_BLOCK_COMMENT = "# haus workspace link-context copies \u2014 derived cross-repo skill/agent/command copies, never committed";
3437
+ function injectLinkContextGitignoreBlock(existing) {
3438
+ return injectBlockFor(
3439
+ existing,
3440
+ LINK_CONTEXT_GITIGNORE_BEGIN,
3441
+ LINK_CONTEXT_GITIGNORE_END,
3442
+ LINK_CONTEXT_BLOCK_COMMENT,
3443
+ LINKED_CONTEXT_GITIGNORE_PATTERNS
3444
+ );
3445
+ }
3446
+ async function writeGitignore(root, dryRun) {
3447
+ const filePath = path20.join(root, ".gitignore");
3448
+ const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3449
+ const next = injectGitignoreBlock(prev);
3450
+ await writeManagedText(root, filePath, next, dryRun);
3451
+ return filePath;
3452
+ }
3453
+ async function writeLinkContextGitignore(root, dryRun) {
3454
+ const filePath = path20.join(root, ".gitignore");
3455
+ const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3456
+ const next = injectLinkContextGitignoreBlock(prev);
3457
+ await writeManagedText(root, filePath, next, dryRun);
3458
+ return filePath;
3459
+ }
3460
+ async function listTrackedArtifactPaths(root) {
3461
+ try {
3462
+ const result = await runGit(["ls-files", "--", ...GITIGNORED_ARTIFACT_PATHS], {
3463
+ cwd: root,
3464
+ timeout: 5e3
3465
+ });
3466
+ if (result.exitCode !== 0) return [];
3467
+ return result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean);
3468
+ } catch {
3469
+ return [];
3470
+ }
3471
+ }
3472
+ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
3473
+ const tracked = await listTrackedArtifactPaths(root);
3474
+ const untracked = [];
3475
+ for (const rel of tracked) {
3476
+ if (dryRun) {
3477
+ notify(
3478
+ `Would untrack ${rel} from git \u2014 it contains machine-local scan output and should never have been committed.`
3479
+ );
3480
+ continue;
3481
+ }
3482
+ const result = await runGit(["rm", "--cached", "--quiet", "--", rel], { cwd: root });
3483
+ if (result.exitCode === 0) {
3484
+ untracked.push(rel);
3485
+ notify(
3486
+ `Untracked ${rel} from git \u2014 it contains machine-local scan output and should never have been committed. Run \`git status\` to review before committing this change.`
3487
+ );
3488
+ } else {
3489
+ notify(`Could not untrack ${rel} from git: ${result.stderr.trim() || result.stdout.trim()}`);
3490
+ }
3491
+ }
3492
+ return untracked;
3493
+ }
3494
+
3250
3495
  // src/refs/fetch-refs.ts
3251
- import path21 from "node:path";
3252
- import fs20 from "fs-extra";
3496
+ import path22 from "node:path";
3497
+ import fs21 from "fs-extra";
3253
3498
 
3254
3499
  // src/refs/cache-meta.ts
3255
- import path20 from "node:path";
3256
- import fs19 from "fs-extra";
3500
+ import path21 from "node:path";
3501
+ import fs20 from "fs-extra";
3257
3502
  var META_FILENAME = "cache-meta.json";
3258
3503
  async function readCacheMeta(cacheDir) {
3259
- const metaPath = path20.join(cacheDir, META_FILENAME);
3504
+ const metaPath = path21.join(cacheDir, META_FILENAME);
3260
3505
  try {
3261
- const raw = await fs19.readFile(metaPath, "utf8");
3506
+ const raw = await fs20.readFile(metaPath, "utf8");
3262
3507
  return JSON.parse(raw);
3263
3508
  } catch {
3264
3509
  return {};
3265
3510
  }
3266
3511
  }
3267
3512
  async function writeCacheMeta(cacheDir, meta) {
3268
- await fs19.ensureDir(cacheDir);
3269
- const metaPath = path20.join(cacheDir, META_FILENAME);
3270
- await fs19.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
3513
+ await fs20.ensureDir(cacheDir);
3514
+ const metaPath = path21.join(cacheDir, META_FILENAME);
3515
+ await fs20.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
3271
3516
  }
3272
3517
 
3273
3518
  // src/refs/fetch-refs.ts
@@ -3287,8 +3532,8 @@ function isLlmsTxtUrl(ref) {
3287
3532
  async function fetchSingleRef(url, cacheDir, meta) {
3288
3533
  const existing = meta[url];
3289
3534
  const headers = {};
3290
- const safeFile = existing?.file && existing.file === path21.basename(existing.file);
3291
- const cachedFileExists = safeFile && await fs20.pathExists(path21.join(cacheDir, existing.file));
3535
+ const safeFile = existing?.file && existing.file === path22.basename(existing.file);
3536
+ const cachedFileExists = safeFile && await fs21.pathExists(path22.join(cacheDir, existing.file));
3292
3537
  if (cachedFileExists) {
3293
3538
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
3294
3539
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -3299,8 +3544,8 @@ async function fetchSingleRef(url, cacheDir, meta) {
3299
3544
  if (!res.ok) return { result: "failed" };
3300
3545
  const text = await res.text();
3301
3546
  const file = `${urlToSlug(url)}.md`;
3302
- await fs20.ensureDir(cacheDir);
3303
- await fs20.writeFile(path21.join(cacheDir, file), text, "utf8");
3547
+ await fs21.ensureDir(cacheDir);
3548
+ await fs21.writeFile(path22.join(cacheDir, file), text, "utf8");
3304
3549
  meta[url] = {
3305
3550
  url,
3306
3551
  etag: res.headers.get("etag") ?? void 0,
@@ -3323,8 +3568,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
3323
3568
  if (keepUrls.has(url)) continue;
3324
3569
  delete meta[url];
3325
3570
  removed++;
3326
- const safeFile = typeof entry.file === "string" && entry.file === path21.basename(entry.file);
3327
- if (safeFile) await fs20.remove(path21.join(cacheDir, entry.file)).catch(() => {
3571
+ const safeFile = typeof entry.file === "string" && entry.file === path22.basename(entry.file);
3572
+ if (safeFile) await fs21.remove(path22.join(cacheDir, entry.file)).catch(() => {
3328
3573
  });
3329
3574
  }
3330
3575
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -3346,10 +3591,10 @@ async function fetchRefsForItems(items, cacheDir) {
3346
3591
  const outcome = await fetchSingleRef(url, cacheDir, meta);
3347
3592
  if (outcome.result === "fetched") {
3348
3593
  summary.fetched++;
3349
- summary.cachedFiles[url] = path21.join(cacheDir, outcome.file);
3594
+ summary.cachedFiles[url] = path22.join(cacheDir, outcome.file);
3350
3595
  } else if (outcome.result === "unchanged") {
3351
3596
  summary.unchanged++;
3352
- if (meta[url]?.file) summary.cachedFiles[url] = path21.join(cacheDir, meta[url].file);
3597
+ if (meta[url]?.file) summary.cachedFiles[url] = path22.join(cacheDir, meta[url].file);
3353
3598
  } else {
3354
3599
  summary.failed++;
3355
3600
  summary.failedUrls.push(url);
@@ -3372,7 +3617,7 @@ function parseIdList(value) {
3372
3617
  // src/commands/apply.ts
3373
3618
  var STALE_CACHE_THRESHOLD_DAYS = 7;
3374
3619
  async function cacheHasItems() {
3375
- const data = await readJson(path22.join(getCacheDir(), "manifest.json"));
3620
+ const data = await readJson(path23.join(getCacheDir(), "manifest.json"));
3376
3621
  return Array.isArray(data?.items) && data.items.length > 0;
3377
3622
  }
3378
3623
  function installableRecommendedItems(recommended) {
@@ -3478,6 +3723,8 @@ async function runApply(options) {
3478
3723
  force: options.force,
3479
3724
  prune: options.prune
3480
3725
  });
3726
+ await writeGitignore(root, isDryRun);
3727
+ await untrackMachineLocalArtifacts(root, isDryRun, warn);
3481
3728
  if (!isDryRun) {
3482
3729
  try {
3483
3730
  const cacheDir = hausPath(root, "llms-cache");
@@ -3503,6 +3750,15 @@ async function runApply(options) {
3503
3750
  );
3504
3751
  }
3505
3752
  }
3753
+ const gitignoredHausPaths = await findGitignoredHausPaths(root);
3754
+ if (gitignoredHausPaths.length > 0) {
3755
+ const dirs = summarizeGitignoredHausDirs(gitignoredHausPaths);
3756
+ const dirList = dirs.join(" and ");
3757
+ const verb = dirs.length === 1 ? "is" : "are";
3758
+ warn(
3759
+ `${dirList} ${verb} gitignored \u2014 installed skills/agents/commands will not be visible to anything relying on git-tracked state. Remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored).`
3760
+ );
3761
+ }
3506
3762
  if (isDryRun) {
3507
3763
  log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
3508
3764
  } else {
@@ -3512,8 +3768,8 @@ async function runApply(options) {
3512
3768
  }
3513
3769
 
3514
3770
  // src/commands/backups.ts
3515
- import path23 from "node:path";
3516
- import fs21 from "fs-extra";
3771
+ import path24 from "node:path";
3772
+ import fs22 from "fs-extra";
3517
3773
 
3518
3774
  // src/utils/prompts.ts
3519
3775
  import { stdin as input, stdout as output } from "node:process";
@@ -3543,14 +3799,14 @@ function classify(name) {
3543
3799
  }
3544
3800
  async function listBackups(root) {
3545
3801
  const dir = hausPath(root, "backups");
3546
- if (!await fs21.pathExists(dir)) return [];
3547
- const names = await fs21.readdir(dir);
3802
+ if (!await fs22.pathExists(dir)) return [];
3803
+ const names = await fs22.readdir(dir);
3548
3804
  const entries = [];
3549
3805
  for (const name of names) {
3550
3806
  const kind = classify(name);
3551
3807
  if (!kind) continue;
3552
- const absPath = path23.join(dir, name);
3553
- const stat = await fs21.lstat(absPath);
3808
+ const absPath = path24.join(dir, name);
3809
+ const stat = await fs22.lstat(absPath);
3554
3810
  if (stat.isSymbolicLink()) continue;
3555
3811
  const expectDirectory = kind !== "lock";
3556
3812
  if (stat.isDirectory() !== expectDirectory) continue;
@@ -3579,13 +3835,13 @@ async function runList(root) {
3579
3835
  }
3580
3836
  }
3581
3837
  async function isUnsafeWriteTarget(root, target) {
3582
- const segments = path23.relative(root, target).split(path23.sep).filter(Boolean);
3838
+ const segments = path24.relative(root, target).split(path24.sep).filter(Boolean);
3583
3839
  let current = root;
3584
3840
  for (let i = 0; i < segments.length; i++) {
3585
- current = path23.join(current, segments[i]);
3841
+ current = path24.join(current, segments[i]);
3586
3842
  const isLastSegment = i === segments.length - 1;
3587
3843
  try {
3588
- const stat = await fs21.lstat(current);
3844
+ const stat = await fs22.lstat(current);
3589
3845
  if (isLastSegment) {
3590
3846
  if (!stat.isFile()) return true;
3591
3847
  } else if (!stat.isDirectory()) {
@@ -3600,7 +3856,7 @@ async function isUnsafeWriteTarget(root, target) {
3600
3856
  }
3601
3857
  async function lstatIfRegularFile(abs) {
3602
3858
  try {
3603
- const stat = await fs21.lstat(abs);
3859
+ const stat = await fs22.lstat(abs);
3604
3860
  return stat.isFile() ? stat : null;
3605
3861
  } catch (err) {
3606
3862
  if (err.code === "ENOENT") return null;
@@ -3615,8 +3871,8 @@ async function restoreLockBackup(root, entry, yes) {
3615
3871
  );
3616
3872
  return false;
3617
3873
  }
3618
- if (await fs21.pathExists(target)) {
3619
- const targetStat = await fs21.stat(target);
3874
+ if (await fs22.pathExists(target)) {
3875
+ const targetStat = await fs22.stat(target);
3620
3876
  if (targetStat.mtimeMs > entry.mtimeMs) {
3621
3877
  warn(
3622
3878
  `Current haus.lock.json (${new Date(targetStat.mtimeMs).toISOString()}) is newer than backup ${entry.id} (${new Date(entry.mtimeMs).toISOString()}) \u2014 it will be overwritten.`
@@ -3636,7 +3892,7 @@ async function restoreLockBackup(root, entry, yes) {
3636
3892
  error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
3637
3893
  return false;
3638
3894
  }
3639
- await fs21.copy(entry.absPath, target, { overwrite: true });
3895
+ await fs22.copy(entry.absPath, target, { overwrite: true });
3640
3896
  log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
3641
3897
  return true;
3642
3898
  }
@@ -3644,10 +3900,10 @@ async function collectFilesRecursive(dir) {
3644
3900
  const files = [];
3645
3901
  const skippedLinks = [];
3646
3902
  const skippedOther = [];
3647
- const names = await fs21.readdir(dir);
3903
+ const names = await fs22.readdir(dir);
3648
3904
  for (const name of names) {
3649
- const abs = path23.join(dir, name);
3650
- const stat = await fs21.lstat(abs);
3905
+ const abs = path24.join(dir, name);
3906
+ const stat = await fs22.lstat(abs);
3651
3907
  if (stat.isSymbolicLink()) {
3652
3908
  skippedLinks.push(abs);
3653
3909
  continue;
@@ -3669,12 +3925,12 @@ async function restoreDirBackup(root, entry, yes) {
3669
3925
  const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
3670
3926
  if (skippedLinks.length > 0) {
3671
3927
  warn(
3672
- `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path23.relative(entry.absPath, abs)).join(", ")}`
3928
+ `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
3673
3929
  );
3674
3930
  }
3675
3931
  if (skippedOther.length > 0) {
3676
3932
  warn(
3677
- `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path23.relative(entry.absPath, abs)).join(", ")}`
3933
+ `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
3678
3934
  );
3679
3935
  }
3680
3936
  if (files.length === 0) {
@@ -3684,8 +3940,8 @@ async function restoreDirBackup(root, entry, yes) {
3684
3940
  const safeFiles = [];
3685
3941
  const unsafeTargets = [];
3686
3942
  for (const abs of files) {
3687
- const rel = path23.relative(entry.absPath, abs);
3688
- const target = path23.join(root, rel);
3943
+ const rel = path24.relative(entry.absPath, abs);
3944
+ const target = path24.join(root, rel);
3689
3945
  if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
3690
3946
  else safeFiles.push(abs);
3691
3947
  }
@@ -3702,16 +3958,16 @@ async function restoreDirBackup(root, entry, yes) {
3702
3958
  const vanished = [];
3703
3959
  const toRestore = [];
3704
3960
  for (const abs of safeFiles) {
3705
- const rel = path23.relative(entry.absPath, abs);
3961
+ const rel = path24.relative(entry.absPath, abs);
3706
3962
  const backupFileStat = await lstatIfRegularFile(abs);
3707
3963
  if (!backupFileStat) {
3708
3964
  vanished.push(rel);
3709
3965
  continue;
3710
3966
  }
3711
3967
  toRestore.push(abs);
3712
- const target = path23.join(root, rel);
3713
- if (await fs21.pathExists(target)) {
3714
- const targetStat = await fs21.stat(target);
3968
+ const target = path24.join(root, rel);
3969
+ if (await fs22.pathExists(target)) {
3970
+ const targetStat = await fs22.stat(target);
3715
3971
  if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
3716
3972
  stale.push({
3717
3973
  rel,
@@ -3750,14 +4006,14 @@ async function restoreDirBackup(root, entry, yes) {
3750
4006
  let restoredCount = 0;
3751
4007
  const skippedAtWrite = [];
3752
4008
  for (const abs of toRestore) {
3753
- const rel = path23.relative(entry.absPath, abs);
3754
- const target = path23.join(root, rel);
4009
+ const rel = path24.relative(entry.absPath, abs);
4010
+ const target = path24.join(root, rel);
3755
4011
  if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
3756
4012
  skippedAtWrite.push(rel);
3757
4013
  continue;
3758
4014
  }
3759
- await fs21.ensureDir(path23.dirname(target));
3760
- await fs21.copy(abs, target, { overwrite: true });
4015
+ await fs22.ensureDir(path24.dirname(target));
4016
+ await fs22.copy(abs, target, { overwrite: true });
3761
4017
  restoredCount++;
3762
4018
  }
3763
4019
  if (skippedAtWrite.length > 0) {
@@ -3814,7 +4070,7 @@ Keeping ${keepCount}.`
3814
4070
  const failed = [];
3815
4071
  for (const entry of removeList) {
3816
4072
  try {
3817
- await fs21.remove(entry.absPath);
4073
+ await fs22.remove(entry.absPath);
3818
4074
  removed.push(entry.id);
3819
4075
  } catch (err) {
3820
4076
  failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
@@ -3895,16 +4151,16 @@ async function runCatalogAudit() {
3895
4151
  }
3896
4152
 
3897
4153
  // src/commands/doctor.ts
3898
- import path33 from "node:path";
3899
- import fs28 from "fs-extra";
4154
+ import path37 from "node:path";
4155
+ import fs29 from "fs-extra";
3900
4156
 
3901
4157
  // src/decisions/doctor.ts
3902
- import path27 from "node:path";
3903
- import fs25 from "fs-extra";
4158
+ import path28 from "node:path";
4159
+ import fs26 from "fs-extra";
3904
4160
 
3905
4161
  // src/decisions/check.ts
3906
- import path26 from "node:path";
3907
- import fs24 from "fs-extra";
4162
+ import path27 from "node:path";
4163
+ import fs25 from "fs-extra";
3908
4164
 
3909
4165
  // src/decisions/diff.ts
3910
4166
  init_exec();
@@ -3959,22 +4215,22 @@ function matchesAnyRegex(filePath, patterns) {
3959
4215
  }
3960
4216
 
3961
4217
  // src/decisions/paths.ts
3962
- import path24 from "node:path";
3963
- import fs22 from "fs-extra";
4218
+ import path25 from "node:path";
4219
+ import fs23 from "fs-extra";
3964
4220
  async function resolveDecisionsDir(root) {
3965
- const primary = path24.join(root, DECISIONS_TRIGGERS.decisionsDir);
3966
- if (await fs22.pathExists(primary)) return primary;
3967
- const legacy = path24.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3968
- if (await fs22.pathExists(legacy)) return legacy;
4221
+ const primary = path25.join(root, DECISIONS_TRIGGERS.decisionsDir);
4222
+ if (await fs23.pathExists(primary)) return primary;
4223
+ const legacy = path25.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4224
+ if (await fs23.pathExists(legacy)) return legacy;
3969
4225
  return primary;
3970
4226
  }
3971
4227
  function relativeDecisionPath(decisionsDir, root) {
3972
- return path24.relative(root, decisionsDir).replace(/\\/g, "/");
4228
+ return path25.relative(root, decisionsDir).replace(/\\/g, "/");
3973
4229
  }
3974
4230
 
3975
4231
  // src/decisions/validate.ts
3976
- import path25 from "node:path";
3977
- import fs23 from "fs-extra";
4232
+ import path26 from "node:path";
4233
+ import fs24 from "fs-extra";
3978
4234
  function validateDecisionContent(content2) {
3979
4235
  const errors = [];
3980
4236
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -4003,14 +4259,14 @@ function decisionNumberFromFile(name) {
4003
4259
  return match?.[1] ?? null;
4004
4260
  }
4005
4261
  async function validateIndexSync(decisionsDir, decisionFiles) {
4006
- const readmePath = path25.join(decisionsDir, "README.md");
4007
- if (!await fs23.pathExists(readmePath)) {
4262
+ const readmePath = path26.join(decisionsDir, "README.md");
4263
+ if (!await fs24.pathExists(readmePath)) {
4008
4264
  return ["docs/decisions/README.md is missing"];
4009
4265
  }
4010
4266
  const readme = await readText(readmePath) ?? "";
4011
4267
  const errors = [];
4012
4268
  for (const file of decisionFiles) {
4013
- const num = decisionNumberFromFile(path25.basename(file));
4269
+ const num = decisionNumberFromFile(path26.basename(file));
4014
4270
  if (num && !indexReferencesNumber(readme, num)) {
4015
4271
  errors.push(`README.md missing index row for ADR-${num}`);
4016
4272
  }
@@ -4064,22 +4320,22 @@ async function checkSatisfaction(input2) {
4064
4320
  const errors = [];
4065
4321
  const decisionsPrefix = `${input2.decisionsRel}/`;
4066
4322
  const newDecisionFiles = input2.changedFiles.filter(
4067
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path26.basename(f))
4323
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path27.basename(f))
4068
4324
  );
4069
4325
  if (newDecisionFiles.length === 0) {
4070
4326
  errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
4071
4327
  return errors;
4072
4328
  }
4073
- const decisionsDir = path26.join(input2.root, input2.decisionsRel);
4329
+ const decisionsDir = path27.join(input2.root, input2.decisionsRel);
4074
4330
  for (const rel of newDecisionFiles) {
4075
- const abs = path26.join(input2.root, rel);
4331
+ const abs = path27.join(input2.root, rel);
4076
4332
  const content2 = await readText(abs) ?? "";
4077
4333
  const valid = validateDecisionContent(content2);
4078
4334
  if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
4079
4335
  }
4080
4336
  const indexErrors = await validateIndexSync(
4081
4337
  decisionsDir,
4082
- newDecisionFiles.map((f) => path26.basename(f))
4338
+ newDecisionFiles.map((f) => path27.basename(f))
4083
4339
  );
4084
4340
  errors.push(...indexErrors);
4085
4341
  return errors;
@@ -4123,8 +4379,8 @@ ${opts.commitMessages ?? ""}`;
4123
4379
  changedFiles: stats.files
4124
4380
  });
4125
4381
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
4126
- const legacyExists = await fs24.pathExists(path26.join(root, legacyRel));
4127
- if (legacyExists && !await fs24.pathExists(decisionsDir)) {
4382
+ const legacyExists = await fs25.pathExists(path27.join(root, legacyRel));
4383
+ if (legacyExists && !await fs25.pathExists(decisionsDir)) {
4128
4384
  satisfactionErrors = await checkSatisfaction({
4129
4385
  root,
4130
4386
  decisionsRel: legacyRel,
@@ -4144,18 +4400,18 @@ ${opts.commitMessages ?? ""}`;
4144
4400
  return { triggered: true, satisfied: false, reasons, securityTriggered };
4145
4401
  }
4146
4402
  async function listDecisionNumbers(decisionsDir) {
4147
- if (!await fs24.pathExists(decisionsDir)) return [];
4148
- const entries = await fs24.readdir(decisionsDir);
4403
+ if (!await fs25.pathExists(decisionsDir)) return [];
4404
+ const entries = await fs25.readdir(decisionsDir);
4149
4405
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
4150
4406
  }
4151
4407
 
4152
4408
  // src/decisions/doctor.ts
4153
4409
  async function auditDecisionsLayout(root) {
4154
4410
  const findings = [];
4155
- const primary = path27.join(root, DECISIONS_TRIGGERS.decisionsDir);
4156
- const legacy = path27.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4157
- const hasPrimary = await fs25.pathExists(primary);
4158
- const hasLegacy = await fs25.pathExists(legacy);
4411
+ const primary = path28.join(root, DECISIONS_TRIGGERS.decisionsDir);
4412
+ const legacy = path28.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4413
+ const hasPrimary = await fs26.pathExists(primary);
4414
+ const hasLegacy = await fs26.pathExists(legacy);
4159
4415
  if (!hasPrimary && hasLegacy) {
4160
4416
  findings.push({
4161
4417
  level: "advisory",
@@ -4168,8 +4424,8 @@ async function auditDecisionsLayout(root) {
4168
4424
  if (!hasPrimary) return findings;
4169
4425
  const decisionsDir = await resolveDecisionsDir(root);
4170
4426
  const rel = relativeDecisionPath(decisionsDir, root);
4171
- const readmePath = path27.join(decisionsDir, "README.md");
4172
- if (!await fs25.pathExists(readmePath)) {
4427
+ const readmePath = path28.join(decisionsDir, "README.md");
4428
+ if (!await fs26.pathExists(readmePath)) {
4173
4429
  findings.push({
4174
4430
  level: "advisory",
4175
4431
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -4179,7 +4435,7 @@ async function auditDecisionsLayout(root) {
4179
4435
  return findings;
4180
4436
  }
4181
4437
  const readme = await readText(readmePath);
4182
- const entries = (await fs25.readdir(decisionsDir)).filter(
4438
+ const entries = (await fs26.readdir(decisionsDir)).filter(
4183
4439
  (f) => f.endsWith(".md") && f !== "README.md"
4184
4440
  );
4185
4441
  for (const file of entries) {
@@ -4193,7 +4449,7 @@ async function auditDecisionsLayout(root) {
4193
4449
  });
4194
4450
  }
4195
4451
  }
4196
- const claude = await readText(path27.join(root, "CLAUDE.md"));
4452
+ const claude = await readText(path28.join(root, "CLAUDE.md"));
4197
4453
  if (claude && !claude.includes(`@${rel}/README.md`)) {
4198
4454
  findings.push({
4199
4455
  level: "advisory",
@@ -4224,17 +4480,187 @@ async function auditDecisionsLayout(root) {
4224
4480
  }
4225
4481
 
4226
4482
  // src/scanner/read-context.ts
4227
- import path32 from "node:path";
4228
- import fs27 from "fs-extra";
4483
+ import path36 from "node:path";
4484
+ import fs28 from "fs-extra";
4485
+
4486
+ // src/commands/workspace/config.ts
4487
+ import path29 from "node:path";
4488
+ import YAML from "yaml";
4489
+ var WORKSPACE_FILE = "haus.workspace.yaml";
4490
+ function parseWorkspaceConfig(text) {
4491
+ if (!text) return void 0;
4492
+ let parsed;
4493
+ try {
4494
+ parsed = YAML.parse(text);
4495
+ } catch {
4496
+ return void 0;
4497
+ }
4498
+ if (!parsed || typeof parsed !== "object") return void 0;
4499
+ const obj = parsed;
4500
+ const repos = Array.isArray(obj.repos) ? obj.repos.filter(
4501
+ (r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
4502
+ ) : [];
4503
+ return {
4504
+ client: typeof obj.client === "string" ? obj.client : "unknown",
4505
+ repos,
4506
+ relationships: Array.isArray(obj.relationships) ? obj.relationships : []
4507
+ };
4508
+ }
4509
+ async function readWorkspaceConfig(workspaceRoot) {
4510
+ return parseWorkspaceConfig(await readText(path29.join(workspaceRoot, WORKSPACE_FILE)));
4511
+ }
4512
+
4513
+ // src/workspace/members.ts
4514
+ import path30 from "node:path";
4515
+ var REPOS_MANIFEST_FILE = "repos.manifest.json";
4516
+ var REPOS_LOCAL_FILE = "repos.local.json";
4517
+ var MemberConfigError = class extends Error {
4518
+ constructor(filePath, reason) {
4519
+ super(`Cannot read workspace member config at ${filePath}: ${reason}`);
4520
+ this.filePath = filePath;
4521
+ }
4522
+ filePath;
4523
+ name = "MemberConfigError";
4524
+ };
4525
+ function resolveAbsPath(workspaceRoot, folder, overrides) {
4526
+ const override = overrides[folder];
4527
+ if (override) return path30.resolve(workspaceRoot, override);
4528
+ return path30.resolve(workspaceRoot, folder);
4529
+ }
4530
+ async function readPathOverrides(workspaceRoot) {
4531
+ const filePath = path30.join(workspaceRoot, REPOS_LOCAL_FILE);
4532
+ const result = await readJsonDetailed(filePath);
4533
+ if (result.status === "missing") return {};
4534
+ if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
4535
+ const parsed = result.value;
4536
+ const rawOverrides = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.pathOverrides : void 0;
4537
+ if (rawOverrides === null || typeof rawOverrides !== "object" || Array.isArray(rawOverrides)) {
4538
+ return {};
4539
+ }
4540
+ const overrides = {};
4541
+ for (const [folder, value] of Object.entries(rawOverrides)) {
4542
+ if (typeof value === "string") overrides[folder] = value;
4543
+ }
4544
+ return overrides;
4545
+ }
4546
+ async function readFromWorkspaceYaml(workspaceRoot, overrides) {
4547
+ const filePath = path30.join(workspaceRoot, WORKSPACE_FILE);
4548
+ const text = await readText(filePath);
4549
+ if (text === void 0) return void 0;
4550
+ const config = parseWorkspaceConfig(text);
4551
+ if (!config) throw new MemberConfigError(filePath, "malformed YAML or invalid shape");
4552
+ return config.repos.map((repo) => ({
4553
+ id: repo.name,
4554
+ folder: repo.path,
4555
+ absPath: resolveAbsPath(workspaceRoot, repo.path, overrides),
4556
+ source: "haus.workspace.yaml"
4557
+ }));
4558
+ }
4559
+ async function readFromManifest(workspaceRoot, overrides) {
4560
+ const filePath = path30.join(workspaceRoot, REPOS_MANIFEST_FILE);
4561
+ const result = await readJsonDetailed(filePath);
4562
+ if (result.status === "missing") return void 0;
4563
+ if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
4564
+ const parsed = result.value;
4565
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4566
+ throw new MemberConfigError(filePath, 'expected a top-level object with a "repos" array');
4567
+ }
4568
+ const raw = parsed.repos;
4569
+ if (!Array.isArray(raw)) {
4570
+ throw new MemberConfigError(filePath, '"repos" must be an array');
4571
+ }
4572
+ const members = [];
4573
+ raw.forEach((entry, index) => {
4574
+ if (typeof entry !== "object" || entry === null || typeof entry.id !== "string" || typeof entry.folder !== "string") {
4575
+ throw new MemberConfigError(
4576
+ filePath,
4577
+ `repos[${index}] must be an object with string "id" and "folder" fields`
4578
+ );
4579
+ }
4580
+ const e = entry;
4581
+ members.push({
4582
+ id: e.id,
4583
+ folder: e.folder,
4584
+ url: typeof e.repo === "string" ? e.repo : void 0,
4585
+ absPath: resolveAbsPath(workspaceRoot, e.folder, overrides),
4586
+ source: "repos.manifest.json"
4587
+ });
4588
+ });
4589
+ return members;
4590
+ }
4591
+ async function readMembers(rootInfo) {
4592
+ const workspaceRoot = rootInfo.mainRoot;
4593
+ const overrides = await readPathOverrides(workspaceRoot);
4594
+ const fromYaml = await readFromWorkspaceYaml(workspaceRoot, overrides);
4595
+ if (fromYaml) return fromYaml;
4596
+ const fromManifest = await readFromManifest(workspaceRoot, overrides);
4597
+ if (fromManifest) return fromManifest;
4598
+ return [];
4599
+ }
4229
4600
 
4230
4601
  // src/scanner/scan-project.ts
4231
- import path31 from "node:path";
4602
+ import path35 from "node:path";
4232
4603
 
4233
4604
  // src/utils/audit-checks.ts
4234
4605
  function isRecord(v) {
4235
4606
  return typeof v === "object" && v !== null;
4236
4607
  }
4237
4608
 
4609
+ // src/utils/git-root.ts
4610
+ init_exec();
4611
+ import path31 from "node:path";
4612
+ function fallback(start) {
4613
+ return {
4614
+ cwd: start,
4615
+ repoRoot: start,
4616
+ gitDir: "",
4617
+ gitCommonDir: "",
4618
+ isLinkedWorktree: false,
4619
+ mainRoot: start,
4620
+ worktreeName: null,
4621
+ isGitRepo: false
4622
+ };
4623
+ }
4624
+ async function resolveAbsoluteGitPath(flag, cwd, repoRoot) {
4625
+ const withFormat = await runGit(["rev-parse", "--path-format=absolute", flag], { cwd });
4626
+ const raw = withFormat.exitCode === 0 ? withFormat.stdout.trim() : "";
4627
+ if (raw && path31.isAbsolute(raw)) return path31.resolve(raw);
4628
+ const plain = await runGit(["rev-parse", flag], { cwd });
4629
+ const plainRaw = plain.exitCode === 0 ? plain.stdout.trim() : "";
4630
+ if (!plainRaw) return "";
4631
+ return path31.isAbsolute(plainRaw) ? path31.resolve(plainRaw) : path31.resolve(repoRoot, plainRaw);
4632
+ }
4633
+ async function resolveRoots(start) {
4634
+ const cwd = start ?? process.cwd();
4635
+ try {
4636
+ const toplevel = await runGit(["rev-parse", "--show-toplevel"], { cwd });
4637
+ if (toplevel.exitCode !== 0) return fallback(cwd);
4638
+ const toplevelRaw = toplevel.stdout.trim();
4639
+ if (!toplevelRaw) return fallback(cwd);
4640
+ const repoRoot = path31.resolve(toplevelRaw);
4641
+ const [gitDir, gitCommonDir] = await Promise.all([
4642
+ resolveAbsoluteGitPath("--git-dir", cwd, repoRoot),
4643
+ resolveAbsoluteGitPath("--git-common-dir", cwd, repoRoot)
4644
+ ]);
4645
+ if (!gitDir || !gitCommonDir) return fallback(cwd);
4646
+ const isLinkedWorktree = gitDir !== gitCommonDir;
4647
+ const mainRoot = isLinkedWorktree && path31.basename(gitCommonDir) === ".git" ? path31.dirname(gitCommonDir) : repoRoot;
4648
+ const worktreeName = isLinkedWorktree ? path31.basename(gitDir) : null;
4649
+ return {
4650
+ cwd,
4651
+ repoRoot,
4652
+ gitDir,
4653
+ gitCommonDir,
4654
+ isLinkedWorktree,
4655
+ mainRoot,
4656
+ worktreeName,
4657
+ isGitRepo: true
4658
+ };
4659
+ } catch {
4660
+ return fallback(cwd);
4661
+ }
4662
+ }
4663
+
4238
4664
  // src/utils/versions.ts
4239
4665
  import semver from "semver";
4240
4666
  function normalizeVersion(version) {
@@ -4255,8 +4681,8 @@ function compareVersions(a, b) {
4255
4681
  }
4256
4682
 
4257
4683
  // src/scanner/detect-package-manager.ts
4258
- import path28 from "node:path";
4259
- import fs26 from "fs-extra";
4684
+ import path32 from "node:path";
4685
+ import fs27 from "fs-extra";
4260
4686
  function detectPackageManager(root, packageManagerField) {
4261
4687
  const field = String(packageManagerField ?? "").trim();
4262
4688
  if (field.startsWith("yarn@")) {
@@ -4274,9 +4700,9 @@ function detectPackageManager(root, packageManagerField) {
4274
4700
  if (satisfiesVersion(version, ">=9")) return "npm";
4275
4701
  return "unknown";
4276
4702
  }
4277
- if (fs26.existsSync(path28.join(root, "yarn.lock"))) return "yarn";
4278
- if (fs26.existsSync(path28.join(root, "pnpm-lock.yaml"))) return "pnpm";
4279
- if (fs26.existsSync(path28.join(root, "package-lock.json"))) return "npm";
4703
+ if (fs27.existsSync(path32.join(root, "yarn.lock"))) return "yarn";
4704
+ if (fs27.existsSync(path32.join(root, "pnpm-lock.yaml"))) return "pnpm";
4705
+ if (fs27.existsSync(path32.join(root, "package-lock.json"))) return "npm";
4280
4706
  return "unknown";
4281
4707
  }
4282
4708
 
@@ -4346,6 +4772,14 @@ function matchRule(rule, ctx) {
4346
4772
  return false;
4347
4773
  }
4348
4774
  var ROLE_RULES = [
4775
+ // Workspace/meta-repo root — same bridge signal `readMembers()`/
4776
+ // `resolveWorkspaceForWorktree()` already use (src/workspace/members.ts,
4777
+ // src/workspace/worktree/root.ts): presence of either config file marks this
4778
+ // root as a workspace aggregating sibling repos, not a runnable app of its own.
4779
+ // `fileEquals` only matches the file at the scan root (not nested under a
4780
+ // sub-repo, which the nested-`.git` boundary excludes from `files` anyway) —
4781
+ // see Task 3.5, docs/plans/workspace-detection-and-permissions-fixes.md.
4782
+ { role: "workspace", any: [fileEquals(WORKSPACE_FILE), fileEquals(REPOS_MANIFEST_FILE)] },
4349
4783
  { role: "next-app", any: [dep("next"), fileIncludes("next.config.")] },
4350
4784
  { role: "react-app", any: [dep("react")] },
4351
4785
  { role: "vite-app", any: [dep("vite"), fileIncludes("vite.config.")] },
@@ -4478,7 +4912,7 @@ function runDetection(ctx, rules = STACK_RULES) {
4478
4912
  }
4479
4913
 
4480
4914
  // src/scanner/detection.ts
4481
- import path29 from "node:path";
4915
+ import path33 from "node:path";
4482
4916
  var UNSUPPORTED_MARKERS = {
4483
4917
  "requirements.txt": "python",
4484
4918
  "pyproject.toml": "python",
@@ -4498,6 +4932,12 @@ function computeDetectionStatus(roles, stacks, unsupportedSignals) {
4498
4932
  if (!hasRealSignal) return "unknown";
4499
4933
  return unsupportedSignals.length > 0 ? "partial" : "supported";
4500
4934
  }
4935
+ function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
4936
+ const markers = unsupportedSignals.join(", ");
4937
+ const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
4938
+ const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree \u2014 sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
4939
+ return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
4940
+ }
4501
4941
  function blocked(rel) {
4502
4942
  return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
4503
4943
  }
@@ -4532,14 +4972,14 @@ function finalizeRoles(registryRoles, deps, files) {
4532
4972
  function collectUnsupportedSignals(files) {
4533
4973
  return [
4534
4974
  ...new Set(
4535
- files.map((f) => UNSUPPORTED_MARKERS[path29.basename(f)]).filter((s) => Boolean(s))
4975
+ files.map((f) => UNSUPPORTED_MARKERS[path33.basename(f)]).filter((s) => Boolean(s))
4536
4976
  )
4537
4977
  ].sort();
4538
4978
  }
4539
4979
 
4540
4980
  // src/scanner/render.ts
4541
4981
  import { readFile } from "node:fs/promises";
4542
- import path30 from "node:path";
4982
+ import path34 from "node:path";
4543
4983
  async function buildContentBlob(root, files) {
4544
4984
  const candidates = files.filter(
4545
4985
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -4547,7 +4987,7 @@ async function buildContentBlob(root, files) {
4547
4987
  const slice = candidates.slice(0, 300);
4548
4988
  const parts = await mapWithConcurrency(slice, async (rel) => {
4549
4989
  try {
4550
- return await readFile(path30.join(root, rel), "utf8");
4990
+ return await readFile(path34.join(root, rel), "utf8");
4551
4991
  } catch {
4552
4992
  return "";
4553
4993
  }
@@ -4594,6 +5034,11 @@ var SAFE_FILES = [
4594
5034
  "pnpm-lock.yaml",
4595
5035
  "composer.json",
4596
5036
  "composer.lock",
5037
+ // Workspace/meta-repo root markers — presence only, drives the 'workspace' role
5038
+ // (src/scanner/detection-registry.ts). Same files resolveWorkspaceForWorktree()
5039
+ // and readMembers() already treat as the workspace-root signal.
5040
+ WORKSPACE_FILE,
5041
+ REPOS_MANIFEST_FILE,
4597
5042
  "nx.json",
4598
5043
  "turbo.json",
4599
5044
  "tsconfig.json",
@@ -4634,8 +5079,8 @@ var SAFE_FILES = [
4634
5079
  "Gemfile"
4635
5080
  ];
4636
5081
  async function scanProject(root) {
4637
- const pkg = await readJson(path31.join(root, "package.json"));
4638
- const composer = await readJson(path31.join(root, "composer.json"));
5082
+ const pkg = await readJson(path35.join(root, "package.json"));
5083
+ const composer = await readJson(path35.join(root, "composer.json"));
4639
5084
  const files = await listFiles(root, SAFE_FILES);
4640
5085
  const safeFiles = files.filter((f) => !blocked(f));
4641
5086
  const deps = dependencySet(pkg, composer);
@@ -4659,10 +5104,10 @@ async function scanProject(root) {
4659
5104
  crossRepoHints.push("Monorepo orchestration detected");
4660
5105
  const unsupportedSignals = collectUnsupportedSignals(safeFiles);
4661
5106
  const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
5107
+ const repoNameFallback = pkg?.name ? String(pkg.name) : path35.basename((await resolveRoots(root)).mainRoot);
4662
5108
  const context = {
4663
5109
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4664
- root,
4665
- repoName: String(pkg?.name ?? path31.basename(root)),
5110
+ repoName: repoNameFallback,
4666
5111
  packageManager,
4667
5112
  repoRoles: roles,
4668
5113
  detectedStacks: stacks,
@@ -4680,10 +5125,18 @@ async function scanProject(root) {
4680
5125
 
4681
5126
  // src/scanner/read-context.ts
4682
5127
  async function isCacheFresh(root, cachePath) {
4683
- const pkgJsonPath = path32.join(root, "package.json");
4684
- if (!await fs27.pathExists(pkgJsonPath)) return true;
4685
- const [cacheStat, pkgStat] = await Promise.all([fs27.stat(cachePath), fs27.stat(pkgJsonPath)]);
4686
- return cacheStat.mtimeMs >= pkgStat.mtimeMs;
5128
+ const markerPaths = [
5129
+ path36.join(root, "package.json"),
5130
+ path36.join(root, WORKSPACE_FILE),
5131
+ path36.join(root, REPOS_MANIFEST_FILE)
5132
+ ];
5133
+ const cacheStat = await fs28.stat(cachePath);
5134
+ for (const markerPath of markerPaths) {
5135
+ if (!await fs28.pathExists(markerPath)) continue;
5136
+ const markerStat = await fs28.stat(markerPath);
5137
+ if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
5138
+ }
5139
+ return true;
4687
5140
  }
4688
5141
  async function readContextOrScan(root) {
4689
5142
  const cachePath = hausPath(root, "context-map.json");
@@ -4781,7 +5234,7 @@ async function runDoctor(options) {
4781
5234
  } else {
4782
5235
  ok(`- HOOKS OK: ${hooks.message}`);
4783
5236
  }
4784
- const rootClaudeMdPath = path33.join(root, "CLAUDE.md");
5237
+ const rootClaudeMdPath = path37.join(root, "CLAUDE.md");
4785
5238
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
4786
5239
  if (!rootClaudeMdContent) {
4787
5240
  flag(
@@ -4809,7 +5262,7 @@ async function runDoctor(options) {
4809
5262
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
4810
5263
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
4811
5264
  for (const target of importTargets) {
4812
- if (!await fs28.pathExists(hausPath(root, target))) {
5265
+ if (!await fs29.pathExists(hausPath(root, target))) {
4813
5266
  flag(
4814
5267
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
4815
5268
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -4820,7 +5273,7 @@ async function runDoctor(options) {
4820
5273
  }
4821
5274
  }
4822
5275
  const workflowPath = hausPath(root, "WORKFLOW.md");
4823
- const workflowExists = await fs28.pathExists(workflowPath);
5276
+ const workflowExists = await fs29.pathExists(workflowPath);
4824
5277
  if (!workflowExists) {
4825
5278
  flag(
4826
5279
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -4835,15 +5288,15 @@ async function runDoctor(options) {
4835
5288
  } else {
4836
5289
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
4837
5290
  const bodyContent = workflowContent.slice(firstLine.length + 1);
4838
- const cachePath = path33.join(getCacheDir(), "templates/agentic-workflow-standard.md");
4839
- const bundledPath = path33.join(
5291
+ const cachePath = path37.join(getCacheDir(), "templates/agentic-workflow-standard.md");
5292
+ const bundledPath = path37.join(
4840
5293
  packageRoot(),
4841
5294
  "library",
4842
5295
  "global",
4843
5296
  "templates",
4844
5297
  "agentic-workflow-standard.md"
4845
5298
  );
4846
- const templatePath = await fs28.pathExists(cachePath) ? cachePath : bundledPath;
5299
+ const templatePath = await fs29.pathExists(cachePath) ? cachePath : bundledPath;
4847
5300
  const templateContent = await readText(templatePath);
4848
5301
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
4849
5302
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -4864,7 +5317,7 @@ async function runDoctor(options) {
4864
5317
  }
4865
5318
  }
4866
5319
  const workflowConfigPath = hausPath(root, "workflow-config.md");
4867
- const workflowConfigExists = await fs28.pathExists(workflowConfigPath);
5320
+ const workflowConfigExists = await fs29.pathExists(workflowConfigPath);
4868
5321
  if (!workflowConfigExists) {
4869
5322
  flag(
4870
5323
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -4872,7 +5325,7 @@ async function runDoctor(options) {
4872
5325
  "haus apply --write"
4873
5326
  );
4874
5327
  } else {
4875
- const cfg = await fs28.readFile(workflowConfigPath, "utf8");
5328
+ const cfg = await fs29.readFile(workflowConfigPath, "utf8");
4876
5329
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
4877
5330
  if (unfilled > 0) {
4878
5331
  suggest(
@@ -4885,7 +5338,7 @@ async function runDoctor(options) {
4885
5338
  }
4886
5339
  }
4887
5340
  if (workflowExists) {
4888
- const prettierIgnore = await readText(path33.join(root, ".prettierignore")) ?? "";
5341
+ const prettierIgnore = await readText(path37.join(root, ".prettierignore")) ?? "";
4889
5342
  const lines = prettierIgnore.split("\n");
4890
5343
  const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
4891
5344
  if (missing.length > 0) {
@@ -4898,6 +5351,29 @@ async function runDoctor(options) {
4898
5351
  ok(`- .prettierignore: protects ${IGNORED_PATHS.join(" and ")}`);
4899
5352
  }
4900
5353
  }
5354
+ const trackedArtifacts = await listTrackedArtifactPaths(root);
5355
+ if (trackedArtifacts.length > 0) {
5356
+ flag(
5357
+ `- GITIGNORE: ${trackedArtifacts.length} machine-local scan artifact(s) still tracked in git (${trackedArtifacts.join(", ")})`,
5358
+ `${trackedArtifacts.length} machine-local scan artifact(s) are still tracked in git`,
5359
+ `git rm --cached ${trackedArtifacts.join(" ")} (or run \`haus apply --write\` to do this and update .gitignore for you)`
5360
+ );
5361
+ } else {
5362
+ ok("- GITIGNORE: OK (no machine-local scan artifacts tracked in git)");
5363
+ }
5364
+ const gitignoredHausPaths = await findGitignoredHausPaths(root);
5365
+ if (gitignoredHausPaths.length > 0) {
5366
+ const dirs = summarizeGitignoredHausDirs(gitignoredHausPaths);
5367
+ const dirList = dirs.join(" and ");
5368
+ const verb = dirs.length === 1 ? "is" : "are";
5369
+ flag(
5370
+ `- GITIGNORE (haus content): ${dirList} ${verb} gitignored \u2014 installed skills/agents/commands will not be visible to anything relying on git-tracked state`,
5371
+ `${dirList} ${verb} gitignored, so haus-installed content isn't visible to git-tracked state`,
5372
+ "remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored)"
5373
+ );
5374
+ } else {
5375
+ ok("- GITIGNORE (haus content): OK (.claude/ and .haus-workflow/ are tracked)");
5376
+ }
4901
5377
  for (const finding of await auditDecisionsLayout(root)) {
4902
5378
  if (finding.level === "ok") ok(finding.line);
4903
5379
  else suggest(finding.line, finding.sentence, finding.fix);
@@ -4935,7 +5411,7 @@ async function runDoctor(options) {
4935
5411
  );
4936
5412
  }
4937
5413
  }
4938
- const pkgJson = await readJson(path33.join(packageRoot(), "package.json"));
5414
+ const pkgJson = await readJson(path37.join(packageRoot(), "package.json"));
4939
5415
  const currentVersion = pkgJson?.version ?? "0.0.0";
4940
5416
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4941
5417
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -4968,12 +5444,12 @@ async function runDoctor(options) {
4968
5444
  }
4969
5445
 
4970
5446
  // src/commands/update.ts
4971
- import path36 from "node:path";
5447
+ import path40 from "node:path";
4972
5448
 
4973
5449
  // src/claude/refresh-project.ts
4974
- import fs29 from "fs-extra";
5450
+ import fs30 from "fs-extra";
4975
5451
  async function isHausProject(root) {
4976
- if (await fs29.pathExists(hausPath(root, "recommendation.json"))) return true;
5452
+ if (await fs30.pathExists(hausPath(root, "recommendation.json"))) return true;
4977
5453
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
4978
5454
  if (result.status === "ok") return result.value._haus != null;
4979
5455
  return false;
@@ -4985,8 +5461,8 @@ async function refreshProjectApply(root) {
4985
5461
 
4986
5462
  // src/install/apply.ts
4987
5463
  import crypto2 from "node:crypto";
4988
- import path34 from "node:path";
4989
- import fs30 from "fs-extra";
5464
+ import path38 from "node:path";
5465
+ import fs31 from "fs-extra";
4990
5466
 
4991
5467
  // src/install/header.ts
4992
5468
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -5072,57 +5548,57 @@ function hashContent(content2) {
5072
5548
  }
5073
5549
  function sourceVersion() {
5074
5550
  try {
5075
- const pkgPath = path34.join(packageRoot(), "package.json");
5076
- const pkg = JSON.parse(fs30.readFileSync(pkgPath, "utf8"));
5551
+ const pkgPath = path38.join(packageRoot(), "package.json");
5552
+ const pkg = JSON.parse(fs31.readFileSync(pkgPath, "utf8"));
5077
5553
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
5078
5554
  } catch {
5079
5555
  return "haus@0.0.0";
5080
5556
  }
5081
5557
  }
5082
5558
  function globalSrcDir() {
5083
- return path34.join(packageRoot(), "library", "global");
5559
+ return path38.join(packageRoot(), "library", "global");
5084
5560
  }
5085
5561
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
5086
- const skillDir = path34.join(skillsDir, skillName);
5562
+ const skillDir = path38.join(skillsDir, skillName);
5087
5563
  const entries = [];
5088
- for (const rel of fs30.readdirSync(skillDir, { recursive: true })) {
5564
+ for (const rel of fs31.readdirSync(skillDir, { recursive: true })) {
5089
5565
  if (rel === "SKILL.md") continue;
5090
- const abs = path34.join(skillDir, rel);
5091
- if (!fs30.statSync(abs).isFile()) continue;
5092
- const posixRel = rel.split(path34.sep).join("/");
5566
+ const abs = path38.join(skillDir, rel);
5567
+ if (!fs31.statSync(abs).isFile()) continue;
5568
+ const posixRel = rel.split(path38.sep).join("/");
5093
5569
  entries.push({
5094
5570
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
5095
- srcRelPath: path34.join("library", "global", "skills", skillName, rel),
5096
- destPath: path34.join(claudeDir, "skills", skillName, rel)
5571
+ srcRelPath: path38.join("library", "global", "skills", skillName, rel),
5572
+ destPath: path38.join(claudeDir, "skills", skillName, rel)
5097
5573
  });
5098
5574
  }
5099
5575
  return entries;
5100
5576
  }
5101
5577
  function collectSourceFiles(srcDir, claudeDir) {
5102
5578
  const entries = [];
5103
- const skillsDir = path34.join(srcDir, "skills");
5104
- if (fs30.pathExistsSync(skillsDir)) {
5105
- for (const skillName of fs30.readdirSync(skillsDir)) {
5106
- const skillFile = path34.join(skillsDir, skillName, "SKILL.md");
5107
- if (fs30.pathExistsSync(skillFile)) {
5579
+ const skillsDir = path38.join(srcDir, "skills");
5580
+ if (fs31.pathExistsSync(skillsDir)) {
5581
+ for (const skillName of fs31.readdirSync(skillsDir)) {
5582
+ const skillFile = path38.join(skillsDir, skillName, "SKILL.md");
5583
+ if (fs31.pathExistsSync(skillFile)) {
5108
5584
  entries.push({
5109
5585
  stableId: `skill.${skillName}`,
5110
- srcRelPath: path34.join("library", "global", "skills", skillName, "SKILL.md"),
5111
- destPath: path34.join(claudeDir, "skills", skillName, "SKILL.md")
5586
+ srcRelPath: path38.join("library", "global", "skills", skillName, "SKILL.md"),
5587
+ destPath: path38.join(claudeDir, "skills", skillName, "SKILL.md")
5112
5588
  });
5113
5589
  entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
5114
5590
  }
5115
5591
  }
5116
5592
  }
5117
- const commandsDir = path34.join(srcDir, "commands");
5118
- if (fs30.pathExistsSync(commandsDir)) {
5119
- for (const fileName of fs30.readdirSync(commandsDir)) {
5593
+ const commandsDir = path38.join(srcDir, "commands");
5594
+ if (fs31.pathExistsSync(commandsDir)) {
5595
+ for (const fileName of fs31.readdirSync(commandsDir)) {
5120
5596
  if (!fileName.endsWith(".md")) continue;
5121
5597
  const commandName = fileName.slice(0, -".md".length);
5122
5598
  entries.push({
5123
5599
  stableId: `command.${commandName}`,
5124
- srcRelPath: path34.join("library", "global", "commands", fileName),
5125
- destPath: path34.join(claudeDir, "commands", fileName)
5600
+ srcRelPath: path38.join("library", "global", "commands", fileName),
5601
+ destPath: path38.join(claudeDir, "commands", fileName)
5126
5602
  });
5127
5603
  }
5128
5604
  }
@@ -5148,7 +5624,7 @@ async function applyInstall(options = {}) {
5148
5624
  };
5149
5625
  const manifestFiles = [];
5150
5626
  for (const entry of sourceFiles) {
5151
- const srcPath = path34.join(packageRoot(), entry.srcRelPath);
5627
+ const srcPath = path38.join(packageRoot(), entry.srcRelPath);
5152
5628
  const rawContent = await readText(srcPath);
5153
5629
  if (rawContent === void 0) {
5154
5630
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -5168,7 +5644,7 @@ async function applyInstall(options = {}) {
5168
5644
  }
5169
5645
  continue;
5170
5646
  }
5171
- const destExists = fs30.pathExistsSync(entry.destPath);
5647
+ const destExists = fs31.pathExistsSync(entry.destPath);
5172
5648
  if (destExists) {
5173
5649
  const currentContent = await readText(entry.destPath);
5174
5650
  if (currentContent !== void 0) {
@@ -5208,7 +5684,7 @@ async function applyInstall(options = {}) {
5208
5684
  }
5209
5685
  let mergedSettings;
5210
5686
  if (!check) {
5211
- const fragmentPath = path34.join(srcDir, "settings-fragments", "hooks.json");
5687
+ const fragmentPath = path38.join(srcDir, "settings-fragments", "hooks.json");
5212
5688
  const fragments = await loadHooksFragment(fragmentPath);
5213
5689
  const settings = await readSettings();
5214
5690
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -5222,13 +5698,13 @@ async function applyInstall(options = {}) {
5222
5698
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
5223
5699
  for (const entry of existingManifest.files) {
5224
5700
  if (currentDestPaths.has(entry.destPath)) continue;
5225
- if (!fs30.pathExistsSync(entry.destPath)) continue;
5701
+ if (!fs31.pathExistsSync(entry.destPath)) continue;
5226
5702
  const content2 = await readText(entry.destPath);
5227
5703
  if (!content2) continue;
5228
5704
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
5229
5705
  const currentHash = hashContent(content2);
5230
5706
  if (hasHeader && currentHash === entry.hash) {
5231
- if (!dryRun) await fs30.remove(entry.destPath);
5707
+ if (!dryRun) await fs31.remove(entry.destPath);
5232
5708
  result.deleted.push(entry.destPath);
5233
5709
  } else {
5234
5710
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -5297,7 +5773,7 @@ function summarizeLockDiff(before, after) {
5297
5773
 
5298
5774
  // src/update/lockfile.ts
5299
5775
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
5300
- import path35 from "node:path";
5776
+ import path39 from "node:path";
5301
5777
  async function readLockSummary(root) {
5302
5778
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
5303
5779
  const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
@@ -5333,7 +5809,7 @@ async function applyLock(root) {
5333
5809
  try {
5334
5810
  const backupDir = hausPath(root, "backups");
5335
5811
  await mkdir(backupDir, { recursive: true });
5336
- await copyFile(lockPath, path35.join(backupDir, `haus.lock.${Date.now()}.json`));
5812
+ await copyFile(lockPath, path39.join(backupDir, `haus.lock.${Date.now()}.json`));
5337
5813
  } catch {
5338
5814
  }
5339
5815
  const enriched = await Promise.all(
@@ -5355,7 +5831,7 @@ function diffLock(before, after) {
5355
5831
  }
5356
5832
  async function hasLocalOverrides(root) {
5357
5833
  try {
5358
- await readFile2(path35.join(root, ".claude", "settings.json"), "utf8");
5834
+ await readFile2(path39.join(root, ".claude", "settings.json"), "utf8");
5359
5835
  return true;
5360
5836
  } catch {
5361
5837
  return false;
@@ -5371,7 +5847,7 @@ async function runUpdate(options) {
5371
5847
  return;
5372
5848
  }
5373
5849
  if (options.check) {
5374
- const pkgJson2 = await readJson(path36.join(packageRoot(), "package.json"));
5850
+ const pkgJson2 = await readJson(path40.join(packageRoot(), "package.json"));
5375
5851
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
5376
5852
  const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
5377
5853
  options.fast ? fastLockStatus(root) : checkLock(root),
@@ -5406,7 +5882,7 @@ async function runUpdate(options) {
5406
5882
  if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
5407
5883
  return;
5408
5884
  }
5409
- const pkgJson = await readJson(path36.join(packageRoot(), "package.json"));
5885
+ const pkgJson = await readJson(path40.join(packageRoot(), "package.json"));
5410
5886
  const currentVersion = pkgJson?.version ?? "0.0.0";
5411
5887
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
5412
5888
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -5448,7 +5924,7 @@ async function runFromHookCheck(root) {
5448
5924
  try {
5449
5925
  const summary = await readLockSummary(root);
5450
5926
  if (summary.count === 0) return;
5451
- const pkgJson = await readJson(path36.join(packageRoot(), "package.json"));
5927
+ const pkgJson = await readJson(path40.join(packageRoot(), "package.json"));
5452
5928
  const currentVersion = pkgJson?.version ?? "0.0.0";
5453
5929
  const [npmVersion, latestCatalogTag] = await Promise.all([
5454
5930
  fetchNpmVersionStatus(currentVersion),
@@ -5598,7 +6074,7 @@ async function runCiGate(options = {}) {
5598
6074
  // src/commands/clone.ts
5599
6075
  init_exec();
5600
6076
  import { existsSync as existsSync2 } from "node:fs";
5601
- import path37 from "node:path";
6077
+ import path41 from "node:path";
5602
6078
  var GIT_LOCATION_VARS = [
5603
6079
  "GIT_DIR",
5604
6080
  "GIT_WORK_TREE",
@@ -5648,11 +6124,11 @@ async function runClone(url, opts = {}) {
5648
6124
  process.exitCode = 1;
5649
6125
  return;
5650
6126
  }
5651
- const target = path37.resolve(opts.dir?.trim() || repoNameFromUrl(url));
6127
+ const target = path41.resolve(opts.dir?.trim() || repoNameFromUrl(url));
5652
6128
  if (existsSync2(target)) {
5653
6129
  const check = await checkExistingTarget(target, url);
5654
6130
  if (check.kind === "matches") {
5655
- log(`\u2022 ${path37.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
6131
+ log(`\u2022 ${path41.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
5656
6132
  return;
5657
6133
  }
5658
6134
  const message = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}) \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
@@ -5762,7 +6238,7 @@ async function runDecisionsGuard(options = {}) {
5762
6238
  }
5763
6239
 
5764
6240
  // src/commands/decisions.ts
5765
- import path38 from "node:path";
6241
+ import path42 from "node:path";
5766
6242
 
5767
6243
  // src/decisions/next-number.ts
5768
6244
  async function nextDecisionNumber(root) {
@@ -5873,7 +6349,7 @@ async function runDecisions(sub, arg, options = {}) {
5873
6349
  return;
5874
6350
  }
5875
6351
  if (sub === "validate") {
5876
- const target = arg ? path38.resolve(root, arg) : void 0;
6352
+ const target = arg ? path42.resolve(root, arg) : void 0;
5877
6353
  if (!target) {
5878
6354
  error("usage: haus decisions validate <path-to-decision.md>");
5879
6355
  process.exitCode = 1;
@@ -6142,8 +6618,8 @@ async function runGuard(kind, _options) {
6142
6618
  }
6143
6619
 
6144
6620
  // src/commands/init.ts
6145
- import path39 from "node:path";
6146
- import fs31 from "fs-extra";
6621
+ import path44 from "node:path";
6622
+ import fs32 from "fs-extra";
6147
6623
 
6148
6624
  // src/recommender/git-signal.ts
6149
6625
  init_exec();
@@ -6206,11 +6682,9 @@ function describeRequiresAny(clauses) {
6206
6682
  return "unknown";
6207
6683
  }).join(" | ");
6208
6684
  }
6209
- function mergeRecommendationWarnings(context) {
6685
+ function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
6210
6686
  const markers = context.unsupportedSignals?.join(", ");
6211
- const statusLines = context.detectionStatus === "unknown" ? [
6212
- markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support. Only stack-agnostic workflow and security guidance is applied.` : "Stack not recognised \u2014 no supported framework detected. Only stack-agnostic workflow and security guidance is applied."
6213
- ] : context.detectionStatus === "partial" && markers ? [
6687
+ const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
6214
6688
  `Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
6215
6689
  ] : [];
6216
6690
  const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
@@ -6429,11 +6903,12 @@ async function recommend(root, context, opts = {}) {
6429
6903
  const skippedRules = skipped.length;
6430
6904
  const estimatedContextTokens = estimateContextTokens(selectedRules);
6431
6905
  const estimatedTokenReductionPct = tokenReductionPct(selectedRules, skippedRules);
6906
+ const { isLinkedWorktree } = await resolveRoots(root);
6432
6907
  return {
6433
6908
  recommended,
6434
6909
  skipped,
6435
6910
  optInEligible,
6436
- warnings: [...mergeRecommendationWarnings(context), ...includeWarnings],
6911
+ warnings: [...mergeRecommendationWarnings(context, isLinkedWorktree), ...includeWarnings],
6437
6912
  estimatedContextTokens,
6438
6913
  selectedRules,
6439
6914
  skippedRules,
@@ -6610,9 +7085,162 @@ function buildOptInEligible(items, skipped) {
6610
7085
  return eligible;
6611
7086
  }
6612
7087
 
7088
+ // src/commands/workspace/discover.ts
7089
+ import path43 from "node:path";
7090
+ import fg4 from "fast-glob";
7091
+ import YAML2 from "yaml";
7092
+ var DEFAULT_MAX_DEPTH = 3;
7093
+ var GIT_MARKER = ["**/.git"];
7094
+ var MANIFEST_MARKERS = [
7095
+ "**/package.json",
7096
+ "**/composer.json",
7097
+ "**/*.csproj",
7098
+ "**/*.sln",
7099
+ "**/*.fsproj",
7100
+ // .NET
7101
+ "**/pom.xml",
7102
+ "**/build.gradle*",
7103
+ // Java
7104
+ "**/Gemfile"
7105
+ // Ruby
7106
+ ];
7107
+ var IGNORE = ["**/node_modules/**", "**/vendor/**", "**/dist/**", "**/.haus-workflow/**"];
7108
+ function isDescendant(child, ancestor) {
7109
+ if (ancestor === ".") return child !== ".";
7110
+ return child === ancestor ? false : child.startsWith(`${ancestor}/`);
7111
+ }
7112
+ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7113
+ const commonOpts = {
7114
+ cwd: workspaceRoot,
7115
+ dot: true,
7116
+ onlyFiles: false,
7117
+ deep: maxDepth,
7118
+ followSymbolicLinks: false,
7119
+ // Discovery walks arbitrary directories under the workspace root; an
7120
+ // unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
7121
+ suppressErrors: true
7122
+ };
7123
+ const [gitMatches, manifestMatches] = await Promise.all([
7124
+ fg4(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
7125
+ fg4(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
7126
+ ]);
7127
+ const gitDirs = /* @__PURE__ */ new Set();
7128
+ const manifestDirs = /* @__PURE__ */ new Set();
7129
+ for (const match of gitMatches) {
7130
+ gitDirs.add(path43.posix.dirname(match));
7131
+ }
7132
+ for (const match of manifestMatches) {
7133
+ manifestDirs.add(path43.posix.dirname(match));
7134
+ }
7135
+ const repoRoots = [...gitDirs];
7136
+ const manifestSorted = [...manifestDirs].sort(
7137
+ (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
7138
+ );
7139
+ for (const dir of manifestSorted) {
7140
+ if (gitDirs.has(dir)) continue;
7141
+ if (repoRoots.some((root) => isDescendant(dir, root))) continue;
7142
+ repoRoots.push(dir);
7143
+ }
7144
+ repoRoots.sort((a, b) => a.localeCompare(b));
7145
+ return repoRoots;
7146
+ }
7147
+ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7148
+ const repoRoots = await findRepoRoots(workspaceRoot, maxDepth);
7149
+ return mapWithConcurrency(repoRoots, async (relDir) => {
7150
+ const absDir = path43.resolve(workspaceRoot, relDir);
7151
+ const pkg = await readJson(path43.join(absDir, "package.json"));
7152
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path43.basename(relDir === "." ? workspaceRoot : absDir);
7153
+ let role = "auto";
7154
+ try {
7155
+ const scan = await scanProject(absDir);
7156
+ if (scan.repoRoles.length > 0) role = scan.repoRoles.join("+");
7157
+ } catch {
7158
+ }
7159
+ return { name, path: relDir === "." ? "." : relDir, role };
7160
+ });
7161
+ }
7162
+ function mergeWorkspaceConfig(existing, discovered, opts = {}) {
7163
+ const existingRepos = existing?.repos ?? [];
7164
+ const byPath = new Map(existingRepos.map((r) => [r.path, r]));
7165
+ for (const repo of discovered) {
7166
+ if (!byPath.has(repo.path)) {
7167
+ byPath.set(repo.path, { name: repo.name, path: repo.path, role: repo.role });
7168
+ }
7169
+ }
7170
+ const ordered = [];
7171
+ const seen = /* @__PURE__ */ new Set();
7172
+ for (const repo of existingRepos) {
7173
+ ordered.push(byPath.get(repo.path));
7174
+ seen.add(repo.path);
7175
+ }
7176
+ for (const repo of discovered) {
7177
+ if (seen.has(repo.path)) continue;
7178
+ ordered.push(byPath.get(repo.path));
7179
+ seen.add(repo.path);
7180
+ }
7181
+ return {
7182
+ client: opts.client ?? existing?.client ?? "unknown",
7183
+ repos: ordered,
7184
+ relationships: existing?.relationships ?? []
7185
+ };
7186
+ }
7187
+ function renderWorkspaceYaml(config) {
7188
+ return YAML2.stringify({
7189
+ client: config.client,
7190
+ repos: config.repos.map((r) => ({ name: r.name, path: r.path, role: r.role ?? "auto" })),
7191
+ relationships: config.relationships
7192
+ });
7193
+ }
7194
+ async function runDiscover(workspaceRoot, opts = {}) {
7195
+ const yamlPath = path43.join(workspaceRoot, "haus.workspace.yaml");
7196
+ const existingText = await readText(yamlPath);
7197
+ const existing = parseWorkspaceConfig(existingText);
7198
+ if (existingText && !existing) {
7199
+ error(
7200
+ "Existing haus.workspace.yaml is malformed \u2014 fix or remove it before running discover (refusing to overwrite)."
7201
+ );
7202
+ process.exitCode = 1;
7203
+ return;
7204
+ }
7205
+ const discovered = await discoverRepos(workspaceRoot, opts.maxDepth ?? DEFAULT_MAX_DEPTH);
7206
+ if (discovered.length === 0) {
7207
+ error("No repos discovered under the workspace root.");
7208
+ process.exitCode = 1;
7209
+ return;
7210
+ }
7211
+ const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
7212
+ const yamlText = renderWorkspaceYaml(merged);
7213
+ if (opts.write) {
7214
+ await writeText(yamlPath, yamlText);
7215
+ if (opts.json) {
7216
+ log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
7217
+ } else {
7218
+ log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
7219
+ }
7220
+ return;
7221
+ }
7222
+ if (opts.json) {
7223
+ log(JSON.stringify({ discovered, config: merged }, null, 2));
7224
+ return;
7225
+ }
7226
+ log("Proposed haus.workspace.yaml (run with --write to persist):\n");
7227
+ log(yamlText);
7228
+ }
7229
+
7230
+ // src/scanner/sibling-repos.ts
7231
+ var SIBLING_REPO_WARNING = 'Multiple sibling repos detected under this root \u2014 consider "haus workspace discover" instead of running setup-project/scan per repo.';
7232
+ async function hasMultipleSiblingRepos(root) {
7233
+ try {
7234
+ const repoRoots = await findRepoRoots(root);
7235
+ return repoRoots.filter((relDir) => relDir !== ".").length >= 2;
7236
+ } catch {
7237
+ return false;
7238
+ }
7239
+ }
7240
+
6613
7241
  // src/claude/setup-core.ts
6614
7242
  async function runSetupCore(root, opts) {
6615
- const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
7243
+ const { json, apply, dryRun, quiet, confirm: confirm2, force } = opts;
6616
7244
  const say = quiet ? () => {
6617
7245
  } : log;
6618
7246
  const scanResult = await scanProject(root);
@@ -6623,8 +7251,35 @@ async function runSetupCore(root, opts) {
6623
7251
  say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
6624
7252
  say(`Package manager: ${scanResult.packageManager}`);
6625
7253
  }
7254
+ const siblingWarning = await hasMultipleSiblingRepos(root) ? [SIBLING_REPO_WARNING] : [];
6626
7255
  const context = await readContextOrScan(root);
6627
7256
  const recommendation = await recommend(root, context);
7257
+ if (recommendation.recommended.length === 0 && !force) {
7258
+ const warningLines2 = [
7259
+ .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
7260
+ ];
7261
+ if (json) {
7262
+ say(JSON.stringify(recommendation, null, 2));
7263
+ } else {
7264
+ say("Haus recommendation ready");
7265
+ say("Recommended: 0");
7266
+ say(`Skipped: ${recommendation.skipped.length}`);
7267
+ say(`Repo: ${context.repoName}`);
7268
+ for (const warning of warningLines2) say(`- WARN: ${warning}`);
7269
+ }
7270
+ const message = "No catalog items matched this project at all (zero signal) \u2014 refusing to write recommendation.json/haus.lock.json. Re-run with `haus setup-project --force` to write anyway.";
7271
+ warn(message);
7272
+ process.exitCode = 1;
7273
+ return {
7274
+ root,
7275
+ repoName: context.repoName,
7276
+ roles: scanResult.repoRoles,
7277
+ recommendedCount: 0,
7278
+ warnings: [...warningLines2, message],
7279
+ hooksOk: false,
7280
+ written: []
7281
+ };
7282
+ }
6628
7283
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
6629
7284
  if (json) {
6630
7285
  say(JSON.stringify(recommendation, null, 2));
@@ -6634,7 +7289,9 @@ async function runSetupCore(root, opts) {
6634
7289
  say(`Skipped: ${recommendation.skipped.length}`);
6635
7290
  }
6636
7291
  const hooks = await verifyProjectSettingsHooksContract(root);
6637
- const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
7292
+ const warningLines = [
7293
+ .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
7294
+ ];
6638
7295
  say(`Repo: ${context.repoName}`);
6639
7296
  for (const warning of warningLines) say(`- WARN: ${warning}`);
6640
7297
  const hooksOk = hooks.ok;
@@ -6699,6 +7356,7 @@ async function runSetupProject(options) {
6699
7356
  json: options.json,
6700
7357
  apply: !options.json,
6701
7358
  dryRun: false,
7359
+ force: options.force,
6702
7360
  confirm: () => confirm("Approve and write Claude files now?")
6703
7361
  });
6704
7362
  }
@@ -6706,8 +7364,8 @@ async function runSetupProject(options) {
6706
7364
  // src/commands/init.ts
6707
7365
  async function runInit(options) {
6708
7366
  const root = process.cwd();
6709
- const hausDir = path39.join(root, ".haus-workflow");
6710
- const alreadyInit = await fs31.pathExists(hausDir);
7367
+ const hausDir = path44.join(root, ".haus-workflow");
7368
+ const alreadyInit = await fs32.pathExists(hausDir);
6711
7369
  if (alreadyInit) {
6712
7370
  log("Haus AI already initialized in this project.");
6713
7371
  log("Run `haus setup-project` to reconfigure.");
@@ -6842,8 +7500,8 @@ async function runRefresh() {
6842
7500
  }
6843
7501
 
6844
7502
  // src/install/scaffold.ts
6845
- import path40 from "node:path";
6846
- import fs32 from "fs-extra";
7503
+ import path45 from "node:path";
7504
+ import fs33 from "fs-extra";
6847
7505
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
6848
7506
  const scaffolded = [];
6849
7507
  const skipped = [];
@@ -6856,27 +7514,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
6856
7514
  skipped.push(name);
6857
7515
  if (outcome === "skipped-exists") skippedExisting.push(name);
6858
7516
  };
6859
- const realRoot = await fs32.realpath(catalogRoot).catch(() => null);
7517
+ const realRoot = await fs33.realpath(catalogRoot).catch(() => null);
6860
7518
  for (const item of items) {
6861
7519
  if (item.type !== "config") continue;
6862
- const sourcePath = path40.resolve(catalogRoot, item.path);
6863
- const rel = path40.relative(catalogRoot, sourcePath);
6864
- const escapes = rel === "" || path40.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
7520
+ const sourcePath = path45.resolve(catalogRoot, item.path);
7521
+ const rel = path45.relative(catalogRoot, sourcePath);
7522
+ const escapes = rel === "" || path45.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
6865
7523
  if (escapes) {
6866
7524
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
6867
7525
  continue;
6868
7526
  }
6869
- const realSource = await fs32.realpath(sourcePath).catch(() => null);
7527
+ const realSource = await fs33.realpath(sourcePath).catch(() => null);
6870
7528
  if (!realRoot || !realSource) {
6871
7529
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
6872
7530
  continue;
6873
7531
  }
6874
- const realRel = path40.relative(realRoot, realSource);
6875
- if (realRel !== "" && (path40.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
7532
+ const realRel = path45.relative(realRoot, realSource);
7533
+ if (realRel !== "" && (path45.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
6876
7534
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
6877
7535
  continue;
6878
7536
  }
6879
- const stat = await fs32.lstat(sourcePath).catch(() => null);
7537
+ const stat = await fs33.lstat(sourcePath).catch(() => null);
6880
7538
  if (!stat) {
6881
7539
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
6882
7540
  continue;
@@ -6886,14 +7544,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
6886
7544
  continue;
6887
7545
  }
6888
7546
  if (stat.isFile()) {
6889
- const filename = path40.basename(sourcePath);
6890
- const dest = path40.join(projectRoot, filename);
7547
+ const filename = path45.basename(sourcePath);
7548
+ const dest = path45.join(projectRoot, filename);
6891
7549
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
6892
7550
  } else if (stat.isDirectory()) {
6893
- const entries = await fs32.readdir(sourcePath);
7551
+ const entries = await fs33.readdir(sourcePath);
6894
7552
  for (const entry of entries) {
6895
- const src = path40.join(sourcePath, entry);
6896
- const dest = path40.join(projectRoot, entry);
7553
+ const src = path45.join(sourcePath, entry);
7554
+ const dest = path45.join(projectRoot, entry);
6897
7555
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
6898
7556
  }
6899
7557
  }
@@ -6901,42 +7559,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
6901
7559
  return { scaffolded, skipped, skippedExisting };
6902
7560
  }
6903
7561
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
6904
- const destDisplay = path40.relative(projectRoot, dest) || path40.basename(dest);
6905
- const srcStat = await fs32.lstat(src).catch(() => null);
7562
+ const destDisplay = path45.relative(projectRoot, dest) || path45.basename(dest);
7563
+ const srcStat = await fs33.lstat(src).catch(() => null);
6906
7564
  if (!srcStat) {
6907
- warn(`Skipping ${path40.basename(src)}: source not found`);
7565
+ warn(`Skipping ${path45.basename(src)}: source not found`);
6908
7566
  return "skipped-refused";
6909
7567
  }
6910
7568
  if (srcStat.isSymbolicLink()) {
6911
- warn(`Skipping ${path40.basename(src)}: source is a symlink`);
7569
+ warn(`Skipping ${path45.basename(src)}: source is a symlink`);
6912
7570
  return "skipped-refused";
6913
7571
  }
6914
- const exists = await fs32.pathExists(dest);
7572
+ const exists = await fs33.pathExists(dest);
6915
7573
  if (exists && !opts.force) {
6916
7574
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
6917
7575
  return "skipped-exists";
6918
7576
  }
6919
7577
  if (opts.dryRun) {
6920
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path40.basename(dest)} (${itemId})`);
7578
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path45.basename(dest)} (${itemId})`);
6921
7579
  return "scaffolded";
6922
7580
  }
6923
7581
  if (exists && opts.force) {
6924
- await fs32.remove(dest);
7582
+ await fs33.remove(dest);
6925
7583
  }
6926
- await fs32.ensureDir(path40.dirname(dest));
6927
- await fs32.copy(src, dest, {
7584
+ await fs33.ensureDir(path45.dirname(dest));
7585
+ await fs33.copy(src, dest, {
6928
7586
  overwrite: true,
6929
7587
  dereference: false,
6930
7588
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
6931
7589
  filter: (s) => {
6932
7590
  try {
6933
- return !fs32.lstatSync(s).isSymbolicLink();
7591
+ return !fs33.lstatSync(s).isSymbolicLink();
6934
7592
  } catch {
6935
7593
  return false;
6936
7594
  }
6937
7595
  }
6938
7596
  });
6939
- log(`${exists ? "Overwrote" : "Created"} ${path40.basename(dest)} (${itemId})`);
7597
+ log(`${exists ? "Overwrote" : "Created"} ${path45.basename(dest)} (${itemId})`);
6940
7598
  return "scaffolded";
6941
7599
  }
6942
7600
 
@@ -6981,7 +7639,8 @@ async function runScaffold(ids, options) {
6981
7639
 
6982
7640
  // src/commands/scan.ts
6983
7641
  async function runScan(options) {
6984
- const result = await scanProject(process.cwd());
7642
+ const root = process.cwd();
7643
+ const result = await scanProject(root);
6985
7644
  if (options.json) {
6986
7645
  log(JSON.stringify(result, null, 2));
6987
7646
  return;
@@ -6989,12 +7648,19 @@ async function runScan(options) {
6989
7648
  log("Haus scan complete");
6990
7649
  log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
6991
7650
  log(`Package manager: ${result.packageManager}`);
7651
+ if (result.detectionStatus === "unknown") {
7652
+ const { isLinkedWorktree } = await resolveRoots(root);
7653
+ log(`- WARN: ${describeUnknownDetection(result.unsupportedSignals, isLinkedWorktree)}`);
7654
+ }
6992
7655
  for (const w of result.warnings) log(`- WARN: ${w}`);
7656
+ if (await hasMultipleSiblingRepos(root)) {
7657
+ log(`- WARN: ${SIBLING_REPO_WARNING}`);
7658
+ }
6993
7659
  }
6994
7660
 
6995
7661
  // src/commands/undo.ts
6996
- import path41 from "node:path";
6997
- import fs33 from "fs-extra";
7662
+ import path46 from "node:path";
7663
+ import fs34 from "fs-extra";
6998
7664
 
6999
7665
  // src/claude/managed-paths.ts
7000
7666
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
@@ -7009,7 +7675,7 @@ function coreManagedAbsolutePaths(root) {
7009
7675
  async function collectCoreManagedPaths(root) {
7010
7676
  const existing = [];
7011
7677
  for (const abs of coreManagedAbsolutePaths(root)) {
7012
- if (await fs33.pathExists(abs)) existing.push(abs);
7678
+ if (await fs34.pathExists(abs)) existing.push(abs);
7013
7679
  }
7014
7680
  return existing;
7015
7681
  }
@@ -7021,18 +7687,18 @@ async function collectLockTrackedPaths(root) {
7021
7687
  const relPaths = row.paths ?? [];
7022
7688
  const existingRel = [];
7023
7689
  for (const rel of relPaths) {
7024
- if (await fs33.pathExists(path41.resolve(root, rel))) existingRel.push(rel);
7690
+ if (await fs34.pathExists(path46.resolve(root, rel))) existingRel.push(rel);
7025
7691
  }
7026
7692
  if (existingRel.length === 0) continue;
7027
7693
  if (row.hash === void 0) {
7028
- removable.push(...existingRel.map((rel) => path41.resolve(root, rel)));
7694
+ removable.push(...existingRel.map((rel) => path46.resolve(root, rel)));
7029
7695
  continue;
7030
7696
  }
7031
7697
  const currentHash = await hashInstalledPaths(root, existingRel);
7032
7698
  if (currentHash === row.hash) {
7033
- removable.push(...existingRel.map((rel) => path41.resolve(root, rel)));
7699
+ removable.push(...existingRel.map((rel) => path46.resolve(root, rel)));
7034
7700
  } else {
7035
- preserved.push(...existingRel.map((rel) => path41.resolve(root, rel)));
7701
+ preserved.push(...existingRel.map((rel) => path46.resolve(root, rel)));
7036
7702
  log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
7037
7703
  }
7038
7704
  }
@@ -7040,51 +7706,51 @@ async function collectLockTrackedPaths(root) {
7040
7706
  }
7041
7707
  async function settingsHasHausContent(root) {
7042
7708
  const settingsPath = claudePath(root, "settings.json");
7043
- if (!await fs33.pathExists(settingsPath)) return false;
7709
+ if (!await fs34.pathExists(settingsPath)) return false;
7044
7710
  const settings = await readProjectSettings(root);
7045
7711
  return settings._haus != null;
7046
7712
  }
7047
7713
  async function claudeMdHasHausBlock(root) {
7048
- const filePath = path41.join(root, "CLAUDE.md");
7049
- if (!await fs33.pathExists(filePath)) return false;
7050
- const text = await fs33.readFile(filePath, "utf8");
7714
+ const filePath = path46.join(root, "CLAUDE.md");
7715
+ if (!await fs34.pathExists(filePath)) return false;
7716
+ const text = await fs34.readFile(filePath, "utf8");
7051
7717
  return text.includes(BLOCK_BEGIN);
7052
7718
  }
7053
7719
  async function stripProjectSettings(root) {
7054
7720
  const settingsPath = claudePath(root, "settings.json");
7055
- if (!await fs33.pathExists(settingsPath)) return false;
7721
+ if (!await fs34.pathExists(settingsPath)) return false;
7056
7722
  let settings = await readProjectSettings(root);
7057
7723
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
7058
7724
  const hasContent = Object.keys(settings).length > 0;
7059
7725
  if (hasContent) {
7060
7726
  await writeProjectSettings(root, settings);
7061
- log(`Stripped haus rules from ${path41.relative(root, settingsPath)} (user settings preserved).`);
7727
+ log(`Stripped haus rules from ${path46.relative(root, settingsPath)} (user settings preserved).`);
7062
7728
  return true;
7063
7729
  }
7064
- await fs33.remove(settingsPath);
7065
- log(`Removed ${path41.relative(root, settingsPath)} (no user-owned settings remained).`);
7730
+ await fs34.remove(settingsPath);
7731
+ log(`Removed ${path46.relative(root, settingsPath)} (no user-owned settings remained).`);
7066
7732
  return true;
7067
7733
  }
7068
7734
  async function stripRootClaudeMd(root) {
7069
- const filePath = path41.join(root, "CLAUDE.md");
7070
- if (!await fs33.pathExists(filePath)) return false;
7071
- const prev = await fs33.readFile(filePath, "utf8");
7735
+ const filePath = path46.join(root, "CLAUDE.md");
7736
+ if (!await fs34.pathExists(filePath)) return false;
7737
+ const prev = await fs34.readFile(filePath, "utf8");
7072
7738
  if (!prev.includes(BLOCK_BEGIN)) return false;
7073
7739
  const next = stripHausBlock(prev);
7074
7740
  if (next.length === 0) {
7075
- await fs33.remove(filePath);
7741
+ await fs34.remove(filePath);
7076
7742
  log("Removed CLAUDE.md (only contained haus import block).");
7077
7743
  } else {
7078
- await fs33.writeFile(filePath, next, "utf8");
7744
+ await fs34.writeFile(filePath, next, "utf8");
7079
7745
  log("Removed haus import block from CLAUDE.md (user content preserved).");
7080
7746
  }
7081
7747
  return true;
7082
7748
  }
7083
7749
  async function pruneDirIfEmpty(dir) {
7084
- if (!await fs33.pathExists(dir)) return;
7750
+ if (!await fs34.pathExists(dir)) return;
7085
7751
  try {
7086
- const entries = await fs33.readdir(dir);
7087
- if (entries.length === 0) await fs33.remove(dir);
7752
+ const entries = await fs34.readdir(dir);
7753
+ if (entries.length === 0) await fs34.remove(dir);
7088
7754
  } catch {
7089
7755
  }
7090
7756
  }
@@ -7093,13 +7759,13 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
7093
7759
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7094
7760
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
7095
7761
  for (const abs of managedAbsPaths) {
7096
- if (!await fs33.pathExists(abs)) continue;
7097
- const rel = path41.relative(root, abs);
7098
- const backupPath = path41.join(backupRoot, rel);
7099
- await fs33.ensureDir(path41.dirname(backupPath));
7100
- await fs33.copy(abs, backupPath);
7762
+ if (!await fs34.pathExists(abs)) continue;
7763
+ const rel = path46.relative(root, abs);
7764
+ const backupPath = path46.join(backupRoot, rel);
7765
+ await fs34.ensureDir(path46.dirname(backupPath));
7766
+ await fs34.copy(abs, backupPath);
7101
7767
  }
7102
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path41.relative(root, backupRoot)}.`);
7768
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path46.relative(root, backupRoot)}.`);
7103
7769
  }
7104
7770
  async function runUndo(options) {
7105
7771
  const root = options.root ?? process.cwd();
@@ -7112,7 +7778,7 @@ async function runUndo(options) {
7112
7778
  log("Nothing to remove: no haus-managed files found in this directory.");
7113
7779
  return;
7114
7780
  }
7115
- const relTargets = managed.map((p) => path41.relative(root, p));
7781
+ const relTargets = managed.map((p) => path46.relative(root, p));
7116
7782
  const summaryParts = [...relTargets];
7117
7783
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
7118
7784
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -7134,9 +7800,9 @@ User-owned .claude/ files will be preserved.`
7134
7800
  }
7135
7801
  await backupManagedFilesBeforeUndo(root, managed);
7136
7802
  for (const abs of managed) {
7137
- if (!await fs33.pathExists(abs)) continue;
7138
- await fs33.remove(abs);
7139
- log(`Removed ${path41.relative(root, abs)}`);
7803
+ if (!await fs34.pathExists(abs)) continue;
7804
+ await fs34.remove(abs);
7805
+ log(`Removed ${path46.relative(root, abs)}`);
7140
7806
  }
7141
7807
  if (stripSettings) await stripProjectSettings(root);
7142
7808
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -7147,8 +7813,8 @@ User-owned .claude/ files will be preserved.`
7147
7813
 
7148
7814
  // src/install/uninstall.ts
7149
7815
  import crypto3 from "node:crypto";
7150
- import path42 from "node:path";
7151
- import fs34 from "fs-extra";
7816
+ import path47 from "node:path";
7817
+ import fs35 from "fs-extra";
7152
7818
  async function runUninstall(options = {}) {
7153
7819
  const { force = false } = options;
7154
7820
  const manifest = await readManifest();
@@ -7158,7 +7824,7 @@ async function runUninstall(options = {}) {
7158
7824
  return result;
7159
7825
  }
7160
7826
  for (const entry of manifest.files) {
7161
- const exists = fs34.pathExistsSync(entry.destPath);
7827
+ const exists = fs35.pathExistsSync(entry.destPath);
7162
7828
  if (!exists) continue;
7163
7829
  const content2 = await readText(entry.destPath);
7164
7830
  if (content2 === void 0) continue;
@@ -7176,23 +7842,23 @@ async function runUninstall(options = {}) {
7176
7842
  result.skipped.push(entry.destPath);
7177
7843
  continue;
7178
7844
  }
7179
- await fs34.remove(entry.destPath);
7180
- await pruneEmptyDir(path42.dirname(entry.destPath));
7845
+ await fs35.remove(entry.destPath);
7846
+ await pruneEmptyDir(path47.dirname(entry.destPath));
7181
7847
  result.deleted.push(entry.destPath);
7182
7848
  }
7183
7849
  const settings = await readSettings();
7184
7850
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
7185
7851
  await writeSettings(stripped);
7186
7852
  result.hooksStripped = true;
7187
- const hausDir = path42.join(globalClaudeDir(), "haus");
7853
+ const hausDir = path47.join(globalClaudeDir(), "haus");
7188
7854
  const manifestPath2 = hausManifestPath();
7189
- if (fs34.pathExistsSync(manifestPath2)) {
7190
- await fs34.remove(manifestPath2);
7855
+ if (fs35.pathExistsSync(manifestPath2)) {
7856
+ await fs35.remove(manifestPath2);
7191
7857
  }
7192
- if (fs34.pathExistsSync(hausDir)) {
7858
+ if (fs35.pathExistsSync(hausDir)) {
7193
7859
  try {
7194
- const remaining = await fs34.readdir(hausDir);
7195
- if (remaining.length === 0) await fs34.remove(hausDir);
7860
+ const remaining = await fs35.readdir(hausDir);
7861
+ if (remaining.length === 0) await fs35.remove(hausDir);
7196
7862
  } catch {
7197
7863
  }
7198
7864
  }
@@ -7225,11 +7891,11 @@ async function runUninstallCommand(options) {
7225
7891
  }
7226
7892
 
7227
7893
  // src/commands/validate-catalog.ts
7228
- import path44 from "node:path";
7894
+ import path49 from "node:path";
7229
7895
 
7230
7896
  // src/catalog/validate-core.ts
7231
- import fs35 from "node:fs";
7232
- import path43 from "node:path";
7897
+ import fs36 from "node:fs";
7898
+ import path48 from "node:path";
7233
7899
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
7234
7900
  function auditForbiddenStacks(items) {
7235
7901
  const failures = [];
@@ -7367,14 +8033,14 @@ function checkRequiredFrontmatter(text, label) {
7367
8033
  }
7368
8034
  function readForAudit(absPath, label, failures) {
7369
8035
  try {
7370
- return fs35.readFileSync(absPath, "utf8");
8036
+ return fs36.readFileSync(absPath, "utf8");
7371
8037
  } catch (err) {
7372
8038
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
7373
8039
  return null;
7374
8040
  }
7375
8041
  }
7376
8042
  function auditTemplateContent(manifestDir, absPath, itemId) {
7377
- const rel = path43.relative(manifestDir, absPath);
8043
+ const rel = path48.relative(manifestDir, absPath);
7378
8044
  const failures = [];
7379
8045
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
7380
8046
  if (text === null) return failures;
@@ -7396,47 +8062,47 @@ function auditShippedFiles(manifestDir, items) {
7396
8062
  const failures = [];
7397
8063
  for (const item of items) {
7398
8064
  if (!item.path || !isSafeCatalogPath(item.path)) continue;
7399
- const absPath = path43.join(manifestDir, item.path);
8065
+ const absPath = path48.join(manifestDir, item.path);
7400
8066
  if (item.type === "skill") {
7401
- const skillMd = path43.join(absPath, "SKILL.md");
7402
- if (!fs35.existsSync(skillMd)) {
7403
- failures.push(`${item.id}: missing ${path43.relative(manifestDir, skillMd)}`);
8067
+ const skillMd = path48.join(absPath, "SKILL.md");
8068
+ if (!fs36.existsSync(skillMd)) {
8069
+ failures.push(`${item.id}: missing ${path48.relative(manifestDir, skillMd)}`);
7404
8070
  continue;
7405
8071
  }
7406
8072
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
7407
8073
  if (text === null) continue;
7408
8074
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
7409
8075
  failures.push(
7410
- ...auditForbiddenTagsInText(text, `${item.id}: ${path43.relative(manifestDir, skillMd)}`)
8076
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path48.relative(manifestDir, skillMd)}`)
7411
8077
  );
7412
8078
  } else if (item.type === "agent") {
7413
- if (!fs35.existsSync(absPath)) {
8079
+ if (!fs36.existsSync(absPath)) {
7414
8080
  failures.push(`${item.id}: missing agent file ${item.path}`);
7415
8081
  continue;
7416
8082
  }
7417
- const rel = path43.relative(manifestDir, absPath);
8083
+ const rel = path48.relative(manifestDir, absPath);
7418
8084
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
7419
8085
  if (text === null) continue;
7420
8086
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
7421
8087
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
7422
8088
  } else if (item.type === "template") {
7423
- if (!fs35.existsSync(absPath)) {
8089
+ if (!fs36.existsSync(absPath)) {
7424
8090
  failures.push(`${item.id}: missing template file ${item.path}`);
7425
8091
  continue;
7426
8092
  }
7427
8093
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
7428
8094
  } else if (item.type === "command") {
7429
- if (!fs35.existsSync(absPath)) {
8095
+ if (!fs36.existsSync(absPath)) {
7430
8096
  failures.push(`${item.id}: missing command file ${item.path}`);
7431
8097
  continue;
7432
8098
  }
7433
- const rel = path43.relative(manifestDir, absPath);
8099
+ const rel = path48.relative(manifestDir, absPath);
7434
8100
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
7435
8101
  if (text === null) continue;
7436
8102
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
7437
8103
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
7438
8104
  } else if (item.type === "config") {
7439
- if (!fs35.existsSync(absPath)) {
8105
+ if (!fs36.existsSync(absPath)) {
7440
8106
  failures.push(`${item.id}: missing config path ${item.path}`);
7441
8107
  }
7442
8108
  }
@@ -7453,7 +8119,7 @@ var CONTENT_ROOTS = ["skills", "agents", "templates", "commands", "configs"];
7453
8119
  function walkAllFiles(dir, fn, failures) {
7454
8120
  let entries;
7455
8121
  try {
7456
- entries = fs35.readdirSync(dir, { withFileTypes: true });
8122
+ entries = fs36.readdirSync(dir, { withFileTypes: true });
7457
8123
  } catch (err) {
7458
8124
  failures.push(
7459
8125
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -7462,7 +8128,7 @@ function walkAllFiles(dir, fn, failures) {
7462
8128
  }
7463
8129
  for (const entry of entries) {
7464
8130
  if (entry.name === ".DS_Store") continue;
7465
- const full = path43.join(dir, entry.name);
8131
+ const full = path48.join(dir, entry.name);
7466
8132
  if (entry.isDirectory()) walkAllFiles(full, fn, failures);
7467
8133
  else fn(full);
7468
8134
  }
@@ -7480,12 +8146,12 @@ function auditDiskOrphans(manifestDir, items) {
7480
8146
  const failures = [];
7481
8147
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
7482
8148
  for (const dir of CONTENT_ROOTS) {
7483
- const abs = path43.join(manifestDir, dir);
7484
- if (!fs35.existsSync(abs)) continue;
8149
+ const abs = path48.join(manifestDir, dir);
8150
+ if (!fs36.existsSync(abs)) continue;
7485
8151
  walkAllFiles(
7486
8152
  abs,
7487
8153
  (file) => {
7488
- const relPath = path43.relative(manifestDir, file).replace(/\\/g, "/");
8154
+ const relPath = path48.relative(manifestDir, file).replace(/\\/g, "/");
7489
8155
  if (!isPathClaimed(relPath, claimedPaths)) {
7490
8156
  failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
7491
8157
  }
@@ -7498,7 +8164,7 @@ function auditDiskOrphans(manifestDir, items) {
7498
8164
  function walkMd(dir, fn, failures) {
7499
8165
  let entries;
7500
8166
  try {
7501
- entries = fs35.readdirSync(dir, { withFileTypes: true });
8167
+ entries = fs36.readdirSync(dir, { withFileTypes: true });
7502
8168
  } catch (err) {
7503
8169
  failures.push(
7504
8170
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -7506,7 +8172,7 @@ function walkMd(dir, fn, failures) {
7506
8172
  return;
7507
8173
  }
7508
8174
  for (const entry of entries) {
7509
- const full = path43.join(dir, entry.name);
8175
+ const full = path48.join(dir, entry.name);
7510
8176
  if (entry.isDirectory()) walkMd(full, fn, failures);
7511
8177
  else if (entry.name.endsWith(".md")) fn(full);
7512
8178
  }
@@ -7515,13 +8181,13 @@ function auditMarkdownContent(manifestDir, items) {
7515
8181
  const failures = [];
7516
8182
  const pathSourceMap = buildItemPathSourceMap(items);
7517
8183
  for (const dir of ["skills", "agents", "templates", "commands"]) {
7518
- const abs = path43.join(manifestDir, dir);
7519
- if (!fs35.existsSync(abs)) continue;
8184
+ const abs = path48.join(manifestDir, dir);
8185
+ if (!fs36.existsSync(abs)) continue;
7520
8186
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
7521
8187
  walkMd(
7522
8188
  abs,
7523
8189
  (file) => {
7524
- const rel = path43.relative(manifestDir, file);
8190
+ const rel = path48.relative(manifestDir, file);
7525
8191
  const text = readForAudit(file, rel, failures);
7526
8192
  if (text === null) return;
7527
8193
  const norm = rel.replace(/\\/g, "/");
@@ -7566,8 +8232,8 @@ async function runValidateCatalog(manifestPath2) {
7566
8232
  process.exitCode = 1;
7567
8233
  return;
7568
8234
  }
7569
- const abs = path44.resolve(process.cwd(), manifestPath2);
7570
- const manifestDir = path44.dirname(abs);
8235
+ const abs = path49.resolve(process.cwd(), manifestPath2);
8236
+ const manifestDir = path49.dirname(abs);
7571
8237
  const data = await readJson(abs);
7572
8238
  if (!data?.items) {
7573
8239
  error(`Could not read catalog manifest at ${abs}`);
@@ -7583,231 +8249,1026 @@ async function runValidateCatalog(manifestPath2) {
7583
8249
  log(`Catalog valid. ${result.items.length} items checked.`);
7584
8250
  }
7585
8251
 
7586
- // src/commands/workspace.ts
7587
- import { existsSync as existsSync6, statSync as statSync3 } from "node:fs";
7588
- import path52 from "node:path";
8252
+ // src/workspace/worktree/add.ts
8253
+ import path56 from "node:path";
8254
+ import fs40 from "fs-extra";
7589
8255
 
7590
- // src/commands/workspace/aggregate.ts
7591
- async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
7592
- const summaries = repos.map((repo) => ({
7593
- name: repo.name,
7594
- path: repo.path,
7595
- roles: repo.context.repoRoles ?? [],
7596
- packageManager: repo.context.packageManager,
7597
- deps: repo.context.dependencies ?? []
7598
- }));
7599
- const ownership = {};
7600
- for (const repo of summaries) {
7601
- for (const dep2 of repo.deps) {
7602
- ownership[dep2] ??= [];
7603
- ownership[dep2].push(repo.name);
7604
- }
8256
+ // src/workspace/worktree/git-worktree.ts
8257
+ init_exec();
8258
+ async function branchExists(cwd, branch) {
8259
+ const result = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd });
8260
+ return result.exitCode === 0;
8261
+ }
8262
+ async function resolveDefaultBranch(cwd) {
8263
+ const symbolic = await runGit(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd });
8264
+ if (symbolic.exitCode === 0 && symbolic.stdout.trim()) {
8265
+ return { ref: symbolic.stdout.trim(), source: "origin-head" };
7605
8266
  }
7606
- const roles = [...new Set(summaries.flatMap((r) => r.roles))].sort();
7607
- const crossRepoHints = [...new Set(repos.flatMap((r) => r.context.crossRepoHints ?? []))].sort();
7608
- const summaryPath = hausPath(workspaceRoot, "workspace-summary.json");
7609
- const ownershipPath = hausPath(workspaceRoot, "dependency-ownership-map.json");
7610
- const crossRepoPath = hausPath(workspaceRoot, "cross-repo-summary.md");
7611
- const contextMapPath = hausPath(workspaceRoot, "workspace-context-map.json");
7612
- await writeJson(summaryPath, {
7613
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
7614
- repos: summaries
7615
- });
7616
- await writeJson(ownershipPath, ownership);
7617
- await writeText(
7618
- crossRepoPath,
7619
- `# Cross Repo Summary
7620
-
7621
- ${summaries.map(
7622
- (repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
7623
- ).join("\n")}
7624
- `
7625
- );
7626
- await writeJson(contextMapPath, {
7627
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
7628
- roles,
7629
- crossRepoHints,
7630
- repos: summaries.map((r) => ({
7631
- name: r.name,
7632
- path: r.path,
7633
- roles: r.roles,
7634
- packageManager: r.packageManager
7635
- })),
7636
- relationships
7637
- });
7638
- return [summaryPath, ownershipPath, crossRepoPath, contextMapPath];
8267
+ if (await branchExists(cwd, "main")) return { ref: "main", source: "local-main" };
8268
+ if (await branchExists(cwd, "master")) return { ref: "master", source: "local-master" };
8269
+ return void 0;
7639
8270
  }
7640
-
7641
- // src/commands/workspace/config.ts
7642
- import path45 from "node:path";
7643
- import YAML from "yaml";
7644
- var WORKSPACE_FILE = "haus.workspace.yaml";
7645
- function parseWorkspaceConfig(text) {
7646
- if (!text) return void 0;
7647
- let parsed;
7648
- try {
7649
- parsed = YAML.parse(text);
7650
- } catch {
7651
- return void 0;
8271
+ var CHECKED_OUT_ELSEWHERE_PATTERNS = [/already checked out at/i, /already used by worktree/i];
8272
+ function isBranchCheckedOutElsewhereError(stderr) {
8273
+ return CHECKED_OUT_ELSEWHERE_PATTERNS.some((re) => re.test(stderr));
8274
+ }
8275
+ async function addWorktree(cwd, worktreePath2, branch, opts = {}) {
8276
+ const exists = await branchExists(cwd, branch);
8277
+ if (exists) {
8278
+ const result2 = await runGit(["worktree", "add", worktreePath2, branch], { cwd });
8279
+ return {
8280
+ ok: result2.exitCode === 0,
8281
+ branchAction: "checkout",
8282
+ stderr: result2.exitCode === 0 ? void 0 : result2.stderr,
8283
+ checkedOutElsewhere: result2.exitCode !== 0 && isBranchCheckedOutElsewhereError(result2.stderr)
8284
+ };
7652
8285
  }
7653
- if (!parsed || typeof parsed !== "object") return void 0;
7654
- const obj = parsed;
7655
- const repos = Array.isArray(obj.repos) ? obj.repos.filter(
7656
- (r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
7657
- ) : [];
8286
+ let startPoint;
8287
+ if (opts.preferDefaultBranchFrom) {
8288
+ const defaultBranch = await resolveDefaultBranch(cwd);
8289
+ startPoint = defaultBranch?.ref;
8290
+ }
8291
+ const args = startPoint ? ["worktree", "add", worktreePath2, "-b", branch, startPoint] : ["worktree", "add", worktreePath2, "-b", branch];
8292
+ const result = await runGit(args, { cwd });
7658
8293
  return {
7659
- client: typeof obj.client === "string" ? obj.client : "unknown",
7660
- repos,
7661
- relationships: Array.isArray(obj.relationships) ? obj.relationships : []
8294
+ ok: result.exitCode === 0,
8295
+ branchAction: "create",
8296
+ startPoint,
8297
+ stderr: result.exitCode === 0 ? void 0 : result.stderr,
8298
+ checkedOutElsewhere: result.exitCode !== 0 && isBranchCheckedOutElsewhereError(result.stderr)
7662
8299
  };
7663
8300
  }
7664
- async function readWorkspaceConfig(workspaceRoot) {
7665
- return parseWorkspaceConfig(await readText(path45.join(workspaceRoot, WORKSPACE_FILE)));
8301
+ async function removeWorktree(ownerRepo, worktreePath2, force = true) {
8302
+ const args = ["worktree", "remove", worktreePath2];
8303
+ if (force) args.push("--force");
8304
+ const result = await runGit(args, { cwd: ownerRepo });
8305
+ return { ok: result.exitCode === 0, stderr: result.exitCode === 0 ? void 0 : result.stderr };
7666
8306
  }
7667
-
7668
- // src/commands/workspace/discover.ts
7669
- import path46 from "node:path";
7670
- import fg4 from "fast-glob";
7671
- import YAML2 from "yaml";
7672
- var DEFAULT_MAX_DEPTH = 3;
7673
- var REPO_MARKERS = ["**/.git", "**/package.json", "**/composer.json"];
7674
- var IGNORE = [
7675
- "**/node_modules/**",
7676
- "**/.git/**",
7677
- "**/vendor/**",
7678
- "**/dist/**",
7679
- "**/.haus-workflow/**"
7680
- ];
7681
- function isDescendant(child, ancestor) {
7682
- if (ancestor === ".") return child !== ".";
7683
- return child === ancestor ? false : child.startsWith(`${ancestor}/`);
8307
+ async function pruneWorktrees(ownerRepo) {
8308
+ await runGit(["worktree", "prune"], { cwd: ownerRepo });
7684
8309
  }
7685
- async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7686
- const matches = await fg4(REPO_MARKERS, {
7687
- cwd: workspaceRoot,
7688
- dot: true,
7689
- onlyFiles: false,
7690
- deep: maxDepth,
7691
- followSymbolicLinks: false,
7692
- // Discovery walks arbitrary directories under the workspace root; an
7693
- // unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
7694
- suppressErrors: true,
7695
- ignore: IGNORE
8310
+ async function listWorktrees(ownerRepo) {
8311
+ const result = await runGit(["worktree", "list", "--porcelain"], { cwd: ownerRepo });
8312
+ if (result.exitCode !== 0) return [];
8313
+ const entries = [];
8314
+ let current = {};
8315
+ for (const line2 of result.stdout.split("\n")) {
8316
+ if (line2.startsWith("worktree ")) {
8317
+ if (current.path) entries.push(current);
8318
+ current = { path: line2.slice("worktree ".length).trim() };
8319
+ } else if (line2.startsWith("branch ")) {
8320
+ current.branch = line2.slice("branch ".length).replace("refs/heads/", "").trim();
8321
+ } else if (line2.startsWith("HEAD ")) {
8322
+ current.head = line2.slice("HEAD ".length).trim();
8323
+ } else if (line2 === "bare") {
8324
+ current.bare = true;
8325
+ } else if (line2 === "detached") {
8326
+ current.detached = true;
8327
+ }
8328
+ }
8329
+ if (current.path) entries.push(current);
8330
+ return entries;
8331
+ }
8332
+ async function currentBranch(cwd) {
8333
+ const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
8334
+ return result.exitCode === 0 ? result.stdout.trim() : void 0;
8335
+ }
8336
+ async function hasUncommittedChanges(cwd, ignorePaths = []) {
8337
+ const args = ["status", "--porcelain"];
8338
+ if (ignorePaths.length > 0) {
8339
+ args.push("--", ".", ...ignorePaths.map((p) => `:!${p}`));
8340
+ }
8341
+ const result = await runGit(args, { cwd });
8342
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
8343
+ }
8344
+ async function hasUnpushedWork(cwd) {
8345
+ const upstream = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
8346
+ cwd
7696
8347
  });
7697
- const gitDirs = /* @__PURE__ */ new Set();
7698
- const manifestDirs = /* @__PURE__ */ new Set();
7699
- for (const match of matches) {
7700
- const base = path46.posix.basename(match);
7701
- const dir = path46.posix.dirname(match);
7702
- const owner = dir === "." ? "." : dir;
7703
- if (base === ".git") gitDirs.add(owner);
7704
- else manifestDirs.add(owner);
8348
+ if (upstream.exitCode === 0 && upstream.stdout.trim()) {
8349
+ const upstreamRef = upstream.stdout.trim();
8350
+ const ahead = await runGit(["rev-list", "--count", `${upstreamRef}..HEAD`], { cwd });
8351
+ const count = Number.parseInt(ahead.stdout.trim(), 10);
8352
+ if (Number.isFinite(count) && count > 0) {
8353
+ return { unpushed: true, reason: `${count} commit(s) ahead of ${upstreamRef}` };
8354
+ }
8355
+ return { unpushed: false };
8356
+ }
8357
+ const defaultBranch = await resolveDefaultBranch(cwd);
8358
+ if (!defaultBranch) return { unpushed: false };
8359
+ const [head, defaultSha] = await Promise.all([
8360
+ runGit(["rev-parse", "HEAD"], { cwd }),
8361
+ runGit(["rev-parse", defaultBranch.ref], { cwd })
8362
+ ]);
8363
+ if (head.exitCode === 0 && defaultSha.exitCode === 0 && head.stdout.trim() === defaultSha.stdout.trim()) {
8364
+ return { unpushed: false };
7705
8365
  }
7706
- const repoRoots = [...gitDirs];
7707
- const manifestSorted = [...manifestDirs].sort(
7708
- (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
7709
- );
7710
- for (const dir of manifestSorted) {
7711
- if (gitDirs.has(dir)) continue;
7712
- if (repoRoots.some((root) => isDescendant(dir, root))) continue;
7713
- repoRoots.push(dir);
8366
+ return {
8367
+ unpushed: true,
8368
+ reason: `no upstream tracking branch configured, and HEAD differs from ${defaultBranch.ref}`
8369
+ };
8370
+ }
8371
+
8372
+ // src/workspace/worktree/hydrate.ts
8373
+ import path52 from "node:path";
8374
+ import fs38 from "fs-extra";
8375
+
8376
+ // src/workspace/worktree/cow-copy.ts
8377
+ init_exec();
8378
+ import path50 from "node:path";
8379
+ import fs37 from "fs-extra";
8380
+ async function getMacFilesystemType(dir) {
8381
+ const df = await runCommand("df", ["-P", dir]);
8382
+ if (df.exitCode !== 0) return void 0;
8383
+ const lines = df.stdout.trim().split("\n");
8384
+ const dataLine = lines[lines.length - 1];
8385
+ const device = dataLine?.split(/\s+/)[0];
8386
+ if (!device) return void 0;
8387
+ const mount = await runCommand("mount");
8388
+ if (mount.exitCode !== 0) return void 0;
8389
+ const line2 = mount.stdout.split("\n").find((l) => l.startsWith(`${device} `));
8390
+ const match = line2?.match(/\(([^,)]+)/);
8391
+ return match?.[1]?.trim();
8392
+ }
8393
+ async function getLinuxFilesystemType(dir) {
8394
+ const result = await runCommand("stat", ["-f", "-c", "%T", dir]);
8395
+ if (result.exitCode !== 0) return void 0;
8396
+ return result.stdout.trim();
8397
+ }
8398
+ async function detectCowStrategy(dir) {
8399
+ if (process.platform === "darwin") {
8400
+ const fsType = await getMacFilesystemType(dir);
8401
+ return fsType === "apfs" ? "darwin-clonefile" : "unsupported";
8402
+ }
8403
+ if (process.platform === "linux") {
8404
+ const fsType = await getLinuxFilesystemType(dir);
8405
+ return fsType === "btrfs" || fsType === "xfs" ? "linux-reflink" : "unsupported";
8406
+ }
8407
+ return "unknown-platform";
8408
+ }
8409
+ async function cowCopyDir(src, dest) {
8410
+ const srcExists = await fs37.pathExists(src);
8411
+ if (!srcExists)
8412
+ return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
8413
+ const strategy = await detectCowStrategy(path50.dirname(dest));
8414
+ if (strategy === "unsupported" || strategy === "unknown-platform") {
8415
+ return { strategy, attempted: false, ok: false };
8416
+ }
8417
+ await fs37.ensureDir(path50.dirname(dest));
8418
+ const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
8419
+ const result = await runCommand("cp", args);
8420
+ return {
8421
+ strategy,
8422
+ attempted: true,
8423
+ ok: result.exitCode === 0,
8424
+ error: result.exitCode === 0 ? void 0 : result.stderr
8425
+ };
8426
+ }
8427
+
8428
+ // src/workspace/worktree/install.ts
8429
+ init_exec();
8430
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
8431
+ import path51 from "node:path";
8432
+ import fg5 from "fast-glob";
8433
+ function detectInstallPlan(signals) {
8434
+ if (signals.hasYarnLock) {
8435
+ return signals.hasPackageManagerField ? { manager: "yarn", command: "corepack", args: ["yarn", "install"] } : { manager: "yarn", command: "yarn", args: ["install"] };
7714
8436
  }
7715
- repoRoots.sort((a, b) => a.localeCompare(b));
7716
- return mapWithConcurrency(repoRoots, async (relDir) => {
7717
- const absDir = path46.resolve(workspaceRoot, relDir);
7718
- const pkg = await readJson(path46.join(absDir, "package.json"));
7719
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path46.basename(relDir === "." ? workspaceRoot : absDir);
7720
- let role = "auto";
8437
+ if (signals.hasPnpmLock) {
8438
+ return { manager: "pnpm", command: "pnpm", args: ["install", "--frozen-lockfile"] };
8439
+ }
8440
+ if (signals.hasPackageLock) {
8441
+ return { manager: "npm", command: "npm", args: ["install"] };
8442
+ }
8443
+ if (signals.hasComposerLock) {
8444
+ return { manager: "composer", command: "composer", args: ["install"] };
8445
+ }
8446
+ if (signals.hasDotnetProject) {
8447
+ return { manager: "dotnet", command: "dotnet", args: ["restore"] };
8448
+ }
8449
+ return { manager: "none" };
8450
+ }
8451
+ async function computeLockfileSignals(dir) {
8452
+ const hasYarnLock = existsSync3(path51.join(dir, "yarn.lock"));
8453
+ const hasPnpmLock = existsSync3(path51.join(dir, "pnpm-lock.yaml"));
8454
+ const hasPackageLock = existsSync3(path51.join(dir, "package-lock.json"));
8455
+ const hasComposerLock = existsSync3(path51.join(dir, "composer.lock"));
8456
+ const dotnetFiles = await fg5(["**/*.csproj", "**/*.sln"], {
8457
+ cwd: dir,
8458
+ onlyFiles: true,
8459
+ suppressErrors: true,
8460
+ // Consistent with this codebase's no-symlink-follow posture elsewhere
8461
+ // (fs.ts's findNestedRepoDirs, discover.ts's discoverRepos): a symlink
8462
+ // shouldn't be able to route this search outside the intended tree or
8463
+ // reintroduce a symlink-cycle/perf concern.
8464
+ followSymbolicLinks: false,
8465
+ ignore: ["**/node_modules/**", "**/bin/**", "**/obj/**"]
8466
+ });
8467
+ const hasDotnetProject = dotnetFiles.length > 0;
8468
+ let hasPackageManagerField = false;
8469
+ const pkgPath = path51.join(dir, "package.json");
8470
+ if (existsSync3(pkgPath)) {
7721
8471
  try {
7722
- const scan = await scanProject(absDir);
7723
- if (scan.repoRoles[0]) role = scan.repoRoles[0];
8472
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
8473
+ hasPackageManagerField = typeof pkg.packageManager === "string" && pkg.packageManager.length > 0;
7724
8474
  } catch {
7725
8475
  }
7726
- return { name, path: relDir === "." ? "." : relDir, role };
7727
- });
8476
+ }
8477
+ return {
8478
+ hasYarnLock,
8479
+ hasPackageManagerField,
8480
+ hasPnpmLock,
8481
+ hasPackageLock,
8482
+ hasComposerLock,
8483
+ hasDotnetProject
8484
+ };
7728
8485
  }
7729
- function mergeWorkspaceConfig(existing, discovered, opts = {}) {
7730
- const existingRepos = existing?.repos ?? [];
7731
- const byPath = new Map(existingRepos.map((r) => [r.path, r]));
7732
- for (const repo of discovered) {
7733
- if (!byPath.has(repo.path)) {
7734
- byPath.set(repo.path, { name: repo.name, path: repo.path, role: repo.role });
8486
+ async function runInstallPlan(dir, plan) {
8487
+ if (plan.manager === "none") {
8488
+ return { plan, ran: false, ok: true, skippedReason: "no recognized lockfile" };
8489
+ }
8490
+ const available = await commandExists(plan.command);
8491
+ if (!available) {
8492
+ return { plan, ran: false, ok: true, skippedReason: `${plan.command} not found on PATH` };
8493
+ }
8494
+ const result = await runCommand(plan.command, plan.args, { cwd: dir });
8495
+ return {
8496
+ plan,
8497
+ ran: true,
8498
+ ok: result.exitCode === 0,
8499
+ exitCode: result.exitCode,
8500
+ stderr: result.exitCode === 0 ? void 0 : result.stderr
8501
+ };
8502
+ }
8503
+
8504
+ // src/workspace/worktree/hydrate.ts
8505
+ var HYDRATION_TARGETS = ["node_modules", "vendor", "obj", "bin"];
8506
+ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
8507
+ const start = Date.now();
8508
+ const targets = [];
8509
+ for (const target of HYDRATION_TARGETS) {
8510
+ const src = path52.join(mainMemberPath, target);
8511
+ const dest = path52.join(worktreeMemberPath, target);
8512
+ if (!await fs38.pathExists(src)) continue;
8513
+ const destExists = await fs38.pathExists(dest);
8514
+ if (!opts.force && destExists) continue;
8515
+ if (opts.force && destExists) {
8516
+ await fs38.remove(dest);
7735
8517
  }
8518
+ const copy = await cowCopyDir(src, dest);
8519
+ targets.push({ target, copy });
7736
8520
  }
7737
- const ordered = [];
7738
- const seen = /* @__PURE__ */ new Set();
7739
- for (const repo of existingRepos) {
7740
- ordered.push(byPath.get(repo.path));
7741
- seen.add(repo.path);
8521
+ const signals = await computeLockfileSignals(worktreeMemberPath);
8522
+ const plan = detectInstallPlan(signals);
8523
+ const install = await runInstallPlan(worktreeMemberPath, plan);
8524
+ return { targets, install, durationMs: Date.now() - start };
8525
+ }
8526
+
8527
+ // src/workspace/worktree/local-files.ts
8528
+ init_exec();
8529
+ import path53 from "node:path";
8530
+ import fs39 from "fs-extra";
8531
+ var MACHINE_LOCAL_FILES = [
8532
+ "docker-compose.local.yml",
8533
+ path53.join(".claude", "settings.local.json")
8534
+ ];
8535
+ async function isTracked(repoRoot, relFile) {
8536
+ const gitPath = relFile.replace(/\\/g, "/");
8537
+ const result = await runGit(["ls-files", "--error-unmatch", gitPath], { cwd: repoRoot });
8538
+ return result.exitCode === 0;
8539
+ }
8540
+ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
8541
+ const copied = [];
8542
+ for (const rel of MACHINE_LOCAL_FILES) {
8543
+ const src = path53.join(mainMemberPath, rel);
8544
+ if (!await fs39.pathExists(src)) continue;
8545
+ if (await isTracked(mainMemberPath, rel)) continue;
8546
+ const dest = path53.join(worktreeMemberPath, rel);
8547
+ await fs39.ensureDir(path53.dirname(dest));
8548
+ await fs39.copy(src, dest, { overwrite: true });
8549
+ copied.push(rel);
8550
+ }
8551
+ return copied;
8552
+ }
8553
+
8554
+ // src/workspace/worktree/root.ts
8555
+ import { existsSync as existsSync4 } from "node:fs";
8556
+ import path54 from "node:path";
8557
+ async function resolveWorkspaceForWorktree(start) {
8558
+ const rootInfo = await resolveRoots(start);
8559
+ if (!rootInfo.isGitRepo) {
8560
+ return { ok: false, reason: "Not inside a git repository." };
8561
+ }
8562
+ const workspaceRoot = rootInfo.mainRoot;
8563
+ const hasYaml = existsSync4(path54.join(workspaceRoot, WORKSPACE_FILE));
8564
+ const hasManifest = existsSync4(path54.join(workspaceRoot, REPOS_MANIFEST_FILE));
8565
+ if (!hasYaml && !hasManifest) {
8566
+ return {
8567
+ ok: false,
8568
+ reason: `No workspace found at ${workspaceRoot} \u2014 expected ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE}. Run \`haus workspace discover --write\` or \`haus workspace init\` first.`
8569
+ };
7742
8570
  }
7743
- for (const repo of discovered) {
7744
- if (seen.has(repo.path)) continue;
7745
- ordered.push(byPath.get(repo.path));
7746
- seen.add(repo.path);
8571
+ return { ok: true, rootInfo, workspaceRoot };
8572
+ }
8573
+
8574
+ // src/workspace/worktree/select-members.ts
8575
+ function selectMembers(members, only) {
8576
+ if (!only || only.length === 0) return { selected: members, unknown: [] };
8577
+ const wanted = new Set(only);
8578
+ const selected = members.filter((m) => wanted.has(m.id) || wanted.has(m.folder));
8579
+ const matchedKeys = /* @__PURE__ */ new Set();
8580
+ for (const m of selected) {
8581
+ if (wanted.has(m.id)) matchedKeys.add(m.id);
8582
+ if (wanted.has(m.folder)) matchedKeys.add(m.folder);
8583
+ }
8584
+ const unknown = only.filter((name) => !matchedKeys.has(name));
8585
+ return { selected, unknown };
8586
+ }
8587
+
8588
+ // src/workspace/worktree/state.ts
8589
+ import path55 from "node:path";
8590
+ var WORKTREE_STATE_FILE = ".haus-worktree.json";
8591
+ function worktreesDir(mainRoot) {
8592
+ return path55.join(mainRoot, ".claude", "worktrees");
8593
+ }
8594
+ function worktreePath(mainRoot, slug) {
8595
+ return path55.join(worktreesDir(mainRoot), slug);
8596
+ }
8597
+ async function readWorktreeState(worktreeRoot) {
8598
+ return readJson(path55.join(worktreeRoot, WORKTREE_STATE_FILE));
8599
+ }
8600
+ async function writeWorktreeState(worktreeRoot, state) {
8601
+ await writeJson(path55.join(worktreeRoot, WORKTREE_STATE_FILE), state);
8602
+ }
8603
+
8604
+ // src/workspace/worktree/add.ts
8605
+ async function runAdd(opts) {
8606
+ const resolved = await resolveWorkspaceForWorktree();
8607
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
8608
+ const { rootInfo, workspaceRoot } = resolved;
8609
+ const allMembers = await readMembers(rootInfo);
8610
+ const { selected, unknown } = selectMembers(allMembers, opts.only);
8611
+ if (unknown.length > 0) {
8612
+ return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
8613
+ }
8614
+ const branch = opts.branch ?? opts.slug;
8615
+ const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
8616
+ if (await fs40.pathExists(wsWorktreePath)) {
8617
+ return {
8618
+ ok: false,
8619
+ error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate\` or \`remove\` first.`
8620
+ };
8621
+ }
8622
+ if (opts.dryRun) {
8623
+ return runDryRun({ workspaceRoot, wsWorktreePath, slug: opts.slug, branch, selected });
8624
+ }
8625
+ const wsOutcome = await addWorktree(workspaceRoot, wsWorktreePath, branch);
8626
+ if (!wsOutcome.ok) {
8627
+ return {
8628
+ ok: false,
8629
+ error: wsOutcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in the workspace repo \u2014 pick a different --branch.` : `Failed to create the workspace worktree: ${wsOutcome.stderr ?? "unknown error"}`
8630
+ };
8631
+ }
8632
+ const memberResults = [];
8633
+ for (const member of selected) {
8634
+ const start = Date.now();
8635
+ const memberWorktreePath = path56.join(wsWorktreePath, member.folder);
8636
+ const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
8637
+ preferDefaultBranchFrom: true
8638
+ });
8639
+ if (!outcome.ok) {
8640
+ memberResults.push({
8641
+ member: member.id,
8642
+ folder: member.folder,
8643
+ status: "failed",
8644
+ branch,
8645
+ branchAction: outcome.branchAction,
8646
+ error: outcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in ${member.id} \u2014 pick a different --branch.` : outcome.stderr ?? "unknown error",
8647
+ durationMs: Date.now() - start
8648
+ });
8649
+ continue;
8650
+ }
8651
+ memberResults.push({
8652
+ member: member.id,
8653
+ folder: member.folder,
8654
+ status: "ok",
8655
+ branch,
8656
+ branchAction: outcome.branchAction,
8657
+ startPoint: outcome.startPoint,
8658
+ durationMs: Date.now() - start
8659
+ });
7747
8660
  }
8661
+ const okResults = memberResults.filter((m) => m.status === "ok");
8662
+ await mapWithConcurrency(okResults, async (result) => {
8663
+ const member = selected.find((m) => m.id === result.member);
8664
+ if (!member) return;
8665
+ const memberWorktreePath = path56.join(wsWorktreePath, member.folder);
8666
+ const hydrationStart = Date.now();
8667
+ if (opts.hydrate !== false) {
8668
+ result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
8669
+ }
8670
+ result.localFilesCopied = await copyMachineLocalFiles(member.absPath, memberWorktreePath);
8671
+ result.durationMs += Date.now() - hydrationStart;
8672
+ });
8673
+ const stateMembers = okResults.map((m) => ({
8674
+ id: m.member,
8675
+ folder: m.folder,
8676
+ branch: m.branch,
8677
+ absPath: selected.find((s) => s.id === m.member)?.absPath
8678
+ }));
8679
+ await writeWorktreeState(wsWorktreePath, {
8680
+ slug: opts.slug,
8681
+ branch,
8682
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8683
+ members: stateMembers
8684
+ });
8685
+ const anyFailed = memberResults.some((m) => m.status === "failed");
7748
8686
  return {
7749
- client: opts.client ?? existing?.client ?? "unknown",
7750
- repos: ordered,
7751
- relationships: existing?.relationships ?? []
8687
+ ok: !anyFailed,
8688
+ dryRun: false,
8689
+ workspaceRoot,
8690
+ slug: opts.slug,
8691
+ branch,
8692
+ worktreePath: wsWorktreePath,
8693
+ workspaceWorktree: { status: "ok", branchAction: wsOutcome.branchAction },
8694
+ members: memberResults
7752
8695
  };
7753
8696
  }
7754
- function renderWorkspaceYaml(config) {
7755
- return YAML2.stringify({
7756
- client: config.client,
7757
- repos: config.repos.map((r) => ({ name: r.name, path: r.path, role: r.role ?? "auto" })),
7758
- relationships: config.relationships
7759
- });
8697
+ async function runDryRun(args) {
8698
+ const { workspaceRoot, wsWorktreePath, slug, branch, selected } = args;
8699
+ const wsBranchExists = await branchExists(workspaceRoot, branch);
8700
+ const members = [];
8701
+ for (const member of selected) {
8702
+ const exists = await branchExists(member.absPath, branch);
8703
+ let startPoint;
8704
+ if (!exists) {
8705
+ const def = await resolveDefaultBranch(member.absPath);
8706
+ startPoint = def?.ref;
8707
+ }
8708
+ members.push({
8709
+ member: member.id,
8710
+ folder: member.folder,
8711
+ status: "planned",
8712
+ branch,
8713
+ branchAction: exists ? "checkout" : "create",
8714
+ startPoint,
8715
+ durationMs: 0
8716
+ });
8717
+ }
8718
+ return {
8719
+ ok: true,
8720
+ dryRun: true,
8721
+ workspaceRoot,
8722
+ slug,
8723
+ branch,
8724
+ worktreePath: wsWorktreePath,
8725
+ workspaceWorktree: { status: "planned", branchAction: wsBranchExists ? "checkout" : "create" },
8726
+ members
8727
+ };
7760
8728
  }
7761
- async function runDiscover(workspaceRoot, opts = {}) {
7762
- const yamlPath = path46.join(workspaceRoot, "haus.workspace.yaml");
7763
- const existingText = await readText(yamlPath);
7764
- const existing = parseWorkspaceConfig(existingText);
7765
- if (existingText && !existing) {
8729
+
8730
+ // src/workspace/worktree/doctor.ts
8731
+ import { existsSync as existsSync5 } from "node:fs";
8732
+ import path57 from "node:path";
8733
+ var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
8734
+ function looksHydrated(memberPath) {
8735
+ return HYDRATION_MARKERS.some((marker) => existsSync5(path57.join(memberPath, marker)));
8736
+ }
8737
+ async function runDoctor2() {
8738
+ const rootInfo = await resolveRoots();
8739
+ if (!rootInfo.isGitRepo) {
8740
+ return {
8741
+ isWorkspace: false,
8742
+ inWorkspaceWorktree: false,
8743
+ members: [],
8744
+ orphans: [],
8745
+ problems: ["Not inside a git repository."]
8746
+ };
8747
+ }
8748
+ const workspaceRoot = rootInfo.mainRoot;
8749
+ const isWorkspace = existsSync5(path57.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path57.join(workspaceRoot, REPOS_MANIFEST_FILE));
8750
+ if (!isWorkspace) {
8751
+ return {
8752
+ isWorkspace: false,
8753
+ inWorkspaceWorktree: false,
8754
+ members: [],
8755
+ orphans: [],
8756
+ problems: [
8757
+ `Not a haus workspace \u2014 no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
8758
+ ]
8759
+ };
8760
+ }
8761
+ const wsWorktreesDir = worktreesDir(workspaceRoot);
8762
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path57.sep}`);
8763
+ const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
8764
+ const members = await readMembers(rootInfo);
8765
+ const problems = [];
8766
+ const memberChecks = [];
8767
+ let slug;
8768
+ if (inWorkspaceWorktree) {
8769
+ const wtPath = rootInfo.repoRoot;
8770
+ slug = rootInfo.worktreeName ?? path57.basename(wtPath);
8771
+ const state = await readWorktreeState(wtPath);
8772
+ for (const member of members) {
8773
+ const memberPath = path57.join(wtPath, member.folder);
8774
+ const materialized = existsSync5(memberPath);
8775
+ const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
8776
+ const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
8777
+ const branchMismatch = Boolean(
8778
+ expectedBranch && actualBranch && expectedBranch !== actualBranch
8779
+ );
8780
+ const hydrated = materialized && looksHydrated(memberPath);
8781
+ memberChecks.push({
8782
+ id: member.id,
8783
+ folder: member.folder,
8784
+ materialized,
8785
+ expectedBranch,
8786
+ actualBranch,
8787
+ branchMismatch,
8788
+ hydrated
8789
+ });
8790
+ if (!materialized) problems.push(`${member.id}: not materialized in this worktree.`);
8791
+ else if (branchMismatch) {
8792
+ problems.push(`${member.id}: on branch "${actualBranch}", expected "${expectedBranch}".`);
8793
+ } else if (!hydrated) {
8794
+ problems.push(
8795
+ `${member.id}: materialized but not hydrated (no node_modules/vendor/obj/bin). Run \`haus workspace worktree hydrate\` to fix.`
8796
+ );
8797
+ }
8798
+ }
8799
+ }
8800
+ const orphans = [];
8801
+ for (const member of members) {
8802
+ const entries = await listWorktrees(member.absPath);
8803
+ for (const entry of entries) {
8804
+ const entryPath = path57.normalize(entry.path);
8805
+ const marker = `${path57.sep}.claude${path57.sep}worktrees${path57.sep}`;
8806
+ const idx = entryPath.indexOf(marker);
8807
+ if (idx === -1) continue;
8808
+ const rest = entryPath.slice(idx + marker.length);
8809
+ const slugName = rest.split(path57.sep)[0];
8810
+ if (!slugName) continue;
8811
+ if (!existsSync5(path57.join(wsWorktreesDir, slugName))) {
8812
+ orphans.push(
8813
+ `${member.id}: worktree registered at ${entry.path} but ${path57.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
8814
+ );
8815
+ }
8816
+ }
8817
+ }
8818
+ if (orphans.length > 0) problems.push(...orphans);
8819
+ return { isWorkspace: true, inWorkspaceWorktree, slug, members: memberChecks, orphans, problems };
8820
+ }
8821
+
8822
+ // src/workspace/worktree/hydrate-command.ts
8823
+ import { existsSync as existsSync6 } from "node:fs";
8824
+ import path58 from "node:path";
8825
+ async function runHydrateWorktree(opts) {
8826
+ const rootInfo = await resolveRoots();
8827
+ if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
8828
+ return {
8829
+ ok: false,
8830
+ error: "Run this from inside a workspace worktree created by `haus workspace worktree add` (.claude/worktrees/<slug>)."
8831
+ };
8832
+ }
8833
+ const workspaceRoot = rootInfo.mainRoot;
8834
+ const wtPath = rootInfo.repoRoot;
8835
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path58.sep}`)) {
8836
+ return {
8837
+ ok: false,
8838
+ error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
8839
+ };
8840
+ }
8841
+ const members = await readMembers(rootInfo);
8842
+ const { selected, unknown } = selectMembers(members, opts.only);
8843
+ if (unknown.length > 0) {
8844
+ return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
8845
+ }
8846
+ const state = await readWorktreeState(wtPath);
8847
+ const slug = state?.slug ?? path58.basename(wtPath);
8848
+ if (opts.dryRun) {
8849
+ const results2 = selected.map((member) => {
8850
+ const memberPath = path58.join(wtPath, member.folder);
8851
+ return {
8852
+ member: member.id,
8853
+ folder: member.folder,
8854
+ status: existsSync6(memberPath) ? "planned" : "skipped",
8855
+ error: existsSync6(memberPath) ? void 0 : "not materialized in this worktree"
8856
+ };
8857
+ });
8858
+ return { ok: true, dryRun: true, slug, results: results2 };
8859
+ }
8860
+ const results = await mapWithConcurrency(
8861
+ selected,
8862
+ async (member) => {
8863
+ const memberPath = path58.join(wtPath, member.folder);
8864
+ if (!existsSync6(memberPath)) {
8865
+ return {
8866
+ member: member.id,
8867
+ folder: member.folder,
8868
+ status: "skipped",
8869
+ error: "not materialized in this worktree"
8870
+ };
8871
+ }
8872
+ try {
8873
+ const hydration = await hydrateMember(member.absPath, memberPath, { force: opts.force });
8874
+ return { member: member.id, folder: member.folder, status: "ok", hydration };
8875
+ } catch (err) {
8876
+ return {
8877
+ member: member.id,
8878
+ folder: member.folder,
8879
+ status: "failed",
8880
+ error: err instanceof Error ? err.message : String(err)
8881
+ };
8882
+ }
8883
+ }
8884
+ );
8885
+ return { ok: results.every((r) => r.status !== "failed"), dryRun: false, slug, results };
8886
+ }
8887
+
8888
+ // src/workspace/worktree/list.ts
8889
+ import path59 from "node:path";
8890
+ import fs41 from "fs-extra";
8891
+ async function runList2() {
8892
+ const resolved = await resolveWorkspaceForWorktree();
8893
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
8894
+ const { rootInfo, workspaceRoot } = resolved;
8895
+ const dir = worktreesDir(workspaceRoot);
8896
+ if (!await fs41.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
8897
+ const entries = await fs41.readdir(dir, { withFileTypes: true });
8898
+ const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
8899
+ const configuredMembers = await readMembers(rootInfo);
8900
+ const worktrees = [];
8901
+ for (const slug of slugs) {
8902
+ const wtPath = path59.join(dir, slug);
8903
+ const state = await readWorktreeState(wtPath);
8904
+ const members = [];
8905
+ const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
8906
+ for (const m of source) {
8907
+ const memberPath = path59.join(wtPath, m.folder);
8908
+ const materialized = await fs41.pathExists(memberPath);
8909
+ const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
8910
+ members.push({
8911
+ id: m.id,
8912
+ folder: m.folder,
8913
+ expectedBranch: m.branch,
8914
+ materialized,
8915
+ actualBranch
8916
+ });
8917
+ }
8918
+ worktrees.push({
8919
+ slug,
8920
+ branch: state?.branch ?? await currentBranch(wtPath) ?? "unknown",
8921
+ createdAt: state?.createdAt,
8922
+ members
8923
+ });
8924
+ }
8925
+ return { ok: true, workspaceRoot, worktrees };
8926
+ }
8927
+
8928
+ // src/workspace/worktree/remove.ts
8929
+ import path60 from "node:path";
8930
+ import fs42 from "fs-extra";
8931
+ async function runRemove(opts) {
8932
+ const resolved = await resolveWorkspaceForWorktree();
8933
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
8934
+ const { rootInfo, workspaceRoot } = resolved;
8935
+ const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
8936
+ if (!await fs42.pathExists(wsWorktreePath)) {
8937
+ return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
8938
+ }
8939
+ const state = await readWorktreeState(wsWorktreePath);
8940
+ const allMembers = await readMembers(rootInfo);
8941
+ const stateMembers = state?.members.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath })) ?? allMembers.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath }));
8942
+ const targets = [
8943
+ { repo: "(workspace)", ownerRepo: workspaceRoot, path: wsWorktreePath }
8944
+ ];
8945
+ const unresolvedDroppedMembers = [];
8946
+ for (const stateMember of stateMembers) {
8947
+ const configMember = allMembers.find((m) => m.folder === stateMember.folder);
8948
+ const ownerRepo = configMember?.absPath ?? stateMember.absPath;
8949
+ if (!ownerRepo) {
8950
+ if (!configMember) unresolvedDroppedMembers.push(stateMember.folder);
8951
+ continue;
8952
+ }
8953
+ targets.push({
8954
+ repo: configMember?.id ?? stateMember.id,
8955
+ ownerRepo,
8956
+ path: path60.join(wsWorktreePath, stateMember.folder)
8957
+ });
8958
+ }
8959
+ const existingTargets = [];
8960
+ for (const target of targets) {
8961
+ if (await fs42.pathExists(target.path)) existingTargets.push(target);
8962
+ }
8963
+ if (!opts.force) {
8964
+ const blockers = [];
8965
+ for (const folder of unresolvedDroppedMembers) {
8966
+ blockers.push({
8967
+ repo: folder,
8968
+ reason: "no longer in the workspace config and this worktree predates absPath tracking \u2014 cannot verify for uncommitted work or unregister its git worktree; removing anyway will delete its directory here but leak the registration in whatever repo it came from (run `git worktree prune` there manually afterward)"
8969
+ });
8970
+ }
8971
+ for (const target of existingTargets) {
8972
+ const ignorePaths = target.repo === "(workspace)" ? [WORKTREE_STATE_FILE, ...stateMembers.map((m) => m.folder)] : [];
8973
+ if (await hasUncommittedChanges(target.path, ignorePaths)) {
8974
+ blockers.push({ repo: target.repo, reason: "uncommitted changes" });
8975
+ continue;
8976
+ }
8977
+ const unpushed = await hasUnpushedWork(target.path);
8978
+ if (unpushed.unpushed) blockers.push({ repo: target.repo, reason: unpushed.reason });
8979
+ }
8980
+ if (blockers.length > 0) return { ok: false, blocked: true, blockers };
8981
+ }
8982
+ if (opts.dryRun) {
8983
+ return {
8984
+ ok: true,
8985
+ dryRun: true,
8986
+ slug: opts.slug,
8987
+ wouldRemove: existingTargets.map((t) => t.repo)
8988
+ };
8989
+ }
8990
+ const removed = [];
8991
+ const failed = [];
8992
+ for (const folder of unresolvedDroppedMembers) {
8993
+ failed.push({
8994
+ repo: folder,
8995
+ error: "directory removed, but its git worktree registration could not be unregistered (no recorded absPath) \u2014 run `git worktree prune` in its original owning repo"
8996
+ });
8997
+ }
8998
+ const memberTargets = existingTargets.filter((t) => t.repo !== "(workspace)");
8999
+ for (const target of memberTargets) {
9000
+ const result = await removeWorktree(target.ownerRepo, target.path);
9001
+ if (result.ok) removed.push(target.repo);
9002
+ else failed.push({ repo: target.repo, error: result.stderr ?? "unknown error" });
9003
+ await pruneWorktrees(target.ownerRepo);
9004
+ }
9005
+ const wsTarget = existingTargets.find((t) => t.repo === "(workspace)");
9006
+ if (wsTarget) {
9007
+ const wsResult = await removeWorktree(workspaceRoot, wsWorktreePath);
9008
+ if (wsResult.ok) removed.push("(workspace)");
9009
+ else failed.push({ repo: "(workspace)", error: wsResult.stderr ?? "unknown error" });
9010
+ await pruneWorktrees(workspaceRoot);
9011
+ }
9012
+ if (await fs42.pathExists(wsWorktreePath)) {
9013
+ await fs42.remove(wsWorktreePath);
9014
+ }
9015
+ return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
9016
+ }
9017
+
9018
+ // src/commands/workspace/worktree.ts
9019
+ function normalizeOnly(only) {
9020
+ if (!only) return void 0;
9021
+ const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
9022
+ const cleaned = list.map((s) => s.trim()).filter(Boolean);
9023
+ return cleaned.length > 0 ? cleaned : void 0;
9024
+ }
9025
+ function describeInstall(hydration) {
9026
+ if (hydration.install.plan.manager === "none") return "no install step (no recognized lockfile)";
9027
+ if (!hydration.install.ran)
9028
+ return `install skipped (${hydration.install.skippedReason ?? "unavailable"})`;
9029
+ return hydration.install.ok ? `hydrated via ${hydration.install.plan.manager}` : `install FAILED via ${hydration.install.plan.manager}`;
9030
+ }
9031
+ async function handleAdd(opts) {
9032
+ if (!opts.slug) {
9033
+ error("Usage: haus workspace worktree add <slug>");
9034
+ process.exitCode = 1;
9035
+ return;
9036
+ }
9037
+ const addOpts = {
9038
+ slug: opts.slug,
9039
+ branch: opts.branch,
9040
+ only: normalizeOnly(opts.only),
9041
+ hydrate: opts.hydrate !== false,
9042
+ dryRun: Boolean(opts.dryRun)
9043
+ };
9044
+ const result = await runAdd(addOpts);
9045
+ if ("error" in result) {
9046
+ error(result.error);
9047
+ process.exitCode = 1;
9048
+ return;
9049
+ }
9050
+ if (result.dryRun) {
9051
+ log(
9052
+ `[dry-run] Would create the workspace worktree at ${result.worktreePath} on branch "${result.branch}" (${result.workspaceWorktree.branchAction}).`
9053
+ );
9054
+ for (const m of result.members) {
9055
+ log(
9056
+ `[dry-run] ${m.member} (${m.folder}): ${m.branchAction}${m.startPoint ? ` from ${m.startPoint}` : ""}`
9057
+ );
9058
+ }
9059
+ return;
9060
+ }
9061
+ log(
9062
+ `Workspace worktree "${result.slug}" ready at ${result.worktreePath} (branch "${result.branch}", ${result.workspaceWorktree.branchAction}).`
9063
+ );
9064
+ for (const m of result.members) {
9065
+ if (m.status === "ok") {
9066
+ const hydrationNote = m.hydration ? describeInstall(m.hydration) : "not hydrated (--no-hydrate)";
9067
+ log(` ${m.member}: ${m.branchAction} "${m.branch}" \u2014 ${hydrationNote} (${m.durationMs}ms)`);
9068
+ } else {
9069
+ warn(` ${m.member}: FAILED \u2014 ${m.error}`);
9070
+ }
9071
+ }
9072
+ if (!result.ok) {
9073
+ const failedCount = result.members.filter((m) => m.status === "failed").length;
7766
9074
  error(
7767
- "Existing haus.workspace.yaml is malformed \u2014 fix or remove it before running discover (refusing to overwrite)."
9075
+ `${failedCount} member(s) failed to materialize \u2014 the worktree is partially built. See the per-member detail above for exactly what exists.`
7768
9076
  );
7769
9077
  process.exitCode = 1;
9078
+ }
9079
+ }
9080
+ async function handleHydrate(opts) {
9081
+ const hydrateOpts = {
9082
+ only: normalizeOnly(opts.only),
9083
+ force: Boolean(opts.force),
9084
+ dryRun: Boolean(opts.dryRun)
9085
+ };
9086
+ const result = await runHydrateWorktree(hydrateOpts);
9087
+ if (!result.ok && "error" in result) {
9088
+ error(result.error);
9089
+ process.exitCode = 1;
7770
9090
  return;
7771
9091
  }
7772
- const discovered = await discoverRepos(workspaceRoot, opts.maxDepth ?? DEFAULT_MAX_DEPTH);
7773
- if (discovered.length === 0) {
7774
- error("No repos discovered under the workspace root.");
9092
+ if (result.dryRun) {
9093
+ log(`[dry-run] Would hydrate "${result.slug}":`);
9094
+ for (const r of result.results)
9095
+ log(`[dry-run] ${r.member}: ${r.status}${r.error ? ` (${r.error})` : ""}`);
9096
+ return;
9097
+ }
9098
+ log(`Hydrated "${result.slug}":`);
9099
+ for (const r of result.results) {
9100
+ if (r.status === "ok" && r.hydration) log(` ${r.member}: ${describeInstall(r.hydration)}`);
9101
+ else warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
9102
+ }
9103
+ if (!result.ok) process.exitCode = 1;
9104
+ }
9105
+ async function handleRemove(opts) {
9106
+ if (!opts.slug) {
9107
+ error("Usage: haus workspace worktree remove <slug>");
7775
9108
  process.exitCode = 1;
7776
9109
  return;
7777
9110
  }
7778
- const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
7779
- const yamlText = renderWorkspaceYaml(merged);
7780
- if (opts.write) {
7781
- await writeText(yamlPath, yamlText);
7782
- if (opts.json) {
7783
- log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
7784
- } else {
7785
- log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
9111
+ const removeOpts = {
9112
+ slug: opts.slug,
9113
+ force: Boolean(opts.force),
9114
+ dryRun: Boolean(opts.dryRun)
9115
+ };
9116
+ const result = await runRemove(removeOpts);
9117
+ if (!result.ok && "error" in result) {
9118
+ error(result.error);
9119
+ process.exitCode = 1;
9120
+ return;
9121
+ }
9122
+ if (!result.ok && "blocked" in result && result.blocked) {
9123
+ error(`Refusing to remove "${opts.slug}" \u2014 uncommitted or unpushed work found:`);
9124
+ for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
9125
+ error("Re-run with --force to remove anyway.");
9126
+ process.exitCode = 1;
9127
+ return;
9128
+ }
9129
+ if ("dryRun" in result && result.dryRun) {
9130
+ log(`[dry-run] Would remove: ${result.wouldRemove.join(", ") || "(nothing)"}`);
9131
+ return;
9132
+ }
9133
+ if ("removed" in result) {
9134
+ log(`Removed: ${result.removed.join(", ") || "(nothing)"}`);
9135
+ if (result.failed.length > 0) {
9136
+ for (const f of result.failed) error(` Failed to remove ${f.repo}: ${f.error}`);
9137
+ process.exitCode = 1;
7786
9138
  }
9139
+ }
9140
+ }
9141
+ async function handleList() {
9142
+ const result = await runList2();
9143
+ if (!result.ok) {
9144
+ error(result.error);
9145
+ process.exitCode = 1;
7787
9146
  return;
7788
9147
  }
7789
- if (opts.json) {
7790
- log(JSON.stringify({ discovered, config: merged }, null, 2));
9148
+ if (result.worktrees.length === 0) {
9149
+ log("No workspace worktrees under .claude/worktrees/.");
7791
9150
  return;
7792
9151
  }
7793
- log("Proposed haus.workspace.yaml (run with --write to persist):\n");
7794
- log(yamlText);
9152
+ for (const wt of result.worktrees) {
9153
+ log(`${wt.slug} (branch: ${wt.branch})`);
9154
+ for (const m of wt.members) {
9155
+ const status = m.materialized ? `ok, branch ${m.actualBranch ?? "unknown"}` : "MISSING";
9156
+ log(` ${m.id}: ${status}`);
9157
+ }
9158
+ }
9159
+ }
9160
+ async function handleDoctor(opts) {
9161
+ try {
9162
+ const report = await runDoctor2();
9163
+ if (!report.isWorkspace) {
9164
+ if (!opts.fromHook) {
9165
+ warn(report.problems[0] ?? "Not a haus workspace.");
9166
+ process.exitCode = 1;
9167
+ }
9168
+ return;
9169
+ }
9170
+ if (report.problems.length === 0) {
9171
+ log(
9172
+ report.inWorkspaceWorktree ? `Workspace worktree "${report.slug}" looks healthy.` : "No issues found."
9173
+ );
9174
+ return;
9175
+ }
9176
+ for (const p of report.problems) warn(p);
9177
+ if (!opts.fromHook) process.exitCode = 1;
9178
+ } catch (err) {
9179
+ const message = err instanceof Error ? err.message : String(err);
9180
+ if (opts.fromHook) {
9181
+ warn(`worktree doctor: ${message}`);
9182
+ } else {
9183
+ error(message);
9184
+ process.exitCode = 1;
9185
+ }
9186
+ }
9187
+ }
9188
+ async function runWorktree(action, opts = {}) {
9189
+ switch (action) {
9190
+ case "add":
9191
+ return handleAdd(opts);
9192
+ case "hydrate":
9193
+ return handleHydrate(opts);
9194
+ case "remove":
9195
+ return handleRemove(opts);
9196
+ case "list":
9197
+ return handleList();
9198
+ case "doctor":
9199
+ return handleDoctor(opts);
9200
+ }
9201
+ }
9202
+
9203
+ // src/commands/workspace.ts
9204
+ import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
9205
+ import path69 from "node:path";
9206
+
9207
+ // src/commands/workspace/aggregate.ts
9208
+ async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
9209
+ const summaries = repos.map((repo) => ({
9210
+ name: repo.name,
9211
+ path: repo.path,
9212
+ roles: repo.context.repoRoles ?? [],
9213
+ packageManager: repo.context.packageManager,
9214
+ deps: repo.context.dependencies ?? []
9215
+ }));
9216
+ const ownership = {};
9217
+ for (const repo of summaries) {
9218
+ for (const dep2 of repo.deps) {
9219
+ ownership[dep2] ??= [];
9220
+ ownership[dep2].push(repo.name);
9221
+ }
9222
+ }
9223
+ const roles = [...new Set(summaries.flatMap((r) => r.roles))].sort();
9224
+ const crossRepoHints = [...new Set(repos.flatMap((r) => r.context.crossRepoHints ?? []))].sort();
9225
+ const summaryPath = hausPath(workspaceRoot, "workspace-summary.json");
9226
+ const ownershipPath = hausPath(workspaceRoot, "dependency-ownership-map.json");
9227
+ const crossRepoPath = hausPath(workspaceRoot, "cross-repo-summary.md");
9228
+ const contextMapPath = hausPath(workspaceRoot, "workspace-context-map.json");
9229
+ await writeJson(summaryPath, {
9230
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9231
+ repos: summaries
9232
+ });
9233
+ await writeJson(ownershipPath, ownership);
9234
+ await writeText(
9235
+ crossRepoPath,
9236
+ `# Cross Repo Summary
9237
+
9238
+ ${summaries.map(
9239
+ (repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
9240
+ ).join("\n")}
9241
+ `
9242
+ );
9243
+ await writeJson(contextMapPath, {
9244
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9245
+ roles,
9246
+ crossRepoHints,
9247
+ repos: summaries.map((r) => ({
9248
+ name: r.name,
9249
+ path: r.path,
9250
+ roles: r.roles,
9251
+ packageManager: r.packageManager
9252
+ })),
9253
+ relationships
9254
+ });
9255
+ return [summaryPath, ownershipPath, crossRepoPath, contextMapPath];
7795
9256
  }
7796
9257
 
7797
9258
  // src/commands/workspace/doctor.ts
7798
- import { existsSync as existsSync3 } from "node:fs";
7799
- import path48 from "node:path";
9259
+ import { existsSync as existsSync7 } from "node:fs";
9260
+ import path62 from "node:path";
7800
9261
 
7801
9262
  // src/commands/workspace/manifest.ts
7802
- import { readFileSync as readFileSync3 } from "node:fs";
7803
- import path47 from "node:path";
9263
+ import { readFileSync as readFileSync4 } from "node:fs";
9264
+ import path61 from "node:path";
7804
9265
  var MANIFEST_FILE = "workspace.manifest.json";
7805
9266
  function manifestPath(workspaceRoot) {
7806
9267
  return hausPath(workspaceRoot, MANIFEST_FILE);
7807
9268
  }
7808
9269
  function hausVersion() {
7809
9270
  try {
7810
- const pkg = JSON.parse(readFileSync3(path47.join(packageRoot(), "package.json"), "utf8"));
9271
+ const pkg = JSON.parse(readFileSync4(path61.join(packageRoot(), "package.json"), "utf8"));
7811
9272
  return pkg.version ?? "0.0.0";
7812
9273
  } catch {
7813
9274
  return "0.0.0";
@@ -7831,7 +9292,8 @@ function buildManifest2(opts) {
7831
9292
  catalogRef: repo.catalogRef,
7832
9293
  status: repo.status,
7833
9294
  ...repo.error ? { error: repo.error } : {}
7834
- }))
9295
+ })),
9296
+ ...opts.linkedContext ? { linkedContext: opts.linkedContext } : {}
7835
9297
  };
7836
9298
  }
7837
9299
  async function readManifest2(workspaceRoot) {
@@ -7842,6 +9304,22 @@ async function writeWorkspaceManifest(workspaceRoot, manifest) {
7842
9304
  await writeJson(target, manifest);
7843
9305
  return target;
7844
9306
  }
9307
+ async function readLinkedContext(workspaceRoot) {
9308
+ const manifest = await readManifest2(workspaceRoot);
9309
+ return manifest?.linkedContext ?? [];
9310
+ }
9311
+ async function writeLinkedContext(workspaceRoot, linkedContext) {
9312
+ const prior = await readManifest2(workspaceRoot);
9313
+ const next = prior ? { ...prior, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), linkedContext } : {
9314
+ version: 1,
9315
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9316
+ hausVersion: hausVersion(),
9317
+ client: "unknown",
9318
+ repos: [],
9319
+ linkedContext
9320
+ };
9321
+ return writeWorkspaceManifest(workspaceRoot, next);
9322
+ }
7845
9323
 
7846
9324
  // src/commands/workspace/doctor.ts
7847
9325
  async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
@@ -7874,7 +9352,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
7874
9352
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
7875
9353
  const catalogRefByRepo = [];
7876
9354
  for (const repo of config.repos) {
7877
- const repoRoot = path48.resolve(workspaceRoot, repo.path);
9355
+ const repoRoot = path62.resolve(workspaceRoot, repo.path);
7878
9356
  const entry = manifestByName.get(repo.name);
7879
9357
  if (!entry) {
7880
9358
  flag({
@@ -7899,7 +9377,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
7899
9377
  detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
7900
9378
  });
7901
9379
  }
7902
- if (!existsSync3(claudePath(repoRoot))) {
9380
+ if (!existsSync7(claudePath(repoRoot))) {
7903
9381
  flag({
7904
9382
  repo: repo.name,
7905
9383
  kind: "missing-claude",
@@ -7908,7 +9386,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
7908
9386
  }
7909
9387
  const lock = await checkLock(repoRoot);
7910
9388
  if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
7911
- if (!existsSync3(hausPath(repoRoot, "haus.lock.json"))) {
9389
+ if (!existsSync7(hausPath(repoRoot, "haus.lock.json"))) {
7912
9390
  flag({
7913
9391
  repo: repo.name,
7914
9392
  kind: "missing-lock",
@@ -7933,6 +9411,40 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
7933
9411
  detail: `Repos are on different catalog refs \u2014 ${catalogRefByRepo.map((r) => `${r.repo}: ${r.ref}`).join(", ")}. Run \`haus workspace setup --write\` to bring them onto the same ref.`
7934
9412
  });
7935
9413
  }
9414
+ if (manifest.linkedContext && manifest.linkedContext.length > 0) {
9415
+ const rootInfo = await resolveRoots(workspaceRoot);
9416
+ const members = await readMembers(rootInfo);
9417
+ const memberById = new Map(members.map((m) => [m.id, m]));
9418
+ for (const entry of manifest.linkedContext) {
9419
+ const member = memberById.get(entry.repo);
9420
+ if (!member || !existsSync7(member.absPath)) {
9421
+ flag({
9422
+ repo: entry.repo,
9423
+ kind: "missing-linked-context-source",
9424
+ detail: `Linked ${entry.type} "${entry.name}" (${entry.path}) \u2014 source repo no longer configured or cloned. Re-run \`haus workspace link-context\` to clean up.`
9425
+ });
9426
+ continue;
9427
+ }
9428
+ if (!existsSync7(path62.join(workspaceRoot, entry.path))) {
9429
+ flag({
9430
+ repo: entry.repo,
9431
+ kind: "missing-linked-context-copy",
9432
+ detail: `Linked ${entry.type} "${entry.name}" is recorded in the manifest but ${entry.path} no longer exists at the workspace root. Re-run \`haus workspace link-context\` to restore it.`
9433
+ });
9434
+ continue;
9435
+ }
9436
+ const liveHash = await hashInstalledPaths(member.absPath, [entry.sourceRelPath], {
9437
+ followSymlinks: false
9438
+ });
9439
+ if (liveHash !== entry.sourceHash) {
9440
+ flag({
9441
+ repo: entry.repo,
9442
+ kind: "stale-linked-context",
9443
+ detail: `Linked ${entry.type} "${entry.name}" (${entry.path}) is stale \u2014 the source in ${entry.repo} changed since it was linked. Re-run \`haus workspace link-context\`.`
9444
+ });
9445
+ }
9446
+ }
9447
+ }
7936
9448
  return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
7937
9449
  }
7938
9450
  function emit(args) {
@@ -7955,13 +9467,256 @@ function emit(args) {
7955
9467
  return { workspaceRoot, manifest, drift };
7956
9468
  }
7957
9469
 
9470
+ // src/workspace/link-context/link.ts
9471
+ import path65 from "node:path";
9472
+ import fs45 from "fs-extra";
9473
+
9474
+ // src/workspace/link-context/plan.ts
9475
+ import path64 from "node:path";
9476
+ import fs44 from "fs-extra";
9477
+
9478
+ // src/workspace/link-context/scan-source.ts
9479
+ import path63 from "node:path";
9480
+ import fs43 from "fs-extra";
9481
+ var ASSET_SUBDIR = {
9482
+ skill: "skills",
9483
+ agent: "agents",
9484
+ command: "commands"
9485
+ };
9486
+ async function hasLinkableContext(member) {
9487
+ for (const subdir of Object.values(ASSET_SUBDIR)) {
9488
+ if (await fs43.pathExists(path63.join(member.absPath, ".claude", subdir))) return true;
9489
+ }
9490
+ return false;
9491
+ }
9492
+ async function collectSkills(member) {
9493
+ const dir = path63.join(member.absPath, ".claude", "skills");
9494
+ if (!await fs43.pathExists(dir)) return [];
9495
+ const names = await fs43.readdir(dir);
9496
+ const out = [];
9497
+ for (const name of [...names].sort()) {
9498
+ const abs = path63.join(dir, name);
9499
+ const stat = await fs43.lstat(abs);
9500
+ if (!stat.isDirectory()) continue;
9501
+ if (!await fs43.pathExists(path63.join(abs, "SKILL.md"))) continue;
9502
+ out.push({ type: "skill", name, sourceRelPath: path63.posix.join(".claude", "skills", name) });
9503
+ }
9504
+ return out;
9505
+ }
9506
+ async function collectFlatMarkdown(member, type) {
9507
+ const subdir = ASSET_SUBDIR[type];
9508
+ const dir = path63.join(member.absPath, ".claude", subdir);
9509
+ if (!await fs43.pathExists(dir)) return [];
9510
+ const names = await fs43.readdir(dir);
9511
+ const out = [];
9512
+ for (const fileName of [...names].sort()) {
9513
+ if (!fileName.endsWith(".md")) continue;
9514
+ const abs = path63.join(dir, fileName);
9515
+ if (!(await fs43.lstat(abs)).isFile()) continue;
9516
+ const name = fileName.slice(0, -".md".length);
9517
+ out.push({ type, name, sourceRelPath: path63.posix.join(".claude", subdir, fileName) });
9518
+ }
9519
+ return out;
9520
+ }
9521
+ async function collectSourceAssets(member) {
9522
+ const [skills, agents, commands] = await Promise.all([
9523
+ collectSkills(member),
9524
+ collectFlatMarkdown(member, "agent"),
9525
+ collectFlatMarkdown(member, "command")
9526
+ ]);
9527
+ return [...skills, ...agents, ...commands];
9528
+ }
9529
+
9530
+ // src/workspace/link-context/plan.ts
9531
+ function repoFolderPrefix(member) {
9532
+ const base = path64.posix.basename(member.folder);
9533
+ if (base && base !== "." && base !== "/") return base;
9534
+ const fromAbs = path64.basename(member.absPath);
9535
+ return fromAbs || member.id;
9536
+ }
9537
+ function assetKey(type, destKey) {
9538
+ return `${type}:${destKey}`;
9539
+ }
9540
+ function splitAssetKey(key) {
9541
+ const idx = key.indexOf(":");
9542
+ return { type: key.slice(0, idx), destKey: key.slice(idx + 1) };
9543
+ }
9544
+ async function buildLinkPlan(members) {
9545
+ const skipped = [];
9546
+ const byKey = /* @__PURE__ */ new Map();
9547
+ const membersById = new Map(members.map((m) => [m.id, m]));
9548
+ for (const member of members) {
9549
+ if (!await fs44.pathExists(member.absPath)) {
9550
+ skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
9551
+ continue;
9552
+ }
9553
+ if (!await hasLinkableContext(member)) {
9554
+ skipped.push({
9555
+ repo: member.id,
9556
+ reason: "no .claude/skills, .claude/agents, or .claude/commands \u2014 nothing to link"
9557
+ });
9558
+ continue;
9559
+ }
9560
+ const assets = await collectSourceAssets(member);
9561
+ const prefix = repoFolderPrefix(member);
9562
+ for (const asset of assets) {
9563
+ const destKey = `${prefix}--${asset.name}`;
9564
+ const key = assetKey(asset.type, destKey);
9565
+ const list = byKey.get(key) ?? [];
9566
+ list.push({ repo: member.id, asset });
9567
+ byKey.set(key, list);
9568
+ }
9569
+ }
9570
+ const collisions = [];
9571
+ const entries = [];
9572
+ for (const [key, list] of byKey) {
9573
+ const { type, destKey } = splitAssetKey(key);
9574
+ const distinctRepos = [...new Set(list.map((l) => l.repo))];
9575
+ if (distinctRepos.length > 1) {
9576
+ collisions.push({ type, destKey, repos: distinctRepos });
9577
+ continue;
9578
+ }
9579
+ const { repo, asset } = list[0];
9580
+ const member = membersById.get(repo);
9581
+ if (!member) continue;
9582
+ const sourceHash = await hashInstalledPaths(member.absPath, [asset.sourceRelPath], {
9583
+ followSymlinks: false
9584
+ });
9585
+ entries.push({
9586
+ repo,
9587
+ type,
9588
+ name: asset.name,
9589
+ destKey,
9590
+ sourceRelPath: asset.sourceRelPath,
9591
+ sourceAbsPath: path64.join(member.absPath, asset.sourceRelPath),
9592
+ sourceHash
9593
+ });
9594
+ }
9595
+ return { entries, skipped, collisions };
9596
+ }
9597
+
9598
+ // src/workspace/link-context/link.ts
9599
+ function destRelPathFor(type, destKey) {
9600
+ const subdir = `${type}s`;
9601
+ const leaf = type === "skill" ? destKey : `${destKey}.md`;
9602
+ return path65.posix.join(".claude", subdir, leaf);
9603
+ }
9604
+ async function runLinkContext(workspaceRoot, opts = {}) {
9605
+ const write = opts.write ?? false;
9606
+ const say = opts.json ? () => {
9607
+ } : log;
9608
+ const rootInfo = await resolveRoots(workspaceRoot);
9609
+ const members = await readMembers(rootInfo);
9610
+ const plan = await buildLinkPlan(members);
9611
+ if (plan.collisions.length > 0) {
9612
+ const detail = plan.collisions.map((c) => `${c.type} "${c.destKey}" claimed by: ${c.repos.join(", ")}`).join("; ");
9613
+ return {
9614
+ ok: false,
9615
+ error: `Name collision(s) after the repo-folder prefix \u2014 refusing to overwrite either copy. Rename one of the conflicting repos' folders (in haus.workspace.yaml/repos.manifest.json) or the conflicting skill/agent/command itself to resolve: ${detail}`,
9616
+ collisions: plan.collisions
9617
+ };
9618
+ }
9619
+ for (const s of plan.skipped) say(` skip ${s.repo}: ${s.reason}`);
9620
+ const priorManifest = await readManifest2(workspaceRoot);
9621
+ const prior = priorManifest?.linkedContext ?? [];
9622
+ const priorPaths = new Set(prior.map((e) => e.path));
9623
+ const nextEntries = [];
9624
+ const added = [];
9625
+ const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
9626
+ for (const entry of plan.entries) {
9627
+ const destRel = destRelPathFor(entry.type, entry.destKey);
9628
+ const destAbs = path65.join(workspaceRoot, destRel);
9629
+ if (write) {
9630
+ await fs45.ensureDir(path65.dirname(destAbs));
9631
+ if (await fs45.pathExists(destAbs)) await fs45.remove(destAbs);
9632
+ await fs45.copy(entry.sourceAbsPath, destAbs, {
9633
+ overwrite: true,
9634
+ errorOnExist: false,
9635
+ dereference: false,
9636
+ filter: (s) => {
9637
+ try {
9638
+ return !fs45.lstatSync(s).isSymbolicLink();
9639
+ } catch {
9640
+ return false;
9641
+ }
9642
+ }
9643
+ });
9644
+ }
9645
+ if (!priorPaths.has(destRel)) added.push(destRel);
9646
+ nextEntries.push({
9647
+ repo: entry.repo,
9648
+ type: entry.type,
9649
+ name: entry.name,
9650
+ path: destRel,
9651
+ sourceRelPath: entry.sourceRelPath,
9652
+ sourceHash: entry.sourceHash,
9653
+ linkedAt
9654
+ });
9655
+ }
9656
+ const nextPaths = new Set(nextEntries.map((e) => e.path));
9657
+ const removed = [];
9658
+ for (const old of prior) {
9659
+ if (nextPaths.has(old.path)) continue;
9660
+ removed.push(old.path);
9661
+ if (write) {
9662
+ const abs = path65.join(workspaceRoot, old.path);
9663
+ if (await fs45.pathExists(abs)) await fs45.remove(abs);
9664
+ }
9665
+ }
9666
+ if (write) {
9667
+ await writeLinkedContext(workspaceRoot, nextEntries);
9668
+ await writeLinkContextGitignore(workspaceRoot, false);
9669
+ }
9670
+ return {
9671
+ ok: true,
9672
+ workspaceRoot,
9673
+ dryRun: !write,
9674
+ linked: nextEntries,
9675
+ added,
9676
+ removed,
9677
+ skipped: plan.skipped
9678
+ };
9679
+ }
9680
+
9681
+ // src/commands/workspace/link-context.ts
9682
+ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
9683
+ const options = { write: opts.write, json: opts.json };
9684
+ const result = await runLinkContext(workspaceRoot, options);
9685
+ if (!result.ok) {
9686
+ if (opts.json) {
9687
+ log(JSON.stringify(result, null, 2));
9688
+ } else {
9689
+ error(result.error);
9690
+ }
9691
+ process.exitCode = 1;
9692
+ return;
9693
+ }
9694
+ if (opts.json) {
9695
+ log(JSON.stringify(result, null, 2));
9696
+ return;
9697
+ }
9698
+ for (const s of result.skipped) warn(` skip ${s.repo}: ${s.reason}`);
9699
+ if (result.dryRun) {
9700
+ log(
9701
+ `[dry-run] Would link ${result.linked.length} skill/agent/command copy(ies) into .claude/ (${result.added.length} new, ${result.removed.length} to remove). Re-run with --write to apply.`
9702
+ );
9703
+ return;
9704
+ }
9705
+ log(
9706
+ `Linked ${result.linked.length} skill/agent/command copy(ies) into .claude/ (${result.added.length} new, ${result.removed.length} removed).`
9707
+ );
9708
+ log(
9709
+ "Source of truth for each copy stays in its own member repo \u2014 do not edit the copies directly; re-run `haus workspace link-context` after editing a source, or when `haus workspace doctor` flags one stale."
9710
+ );
9711
+ }
9712
+
7958
9713
  // src/commands/workspace/setup.ts
7959
- import { existsSync as existsSync4, statSync } from "node:fs";
7960
- import path50 from "node:path";
9714
+ import { existsSync as existsSync8, statSync } from "node:fs";
9715
+ import path67 from "node:path";
7961
9716
 
7962
9717
  // src/claude/write-workspace-claude-md.ts
7963
- import path49 from "node:path";
7964
- import fs36 from "fs-extra";
9718
+ import path66 from "node:path";
9719
+ import fs46 from "fs-extra";
7965
9720
  function buildWorkspaceImportBlock(client, members) {
7966
9721
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
7967
9722
  const body = [
@@ -7979,11 +9734,11 @@ ${BLOCK_END}`;
7979
9734
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
7980
9735
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
7981
9736
  const dryRun = opts.dryRun ?? false;
7982
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path49.join(workspaceRoot, "CLAUDE.md");
9737
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path66.join(workspaceRoot, "CLAUDE.md");
7983
9738
  const say = opts.quiet ? () => {
7984
9739
  } : log;
7985
- const existed = await fs36.pathExists(filePath);
7986
- const prev = existed ? await fs36.readFile(filePath, "utf8") : "";
9740
+ const existed = await fs46.pathExists(filePath);
9741
+ const prev = existed ? await fs46.readFile(filePath, "utf8") : "";
7987
9742
  const next = opts.collision ? `${block}
7988
9743
  ` : injectHausBlock(prev, block);
7989
9744
  const printable = displayPath(workspaceRoot, filePath);
@@ -8010,20 +9765,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
8010
9765
 
8011
9766
  // src/commands/workspace/setup.ts
8012
9767
  function resolveWorkspaceRoot(start = process.cwd()) {
8013
- let dir = path50.resolve(start);
9768
+ let dir = path67.resolve(start);
8014
9769
  for (; ; ) {
8015
- if (existsSync4(path50.join(dir, WORKSPACE_FILE))) return dir;
8016
- const parent = path50.dirname(dir);
8017
- if (parent === dir) return path50.resolve(start);
9770
+ if (existsSync8(path67.join(dir, WORKSPACE_FILE))) return dir;
9771
+ const parent = path67.dirname(dir);
9772
+ if (parent === dir) return path67.resolve(start);
8018
9773
  dir = parent;
8019
9774
  }
8020
9775
  }
8021
9776
  function isRootRepo(workspaceRoot, repoPath) {
8022
- return path50.resolve(workspaceRoot, repoPath) === path50.resolve(workspaceRoot);
9777
+ return path67.resolve(workspaceRoot, repoPath) === path67.resolve(workspaceRoot);
8023
9778
  }
8024
9779
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
8025
9780
  const apply = options.write ?? false;
8026
- const configText = await readText(path50.join(workspaceRoot, WORKSPACE_FILE));
9781
+ const configText = await readText(path67.join(workspaceRoot, WORKSPACE_FILE));
8027
9782
  if (!configText) {
8028
9783
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
8029
9784
  process.exitCode = 1;
@@ -8050,11 +9805,11 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
8050
9805
  const statuses = [];
8051
9806
  const aggregateInputs = [];
8052
9807
  for (const repo of repos) {
8053
- const repoRoot = path50.resolve(workspaceRoot, repo.path);
9808
+ const repoRoot = path67.resolve(workspaceRoot, repo.path);
8054
9809
  say(`
8055
9810
  \u2192 ${repo.name} (${repo.path})`);
8056
9811
  try {
8057
- if (!existsSync4(repoRoot) || !statSync(repoRoot).isDirectory()) {
9812
+ if (!existsSync8(repoRoot) || !statSync(repoRoot).isDirectory()) {
8058
9813
  throw new Error(`Repo path is not a directory: ${repo.path}`);
8059
9814
  }
8060
9815
  const res = await runSetupCore(repoRoot, {
@@ -8110,7 +9865,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
8110
9865
  if (apply && !options.dryRun) {
8111
9866
  const statusByName = new Map(statuses.map((s) => [s.name, s]));
8112
9867
  const prior = await readManifest2(workspaceRoot);
8113
- if (!prior && existsSync4(manifestPath(workspaceRoot))) {
9868
+ if (!prior && existsSync8(manifestPath(workspaceRoot))) {
8114
9869
  warn(
8115
9870
  "Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
8116
9871
  );
@@ -8121,7 +9876,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
8121
9876
  const status = statusByName.get(repo.name);
8122
9877
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
8123
9878
  if (status?.status === "ok") {
8124
- const lock = await checkLock(path50.resolve(workspaceRoot, repo.path));
9879
+ const lock = await checkLock(path67.resolve(workspaceRoot, repo.path));
8125
9880
  manifestRepos.push({
8126
9881
  name: repo.name,
8127
9882
  path: repo.path,
@@ -8164,9 +9919,33 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
8164
9919
  );
8165
9920
  }
8166
9921
  }
8167
- const manifest = buildManifest2({ client: config.client, repos: manifestRepos });
9922
+ const manifest = buildManifest2({
9923
+ client: config.client,
9924
+ repos: manifestRepos,
9925
+ // buildManifest rebuilds the manifest from scratch each run — carry the prior
9926
+ // `linkedContext` section forward so a plain `setup --write` never silently
9927
+ // drops entries `link-context` wrote on an earlier run.
9928
+ linkedContext: prior?.linkedContext
9929
+ });
8168
9930
  const manifestFile = await writeWorkspaceManifest(workspaceRoot, manifest);
8169
9931
  written.push(manifestFile);
9932
+ try {
9933
+ const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
9934
+ if (linkResult.ok) {
9935
+ written.push(...linkResult.added.map((rel) => path67.join(workspaceRoot, rel)));
9936
+ if (!options.json) {
9937
+ say(
9938
+ `Linked ${linkResult.linked.length} cross-repo skill/agent/command copy(ies) into .claude/ (${linkResult.added.length} new, ${linkResult.removed.length} removed)${linkResult.skipped.length > 0 ? ` \u2014 ${linkResult.skipped.length} member(s) skipped, see above` : ""}.`
9939
+ );
9940
+ }
9941
+ } else {
9942
+ warn(`Skipping workspace link-context: ${linkResult.error}`);
9943
+ }
9944
+ } catch (err) {
9945
+ warn(
9946
+ `Workspace link-context failed: ${err instanceof Error ? err.message : String(err)} \u2014 run \`haus workspace link-context\` directly to retry.`
9947
+ );
9948
+ }
8170
9949
  }
8171
9950
  const ok = statuses.filter((s) => s.status === "ok").length;
8172
9951
  const failed = statuses.length - ok;
@@ -8184,9 +9963,9 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
8184
9963
  }
8185
9964
 
8186
9965
  // src/commands/workspace/undo.ts
8187
- import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
8188
- import path51 from "node:path";
8189
- import fs37 from "fs-extra";
9966
+ import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
9967
+ import path68 from "node:path";
9968
+ import fs47 from "fs-extra";
8190
9969
  var WORKSPACE_AGGREGATE_FILES = [
8191
9970
  "workspace-summary.json",
8192
9971
  "dependency-ownership-map.json",
@@ -8214,11 +9993,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
8214
9993
  }
8215
9994
  let anyRepoFailed = false;
8216
9995
  for (const repo of config.repos) {
8217
- const repoRoot = path51.resolve(workspaceRoot, repo.path);
9996
+ const repoRoot = path68.resolve(workspaceRoot, repo.path);
8218
9997
  log(`
8219
9998
  \u2192 ${repo.name} (${repo.path})`);
8220
9999
  try {
8221
- if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
10000
+ if (!existsSync9(repoRoot) || !statSync2(repoRoot).isDirectory()) {
8222
10001
  throw new Error(`Repo path is not a directory: ${repo.path}`);
8223
10002
  }
8224
10003
  await runUndo({ yes: true, root: repoRoot });
@@ -8227,33 +10006,41 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
8227
10006
  warn(`Undo failed for ${repo.name}: ${err instanceof Error ? err.message : String(err)}`);
8228
10007
  }
8229
10008
  }
10009
+ const linkedContext = await readLinkedContext(workspaceRoot);
10010
+ for (const entry of linkedContext) {
10011
+ const abs = path68.join(workspaceRoot, entry.path);
10012
+ if (await fs47.pathExists(abs)) {
10013
+ await fs47.remove(abs);
10014
+ log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
10015
+ }
10016
+ }
8230
10017
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
8231
10018
  const abs = hausPath(workspaceRoot, rel);
8232
- if (await fs37.pathExists(abs)) {
8233
- await fs37.remove(abs);
8234
- log(`Removed ${path51.relative(workspaceRoot, abs)}`);
10019
+ if (await fs47.pathExists(abs)) {
10020
+ await fs47.remove(abs);
10021
+ log(`Removed ${path68.relative(workspaceRoot, abs)}`);
8235
10022
  }
8236
10023
  }
8237
10024
  const manifestFile = manifestPath(workspaceRoot);
8238
- if (await fs37.pathExists(manifestFile)) {
8239
- await fs37.remove(manifestFile);
8240
- log(`Removed ${path51.relative(workspaceRoot, manifestFile)}`);
10025
+ if (await fs47.pathExists(manifestFile)) {
10026
+ await fs47.remove(manifestFile);
10027
+ log(`Removed ${path68.relative(workspaceRoot, manifestFile)}`);
8241
10028
  }
8242
10029
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
8243
- if (await fs37.pathExists(workspaceMdPath)) {
8244
- await fs37.remove(workspaceMdPath);
8245
- log(`Removed ${path51.relative(workspaceRoot, workspaceMdPath)}`);
10030
+ if (await fs47.pathExists(workspaceMdPath)) {
10031
+ await fs47.remove(workspaceMdPath);
10032
+ log(`Removed ${path68.relative(workspaceRoot, workspaceMdPath)}`);
8246
10033
  } else {
8247
- const claudeMdPath = path51.join(workspaceRoot, "CLAUDE.md");
8248
- if (existsSync5(claudeMdPath)) {
10034
+ const claudeMdPath = path68.join(workspaceRoot, "CLAUDE.md");
10035
+ if (existsSync9(claudeMdPath)) {
8249
10036
  const prev = await readText(claudeMdPath);
8250
10037
  if (prev?.includes(BLOCK_BEGIN)) {
8251
10038
  const next = stripHausBlock(prev);
8252
10039
  if (next.length === 0) {
8253
- await fs37.remove(claudeMdPath);
10040
+ await fs47.remove(claudeMdPath);
8254
10041
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
8255
10042
  } else {
8256
- await fs37.writeFile(claudeMdPath, next, "utf8");
10043
+ await fs47.writeFile(claudeMdPath, next, "utf8");
8257
10044
  log("Removed haus import block from CLAUDE.md (user content preserved).");
8258
10045
  }
8259
10046
  }
@@ -8269,7 +10056,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
8269
10056
  }
8270
10057
 
8271
10058
  // src/commands/workspace.ts
8272
- function normalizeOnly(only) {
10059
+ function normalizeOnly2(only) {
8273
10060
  if (!only) return void 0;
8274
10061
  const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
8275
10062
  const cleaned = list.map((s) => s.trim()).filter(Boolean);
@@ -8296,7 +10083,7 @@ relationships: []
8296
10083
  log("Workspace initialized.");
8297
10084
  }
8298
10085
  async function scanWorkspace(workspaceRoot, opts) {
8299
- const configText = await readText(path52.join(workspaceRoot, WORKSPACE_FILE));
10086
+ const configText = await readText(path69.join(workspaceRoot, WORKSPACE_FILE));
8300
10087
  if (!configText) {
8301
10088
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
8302
10089
  process.exitCode = 1;
@@ -8318,9 +10105,9 @@ async function scanWorkspace(workspaceRoot, opts) {
8318
10105
  const inputs = [];
8319
10106
  const failures = [];
8320
10107
  for (const repo of config.repos) {
8321
- const repoRoot = path52.resolve(workspaceRoot, repo.path);
10108
+ const repoRoot = path69.resolve(workspaceRoot, repo.path);
8322
10109
  try {
8323
- if (!existsSync6(repoRoot) || !statSync3(repoRoot).isDirectory()) {
10110
+ if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
8324
10111
  throw new Error("path is not a directory");
8325
10112
  }
8326
10113
  const result = await scanProject(repoRoot);
@@ -8366,7 +10153,7 @@ async function runWorkspace(action, options = {}) {
8366
10153
  dryRun: options.dryRun,
8367
10154
  json: options.json,
8368
10155
  continueOnError: options.continueOnError,
8369
- only: normalizeOnly(options.only)
10156
+ only: normalizeOnly2(options.only)
8370
10157
  });
8371
10158
  return;
8372
10159
  case "doctor":
@@ -8375,14 +10162,17 @@ async function runWorkspace(action, options = {}) {
8375
10162
  case "undo":
8376
10163
  await runWorkspaceUndo(workspaceRoot, { yes: options.yes });
8377
10164
  return;
10165
+ case "link-context":
10166
+ await runWorkspaceLinkContext(workspaceRoot, { write: options.write, json: options.json });
10167
+ return;
8378
10168
  }
8379
10169
  }
8380
10170
 
8381
10171
  // src/cli.ts
8382
10172
  function cliVersion() {
8383
10173
  try {
8384
- const pkgPath = path53.join(packageRoot(), "package.json");
8385
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
10174
+ const pkgPath = path70.join(packageRoot(), "package.json");
10175
+ const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
8386
10176
  return pkg.version ?? "0.0.0";
8387
10177
  } catch {
8388
10178
  return "0.0.0";
@@ -8391,8 +10181,8 @@ function cliVersion() {
8391
10181
  var program = new Command();
8392
10182
  function validateRuntimeNodeVersion() {
8393
10183
  try {
8394
- const pkgPath = path53.join(packageRoot(), "package.json");
8395
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
10184
+ const pkgPath = path70.join(packageRoot(), "package.json");
10185
+ const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
8396
10186
  const requiredRange = pkg.engines?.node;
8397
10187
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
8398
10188
  throw new Error(`Node ${process.version} does not satisfy required range ${requiredRange}`);
@@ -8410,7 +10200,10 @@ program.command("recommend").option("--json").option(
8410
10200
  "--include <ids...>",
8411
10201
  "Force opt-in catalog items into the recommendation as manual selections (space- or comma-separated ids)"
8412
10202
  ).action(runRecommend);
8413
- program.command("setup-project").option("--json").action(runSetupProject);
10203
+ program.command("setup-project").option("--json").option(
10204
+ "--force",
10205
+ "Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
10206
+ ).action(runSetupProject);
8414
10207
  program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
8415
10208
  program.command("fetch-refs").description("Fetch and cache llms.txt content from catalog item references").option("--all", "Fetch for all catalog items (default)").option("--id <id>", "Fetch for a single catalog item by id").option("--json", "Emit machine-readable JSON summary to stdout").action(runFetchRefs);
8416
10209
  program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
@@ -8472,6 +10265,31 @@ workspace.command("scan").description("Aggregate a cross-repo summary from a fas
8472
10265
  workspace.command("setup").description("Per-repo setup loop + workspace layer + manifest").option("--write", "Apply changes (default previews only)").option("--dry-run", "Preview changes without writing").option("--json", "Emit machine-readable per-repo output").option("--continue-on-error", "Keep going past a failed repo (default fail-fast)").option("--only <names>", "Restrict to comma-separated repo names").action((opts) => runWorkspace("setup", opts));
8473
10266
  workspace.command("doctor").description("Report workspace drift against the manifest").option("--json", "Output the manifest and drift array as JSON").action((opts) => runWorkspace("doctor", opts));
8474
10267
  workspace.command("undo").description("Revert haus setup for every configured repo, plus workspace-root artifacts").option("-y, --yes", "Skip confirmation").action((opts) => runWorkspace("undo", opts));
10268
+ workspace.command("link-context").description(
10269
+ "Copy each member repo's skill/agent/command into the workspace root's .claude/, prefixed to avoid collisions"
10270
+ ).option("--write", "Persist copies + manifest + gitignore (default previews only)").option("--json", "Output the link result as JSON").action((opts) => runWorkspace("link-context", opts));
10271
+ var worktree = workspace.command("worktree").description(
10272
+ "Materialize workspace member repos as real git worktrees (per docs/plans/workspace-worktree-materialization.md, Task 4)"
10273
+ );
10274
+ worktree.command("add <slug>").description(
10275
+ "Create a workspace worktree plus one member worktree per repo, on a mirrored branch"
10276
+ ).option("--branch <name>", "Branch to create/checkout (default: the slug)").option("--only <repos>", "Restrict to comma- or space-separated member ids/folders").option("--no-hydrate", "Skip hydration (CoW clone + install-reconciliation)").option("--dry-run", "Preview without writing or running anything").action(
10277
+ (slug, opts) => runWorktree("add", {
10278
+ slug,
10279
+ branch: opts.branch,
10280
+ only: opts.only,
10281
+ hydrate: opts.hydrate,
10282
+ dryRun: opts.dryRun
10283
+ })
10284
+ );
10285
+ worktree.command("hydrate").description("Re-run CoW clone + install-reconciliation for the current workspace worktree").option("--only <repos>", "Restrict to comma- or space-separated member ids/folders").option("--force", "Re-clone hydration targets even if already present").option("--dry-run", "Preview without writing or running anything").action(
10286
+ (opts) => runWorktree("hydrate", { only: opts.only, force: opts.force, dryRun: opts.dryRun })
10287
+ );
10288
+ worktree.command("list").description("List materialized workspace worktrees and their member status").action(() => runWorktree("list"));
10289
+ worktree.command("remove <slug>").description("Remove a workspace worktree and its member worktrees").option("--force", "Bypass the uncommitted/unpushed-work refusal").option("--dry-run", "Preview without removing anything").action(
10290
+ (slug, opts) => runWorktree("remove", { slug, force: opts.force, dryRun: opts.dryRun })
10291
+ );
10292
+ worktree.command("doctor").description("Fast, side-effect-free health check for the current workspace worktree").option("--from-hook", "Emit for the SessionStart hook \u2014 always exits 0").action((opts) => runWorktree("doctor", { fromHook: opts.fromHook }));
8475
10293
  program.parseAsync(process.argv).catch((err) => {
8476
10294
  const message = err instanceof Error ? err.message : String(err);
8477
10295
  error(message);