@haus-tech/haus-workflow 1.4.1 → 1.5.1

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 path71 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 path72 of DENY_PATHS) {
2107
+ const pattern = DENY_DIRS.has(path72) ? `${path72}/**` : path72;
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,19 +4151,133 @@ 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 path38 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 path29 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 path28 from "node:path";
4163
+ import fs25 from "fs-extra";
4164
+
4165
+ // src/decisions/dep-bump.ts
4166
+ var DEP_SECTIONS = [
4167
+ "dependencies",
4168
+ "devDependencies",
4169
+ "peerDependencies",
4170
+ "optionalDependencies"
4171
+ ];
4172
+ function isRecord(value) {
4173
+ return value != null && typeof value === "object" && !Array.isArray(value);
4174
+ }
4175
+ function extractDepMap(pkg) {
4176
+ const map = /* @__PURE__ */ new Map();
4177
+ if (!isRecord(pkg)) return map;
4178
+ for (const section of DEP_SECTIONS) {
4179
+ const block = pkg[section];
4180
+ if (!isRecord(block)) continue;
4181
+ for (const [name, ver] of Object.entries(block)) {
4182
+ if (typeof ver === "string") map.set(`${section}:${name}`, ver);
4183
+ }
4184
+ }
4185
+ return map;
4186
+ }
4187
+ function stripDepSections(pkg) {
4188
+ const out = { ...pkg };
4189
+ for (const section of DEP_SECTIONS) delete out[section];
4190
+ return out;
4191
+ }
4192
+ function mapsEqualKeys(a, b) {
4193
+ if (a.size !== b.size) return false;
4194
+ for (const k of a.keys()) if (!b.has(k)) return false;
4195
+ return true;
4196
+ }
4197
+ function isDependencyBumpOnly(beforeText, afterText) {
4198
+ let before;
4199
+ let after;
4200
+ try {
4201
+ before = JSON.parse(beforeText);
4202
+ after = JSON.parse(afterText);
4203
+ } catch {
4204
+ return false;
4205
+ }
4206
+ if (!isRecord(before) || !isRecord(after)) return false;
4207
+ const beforeDeps = extractDepMap(before);
4208
+ const afterDeps = extractDepMap(after);
4209
+ if (beforeDeps.size === 0 && afterDeps.size === 0) return false;
4210
+ if (!mapsEqualKeys(beforeDeps, afterDeps)) return false;
4211
+ if (JSON.stringify(stripDepSections(before)) !== JSON.stringify(stripDepSections(after))) {
4212
+ return false;
4213
+ }
4214
+ for (const [key, ver] of beforeDeps) {
4215
+ if (afterDeps.get(key) !== ver) return true;
4216
+ }
4217
+ return false;
4218
+ }
3908
4219
 
3909
4220
  // src/decisions/diff.ts
3910
4221
  init_exec();
4222
+ import path25 from "node:path";
4223
+ function isPackageJsonPath(file) {
4224
+ return file === "package.json" || file.endsWith("/package.json");
4225
+ }
4226
+ async function readBlob(root, spec) {
4227
+ const result = await runGit(["show", spec], { cwd: root });
4228
+ return result.exitCode === 0 ? result.stdout : null;
4229
+ }
4230
+ function parseDiffRange(range) {
4231
+ const tripleIdx = range.indexOf("...");
4232
+ if (tripleIdx !== -1) {
4233
+ return {
4234
+ left: range.slice(0, tripleIdx) || "HEAD",
4235
+ right: range.slice(tripleIdx + 3) || "HEAD",
4236
+ triple: true
4237
+ };
4238
+ }
4239
+ const idx = range.indexOf("..");
4240
+ if (idx !== -1) {
4241
+ return {
4242
+ left: range.slice(0, idx) || "HEAD",
4243
+ right: range.slice(idx + 2) || "HEAD",
4244
+ triple: false
4245
+ };
4246
+ }
4247
+ return null;
4248
+ }
4249
+ async function getCurrentBranch(root) {
4250
+ const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root });
4251
+ if (result.exitCode !== 0) return void 0;
4252
+ const name = result.stdout.trim();
4253
+ if (name && name !== "HEAD") return name;
4254
+ const gha = process.env.GITHUB_HEAD_REF?.trim();
4255
+ if (gha) return gha;
4256
+ return void 0;
4257
+ }
4258
+ async function resolvePackageJsonPair(root, file, opts) {
4259
+ const empty = "{}";
4260
+ if (opts.staged) {
4261
+ const before2 = await readBlob(root, `HEAD:${file}`) ?? empty;
4262
+ const after2 = await readBlob(root, `:${file}`) ?? empty;
4263
+ return { before: before2, after: after2 };
4264
+ }
4265
+ const range = opts.range ?? "HEAD";
4266
+ const parsed = parseDiffRange(range);
4267
+ if (parsed) {
4268
+ let leftRev = parsed.left;
4269
+ if (parsed.triple) {
4270
+ const mb = await runGit(["merge-base", parsed.left, parsed.right], { cwd: root });
4271
+ leftRev = mb.exitCode === 0 ? mb.stdout.trim() : parsed.left;
4272
+ }
4273
+ const before2 = await readBlob(root, `${leftRev}:${file}`) ?? empty;
4274
+ const after2 = await readBlob(root, `${parsed.right}:${file}`) ?? empty;
4275
+ return { before: before2, after: after2 };
4276
+ }
4277
+ const before = await readBlob(root, `${range}:${file}`) ?? empty;
4278
+ const after = await readText(path25.join(root, file)) ?? empty;
4279
+ return { before, after };
4280
+ }
3911
4281
  async function collectDiffStats(root, opts) {
3912
4282
  const gitArgs = opts.staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only", opts.range ?? "HEAD"];
3913
4283
  const names = await runGit(gitArgs, { cwd: root });
@@ -3924,9 +4294,23 @@ async function collectDiffStats(root, opts) {
3924
4294
  if (!Number.isNaN(added)) linesAdded += added;
3925
4295
  if (!Number.isNaN(removed)) linesRemoved += removed;
3926
4296
  }
3927
- return { files, linesAdded, linesRemoved };
4297
+ const packageJsonPairs = {};
4298
+ for (const file of files) {
4299
+ if (!isPackageJsonPath(file)) continue;
4300
+ packageJsonPairs[file] = await resolvePackageJsonPair(root, file, opts);
4301
+ }
4302
+ return {
4303
+ files,
4304
+ linesAdded,
4305
+ linesRemoved,
4306
+ ...Object.keys(packageJsonPairs).length > 0 ? { packageJsonPairs } : {}
4307
+ };
3928
4308
  }
3929
4309
 
4310
+ // src/decisions/local-exemptions.ts
4311
+ var LOCAL_EXEMPT_GLOBS = ["library/catalog/**"];
4312
+ var LOCAL_EXEMPT_BRANCH_PATTERN = /^chore\/sync-catalog/;
4313
+
3930
4314
  // src/decisions/match.ts
3931
4315
  function segmentToRegex(segment) {
3932
4316
  if (segment === "**") return ".*";
@@ -3954,27 +4338,30 @@ function matchesAnyGlob(filePath, patterns) {
3954
4338
  return patterns.some((p) => matchesPathGlob(filePath, p));
3955
4339
  }
3956
4340
  function matchesAnyRegex(filePath, patterns) {
4341
+ return firstMatchingRegex(filePath, patterns) != null;
4342
+ }
4343
+ function firstMatchingRegex(filePath, patterns) {
3957
4344
  const norm = filePath.replace(/\\/g, "/");
3958
- return patterns.some((p) => new RegExp(p).test(norm));
4345
+ return patterns.find((p) => new RegExp(p).test(norm));
3959
4346
  }
3960
4347
 
3961
4348
  // src/decisions/paths.ts
3962
- import path24 from "node:path";
3963
- import fs22 from "fs-extra";
4349
+ import path26 from "node:path";
4350
+ import fs23 from "fs-extra";
3964
4351
  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;
4352
+ const primary = path26.join(root, DECISIONS_TRIGGERS.decisionsDir);
4353
+ if (await fs23.pathExists(primary)) return primary;
4354
+ const legacy = path26.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4355
+ if (await fs23.pathExists(legacy)) return legacy;
3969
4356
  return primary;
3970
4357
  }
3971
4358
  function relativeDecisionPath(decisionsDir, root) {
3972
- return path24.relative(root, decisionsDir).replace(/\\/g, "/");
4359
+ return path26.relative(root, decisionsDir).replace(/\\/g, "/");
3973
4360
  }
3974
4361
 
3975
4362
  // src/decisions/validate.ts
3976
- import path25 from "node:path";
3977
- import fs23 from "fs-extra";
4363
+ import path27 from "node:path";
4364
+ import fs24 from "fs-extra";
3978
4365
  function validateDecisionContent(content2) {
3979
4366
  const errors = [];
3980
4367
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -4003,14 +4390,14 @@ function decisionNumberFromFile(name) {
4003
4390
  return match?.[1] ?? null;
4004
4391
  }
4005
4392
  async function validateIndexSync(decisionsDir, decisionFiles) {
4006
- const readmePath = path25.join(decisionsDir, "README.md");
4007
- if (!await fs23.pathExists(readmePath)) {
4393
+ const readmePath = path27.join(decisionsDir, "README.md");
4394
+ if (!await fs24.pathExists(readmePath)) {
4008
4395
  return ["docs/decisions/README.md is missing"];
4009
4396
  }
4010
4397
  const readme = await readText(readmePath) ?? "";
4011
4398
  const errors = [];
4012
4399
  for (const file of decisionFiles) {
4013
- const num = decisionNumberFromFile(path25.basename(file));
4400
+ const num = decisionNumberFromFile(path27.basename(file));
4014
4401
  if (num && !indexReferencesNumber(readme, num)) {
4015
4402
  errors.push(`README.md missing index row for ADR-${num}`);
4016
4403
  }
@@ -4019,8 +4406,35 @@ async function validateIndexSync(decisionsDir, decisionFiles) {
4019
4406
  }
4020
4407
 
4021
4408
  // src/decisions/check.ts
4409
+ var MANIFEST_LOCK_BASENAMES = /* @__PURE__ */ new Set([
4410
+ "package.json",
4411
+ "yarn.lock",
4412
+ "pnpm-lock.yaml",
4413
+ "package-lock.json"
4414
+ ]);
4022
4415
  function isExempt(file) {
4023
- return matchesAnyGlob(file, DECISIONS_TRIGGERS.exemptGlobs);
4416
+ return matchesAnyGlob(file, DECISIONS_TRIGGERS.exemptGlobs) || matchesAnyGlob(file, [...LOCAL_EXEMPT_GLOBS]);
4417
+ }
4418
+ function isManifestOrLockfile(file) {
4419
+ const base = file.split("/").pop() ?? file;
4420
+ return MANIFEST_LOCK_BASENAMES.has(base);
4421
+ }
4422
+ function isPackageJsonPath2(file) {
4423
+ return file === "package.json" || file.endsWith("/package.json");
4424
+ }
4425
+ function isPureDependencyBumpDiff(stats) {
4426
+ const relevant = stats.files.filter((f) => !isExempt(f));
4427
+ if (relevant.length === 0) return false;
4428
+ if (relevant.some((f) => !isManifestOrLockfile(f))) return false;
4429
+ const pkgJsons = relevant.filter(isPackageJsonPath2);
4430
+ if (pkgJsons.length === 0) {
4431
+ return true;
4432
+ }
4433
+ const pairs = stats.packageJsonPairs ?? {};
4434
+ return pkgJsons.every((f) => {
4435
+ const pair = pairs[f];
4436
+ return pair != null && isDependencyBumpOnly(pair.before, pair.after);
4437
+ });
4024
4438
  }
4025
4439
  function countTopLevelDirs(files) {
4026
4440
  const dirs = /* @__PURE__ */ new Set();
@@ -4030,21 +4444,35 @@ function countTopLevelDirs(files) {
4030
4444
  }
4031
4445
  return dirs.size;
4032
4446
  }
4033
- function isDecisionTriggered(stats) {
4034
- const relevant = stats.files.filter((f) => !isExempt(f));
4035
- if (relevant.length === 0) return false;
4447
+ function firstMatchingGlob(file, globs) {
4448
+ return globs.find((g) => matchesPathGlob(file, g));
4449
+ }
4450
+ function explainDecisionTrigger(stats) {
4451
+ let relevant = stats.files.filter((f) => !isExempt(f));
4452
+ if (relevant.length === 0) return [];
4453
+ if (isPureDependencyBumpDiff(stats)) {
4454
+ relevant = relevant.filter((f) => !isManifestOrLockfile(f));
4455
+ if (relevant.length === 0) return [];
4456
+ }
4036
4457
  for (const file of relevant) {
4037
- if (matchesAnyGlob(file, DECISIONS_TRIGGERS.pathGlobs)) return true;
4038
- if (matchesAnyRegex(file, DECISIONS_TRIGGERS.pathRegex)) return true;
4458
+ const glob = firstMatchingGlob(file, DECISIONS_TRIGGERS.pathGlobs);
4459
+ if (glob) {
4460
+ return [`matched pathGlobs: ${glob}`];
4461
+ }
4462
+ const regex = firstMatchingRegex(file, DECISIONS_TRIGGERS.pathRegex);
4463
+ if (regex) {
4464
+ return [`matched pathRegex: ${regex}`];
4465
+ }
4039
4466
  }
4040
4467
  const totalLines = stats.linesAdded + stats.linesRemoved;
4041
- if (relevant.length >= DECISIONS_TRIGGERS.minFilesChanged && countTopLevelDirs(relevant) >= 2) {
4042
- return true;
4468
+ const dirs = countTopLevelDirs(relevant);
4469
+ if (relevant.length >= DECISIONS_TRIGGERS.minFilesChanged && dirs >= 2) {
4470
+ return [`size threshold: ${relevant.length} files across ${dirs} top-level dirs`];
4043
4471
  }
4044
4472
  if (totalLines >= DECISIONS_TRIGGERS.minLinesChanged && relevant.some((f) => !f.startsWith("docs/"))) {
4045
- return true;
4473
+ return [`size threshold: ${totalLines} lines changed (non-docs)`];
4046
4474
  }
4047
- return false;
4475
+ return [];
4048
4476
  }
4049
4477
  function isSecurityTriggered(stats) {
4050
4478
  const relevant = stats.files.filter((f) => !isExempt(f));
@@ -4064,26 +4492,27 @@ async function checkSatisfaction(input2) {
4064
4492
  const errors = [];
4065
4493
  const decisionsPrefix = `${input2.decisionsRel}/`;
4066
4494
  const newDecisionFiles = input2.changedFiles.filter(
4067
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path26.basename(f))
4495
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path28.basename(f))
4068
4496
  );
4069
4497
  if (newDecisionFiles.length === 0) {
4070
4498
  errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
4071
4499
  return errors;
4072
4500
  }
4073
- const decisionsDir = path26.join(input2.root, input2.decisionsRel);
4501
+ const decisionsDir = path28.join(input2.root, input2.decisionsRel);
4074
4502
  for (const rel of newDecisionFiles) {
4075
- const abs = path26.join(input2.root, rel);
4503
+ const abs = path28.join(input2.root, rel);
4076
4504
  const content2 = await readText(abs) ?? "";
4077
4505
  const valid = validateDecisionContent(content2);
4078
4506
  if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
4079
4507
  }
4080
4508
  const indexErrors = await validateIndexSync(
4081
4509
  decisionsDir,
4082
- newDecisionFiles.map((f) => path26.basename(f))
4510
+ newDecisionFiles.map((f) => path28.basename(f))
4083
4511
  );
4084
4512
  errors.push(...indexErrors);
4085
4513
  return errors;
4086
4514
  }
4515
+ var WAYS_OUT = "Write an ADR under docs/decisions/ (haus decisions suggest), or use [adr-skip] for non-security changes.";
4087
4516
  async function runDecisionsCheck(root, opts = {}) {
4088
4517
  const reasons = [];
4089
4518
  if (await readGateMode(root) === "off") {
@@ -4095,8 +4524,18 @@ async function runDecisionsCheck(root, opts = {}) {
4095
4524
  };
4096
4525
  }
4097
4526
  const stats = await collectDiffStats(root, opts);
4098
- const triggered = isDecisionTriggered(stats);
4099
4527
  const securityTriggered = isSecurityTriggered(stats);
4528
+ const branch = await getCurrentBranch(root);
4529
+ if (branch != null && LOCAL_EXEMPT_BRANCH_PATTERN.test(branch) && !securityTriggered) {
4530
+ return {
4531
+ triggered: false,
4532
+ satisfied: true,
4533
+ reasons: ["catalog-sync branch \u2014 exempt"],
4534
+ securityTriggered: false
4535
+ };
4536
+ }
4537
+ const triggerReasons = explainDecisionTrigger(stats);
4538
+ const triggered = triggerReasons.length > 0;
4100
4539
  if (!triggered) {
4101
4540
  return {
4102
4541
  triggered: false,
@@ -4105,6 +4544,7 @@ async function runDecisionsCheck(root, opts = {}) {
4105
4544
  securityTriggered
4106
4545
  };
4107
4546
  }
4547
+ reasons.push(...triggerReasons);
4108
4548
  const skipText = `${opts.prBody ?? ""}
4109
4549
  ${opts.commitMessages ?? ""}`;
4110
4550
  if (hasSkipToken(skipText) && !securityTriggered) {
@@ -4123,8 +4563,8 @@ ${opts.commitMessages ?? ""}`;
4123
4563
  changedFiles: stats.files
4124
4564
  });
4125
4565
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
4126
- const legacyExists = await fs24.pathExists(path26.join(root, legacyRel));
4127
- if (legacyExists && !await fs24.pathExists(decisionsDir)) {
4566
+ const legacyExists = await fs25.pathExists(path28.join(root, legacyRel));
4567
+ if (legacyExists && !await fs25.pathExists(decisionsDir)) {
4128
4568
  satisfactionErrors = await checkSatisfaction({
4129
4569
  root,
4130
4570
  decisionsRel: legacyRel,
@@ -4133,29 +4573,31 @@ ${opts.commitMessages ?? ""}`;
4133
4573
  }
4134
4574
  }
4135
4575
  if (satisfactionErrors.length === 0) {
4576
+ reasons.push("decision record present");
4136
4577
  return {
4137
4578
  triggered: true,
4138
4579
  satisfied: true,
4139
- reasons: ["decision record present"],
4580
+ reasons,
4140
4581
  securityTriggered
4141
4582
  };
4142
4583
  }
4143
4584
  reasons.push(...satisfactionErrors);
4585
+ reasons.push(WAYS_OUT);
4144
4586
  return { triggered: true, satisfied: false, reasons, securityTriggered };
4145
4587
  }
4146
4588
  async function listDecisionNumbers(decisionsDir) {
4147
- if (!await fs24.pathExists(decisionsDir)) return [];
4148
- const entries = await fs24.readdir(decisionsDir);
4589
+ if (!await fs25.pathExists(decisionsDir)) return [];
4590
+ const entries = await fs25.readdir(decisionsDir);
4149
4591
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
4150
4592
  }
4151
4593
 
4152
4594
  // src/decisions/doctor.ts
4153
4595
  async function auditDecisionsLayout(root) {
4154
4596
  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);
4597
+ const primary = path29.join(root, DECISIONS_TRIGGERS.decisionsDir);
4598
+ const legacy = path29.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4599
+ const hasPrimary = await fs26.pathExists(primary);
4600
+ const hasLegacy = await fs26.pathExists(legacy);
4159
4601
  if (!hasPrimary && hasLegacy) {
4160
4602
  findings.push({
4161
4603
  level: "advisory",
@@ -4168,8 +4610,8 @@ async function auditDecisionsLayout(root) {
4168
4610
  if (!hasPrimary) return findings;
4169
4611
  const decisionsDir = await resolveDecisionsDir(root);
4170
4612
  const rel = relativeDecisionPath(decisionsDir, root);
4171
- const readmePath = path27.join(decisionsDir, "README.md");
4172
- if (!await fs25.pathExists(readmePath)) {
4613
+ const readmePath = path29.join(decisionsDir, "README.md");
4614
+ if (!await fs26.pathExists(readmePath)) {
4173
4615
  findings.push({
4174
4616
  level: "advisory",
4175
4617
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -4179,7 +4621,7 @@ async function auditDecisionsLayout(root) {
4179
4621
  return findings;
4180
4622
  }
4181
4623
  const readme = await readText(readmePath);
4182
- const entries = (await fs25.readdir(decisionsDir)).filter(
4624
+ const entries = (await fs26.readdir(decisionsDir)).filter(
4183
4625
  (f) => f.endsWith(".md") && f !== "README.md"
4184
4626
  );
4185
4627
  for (const file of entries) {
@@ -4193,7 +4635,7 @@ async function auditDecisionsLayout(root) {
4193
4635
  });
4194
4636
  }
4195
4637
  }
4196
- const claude = await readText(path27.join(root, "CLAUDE.md"));
4638
+ const claude = await readText(path29.join(root, "CLAUDE.md"));
4197
4639
  if (claude && !claude.includes(`@${rel}/README.md`)) {
4198
4640
  findings.push({
4199
4641
  level: "advisory",
@@ -4224,15 +4666,185 @@ async function auditDecisionsLayout(root) {
4224
4666
  }
4225
4667
 
4226
4668
  // src/scanner/read-context.ts
4227
- import path32 from "node:path";
4228
- import fs27 from "fs-extra";
4229
-
4230
- // src/scanner/scan-project.ts
4231
- import path31 from "node:path";
4669
+ import path37 from "node:path";
4670
+ import fs28 from "fs-extra";
4232
4671
 
4233
- // src/utils/audit-checks.ts
4234
- function isRecord(v) {
4235
- return typeof v === "object" && v !== null;
4672
+ // src/commands/workspace/config.ts
4673
+ import path30 from "node:path";
4674
+ import YAML from "yaml";
4675
+ var WORKSPACE_FILE = "haus.workspace.yaml";
4676
+ function parseWorkspaceConfig(text) {
4677
+ if (!text) return void 0;
4678
+ let parsed;
4679
+ try {
4680
+ parsed = YAML.parse(text);
4681
+ } catch {
4682
+ return void 0;
4683
+ }
4684
+ if (!parsed || typeof parsed !== "object") return void 0;
4685
+ const obj = parsed;
4686
+ const repos = Array.isArray(obj.repos) ? obj.repos.filter(
4687
+ (r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
4688
+ ) : [];
4689
+ return {
4690
+ client: typeof obj.client === "string" ? obj.client : "unknown",
4691
+ repos,
4692
+ relationships: Array.isArray(obj.relationships) ? obj.relationships : []
4693
+ };
4694
+ }
4695
+ async function readWorkspaceConfig(workspaceRoot) {
4696
+ return parseWorkspaceConfig(await readText(path30.join(workspaceRoot, WORKSPACE_FILE)));
4697
+ }
4698
+
4699
+ // src/workspace/members.ts
4700
+ import path31 from "node:path";
4701
+ var REPOS_MANIFEST_FILE = "repos.manifest.json";
4702
+ var REPOS_LOCAL_FILE = "repos.local.json";
4703
+ var MemberConfigError = class extends Error {
4704
+ constructor(filePath, reason) {
4705
+ super(`Cannot read workspace member config at ${filePath}: ${reason}`);
4706
+ this.filePath = filePath;
4707
+ }
4708
+ filePath;
4709
+ name = "MemberConfigError";
4710
+ };
4711
+ function resolveAbsPath(workspaceRoot, folder, overrides) {
4712
+ const override = overrides[folder];
4713
+ if (override) return path31.resolve(workspaceRoot, override);
4714
+ return path31.resolve(workspaceRoot, folder);
4715
+ }
4716
+ async function readPathOverrides(workspaceRoot) {
4717
+ const filePath = path31.join(workspaceRoot, REPOS_LOCAL_FILE);
4718
+ const result = await readJsonDetailed(filePath);
4719
+ if (result.status === "missing") return {};
4720
+ if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
4721
+ const parsed = result.value;
4722
+ const rawOverrides = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.pathOverrides : void 0;
4723
+ if (rawOverrides === null || typeof rawOverrides !== "object" || Array.isArray(rawOverrides)) {
4724
+ return {};
4725
+ }
4726
+ const overrides = {};
4727
+ for (const [folder, value] of Object.entries(rawOverrides)) {
4728
+ if (typeof value === "string") overrides[folder] = value;
4729
+ }
4730
+ return overrides;
4731
+ }
4732
+ async function readFromWorkspaceYaml(workspaceRoot, overrides) {
4733
+ const filePath = path31.join(workspaceRoot, WORKSPACE_FILE);
4734
+ const text = await readText(filePath);
4735
+ if (text === void 0) return void 0;
4736
+ const config = parseWorkspaceConfig(text);
4737
+ if (!config) throw new MemberConfigError(filePath, "malformed YAML or invalid shape");
4738
+ return config.repos.map((repo) => ({
4739
+ id: repo.name,
4740
+ folder: repo.path,
4741
+ absPath: resolveAbsPath(workspaceRoot, repo.path, overrides),
4742
+ source: "haus.workspace.yaml"
4743
+ }));
4744
+ }
4745
+ async function readFromManifest(workspaceRoot, overrides) {
4746
+ const filePath = path31.join(workspaceRoot, REPOS_MANIFEST_FILE);
4747
+ const result = await readJsonDetailed(filePath);
4748
+ if (result.status === "missing") return void 0;
4749
+ if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
4750
+ const parsed = result.value;
4751
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4752
+ throw new MemberConfigError(filePath, 'expected a top-level object with a "repos" array');
4753
+ }
4754
+ const raw = parsed.repos;
4755
+ if (!Array.isArray(raw)) {
4756
+ throw new MemberConfigError(filePath, '"repos" must be an array');
4757
+ }
4758
+ const members = [];
4759
+ raw.forEach((entry, index) => {
4760
+ if (typeof entry !== "object" || entry === null || typeof entry.id !== "string" || typeof entry.folder !== "string") {
4761
+ throw new MemberConfigError(
4762
+ filePath,
4763
+ `repos[${index}] must be an object with string "id" and "folder" fields`
4764
+ );
4765
+ }
4766
+ const e = entry;
4767
+ members.push({
4768
+ id: e.id,
4769
+ folder: e.folder,
4770
+ url: typeof e.repo === "string" ? e.repo : void 0,
4771
+ absPath: resolveAbsPath(workspaceRoot, e.folder, overrides),
4772
+ source: "repos.manifest.json"
4773
+ });
4774
+ });
4775
+ return members;
4776
+ }
4777
+ async function readMembers(rootInfo) {
4778
+ const workspaceRoot = rootInfo.mainRoot;
4779
+ const overrides = await readPathOverrides(workspaceRoot);
4780
+ const fromYaml = await readFromWorkspaceYaml(workspaceRoot, overrides);
4781
+ if (fromYaml) return fromYaml;
4782
+ const fromManifest = await readFromManifest(workspaceRoot, overrides);
4783
+ if (fromManifest) return fromManifest;
4784
+ return [];
4785
+ }
4786
+
4787
+ // src/scanner/scan-project.ts
4788
+ import path36 from "node:path";
4789
+
4790
+ // src/utils/audit-checks.ts
4791
+ function isRecord2(v) {
4792
+ return typeof v === "object" && v !== null;
4793
+ }
4794
+
4795
+ // src/utils/git-root.ts
4796
+ init_exec();
4797
+ import path32 from "node:path";
4798
+ function fallback(start) {
4799
+ return {
4800
+ cwd: start,
4801
+ repoRoot: start,
4802
+ gitDir: "",
4803
+ gitCommonDir: "",
4804
+ isLinkedWorktree: false,
4805
+ mainRoot: start,
4806
+ worktreeName: null,
4807
+ isGitRepo: false
4808
+ };
4809
+ }
4810
+ async function resolveAbsoluteGitPath(flag, cwd, repoRoot) {
4811
+ const withFormat = await runGit(["rev-parse", "--path-format=absolute", flag], { cwd });
4812
+ const raw = withFormat.exitCode === 0 ? withFormat.stdout.trim() : "";
4813
+ if (raw && path32.isAbsolute(raw)) return path32.resolve(raw);
4814
+ const plain = await runGit(["rev-parse", flag], { cwd });
4815
+ const plainRaw = plain.exitCode === 0 ? plain.stdout.trim() : "";
4816
+ if (!plainRaw) return "";
4817
+ return path32.isAbsolute(plainRaw) ? path32.resolve(plainRaw) : path32.resolve(repoRoot, plainRaw);
4818
+ }
4819
+ async function resolveRoots(start) {
4820
+ const cwd = start ?? process.cwd();
4821
+ try {
4822
+ const toplevel = await runGit(["rev-parse", "--show-toplevel"], { cwd });
4823
+ if (toplevel.exitCode !== 0) return fallback(cwd);
4824
+ const toplevelRaw = toplevel.stdout.trim();
4825
+ if (!toplevelRaw) return fallback(cwd);
4826
+ const repoRoot = path32.resolve(toplevelRaw);
4827
+ const [gitDir, gitCommonDir] = await Promise.all([
4828
+ resolveAbsoluteGitPath("--git-dir", cwd, repoRoot),
4829
+ resolveAbsoluteGitPath("--git-common-dir", cwd, repoRoot)
4830
+ ]);
4831
+ if (!gitDir || !gitCommonDir) return fallback(cwd);
4832
+ const isLinkedWorktree = gitDir !== gitCommonDir;
4833
+ const mainRoot = isLinkedWorktree && path32.basename(gitCommonDir) === ".git" ? path32.dirname(gitCommonDir) : repoRoot;
4834
+ const worktreeName = isLinkedWorktree ? path32.basename(gitDir) : null;
4835
+ return {
4836
+ cwd,
4837
+ repoRoot,
4838
+ gitDir,
4839
+ gitCommonDir,
4840
+ isLinkedWorktree,
4841
+ mainRoot,
4842
+ worktreeName,
4843
+ isGitRepo: true
4844
+ };
4845
+ } catch {
4846
+ return fallback(cwd);
4847
+ }
4236
4848
  }
4237
4849
 
4238
4850
  // src/utils/versions.ts
@@ -4255,8 +4867,8 @@ function compareVersions(a, b) {
4255
4867
  }
4256
4868
 
4257
4869
  // src/scanner/detect-package-manager.ts
4258
- import path28 from "node:path";
4259
- import fs26 from "fs-extra";
4870
+ import path33 from "node:path";
4871
+ import fs27 from "fs-extra";
4260
4872
  function detectPackageManager(root, packageManagerField) {
4261
4873
  const field = String(packageManagerField ?? "").trim();
4262
4874
  if (field.startsWith("yarn@")) {
@@ -4274,9 +4886,9 @@ function detectPackageManager(root, packageManagerField) {
4274
4886
  if (satisfiesVersion(version, ">=9")) return "npm";
4275
4887
  return "unknown";
4276
4888
  }
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";
4889
+ if (fs27.existsSync(path33.join(root, "yarn.lock"))) return "yarn";
4890
+ if (fs27.existsSync(path33.join(root, "pnpm-lock.yaml"))) return "pnpm";
4891
+ if (fs27.existsSync(path33.join(root, "package-lock.json"))) return "npm";
4280
4892
  return "unknown";
4281
4893
  }
4282
4894
 
@@ -4346,6 +4958,14 @@ function matchRule(rule, ctx) {
4346
4958
  return false;
4347
4959
  }
4348
4960
  var ROLE_RULES = [
4961
+ // Workspace/meta-repo root — same bridge signal `readMembers()`/
4962
+ // `resolveWorkspaceForWorktree()` already use (src/workspace/members.ts,
4963
+ // src/workspace/worktree/root.ts): presence of either config file marks this
4964
+ // root as a workspace aggregating sibling repos, not a runnable app of its own.
4965
+ // `fileEquals` only matches the file at the scan root (not nested under a
4966
+ // sub-repo, which the nested-`.git` boundary excludes from `files` anyway) —
4967
+ // see Task 3.5, docs/plans/workspace-detection-and-permissions-fixes.md.
4968
+ { role: "workspace", any: [fileEquals(WORKSPACE_FILE), fileEquals(REPOS_MANIFEST_FILE)] },
4349
4969
  { role: "next-app", any: [dep("next"), fileIncludes("next.config.")] },
4350
4970
  { role: "react-app", any: [dep("react")] },
4351
4971
  { role: "vite-app", any: [dep("vite"), fileIncludes("vite.config.")] },
@@ -4478,7 +5098,7 @@ function runDetection(ctx, rules = STACK_RULES) {
4478
5098
  }
4479
5099
 
4480
5100
  // src/scanner/detection.ts
4481
- import path29 from "node:path";
5101
+ import path34 from "node:path";
4482
5102
  var UNSUPPORTED_MARKERS = {
4483
5103
  "requirements.txt": "python",
4484
5104
  "pyproject.toml": "python",
@@ -4498,13 +5118,19 @@ function computeDetectionStatus(roles, stacks, unsupportedSignals) {
4498
5118
  if (!hasRealSignal) return "unknown";
4499
5119
  return unsupportedSignals.length > 0 ? "partial" : "supported";
4500
5120
  }
5121
+ function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
5122
+ const markers = unsupportedSignals.join(", ");
5123
+ const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
5124
+ 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." : "";
5125
+ return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
5126
+ }
4501
5127
  function blocked(rel) {
4502
5128
  return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
4503
5129
  }
4504
5130
  function dependencySet(pkg, composer) {
4505
5131
  const depNames = /* @__PURE__ */ new Set();
4506
5132
  const pushObj = (obj) => {
4507
- if (!isRecord(obj)) return;
5133
+ if (!isRecord2(obj)) return;
4508
5134
  for (const key of Object.keys(obj)) depNames.add(key);
4509
5135
  };
4510
5136
  pushObj(pkg?.dependencies);
@@ -4532,14 +5158,14 @@ function finalizeRoles(registryRoles, deps, files) {
4532
5158
  function collectUnsupportedSignals(files) {
4533
5159
  return [
4534
5160
  ...new Set(
4535
- files.map((f) => UNSUPPORTED_MARKERS[path29.basename(f)]).filter((s) => Boolean(s))
5161
+ files.map((f) => UNSUPPORTED_MARKERS[path34.basename(f)]).filter((s) => Boolean(s))
4536
5162
  )
4537
5163
  ].sort();
4538
5164
  }
4539
5165
 
4540
5166
  // src/scanner/render.ts
4541
5167
  import { readFile } from "node:fs/promises";
4542
- import path30 from "node:path";
5168
+ import path35 from "node:path";
4543
5169
  async function buildContentBlob(root, files) {
4544
5170
  const candidates = files.filter(
4545
5171
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -4547,7 +5173,7 @@ async function buildContentBlob(root, files) {
4547
5173
  const slice = candidates.slice(0, 300);
4548
5174
  const parts = await mapWithConcurrency(slice, async (rel) => {
4549
5175
  try {
4550
- return await readFile(path30.join(root, rel), "utf8");
5176
+ return await readFile(path35.join(root, rel), "utf8");
4551
5177
  } catch {
4552
5178
  return "";
4553
5179
  }
@@ -4594,6 +5220,11 @@ var SAFE_FILES = [
4594
5220
  "pnpm-lock.yaml",
4595
5221
  "composer.json",
4596
5222
  "composer.lock",
5223
+ // Workspace/meta-repo root markers — presence only, drives the 'workspace' role
5224
+ // (src/scanner/detection-registry.ts). Same files resolveWorkspaceForWorktree()
5225
+ // and readMembers() already treat as the workspace-root signal.
5226
+ WORKSPACE_FILE,
5227
+ REPOS_MANIFEST_FILE,
4597
5228
  "nx.json",
4598
5229
  "turbo.json",
4599
5230
  "tsconfig.json",
@@ -4634,8 +5265,8 @@ var SAFE_FILES = [
4634
5265
  "Gemfile"
4635
5266
  ];
4636
5267
  async function scanProject(root) {
4637
- const pkg = await readJson(path31.join(root, "package.json"));
4638
- const composer = await readJson(path31.join(root, "composer.json"));
5268
+ const pkg = await readJson(path36.join(root, "package.json"));
5269
+ const composer = await readJson(path36.join(root, "composer.json"));
4639
5270
  const files = await listFiles(root, SAFE_FILES);
4640
5271
  const safeFiles = files.filter((f) => !blocked(f));
4641
5272
  const deps = dependencySet(pkg, composer);
@@ -4649,7 +5280,7 @@ async function scanProject(root) {
4649
5280
  const warnings = [];
4650
5281
  const securityRisks = [];
4651
5282
  const crossRepoHints = [];
4652
- const nodeEngine = isRecord(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
5283
+ const nodeEngine = isRecord2(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
4653
5284
  if (nodeEngine && !satisfiesVersion(process.version, nodeEngine)) {
4654
5285
  warnings.push(`Current Node ${process.version} does not satisfy package engine ${nodeEngine}`);
4655
5286
  }
@@ -4659,10 +5290,10 @@ async function scanProject(root) {
4659
5290
  crossRepoHints.push("Monorepo orchestration detected");
4660
5291
  const unsupportedSignals = collectUnsupportedSignals(safeFiles);
4661
5292
  const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
5293
+ const repoNameFallback = pkg?.name ? String(pkg.name) : path36.basename((await resolveRoots(root)).mainRoot);
4662
5294
  const context = {
4663
5295
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4664
- root,
4665
- repoName: String(pkg?.name ?? path31.basename(root)),
5296
+ repoName: repoNameFallback,
4666
5297
  packageManager,
4667
5298
  repoRoles: roles,
4668
5299
  detectedStacks: stacks,
@@ -4680,10 +5311,18 @@ async function scanProject(root) {
4680
5311
 
4681
5312
  // src/scanner/read-context.ts
4682
5313
  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;
5314
+ const markerPaths = [
5315
+ path37.join(root, "package.json"),
5316
+ path37.join(root, WORKSPACE_FILE),
5317
+ path37.join(root, REPOS_MANIFEST_FILE)
5318
+ ];
5319
+ const cacheStat = await fs28.stat(cachePath);
5320
+ for (const markerPath of markerPaths) {
5321
+ if (!await fs28.pathExists(markerPath)) continue;
5322
+ const markerStat = await fs28.stat(markerPath);
5323
+ if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
5324
+ }
5325
+ return true;
4687
5326
  }
4688
5327
  async function readContextOrScan(root) {
4689
5328
  const cachePath = hausPath(root, "context-map.json");
@@ -4781,7 +5420,7 @@ async function runDoctor(options) {
4781
5420
  } else {
4782
5421
  ok(`- HOOKS OK: ${hooks.message}`);
4783
5422
  }
4784
- const rootClaudeMdPath = path33.join(root, "CLAUDE.md");
5423
+ const rootClaudeMdPath = path38.join(root, "CLAUDE.md");
4785
5424
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
4786
5425
  if (!rootClaudeMdContent) {
4787
5426
  flag(
@@ -4809,7 +5448,7 @@ async function runDoctor(options) {
4809
5448
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
4810
5449
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
4811
5450
  for (const target of importTargets) {
4812
- if (!await fs28.pathExists(hausPath(root, target))) {
5451
+ if (!await fs29.pathExists(hausPath(root, target))) {
4813
5452
  flag(
4814
5453
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
4815
5454
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -4820,7 +5459,7 @@ async function runDoctor(options) {
4820
5459
  }
4821
5460
  }
4822
5461
  const workflowPath = hausPath(root, "WORKFLOW.md");
4823
- const workflowExists = await fs28.pathExists(workflowPath);
5462
+ const workflowExists = await fs29.pathExists(workflowPath);
4824
5463
  if (!workflowExists) {
4825
5464
  flag(
4826
5465
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -4835,15 +5474,15 @@ async function runDoctor(options) {
4835
5474
  } else {
4836
5475
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
4837
5476
  const bodyContent = workflowContent.slice(firstLine.length + 1);
4838
- const cachePath = path33.join(getCacheDir(), "templates/agentic-workflow-standard.md");
4839
- const bundledPath = path33.join(
5477
+ const cachePath = path38.join(getCacheDir(), "templates/agentic-workflow-standard.md");
5478
+ const bundledPath = path38.join(
4840
5479
  packageRoot(),
4841
5480
  "library",
4842
5481
  "global",
4843
5482
  "templates",
4844
5483
  "agentic-workflow-standard.md"
4845
5484
  );
4846
- const templatePath = await fs28.pathExists(cachePath) ? cachePath : bundledPath;
5485
+ const templatePath = await fs29.pathExists(cachePath) ? cachePath : bundledPath;
4847
5486
  const templateContent = await readText(templatePath);
4848
5487
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
4849
5488
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -4864,7 +5503,7 @@ async function runDoctor(options) {
4864
5503
  }
4865
5504
  }
4866
5505
  const workflowConfigPath = hausPath(root, "workflow-config.md");
4867
- const workflowConfigExists = await fs28.pathExists(workflowConfigPath);
5506
+ const workflowConfigExists = await fs29.pathExists(workflowConfigPath);
4868
5507
  if (!workflowConfigExists) {
4869
5508
  flag(
4870
5509
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -4872,7 +5511,7 @@ async function runDoctor(options) {
4872
5511
  "haus apply --write"
4873
5512
  );
4874
5513
  } else {
4875
- const cfg = await fs28.readFile(workflowConfigPath, "utf8");
5514
+ const cfg = await fs29.readFile(workflowConfigPath, "utf8");
4876
5515
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
4877
5516
  if (unfilled > 0) {
4878
5517
  suggest(
@@ -4885,7 +5524,7 @@ async function runDoctor(options) {
4885
5524
  }
4886
5525
  }
4887
5526
  if (workflowExists) {
4888
- const prettierIgnore = await readText(path33.join(root, ".prettierignore")) ?? "";
5527
+ const prettierIgnore = await readText(path38.join(root, ".prettierignore")) ?? "";
4889
5528
  const lines = prettierIgnore.split("\n");
4890
5529
  const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
4891
5530
  if (missing.length > 0) {
@@ -4898,6 +5537,29 @@ async function runDoctor(options) {
4898
5537
  ok(`- .prettierignore: protects ${IGNORED_PATHS.join(" and ")}`);
4899
5538
  }
4900
5539
  }
5540
+ const trackedArtifacts = await listTrackedArtifactPaths(root);
5541
+ if (trackedArtifacts.length > 0) {
5542
+ flag(
5543
+ `- GITIGNORE: ${trackedArtifacts.length} machine-local scan artifact(s) still tracked in git (${trackedArtifacts.join(", ")})`,
5544
+ `${trackedArtifacts.length} machine-local scan artifact(s) are still tracked in git`,
5545
+ `git rm --cached ${trackedArtifacts.join(" ")} (or run \`haus apply --write\` to do this and update .gitignore for you)`
5546
+ );
5547
+ } else {
5548
+ ok("- GITIGNORE: OK (no machine-local scan artifacts tracked in git)");
5549
+ }
5550
+ const gitignoredHausPaths = await findGitignoredHausPaths(root);
5551
+ if (gitignoredHausPaths.length > 0) {
5552
+ const dirs = summarizeGitignoredHausDirs(gitignoredHausPaths);
5553
+ const dirList = dirs.join(" and ");
5554
+ const verb = dirs.length === 1 ? "is" : "are";
5555
+ flag(
5556
+ `- GITIGNORE (haus content): ${dirList} ${verb} gitignored \u2014 installed skills/agents/commands will not be visible to anything relying on git-tracked state`,
5557
+ `${dirList} ${verb} gitignored, so haus-installed content isn't visible to git-tracked state`,
5558
+ "remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored)"
5559
+ );
5560
+ } else {
5561
+ ok("- GITIGNORE (haus content): OK (.claude/ and .haus-workflow/ are tracked)");
5562
+ }
4901
5563
  for (const finding of await auditDecisionsLayout(root)) {
4902
5564
  if (finding.level === "ok") ok(finding.line);
4903
5565
  else suggest(finding.line, finding.sentence, finding.fix);
@@ -4935,7 +5597,7 @@ async function runDoctor(options) {
4935
5597
  );
4936
5598
  }
4937
5599
  }
4938
- const pkgJson = await readJson(path33.join(packageRoot(), "package.json"));
5600
+ const pkgJson = await readJson(path38.join(packageRoot(), "package.json"));
4939
5601
  const currentVersion = pkgJson?.version ?? "0.0.0";
4940
5602
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4941
5603
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -4968,12 +5630,12 @@ async function runDoctor(options) {
4968
5630
  }
4969
5631
 
4970
5632
  // src/commands/update.ts
4971
- import path36 from "node:path";
5633
+ import path41 from "node:path";
4972
5634
 
4973
5635
  // src/claude/refresh-project.ts
4974
- import fs29 from "fs-extra";
5636
+ import fs30 from "fs-extra";
4975
5637
  async function isHausProject(root) {
4976
- if (await fs29.pathExists(hausPath(root, "recommendation.json"))) return true;
5638
+ if (await fs30.pathExists(hausPath(root, "recommendation.json"))) return true;
4977
5639
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
4978
5640
  if (result.status === "ok") return result.value._haus != null;
4979
5641
  return false;
@@ -4985,8 +5647,8 @@ async function refreshProjectApply(root) {
4985
5647
 
4986
5648
  // src/install/apply.ts
4987
5649
  import crypto2 from "node:crypto";
4988
- import path34 from "node:path";
4989
- import fs30 from "fs-extra";
5650
+ import path39 from "node:path";
5651
+ import fs31 from "fs-extra";
4990
5652
 
4991
5653
  // src/install/header.ts
4992
5654
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -5072,57 +5734,57 @@ function hashContent(content2) {
5072
5734
  }
5073
5735
  function sourceVersion() {
5074
5736
  try {
5075
- const pkgPath = path34.join(packageRoot(), "package.json");
5076
- const pkg = JSON.parse(fs30.readFileSync(pkgPath, "utf8"));
5737
+ const pkgPath = path39.join(packageRoot(), "package.json");
5738
+ const pkg = JSON.parse(fs31.readFileSync(pkgPath, "utf8"));
5077
5739
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
5078
5740
  } catch {
5079
5741
  return "haus@0.0.0";
5080
5742
  }
5081
5743
  }
5082
5744
  function globalSrcDir() {
5083
- return path34.join(packageRoot(), "library", "global");
5745
+ return path39.join(packageRoot(), "library", "global");
5084
5746
  }
5085
5747
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
5086
- const skillDir = path34.join(skillsDir, skillName);
5748
+ const skillDir = path39.join(skillsDir, skillName);
5087
5749
  const entries = [];
5088
- for (const rel of fs30.readdirSync(skillDir, { recursive: true })) {
5750
+ for (const rel of fs31.readdirSync(skillDir, { recursive: true })) {
5089
5751
  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("/");
5752
+ const abs = path39.join(skillDir, rel);
5753
+ if (!fs31.statSync(abs).isFile()) continue;
5754
+ const posixRel = rel.split(path39.sep).join("/");
5093
5755
  entries.push({
5094
5756
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
5095
- srcRelPath: path34.join("library", "global", "skills", skillName, rel),
5096
- destPath: path34.join(claudeDir, "skills", skillName, rel)
5757
+ srcRelPath: path39.join("library", "global", "skills", skillName, rel),
5758
+ destPath: path39.join(claudeDir, "skills", skillName, rel)
5097
5759
  });
5098
5760
  }
5099
5761
  return entries;
5100
5762
  }
5101
5763
  function collectSourceFiles(srcDir, claudeDir) {
5102
5764
  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)) {
5765
+ const skillsDir = path39.join(srcDir, "skills");
5766
+ if (fs31.pathExistsSync(skillsDir)) {
5767
+ for (const skillName of fs31.readdirSync(skillsDir)) {
5768
+ const skillFile = path39.join(skillsDir, skillName, "SKILL.md");
5769
+ if (fs31.pathExistsSync(skillFile)) {
5108
5770
  entries.push({
5109
5771
  stableId: `skill.${skillName}`,
5110
- srcRelPath: path34.join("library", "global", "skills", skillName, "SKILL.md"),
5111
- destPath: path34.join(claudeDir, "skills", skillName, "SKILL.md")
5772
+ srcRelPath: path39.join("library", "global", "skills", skillName, "SKILL.md"),
5773
+ destPath: path39.join(claudeDir, "skills", skillName, "SKILL.md")
5112
5774
  });
5113
5775
  entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
5114
5776
  }
5115
5777
  }
5116
5778
  }
5117
- const commandsDir = path34.join(srcDir, "commands");
5118
- if (fs30.pathExistsSync(commandsDir)) {
5119
- for (const fileName of fs30.readdirSync(commandsDir)) {
5779
+ const commandsDir = path39.join(srcDir, "commands");
5780
+ if (fs31.pathExistsSync(commandsDir)) {
5781
+ for (const fileName of fs31.readdirSync(commandsDir)) {
5120
5782
  if (!fileName.endsWith(".md")) continue;
5121
5783
  const commandName = fileName.slice(0, -".md".length);
5122
5784
  entries.push({
5123
5785
  stableId: `command.${commandName}`,
5124
- srcRelPath: path34.join("library", "global", "commands", fileName),
5125
- destPath: path34.join(claudeDir, "commands", fileName)
5786
+ srcRelPath: path39.join("library", "global", "commands", fileName),
5787
+ destPath: path39.join(claudeDir, "commands", fileName)
5126
5788
  });
5127
5789
  }
5128
5790
  }
@@ -5148,7 +5810,7 @@ async function applyInstall(options = {}) {
5148
5810
  };
5149
5811
  const manifestFiles = [];
5150
5812
  for (const entry of sourceFiles) {
5151
- const srcPath = path34.join(packageRoot(), entry.srcRelPath);
5813
+ const srcPath = path39.join(packageRoot(), entry.srcRelPath);
5152
5814
  const rawContent = await readText(srcPath);
5153
5815
  if (rawContent === void 0) {
5154
5816
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -5168,7 +5830,7 @@ async function applyInstall(options = {}) {
5168
5830
  }
5169
5831
  continue;
5170
5832
  }
5171
- const destExists = fs30.pathExistsSync(entry.destPath);
5833
+ const destExists = fs31.pathExistsSync(entry.destPath);
5172
5834
  if (destExists) {
5173
5835
  const currentContent = await readText(entry.destPath);
5174
5836
  if (currentContent !== void 0) {
@@ -5208,7 +5870,7 @@ async function applyInstall(options = {}) {
5208
5870
  }
5209
5871
  let mergedSettings;
5210
5872
  if (!check) {
5211
- const fragmentPath = path34.join(srcDir, "settings-fragments", "hooks.json");
5873
+ const fragmentPath = path39.join(srcDir, "settings-fragments", "hooks.json");
5212
5874
  const fragments = await loadHooksFragment(fragmentPath);
5213
5875
  const settings = await readSettings();
5214
5876
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -5222,13 +5884,13 @@ async function applyInstall(options = {}) {
5222
5884
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
5223
5885
  for (const entry of existingManifest.files) {
5224
5886
  if (currentDestPaths.has(entry.destPath)) continue;
5225
- if (!fs30.pathExistsSync(entry.destPath)) continue;
5887
+ if (!fs31.pathExistsSync(entry.destPath)) continue;
5226
5888
  const content2 = await readText(entry.destPath);
5227
5889
  if (!content2) continue;
5228
5890
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
5229
5891
  const currentHash = hashContent(content2);
5230
5892
  if (hasHeader && currentHash === entry.hash) {
5231
- if (!dryRun) await fs30.remove(entry.destPath);
5893
+ if (!dryRun) await fs31.remove(entry.destPath);
5232
5894
  result.deleted.push(entry.destPath);
5233
5895
  } else {
5234
5896
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -5297,7 +5959,7 @@ function summarizeLockDiff(before, after) {
5297
5959
 
5298
5960
  // src/update/lockfile.ts
5299
5961
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
5300
- import path35 from "node:path";
5962
+ import path40 from "node:path";
5301
5963
  async function readLockSummary(root) {
5302
5964
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
5303
5965
  const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
@@ -5333,7 +5995,7 @@ async function applyLock(root) {
5333
5995
  try {
5334
5996
  const backupDir = hausPath(root, "backups");
5335
5997
  await mkdir(backupDir, { recursive: true });
5336
- await copyFile(lockPath, path35.join(backupDir, `haus.lock.${Date.now()}.json`));
5998
+ await copyFile(lockPath, path40.join(backupDir, `haus.lock.${Date.now()}.json`));
5337
5999
  } catch {
5338
6000
  }
5339
6001
  const enriched = await Promise.all(
@@ -5355,7 +6017,7 @@ function diffLock(before, after) {
5355
6017
  }
5356
6018
  async function hasLocalOverrides(root) {
5357
6019
  try {
5358
- await readFile2(path35.join(root, ".claude", "settings.json"), "utf8");
6020
+ await readFile2(path40.join(root, ".claude", "settings.json"), "utf8");
5359
6021
  return true;
5360
6022
  } catch {
5361
6023
  return false;
@@ -5371,7 +6033,7 @@ async function runUpdate(options) {
5371
6033
  return;
5372
6034
  }
5373
6035
  if (options.check) {
5374
- const pkgJson2 = await readJson(path36.join(packageRoot(), "package.json"));
6036
+ const pkgJson2 = await readJson(path41.join(packageRoot(), "package.json"));
5375
6037
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
5376
6038
  const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
5377
6039
  options.fast ? fastLockStatus(root) : checkLock(root),
@@ -5406,7 +6068,7 @@ async function runUpdate(options) {
5406
6068
  if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
5407
6069
  return;
5408
6070
  }
5409
- const pkgJson = await readJson(path36.join(packageRoot(), "package.json"));
6071
+ const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
5410
6072
  const currentVersion = pkgJson?.version ?? "0.0.0";
5411
6073
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
5412
6074
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -5448,7 +6110,7 @@ async function runFromHookCheck(root) {
5448
6110
  try {
5449
6111
  const summary = await readLockSummary(root);
5450
6112
  if (summary.count === 0) return;
5451
- const pkgJson = await readJson(path36.join(packageRoot(), "package.json"));
6113
+ const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
5452
6114
  const currentVersion = pkgJson?.version ?? "0.0.0";
5453
6115
  const [npmVersion, latestCatalogTag] = await Promise.all([
5454
6116
  fetchNpmVersionStatus(currentVersion),
@@ -5598,7 +6260,7 @@ async function runCiGate(options = {}) {
5598
6260
  // src/commands/clone.ts
5599
6261
  init_exec();
5600
6262
  import { existsSync as existsSync2 } from "node:fs";
5601
- import path37 from "node:path";
6263
+ import path42 from "node:path";
5602
6264
  var GIT_LOCATION_VARS = [
5603
6265
  "GIT_DIR",
5604
6266
  "GIT_WORK_TREE",
@@ -5648,11 +6310,11 @@ async function runClone(url, opts = {}) {
5648
6310
  process.exitCode = 1;
5649
6311
  return;
5650
6312
  }
5651
- const target = path37.resolve(opts.dir?.trim() || repoNameFromUrl(url));
6313
+ const target = path42.resolve(opts.dir?.trim() || repoNameFromUrl(url));
5652
6314
  if (existsSync2(target)) {
5653
6315
  const check = await checkExistingTarget(target, url);
5654
6316
  if (check.kind === "matches") {
5655
- log(`\u2022 ${path37.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
6317
+ log(`\u2022 ${path42.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
5656
6318
  return;
5657
6319
  }
5658
6320
  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].`;
@@ -5692,7 +6354,7 @@ function readHookPayload() {
5692
6354
  if (!raw) return { payload: {}, malformed: false };
5693
6355
  try {
5694
6356
  const parsed = JSON.parse(raw);
5695
- return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
6357
+ return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
5696
6358
  } catch {
5697
6359
  return { payload: {}, malformed: true };
5698
6360
  }
@@ -5731,7 +6393,7 @@ function readPayload(options) {
5731
6393
  if (options.stdinPayload != null) {
5732
6394
  try {
5733
6395
  const parsed = JSON.parse(options.stdinPayload);
5734
- return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
6396
+ return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
5735
6397
  } catch {
5736
6398
  return { payload: {}, malformed: true };
5737
6399
  }
@@ -5744,7 +6406,7 @@ async function runDecisionsGuard(options = {}) {
5744
6406
  emitPreToolUseDeny("Malformed hook payload");
5745
6407
  return;
5746
6408
  }
5747
- const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
6409
+ const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
5748
6410
  const command = String(toolInput.command ?? "");
5749
6411
  if (!PR_CREATE_PATTERN.test(command)) return;
5750
6412
  const root = process.cwd();
@@ -5756,13 +6418,13 @@ async function runDecisionsGuard(options = {}) {
5756
6418
  const result = await runDecisionsCheck(root, { range, commitMessages });
5757
6419
  if (result.triggered && !result.satisfied) {
5758
6420
  emitPreToolUseDeny(
5759
- `I didn't run that \u2014 this change needs an ADR before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under docs/decisions/, then retry.`
6421
+ `I didn't run that \u2014 this change needs an ADR before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under docs/decisions/, then retry \u2014 or use [adr-skip] for non-security changes.`
5760
6422
  );
5761
6423
  }
5762
6424
  }
5763
6425
 
5764
6426
  // src/commands/decisions.ts
5765
- import path38 from "node:path";
6427
+ import path43 from "node:path";
5766
6428
 
5767
6429
  // src/decisions/next-number.ts
5768
6430
  async function nextDecisionNumber(root) {
@@ -5805,10 +6467,9 @@ _(Why this over alternatives \u2014 the line agents read for "why".)_
5805
6467
  `;
5806
6468
  }
5807
6469
  async function runDecisionsSuggest(root, opts = {}) {
5808
- const stats = await collectDiffStats(root, { staged: true });
5809
6470
  const stagedCheck = await runDecisionsCheck(root, { staged: true });
5810
- const unstagedStats = await collectDiffStats(root, {});
5811
- const triggered = isDecisionTriggered(stats) || isDecisionTriggered(unstagedStats);
6471
+ const unstagedCheck = await runDecisionsCheck(root, {});
6472
+ const triggered = stagedCheck.triggered || unstagedCheck.triggered;
5812
6473
  if (!triggered) return;
5813
6474
  if (stagedCheck.triggered && stagedCheck.satisfied) return;
5814
6475
  const number = await nextDecisionNumber(root);
@@ -5859,7 +6520,7 @@ async function runDecisions(sub, arg, options = {}) {
5859
6520
  }
5860
6521
  for (const reason of result.reasons) warn(reason);
5861
6522
  error(
5862
- "decisions gate: decision-worthy change without a valid ADR under docs/decisions/. Run `haus decisions suggest`, add docs/decisions/NNNN-*.md + README row, or use [adr-skip] with justification."
6523
+ `decisions gate: decision-worthy change without a valid ADR under docs/decisions/. Why: ${result.reasons.filter((r) => !r.startsWith("no new")).join("; ") || "see above"}. Run \`haus decisions suggest\`, add docs/decisions/NNNN-*.md + README row, or use [adr-skip] with justification (ignored for security paths).`
5863
6524
  );
5864
6525
  process.exitCode = 1;
5865
6526
  return;
@@ -5873,7 +6534,7 @@ async function runDecisions(sub, arg, options = {}) {
5873
6534
  return;
5874
6535
  }
5875
6536
  if (sub === "validate") {
5876
- const target = arg ? path38.resolve(root, arg) : void 0;
6537
+ const target = arg ? path43.resolve(root, arg) : void 0;
5877
6538
  if (!target) {
5878
6539
  error("usage: haus decisions validate <path-to-decision.md>");
5879
6540
  process.exitCode = 1;
@@ -6125,7 +6786,7 @@ async function runGuard(kind, _options) {
6125
6786
  emitPreToolUseDeny("Malformed hook payload");
6126
6787
  return;
6127
6788
  }
6128
- const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
6789
+ const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
6129
6790
  if (kind === "file-access") {
6130
6791
  const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
6131
6792
  const reason2 = guardFileAccess(candidate);
@@ -6142,8 +6803,8 @@ async function runGuard(kind, _options) {
6142
6803
  }
6143
6804
 
6144
6805
  // src/commands/init.ts
6145
- import path39 from "node:path";
6146
- import fs31 from "fs-extra";
6806
+ import path45 from "node:path";
6807
+ import fs32 from "fs-extra";
6147
6808
 
6148
6809
  // src/recommender/git-signal.ts
6149
6810
  init_exec();
@@ -6206,11 +6867,9 @@ function describeRequiresAny(clauses) {
6206
6867
  return "unknown";
6207
6868
  }).join(" | ");
6208
6869
  }
6209
- function mergeRecommendationWarnings(context) {
6870
+ function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
6210
6871
  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 ? [
6872
+ const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
6214
6873
  `Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
6215
6874
  ] : [];
6216
6875
  const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
@@ -6429,11 +7088,12 @@ async function recommend(root, context, opts = {}) {
6429
7088
  const skippedRules = skipped.length;
6430
7089
  const estimatedContextTokens = estimateContextTokens(selectedRules);
6431
7090
  const estimatedTokenReductionPct = tokenReductionPct(selectedRules, skippedRules);
7091
+ const { isLinkedWorktree } = await resolveRoots(root);
6432
7092
  return {
6433
7093
  recommended,
6434
7094
  skipped,
6435
7095
  optInEligible,
6436
- warnings: [...mergeRecommendationWarnings(context), ...includeWarnings],
7096
+ warnings: [...mergeRecommendationWarnings(context, isLinkedWorktree), ...includeWarnings],
6437
7097
  estimatedContextTokens,
6438
7098
  selectedRules,
6439
7099
  skippedRules,
@@ -6610,9 +7270,162 @@ function buildOptInEligible(items, skipped) {
6610
7270
  return eligible;
6611
7271
  }
6612
7272
 
7273
+ // src/commands/workspace/discover.ts
7274
+ import path44 from "node:path";
7275
+ import fg4 from "fast-glob";
7276
+ import YAML2 from "yaml";
7277
+ var DEFAULT_MAX_DEPTH = 3;
7278
+ var GIT_MARKER = ["**/.git"];
7279
+ var MANIFEST_MARKERS = [
7280
+ "**/package.json",
7281
+ "**/composer.json",
7282
+ "**/*.csproj",
7283
+ "**/*.sln",
7284
+ "**/*.fsproj",
7285
+ // .NET
7286
+ "**/pom.xml",
7287
+ "**/build.gradle*",
7288
+ // Java
7289
+ "**/Gemfile"
7290
+ // Ruby
7291
+ ];
7292
+ var IGNORE = ["**/node_modules/**", "**/vendor/**", "**/dist/**", "**/.haus-workflow/**"];
7293
+ function isDescendant(child, ancestor) {
7294
+ if (ancestor === ".") return child !== ".";
7295
+ return child === ancestor ? false : child.startsWith(`${ancestor}/`);
7296
+ }
7297
+ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7298
+ const commonOpts = {
7299
+ cwd: workspaceRoot,
7300
+ dot: true,
7301
+ onlyFiles: false,
7302
+ deep: maxDepth,
7303
+ followSymbolicLinks: false,
7304
+ // Discovery walks arbitrary directories under the workspace root; an
7305
+ // unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
7306
+ suppressErrors: true
7307
+ };
7308
+ const [gitMatches, manifestMatches] = await Promise.all([
7309
+ fg4(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
7310
+ fg4(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
7311
+ ]);
7312
+ const gitDirs = /* @__PURE__ */ new Set();
7313
+ const manifestDirs = /* @__PURE__ */ new Set();
7314
+ for (const match of gitMatches) {
7315
+ gitDirs.add(path44.posix.dirname(match));
7316
+ }
7317
+ for (const match of manifestMatches) {
7318
+ manifestDirs.add(path44.posix.dirname(match));
7319
+ }
7320
+ const repoRoots = [...gitDirs];
7321
+ const manifestSorted = [...manifestDirs].sort(
7322
+ (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
7323
+ );
7324
+ for (const dir of manifestSorted) {
7325
+ if (gitDirs.has(dir)) continue;
7326
+ if (repoRoots.some((root) => isDescendant(dir, root))) continue;
7327
+ repoRoots.push(dir);
7328
+ }
7329
+ repoRoots.sort((a, b) => a.localeCompare(b));
7330
+ return repoRoots;
7331
+ }
7332
+ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7333
+ const repoRoots = await findRepoRoots(workspaceRoot, maxDepth);
7334
+ return mapWithConcurrency(repoRoots, async (relDir) => {
7335
+ const absDir = path44.resolve(workspaceRoot, relDir);
7336
+ const pkg = await readJson(path44.join(absDir, "package.json"));
7337
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path44.basename(relDir === "." ? workspaceRoot : absDir);
7338
+ let role = "auto";
7339
+ try {
7340
+ const scan = await scanProject(absDir);
7341
+ if (scan.repoRoles.length > 0) role = scan.repoRoles.join("+");
7342
+ } catch {
7343
+ }
7344
+ return { name, path: relDir === "." ? "." : relDir, role };
7345
+ });
7346
+ }
7347
+ function mergeWorkspaceConfig(existing, discovered, opts = {}) {
7348
+ const existingRepos = existing?.repos ?? [];
7349
+ const byPath = new Map(existingRepos.map((r) => [r.path, r]));
7350
+ for (const repo of discovered) {
7351
+ if (!byPath.has(repo.path)) {
7352
+ byPath.set(repo.path, { name: repo.name, path: repo.path, role: repo.role });
7353
+ }
7354
+ }
7355
+ const ordered = [];
7356
+ const seen = /* @__PURE__ */ new Set();
7357
+ for (const repo of existingRepos) {
7358
+ ordered.push(byPath.get(repo.path));
7359
+ seen.add(repo.path);
7360
+ }
7361
+ for (const repo of discovered) {
7362
+ if (seen.has(repo.path)) continue;
7363
+ ordered.push(byPath.get(repo.path));
7364
+ seen.add(repo.path);
7365
+ }
7366
+ return {
7367
+ client: opts.client ?? existing?.client ?? "unknown",
7368
+ repos: ordered,
7369
+ relationships: existing?.relationships ?? []
7370
+ };
7371
+ }
7372
+ function renderWorkspaceYaml(config) {
7373
+ return YAML2.stringify({
7374
+ client: config.client,
7375
+ repos: config.repos.map((r) => ({ name: r.name, path: r.path, role: r.role ?? "auto" })),
7376
+ relationships: config.relationships
7377
+ });
7378
+ }
7379
+ async function runDiscover(workspaceRoot, opts = {}) {
7380
+ const yamlPath = path44.join(workspaceRoot, "haus.workspace.yaml");
7381
+ const existingText = await readText(yamlPath);
7382
+ const existing = parseWorkspaceConfig(existingText);
7383
+ if (existingText && !existing) {
7384
+ error(
7385
+ "Existing haus.workspace.yaml is malformed \u2014 fix or remove it before running discover (refusing to overwrite)."
7386
+ );
7387
+ process.exitCode = 1;
7388
+ return;
7389
+ }
7390
+ const discovered = await discoverRepos(workspaceRoot, opts.maxDepth ?? DEFAULT_MAX_DEPTH);
7391
+ if (discovered.length === 0) {
7392
+ error("No repos discovered under the workspace root.");
7393
+ process.exitCode = 1;
7394
+ return;
7395
+ }
7396
+ const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
7397
+ const yamlText = renderWorkspaceYaml(merged);
7398
+ if (opts.write) {
7399
+ await writeText(yamlPath, yamlText);
7400
+ if (opts.json) {
7401
+ log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
7402
+ } else {
7403
+ log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
7404
+ }
7405
+ return;
7406
+ }
7407
+ if (opts.json) {
7408
+ log(JSON.stringify({ discovered, config: merged }, null, 2));
7409
+ return;
7410
+ }
7411
+ log("Proposed haus.workspace.yaml (run with --write to persist):\n");
7412
+ log(yamlText);
7413
+ }
7414
+
7415
+ // src/scanner/sibling-repos.ts
7416
+ var SIBLING_REPO_WARNING = 'Multiple sibling repos detected under this root \u2014 consider "haus workspace discover" instead of running setup-project/scan per repo.';
7417
+ async function hasMultipleSiblingRepos(root) {
7418
+ try {
7419
+ const repoRoots = await findRepoRoots(root);
7420
+ return repoRoots.filter((relDir) => relDir !== ".").length >= 2;
7421
+ } catch {
7422
+ return false;
7423
+ }
7424
+ }
7425
+
6613
7426
  // src/claude/setup-core.ts
6614
7427
  async function runSetupCore(root, opts) {
6615
- const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
7428
+ const { json, apply, dryRun, quiet, confirm: confirm2, force } = opts;
6616
7429
  const say = quiet ? () => {
6617
7430
  } : log;
6618
7431
  const scanResult = await scanProject(root);
@@ -6623,8 +7436,35 @@ async function runSetupCore(root, opts) {
6623
7436
  say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
6624
7437
  say(`Package manager: ${scanResult.packageManager}`);
6625
7438
  }
7439
+ const siblingWarning = await hasMultipleSiblingRepos(root) ? [SIBLING_REPO_WARNING] : [];
6626
7440
  const context = await readContextOrScan(root);
6627
7441
  const recommendation = await recommend(root, context);
7442
+ if (recommendation.recommended.length === 0 && !force) {
7443
+ const warningLines2 = [
7444
+ .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
7445
+ ];
7446
+ if (json) {
7447
+ say(JSON.stringify(recommendation, null, 2));
7448
+ } else {
7449
+ say("Haus recommendation ready");
7450
+ say("Recommended: 0");
7451
+ say(`Skipped: ${recommendation.skipped.length}`);
7452
+ say(`Repo: ${context.repoName}`);
7453
+ for (const warning of warningLines2) say(`- WARN: ${warning}`);
7454
+ }
7455
+ 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.";
7456
+ warn(message);
7457
+ process.exitCode = 1;
7458
+ return {
7459
+ root,
7460
+ repoName: context.repoName,
7461
+ roles: scanResult.repoRoles,
7462
+ recommendedCount: 0,
7463
+ warnings: [...warningLines2, message],
7464
+ hooksOk: false,
7465
+ written: []
7466
+ };
7467
+ }
6628
7468
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
6629
7469
  if (json) {
6630
7470
  say(JSON.stringify(recommendation, null, 2));
@@ -6634,7 +7474,9 @@ async function runSetupCore(root, opts) {
6634
7474
  say(`Skipped: ${recommendation.skipped.length}`);
6635
7475
  }
6636
7476
  const hooks = await verifyProjectSettingsHooksContract(root);
6637
- const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
7477
+ const warningLines = [
7478
+ .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
7479
+ ];
6638
7480
  say(`Repo: ${context.repoName}`);
6639
7481
  for (const warning of warningLines) say(`- WARN: ${warning}`);
6640
7482
  const hooksOk = hooks.ok;
@@ -6699,6 +7541,7 @@ async function runSetupProject(options) {
6699
7541
  json: options.json,
6700
7542
  apply: !options.json,
6701
7543
  dryRun: false,
7544
+ force: options.force,
6702
7545
  confirm: () => confirm("Approve and write Claude files now?")
6703
7546
  });
6704
7547
  }
@@ -6706,8 +7549,8 @@ async function runSetupProject(options) {
6706
7549
  // src/commands/init.ts
6707
7550
  async function runInit(options) {
6708
7551
  const root = process.cwd();
6709
- const hausDir = path39.join(root, ".haus-workflow");
6710
- const alreadyInit = await fs31.pathExists(hausDir);
7552
+ const hausDir = path45.join(root, ".haus-workflow");
7553
+ const alreadyInit = await fs32.pathExists(hausDir);
6711
7554
  if (alreadyInit) {
6712
7555
  log("Haus AI already initialized in this project.");
6713
7556
  log("Run `haus setup-project` to reconfigure.");
@@ -6842,8 +7685,8 @@ async function runRefresh() {
6842
7685
  }
6843
7686
 
6844
7687
  // src/install/scaffold.ts
6845
- import path40 from "node:path";
6846
- import fs32 from "fs-extra";
7688
+ import path46 from "node:path";
7689
+ import fs33 from "fs-extra";
6847
7690
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
6848
7691
  const scaffolded = [];
6849
7692
  const skipped = [];
@@ -6856,27 +7699,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
6856
7699
  skipped.push(name);
6857
7700
  if (outcome === "skipped-exists") skippedExisting.push(name);
6858
7701
  };
6859
- const realRoot = await fs32.realpath(catalogRoot).catch(() => null);
7702
+ const realRoot = await fs33.realpath(catalogRoot).catch(() => null);
6860
7703
  for (const item of items) {
6861
7704
  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 === "..");
7705
+ const sourcePath = path46.resolve(catalogRoot, item.path);
7706
+ const rel = path46.relative(catalogRoot, sourcePath);
7707
+ const escapes = rel === "" || path46.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
6865
7708
  if (escapes) {
6866
7709
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
6867
7710
  continue;
6868
7711
  }
6869
- const realSource = await fs32.realpath(sourcePath).catch(() => null);
7712
+ const realSource = await fs33.realpath(sourcePath).catch(() => null);
6870
7713
  if (!realRoot || !realSource) {
6871
7714
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
6872
7715
  continue;
6873
7716
  }
6874
- const realRel = path40.relative(realRoot, realSource);
6875
- if (realRel !== "" && (path40.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
7717
+ const realRel = path46.relative(realRoot, realSource);
7718
+ if (realRel !== "" && (path46.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
6876
7719
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
6877
7720
  continue;
6878
7721
  }
6879
- const stat = await fs32.lstat(sourcePath).catch(() => null);
7722
+ const stat = await fs33.lstat(sourcePath).catch(() => null);
6880
7723
  if (!stat) {
6881
7724
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
6882
7725
  continue;
@@ -6886,14 +7729,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
6886
7729
  continue;
6887
7730
  }
6888
7731
  if (stat.isFile()) {
6889
- const filename = path40.basename(sourcePath);
6890
- const dest = path40.join(projectRoot, filename);
7732
+ const filename = path46.basename(sourcePath);
7733
+ const dest = path46.join(projectRoot, filename);
6891
7734
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
6892
7735
  } else if (stat.isDirectory()) {
6893
- const entries = await fs32.readdir(sourcePath);
7736
+ const entries = await fs33.readdir(sourcePath);
6894
7737
  for (const entry of entries) {
6895
- const src = path40.join(sourcePath, entry);
6896
- const dest = path40.join(projectRoot, entry);
7738
+ const src = path46.join(sourcePath, entry);
7739
+ const dest = path46.join(projectRoot, entry);
6897
7740
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
6898
7741
  }
6899
7742
  }
@@ -6901,42 +7744,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
6901
7744
  return { scaffolded, skipped, skippedExisting };
6902
7745
  }
6903
7746
  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);
7747
+ const destDisplay = path46.relative(projectRoot, dest) || path46.basename(dest);
7748
+ const srcStat = await fs33.lstat(src).catch(() => null);
6906
7749
  if (!srcStat) {
6907
- warn(`Skipping ${path40.basename(src)}: source not found`);
7750
+ warn(`Skipping ${path46.basename(src)}: source not found`);
6908
7751
  return "skipped-refused";
6909
7752
  }
6910
7753
  if (srcStat.isSymbolicLink()) {
6911
- warn(`Skipping ${path40.basename(src)}: source is a symlink`);
7754
+ warn(`Skipping ${path46.basename(src)}: source is a symlink`);
6912
7755
  return "skipped-refused";
6913
7756
  }
6914
- const exists = await fs32.pathExists(dest);
7757
+ const exists = await fs33.pathExists(dest);
6915
7758
  if (exists && !opts.force) {
6916
7759
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
6917
7760
  return "skipped-exists";
6918
7761
  }
6919
7762
  if (opts.dryRun) {
6920
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path40.basename(dest)} (${itemId})`);
7763
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path46.basename(dest)} (${itemId})`);
6921
7764
  return "scaffolded";
6922
7765
  }
6923
7766
  if (exists && opts.force) {
6924
- await fs32.remove(dest);
7767
+ await fs33.remove(dest);
6925
7768
  }
6926
- await fs32.ensureDir(path40.dirname(dest));
6927
- await fs32.copy(src, dest, {
7769
+ await fs33.ensureDir(path46.dirname(dest));
7770
+ await fs33.copy(src, dest, {
6928
7771
  overwrite: true,
6929
7772
  dereference: false,
6930
7773
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
6931
7774
  filter: (s) => {
6932
7775
  try {
6933
- return !fs32.lstatSync(s).isSymbolicLink();
7776
+ return !fs33.lstatSync(s).isSymbolicLink();
6934
7777
  } catch {
6935
7778
  return false;
6936
7779
  }
6937
7780
  }
6938
7781
  });
6939
- log(`${exists ? "Overwrote" : "Created"} ${path40.basename(dest)} (${itemId})`);
7782
+ log(`${exists ? "Overwrote" : "Created"} ${path46.basename(dest)} (${itemId})`);
6940
7783
  return "scaffolded";
6941
7784
  }
6942
7785
 
@@ -6981,7 +7824,8 @@ async function runScaffold(ids, options) {
6981
7824
 
6982
7825
  // src/commands/scan.ts
6983
7826
  async function runScan(options) {
6984
- const result = await scanProject(process.cwd());
7827
+ const root = process.cwd();
7828
+ const result = await scanProject(root);
6985
7829
  if (options.json) {
6986
7830
  log(JSON.stringify(result, null, 2));
6987
7831
  return;
@@ -6989,12 +7833,19 @@ async function runScan(options) {
6989
7833
  log("Haus scan complete");
6990
7834
  log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
6991
7835
  log(`Package manager: ${result.packageManager}`);
7836
+ if (result.detectionStatus === "unknown") {
7837
+ const { isLinkedWorktree } = await resolveRoots(root);
7838
+ log(`- WARN: ${describeUnknownDetection(result.unsupportedSignals, isLinkedWorktree)}`);
7839
+ }
6992
7840
  for (const w of result.warnings) log(`- WARN: ${w}`);
7841
+ if (await hasMultipleSiblingRepos(root)) {
7842
+ log(`- WARN: ${SIBLING_REPO_WARNING}`);
7843
+ }
6993
7844
  }
6994
7845
 
6995
7846
  // src/commands/undo.ts
6996
- import path41 from "node:path";
6997
- import fs33 from "fs-extra";
7847
+ import path47 from "node:path";
7848
+ import fs34 from "fs-extra";
6998
7849
 
6999
7850
  // src/claude/managed-paths.ts
7000
7851
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
@@ -7009,7 +7860,7 @@ function coreManagedAbsolutePaths(root) {
7009
7860
  async function collectCoreManagedPaths(root) {
7010
7861
  const existing = [];
7011
7862
  for (const abs of coreManagedAbsolutePaths(root)) {
7012
- if (await fs33.pathExists(abs)) existing.push(abs);
7863
+ if (await fs34.pathExists(abs)) existing.push(abs);
7013
7864
  }
7014
7865
  return existing;
7015
7866
  }
@@ -7021,18 +7872,18 @@ async function collectLockTrackedPaths(root) {
7021
7872
  const relPaths = row.paths ?? [];
7022
7873
  const existingRel = [];
7023
7874
  for (const rel of relPaths) {
7024
- if (await fs33.pathExists(path41.resolve(root, rel))) existingRel.push(rel);
7875
+ if (await fs34.pathExists(path47.resolve(root, rel))) existingRel.push(rel);
7025
7876
  }
7026
7877
  if (existingRel.length === 0) continue;
7027
7878
  if (row.hash === void 0) {
7028
- removable.push(...existingRel.map((rel) => path41.resolve(root, rel)));
7879
+ removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
7029
7880
  continue;
7030
7881
  }
7031
7882
  const currentHash = await hashInstalledPaths(root, existingRel);
7032
7883
  if (currentHash === row.hash) {
7033
- removable.push(...existingRel.map((rel) => path41.resolve(root, rel)));
7884
+ removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
7034
7885
  } else {
7035
- preserved.push(...existingRel.map((rel) => path41.resolve(root, rel)));
7886
+ preserved.push(...existingRel.map((rel) => path47.resolve(root, rel)));
7036
7887
  log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
7037
7888
  }
7038
7889
  }
@@ -7040,51 +7891,51 @@ async function collectLockTrackedPaths(root) {
7040
7891
  }
7041
7892
  async function settingsHasHausContent(root) {
7042
7893
  const settingsPath = claudePath(root, "settings.json");
7043
- if (!await fs33.pathExists(settingsPath)) return false;
7894
+ if (!await fs34.pathExists(settingsPath)) return false;
7044
7895
  const settings = await readProjectSettings(root);
7045
7896
  return settings._haus != null;
7046
7897
  }
7047
7898
  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");
7899
+ const filePath = path47.join(root, "CLAUDE.md");
7900
+ if (!await fs34.pathExists(filePath)) return false;
7901
+ const text = await fs34.readFile(filePath, "utf8");
7051
7902
  return text.includes(BLOCK_BEGIN);
7052
7903
  }
7053
7904
  async function stripProjectSettings(root) {
7054
7905
  const settingsPath = claudePath(root, "settings.json");
7055
- if (!await fs33.pathExists(settingsPath)) return false;
7906
+ if (!await fs34.pathExists(settingsPath)) return false;
7056
7907
  let settings = await readProjectSettings(root);
7057
7908
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
7058
7909
  const hasContent = Object.keys(settings).length > 0;
7059
7910
  if (hasContent) {
7060
7911
  await writeProjectSettings(root, settings);
7061
- log(`Stripped haus rules from ${path41.relative(root, settingsPath)} (user settings preserved).`);
7912
+ log(`Stripped haus rules from ${path47.relative(root, settingsPath)} (user settings preserved).`);
7062
7913
  return true;
7063
7914
  }
7064
- await fs33.remove(settingsPath);
7065
- log(`Removed ${path41.relative(root, settingsPath)} (no user-owned settings remained).`);
7915
+ await fs34.remove(settingsPath);
7916
+ log(`Removed ${path47.relative(root, settingsPath)} (no user-owned settings remained).`);
7066
7917
  return true;
7067
7918
  }
7068
7919
  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");
7920
+ const filePath = path47.join(root, "CLAUDE.md");
7921
+ if (!await fs34.pathExists(filePath)) return false;
7922
+ const prev = await fs34.readFile(filePath, "utf8");
7072
7923
  if (!prev.includes(BLOCK_BEGIN)) return false;
7073
7924
  const next = stripHausBlock(prev);
7074
7925
  if (next.length === 0) {
7075
- await fs33.remove(filePath);
7926
+ await fs34.remove(filePath);
7076
7927
  log("Removed CLAUDE.md (only contained haus import block).");
7077
7928
  } else {
7078
- await fs33.writeFile(filePath, next, "utf8");
7929
+ await fs34.writeFile(filePath, next, "utf8");
7079
7930
  log("Removed haus import block from CLAUDE.md (user content preserved).");
7080
7931
  }
7081
7932
  return true;
7082
7933
  }
7083
7934
  async function pruneDirIfEmpty(dir) {
7084
- if (!await fs33.pathExists(dir)) return;
7935
+ if (!await fs34.pathExists(dir)) return;
7085
7936
  try {
7086
- const entries = await fs33.readdir(dir);
7087
- if (entries.length === 0) await fs33.remove(dir);
7937
+ const entries = await fs34.readdir(dir);
7938
+ if (entries.length === 0) await fs34.remove(dir);
7088
7939
  } catch {
7089
7940
  }
7090
7941
  }
@@ -7093,13 +7944,13 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
7093
7944
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7094
7945
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
7095
7946
  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);
7947
+ if (!await fs34.pathExists(abs)) continue;
7948
+ const rel = path47.relative(root, abs);
7949
+ const backupPath = path47.join(backupRoot, rel);
7950
+ await fs34.ensureDir(path47.dirname(backupPath));
7951
+ await fs34.copy(abs, backupPath);
7101
7952
  }
7102
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path41.relative(root, backupRoot)}.`);
7953
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path47.relative(root, backupRoot)}.`);
7103
7954
  }
7104
7955
  async function runUndo(options) {
7105
7956
  const root = options.root ?? process.cwd();
@@ -7112,7 +7963,7 @@ async function runUndo(options) {
7112
7963
  log("Nothing to remove: no haus-managed files found in this directory.");
7113
7964
  return;
7114
7965
  }
7115
- const relTargets = managed.map((p) => path41.relative(root, p));
7966
+ const relTargets = managed.map((p) => path47.relative(root, p));
7116
7967
  const summaryParts = [...relTargets];
7117
7968
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
7118
7969
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -7134,9 +7985,9 @@ User-owned .claude/ files will be preserved.`
7134
7985
  }
7135
7986
  await backupManagedFilesBeforeUndo(root, managed);
7136
7987
  for (const abs of managed) {
7137
- if (!await fs33.pathExists(abs)) continue;
7138
- await fs33.remove(abs);
7139
- log(`Removed ${path41.relative(root, abs)}`);
7988
+ if (!await fs34.pathExists(abs)) continue;
7989
+ await fs34.remove(abs);
7990
+ log(`Removed ${path47.relative(root, abs)}`);
7140
7991
  }
7141
7992
  if (stripSettings) await stripProjectSettings(root);
7142
7993
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -7147,8 +7998,8 @@ User-owned .claude/ files will be preserved.`
7147
7998
 
7148
7999
  // src/install/uninstall.ts
7149
8000
  import crypto3 from "node:crypto";
7150
- import path42 from "node:path";
7151
- import fs34 from "fs-extra";
8001
+ import path48 from "node:path";
8002
+ import fs35 from "fs-extra";
7152
8003
  async function runUninstall(options = {}) {
7153
8004
  const { force = false } = options;
7154
8005
  const manifest = await readManifest();
@@ -7158,7 +8009,7 @@ async function runUninstall(options = {}) {
7158
8009
  return result;
7159
8010
  }
7160
8011
  for (const entry of manifest.files) {
7161
- const exists = fs34.pathExistsSync(entry.destPath);
8012
+ const exists = fs35.pathExistsSync(entry.destPath);
7162
8013
  if (!exists) continue;
7163
8014
  const content2 = await readText(entry.destPath);
7164
8015
  if (content2 === void 0) continue;
@@ -7176,23 +8027,23 @@ async function runUninstall(options = {}) {
7176
8027
  result.skipped.push(entry.destPath);
7177
8028
  continue;
7178
8029
  }
7179
- await fs34.remove(entry.destPath);
7180
- await pruneEmptyDir(path42.dirname(entry.destPath));
8030
+ await fs35.remove(entry.destPath);
8031
+ await pruneEmptyDir(path48.dirname(entry.destPath));
7181
8032
  result.deleted.push(entry.destPath);
7182
8033
  }
7183
8034
  const settings = await readSettings();
7184
8035
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
7185
8036
  await writeSettings(stripped);
7186
8037
  result.hooksStripped = true;
7187
- const hausDir = path42.join(globalClaudeDir(), "haus");
8038
+ const hausDir = path48.join(globalClaudeDir(), "haus");
7188
8039
  const manifestPath2 = hausManifestPath();
7189
- if (fs34.pathExistsSync(manifestPath2)) {
7190
- await fs34.remove(manifestPath2);
8040
+ if (fs35.pathExistsSync(manifestPath2)) {
8041
+ await fs35.remove(manifestPath2);
7191
8042
  }
7192
- if (fs34.pathExistsSync(hausDir)) {
8043
+ if (fs35.pathExistsSync(hausDir)) {
7193
8044
  try {
7194
- const remaining = await fs34.readdir(hausDir);
7195
- if (remaining.length === 0) await fs34.remove(hausDir);
8045
+ const remaining = await fs35.readdir(hausDir);
8046
+ if (remaining.length === 0) await fs35.remove(hausDir);
7196
8047
  } catch {
7197
8048
  }
7198
8049
  }
@@ -7225,12 +8076,13 @@ async function runUninstallCommand(options) {
7225
8076
  }
7226
8077
 
7227
8078
  // src/commands/validate-catalog.ts
7228
- import path44 from "node:path";
8079
+ import path50 from "node:path";
7229
8080
 
7230
8081
  // src/catalog/validate-core.ts
7231
- import fs35 from "node:fs";
7232
- import path43 from "node:path";
8082
+ import fs36 from "node:fs";
8083
+ import path49 from "node:path";
7233
8084
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
8085
+ var ITEM_ID_PATTERN = /^haus\./;
7234
8086
  function auditForbiddenStacks(items) {
7235
8087
  const failures = [];
7236
8088
  for (const item of items) {
@@ -7316,6 +8168,9 @@ function auditManifestStructure(manifestVersion, items) {
7316
8168
  failures.push(`item[${i}]: missing id`);
7317
8169
  continue;
7318
8170
  }
8171
+ if (!ITEM_ID_PATTERN.test(item.id)) {
8172
+ failures.push(`${item.id}: id must start with "haus."`);
8173
+ }
7319
8174
  const provenanceError = validateCuratedProvenance(item);
7320
8175
  if (provenanceError) failures.push(provenanceError);
7321
8176
  const referencesError = validateReferences(item.id, item.references);
@@ -7367,14 +8222,14 @@ function checkRequiredFrontmatter(text, label) {
7367
8222
  }
7368
8223
  function readForAudit(absPath, label, failures) {
7369
8224
  try {
7370
- return fs35.readFileSync(absPath, "utf8");
8225
+ return fs36.readFileSync(absPath, "utf8");
7371
8226
  } catch (err) {
7372
8227
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
7373
8228
  return null;
7374
8229
  }
7375
8230
  }
7376
8231
  function auditTemplateContent(manifestDir, absPath, itemId) {
7377
- const rel = path43.relative(manifestDir, absPath);
8232
+ const rel = path49.relative(manifestDir, absPath);
7378
8233
  const failures = [];
7379
8234
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
7380
8235
  if (text === null) return failures;
@@ -7396,47 +8251,47 @@ function auditShippedFiles(manifestDir, items) {
7396
8251
  const failures = [];
7397
8252
  for (const item of items) {
7398
8253
  if (!item.path || !isSafeCatalogPath(item.path)) continue;
7399
- const absPath = path43.join(manifestDir, item.path);
8254
+ const absPath = path49.join(manifestDir, item.path);
7400
8255
  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)}`);
8256
+ const skillMd = path49.join(absPath, "SKILL.md");
8257
+ if (!fs36.existsSync(skillMd)) {
8258
+ failures.push(`${item.id}: missing ${path49.relative(manifestDir, skillMd)}`);
7404
8259
  continue;
7405
8260
  }
7406
8261
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
7407
8262
  if (text === null) continue;
7408
8263
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
7409
8264
  failures.push(
7410
- ...auditForbiddenTagsInText(text, `${item.id}: ${path43.relative(manifestDir, skillMd)}`)
8265
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path49.relative(manifestDir, skillMd)}`)
7411
8266
  );
7412
8267
  } else if (item.type === "agent") {
7413
- if (!fs35.existsSync(absPath)) {
8268
+ if (!fs36.existsSync(absPath)) {
7414
8269
  failures.push(`${item.id}: missing agent file ${item.path}`);
7415
8270
  continue;
7416
8271
  }
7417
- const rel = path43.relative(manifestDir, absPath);
8272
+ const rel = path49.relative(manifestDir, absPath);
7418
8273
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
7419
8274
  if (text === null) continue;
7420
8275
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
7421
8276
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
7422
8277
  } else if (item.type === "template") {
7423
- if (!fs35.existsSync(absPath)) {
8278
+ if (!fs36.existsSync(absPath)) {
7424
8279
  failures.push(`${item.id}: missing template file ${item.path}`);
7425
8280
  continue;
7426
8281
  }
7427
8282
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
7428
8283
  } else if (item.type === "command") {
7429
- if (!fs35.existsSync(absPath)) {
8284
+ if (!fs36.existsSync(absPath)) {
7430
8285
  failures.push(`${item.id}: missing command file ${item.path}`);
7431
8286
  continue;
7432
8287
  }
7433
- const rel = path43.relative(manifestDir, absPath);
8288
+ const rel = path49.relative(manifestDir, absPath);
7434
8289
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
7435
8290
  if (text === null) continue;
7436
8291
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
7437
8292
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
7438
8293
  } else if (item.type === "config") {
7439
- if (!fs35.existsSync(absPath)) {
8294
+ if (!fs36.existsSync(absPath)) {
7440
8295
  failures.push(`${item.id}: missing config path ${item.path}`);
7441
8296
  }
7442
8297
  }
@@ -7453,7 +8308,7 @@ var CONTENT_ROOTS = ["skills", "agents", "templates", "commands", "configs"];
7453
8308
  function walkAllFiles(dir, fn, failures) {
7454
8309
  let entries;
7455
8310
  try {
7456
- entries = fs35.readdirSync(dir, { withFileTypes: true });
8311
+ entries = fs36.readdirSync(dir, { withFileTypes: true });
7457
8312
  } catch (err) {
7458
8313
  failures.push(
7459
8314
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -7462,7 +8317,7 @@ function walkAllFiles(dir, fn, failures) {
7462
8317
  }
7463
8318
  for (const entry of entries) {
7464
8319
  if (entry.name === ".DS_Store") continue;
7465
- const full = path43.join(dir, entry.name);
8320
+ const full = path49.join(dir, entry.name);
7466
8321
  if (entry.isDirectory()) walkAllFiles(full, fn, failures);
7467
8322
  else fn(full);
7468
8323
  }
@@ -7480,12 +8335,12 @@ function auditDiskOrphans(manifestDir, items) {
7480
8335
  const failures = [];
7481
8336
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
7482
8337
  for (const dir of CONTENT_ROOTS) {
7483
- const abs = path43.join(manifestDir, dir);
7484
- if (!fs35.existsSync(abs)) continue;
8338
+ const abs = path49.join(manifestDir, dir);
8339
+ if (!fs36.existsSync(abs)) continue;
7485
8340
  walkAllFiles(
7486
8341
  abs,
7487
8342
  (file) => {
7488
- const relPath = path43.relative(manifestDir, file).replace(/\\/g, "/");
8343
+ const relPath = path49.relative(manifestDir, file).replace(/\\/g, "/");
7489
8344
  if (!isPathClaimed(relPath, claimedPaths)) {
7490
8345
  failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
7491
8346
  }
@@ -7498,7 +8353,7 @@ function auditDiskOrphans(manifestDir, items) {
7498
8353
  function walkMd(dir, fn, failures) {
7499
8354
  let entries;
7500
8355
  try {
7501
- entries = fs35.readdirSync(dir, { withFileTypes: true });
8356
+ entries = fs36.readdirSync(dir, { withFileTypes: true });
7502
8357
  } catch (err) {
7503
8358
  failures.push(
7504
8359
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -7506,7 +8361,7 @@ function walkMd(dir, fn, failures) {
7506
8361
  return;
7507
8362
  }
7508
8363
  for (const entry of entries) {
7509
- const full = path43.join(dir, entry.name);
8364
+ const full = path49.join(dir, entry.name);
7510
8365
  if (entry.isDirectory()) walkMd(full, fn, failures);
7511
8366
  else if (entry.name.endsWith(".md")) fn(full);
7512
8367
  }
@@ -7515,13 +8370,13 @@ function auditMarkdownContent(manifestDir, items) {
7515
8370
  const failures = [];
7516
8371
  const pathSourceMap = buildItemPathSourceMap(items);
7517
8372
  for (const dir of ["skills", "agents", "templates", "commands"]) {
7518
- const abs = path43.join(manifestDir, dir);
7519
- if (!fs35.existsSync(abs)) continue;
8373
+ const abs = path49.join(manifestDir, dir);
8374
+ if (!fs36.existsSync(abs)) continue;
7520
8375
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
7521
8376
  walkMd(
7522
8377
  abs,
7523
8378
  (file) => {
7524
- const rel = path43.relative(manifestDir, file);
8379
+ const rel = path49.relative(manifestDir, file);
7525
8380
  const text = readForAudit(file, rel, failures);
7526
8381
  if (text === null) return;
7527
8382
  const norm = rel.replace(/\\/g, "/");
@@ -7566,8 +8421,8 @@ async function runValidateCatalog(manifestPath2) {
7566
8421
  process.exitCode = 1;
7567
8422
  return;
7568
8423
  }
7569
- const abs = path44.resolve(process.cwd(), manifestPath2);
7570
- const manifestDir = path44.dirname(abs);
8424
+ const abs = path50.resolve(process.cwd(), manifestPath2);
8425
+ const manifestDir = path50.dirname(abs);
7571
8426
  const data = await readJson(abs);
7572
8427
  if (!data?.items) {
7573
8428
  error(`Could not read catalog manifest at ${abs}`);
@@ -7583,231 +8438,1097 @@ async function runValidateCatalog(manifestPath2) {
7583
8438
  log(`Catalog valid. ${result.items.length} items checked.`);
7584
8439
  }
7585
8440
 
7586
- // src/commands/workspace.ts
7587
- import { existsSync as existsSync6, statSync as statSync3 } from "node:fs";
7588
- import path52 from "node:path";
8441
+ // src/workspace/worktree/add.ts
8442
+ import path57 from "node:path";
8443
+ import fs40 from "fs-extra";
7589
8444
 
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
- }
8445
+ // src/workspace/worktree/git-worktree.ts
8446
+ init_exec();
8447
+ async function branchExists(cwd, branch) {
8448
+ const result = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd });
8449
+ return result.exitCode === 0;
8450
+ }
8451
+ async function resolveDefaultBranch(cwd) {
8452
+ const symbolic = await runGit(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd });
8453
+ if (symbolic.exitCode === 0 && symbolic.stdout.trim()) {
8454
+ return { ref: symbolic.stdout.trim(), source: "origin-head" };
7605
8455
  }
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
8456
+ if (await branchExists(cwd, "main")) return { ref: "main", source: "local-main" };
8457
+ if (await branchExists(cwd, "master")) return { ref: "master", source: "local-master" };
8458
+ return void 0;
8459
+ }
8460
+ var CHECKED_OUT_ELSEWHERE_PATTERNS = [/already checked out at/i, /already used by worktree/i];
8461
+ function isBranchCheckedOutElsewhereError(stderr) {
8462
+ return CHECKED_OUT_ELSEWHERE_PATTERNS.some((re) => re.test(stderr));
8463
+ }
8464
+ async function addWorktree(cwd, worktreePath2, branch, opts = {}) {
8465
+ const exists = await branchExists(cwd, branch);
8466
+ if (exists) {
8467
+ const result2 = await runGit(["worktree", "add", worktreePath2, branch], { cwd });
8468
+ return {
8469
+ ok: result2.exitCode === 0,
8470
+ branchAction: "checkout",
8471
+ stderr: result2.exitCode === 0 ? void 0 : result2.stderr,
8472
+ checkedOutElsewhere: result2.exitCode !== 0 && isBranchCheckedOutElsewhereError(result2.stderr)
8473
+ };
8474
+ }
8475
+ let startPoint;
8476
+ if (opts.preferDefaultBranchFrom) {
8477
+ const defaultBranch = await resolveDefaultBranch(cwd);
8478
+ startPoint = defaultBranch?.ref;
8479
+ }
8480
+ const args = startPoint ? ["worktree", "add", worktreePath2, "-b", branch, startPoint] : ["worktree", "add", worktreePath2, "-b", branch];
8481
+ const result = await runGit(args, { cwd });
8482
+ return {
8483
+ ok: result.exitCode === 0,
8484
+ branchAction: "create",
8485
+ startPoint,
8486
+ stderr: result.exitCode === 0 ? void 0 : result.stderr,
8487
+ checkedOutElsewhere: result.exitCode !== 0 && isBranchCheckedOutElsewhereError(result.stderr)
8488
+ };
8489
+ }
8490
+ async function removeWorktree(ownerRepo, worktreePath2, force = true) {
8491
+ const args = ["worktree", "remove", worktreePath2];
8492
+ if (force) args.push("--force");
8493
+ const result = await runGit(args, { cwd: ownerRepo });
8494
+ return { ok: result.exitCode === 0, stderr: result.exitCode === 0 ? void 0 : result.stderr };
8495
+ }
8496
+ async function pruneWorktrees(ownerRepo) {
8497
+ await runGit(["worktree", "prune"], { cwd: ownerRepo });
8498
+ }
8499
+ async function listWorktrees(ownerRepo) {
8500
+ const result = await runGit(["worktree", "list", "--porcelain"], { cwd: ownerRepo });
8501
+ if (result.exitCode !== 0) return [];
8502
+ const entries = [];
8503
+ let current = {};
8504
+ for (const line2 of result.stdout.split("\n")) {
8505
+ if (line2.startsWith("worktree ")) {
8506
+ if (current.path) entries.push(current);
8507
+ current = { path: line2.slice("worktree ".length).trim() };
8508
+ } else if (line2.startsWith("branch ")) {
8509
+ current.branch = line2.slice("branch ".length).replace("refs/heads/", "").trim();
8510
+ } else if (line2.startsWith("HEAD ")) {
8511
+ current.head = line2.slice("HEAD ".length).trim();
8512
+ } else if (line2 === "bare") {
8513
+ current.bare = true;
8514
+ } else if (line2 === "detached") {
8515
+ current.detached = true;
8516
+ }
8517
+ }
8518
+ if (current.path) entries.push(current);
8519
+ return entries;
8520
+ }
8521
+ async function currentBranch(cwd) {
8522
+ const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
8523
+ return result.exitCode === 0 ? result.stdout.trim() : void 0;
8524
+ }
8525
+ async function hasUncommittedChanges(cwd, ignorePaths = []) {
8526
+ const args = ["status", "--porcelain"];
8527
+ if (ignorePaths.length > 0) {
8528
+ args.push("--", ".", ...ignorePaths.map((p) => `:!${p}`));
8529
+ }
8530
+ const result = await runGit(args, { cwd });
8531
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
8532
+ }
8533
+ async function hasUnpushedWork(cwd) {
8534
+ const upstream = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
8535
+ cwd
7637
8536
  });
7638
- return [summaryPath, ownershipPath, crossRepoPath, contextMapPath];
8537
+ if (upstream.exitCode === 0 && upstream.stdout.trim()) {
8538
+ const upstreamRef = upstream.stdout.trim();
8539
+ const ahead = await runGit(["rev-list", "--count", `${upstreamRef}..HEAD`], { cwd });
8540
+ const count = Number.parseInt(ahead.stdout.trim(), 10);
8541
+ if (Number.isFinite(count) && count > 0) {
8542
+ return { unpushed: true, reason: `${count} commit(s) ahead of ${upstreamRef}` };
8543
+ }
8544
+ return { unpushed: false };
8545
+ }
8546
+ const defaultBranch = await resolveDefaultBranch(cwd);
8547
+ if (!defaultBranch) return { unpushed: false };
8548
+ const [head, defaultSha] = await Promise.all([
8549
+ runGit(["rev-parse", "HEAD"], { cwd }),
8550
+ runGit(["rev-parse", defaultBranch.ref], { cwd })
8551
+ ]);
8552
+ if (head.exitCode === 0 && defaultSha.exitCode === 0 && head.stdout.trim() === defaultSha.stdout.trim()) {
8553
+ return { unpushed: false };
8554
+ }
8555
+ return {
8556
+ unpushed: true,
8557
+ reason: `no upstream tracking branch configured, and HEAD differs from ${defaultBranch.ref}`
8558
+ };
7639
8559
  }
7640
8560
 
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;
8561
+ // src/workspace/worktree/hydrate.ts
8562
+ import path53 from "node:path";
8563
+ import fs38 from "fs-extra";
8564
+
8565
+ // src/workspace/worktree/cow-copy.ts
8566
+ init_exec();
8567
+ import path51 from "node:path";
8568
+ import fs37 from "fs-extra";
8569
+ async function getMacFilesystemType(dir) {
8570
+ const df = await runCommand("df", ["-P", dir]);
8571
+ if (df.exitCode !== 0) return void 0;
8572
+ const lines = df.stdout.trim().split("\n");
8573
+ const dataLine = lines[lines.length - 1];
8574
+ const device = dataLine?.split(/\s+/)[0];
8575
+ if (!device) return void 0;
8576
+ const mount = await runCommand("mount");
8577
+ if (mount.exitCode !== 0) return void 0;
8578
+ const line2 = mount.stdout.split("\n").find((l) => l.startsWith(`${device} `));
8579
+ const match = line2?.match(/\(([^,)]+)/);
8580
+ return match?.[1]?.trim();
8581
+ }
8582
+ async function getLinuxFilesystemType(dir) {
8583
+ const result = await runCommand("stat", ["-f", "-c", "%T", dir]);
8584
+ if (result.exitCode !== 0) return void 0;
8585
+ return result.stdout.trim();
8586
+ }
8587
+ async function detectCowStrategy(dir) {
8588
+ if (process.platform === "darwin") {
8589
+ const fsType = await getMacFilesystemType(dir);
8590
+ return fsType === "apfs" ? "darwin-clonefile" : "unsupported";
8591
+ }
8592
+ if (process.platform === "linux") {
8593
+ const fsType = await getLinuxFilesystemType(dir);
8594
+ return fsType === "btrfs" || fsType === "xfs" ? "linux-reflink" : "unsupported";
8595
+ }
8596
+ return "unknown-platform";
8597
+ }
8598
+ async function cowCopyDir(src, dest) {
8599
+ const srcExists = await fs37.pathExists(src);
8600
+ if (!srcExists)
8601
+ return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
8602
+ const strategy = await detectCowStrategy(path51.dirname(dest));
8603
+ if (strategy === "unsupported" || strategy === "unknown-platform") {
8604
+ return { strategy, attempted: false, ok: false };
8605
+ }
8606
+ await fs37.ensureDir(path51.dirname(dest));
8607
+ const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
8608
+ const result = await runCommand("cp", args);
8609
+ return {
8610
+ strategy,
8611
+ attempted: true,
8612
+ ok: result.exitCode === 0,
8613
+ error: result.exitCode === 0 ? void 0 : result.stderr
8614
+ };
8615
+ }
8616
+
8617
+ // src/workspace/worktree/install.ts
8618
+ init_exec();
8619
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
8620
+ import path52 from "node:path";
8621
+ import fg5 from "fast-glob";
8622
+ function detectInstallPlan(signals) {
8623
+ if (signals.hasYarnLock) {
8624
+ return signals.hasPackageManagerField ? { manager: "yarn", command: "corepack", args: ["yarn", "install"] } : { manager: "yarn", command: "yarn", args: ["install"] };
8625
+ }
8626
+ if (signals.hasPnpmLock) {
8627
+ return { manager: "pnpm", command: "pnpm", args: ["install", "--frozen-lockfile"] };
8628
+ }
8629
+ if (signals.hasPackageLock) {
8630
+ return { manager: "npm", command: "npm", args: ["install"] };
8631
+ }
8632
+ if (signals.hasComposerLock) {
8633
+ return { manager: "composer", command: "composer", args: ["install"] };
8634
+ }
8635
+ if (signals.hasDotnetProject) {
8636
+ return { manager: "dotnet", command: "dotnet", args: ["restore"] };
8637
+ }
8638
+ return { manager: "none" };
8639
+ }
8640
+ async function computeLockfileSignals(dir) {
8641
+ const hasYarnLock = existsSync3(path52.join(dir, "yarn.lock"));
8642
+ const hasPnpmLock = existsSync3(path52.join(dir, "pnpm-lock.yaml"));
8643
+ const hasPackageLock = existsSync3(path52.join(dir, "package-lock.json"));
8644
+ const hasComposerLock = existsSync3(path52.join(dir, "composer.lock"));
8645
+ const dotnetFiles = await fg5(["**/*.csproj", "**/*.sln"], {
8646
+ cwd: dir,
8647
+ onlyFiles: true,
8648
+ suppressErrors: true,
8649
+ // Consistent with this codebase's no-symlink-follow posture elsewhere
8650
+ // (fs.ts's findNestedRepoDirs, discover.ts's discoverRepos): a symlink
8651
+ // shouldn't be able to route this search outside the intended tree or
8652
+ // reintroduce a symlink-cycle/perf concern.
8653
+ followSymbolicLinks: false,
8654
+ ignore: ["**/node_modules/**", "**/bin/**", "**/obj/**"]
8655
+ });
8656
+ const hasDotnetProject = dotnetFiles.length > 0;
8657
+ let hasPackageManagerField = false;
8658
+ const pkgPath = path52.join(dir, "package.json");
8659
+ if (existsSync3(pkgPath)) {
8660
+ try {
8661
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
8662
+ hasPackageManagerField = typeof pkg.packageManager === "string" && pkg.packageManager.length > 0;
8663
+ } catch {
8664
+ }
7652
8665
  }
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
- ) : [];
7658
8666
  return {
7659
- client: typeof obj.client === "string" ? obj.client : "unknown",
7660
- repos,
7661
- relationships: Array.isArray(obj.relationships) ? obj.relationships : []
8667
+ hasYarnLock,
8668
+ hasPackageManagerField,
8669
+ hasPnpmLock,
8670
+ hasPackageLock,
8671
+ hasComposerLock,
8672
+ hasDotnetProject
7662
8673
  };
7663
8674
  }
7664
- async function readWorkspaceConfig(workspaceRoot) {
7665
- return parseWorkspaceConfig(await readText(path45.join(workspaceRoot, WORKSPACE_FILE)));
8675
+ async function runInstallPlan(dir, plan) {
8676
+ if (plan.manager === "none") {
8677
+ return { plan, ran: false, ok: true, skippedReason: "no recognized lockfile" };
8678
+ }
8679
+ const available = await commandExists(plan.command);
8680
+ if (!available) {
8681
+ return { plan, ran: false, ok: true, skippedReason: `${plan.command} not found on PATH` };
8682
+ }
8683
+ const result = await runCommand(plan.command, plan.args, { cwd: dir });
8684
+ return {
8685
+ plan,
8686
+ ran: true,
8687
+ ok: result.exitCode === 0,
8688
+ exitCode: result.exitCode,
8689
+ stderr: result.exitCode === 0 ? void 0 : result.stderr
8690
+ };
7666
8691
  }
7667
8692
 
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/**"
8693
+ // src/workspace/worktree/hydrate.ts
8694
+ var HYDRATION_TARGETS = ["node_modules", "vendor", "obj", "bin"];
8695
+ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
8696
+ const start = Date.now();
8697
+ const targets = [];
8698
+ for (const target of HYDRATION_TARGETS) {
8699
+ const src = path53.join(mainMemberPath, target);
8700
+ const dest = path53.join(worktreeMemberPath, target);
8701
+ if (!await fs38.pathExists(src)) continue;
8702
+ const destExists = await fs38.pathExists(dest);
8703
+ if (!opts.force && destExists) continue;
8704
+ if (opts.force && destExists) {
8705
+ await fs38.remove(dest);
8706
+ }
8707
+ const copy = await cowCopyDir(src, dest);
8708
+ targets.push({ target, copy });
8709
+ }
8710
+ const signals = await computeLockfileSignals(worktreeMemberPath);
8711
+ const plan = detectInstallPlan(signals);
8712
+ const install = await runInstallPlan(worktreeMemberPath, plan);
8713
+ return { targets, install, durationMs: Date.now() - start };
8714
+ }
8715
+
8716
+ // src/workspace/worktree/local-files.ts
8717
+ init_exec();
8718
+ import path54 from "node:path";
8719
+ import fs39 from "fs-extra";
8720
+ var MACHINE_LOCAL_FILES = [
8721
+ "docker-compose.local.yml",
8722
+ path54.join(".claude", "settings.local.json")
7680
8723
  ];
7681
- function isDescendant(child, ancestor) {
7682
- if (ancestor === ".") return child !== ".";
7683
- return child === ancestor ? false : child.startsWith(`${ancestor}/`);
8724
+ async function isTracked(repoRoot, relFile) {
8725
+ const gitPath = relFile.replace(/\\/g, "/");
8726
+ const result = await runGit(["ls-files", "--error-unmatch", gitPath], { cwd: repoRoot });
8727
+ return result.exitCode === 0;
8728
+ }
8729
+ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
8730
+ const copied = [];
8731
+ for (const rel of MACHINE_LOCAL_FILES) {
8732
+ const src = path54.join(mainMemberPath, rel);
8733
+ if (!await fs39.pathExists(src)) continue;
8734
+ if (await isTracked(mainMemberPath, rel)) continue;
8735
+ const dest = path54.join(worktreeMemberPath, rel);
8736
+ await fs39.ensureDir(path54.dirname(dest));
8737
+ await fs39.copy(src, dest, { overwrite: true });
8738
+ copied.push(rel);
8739
+ }
8740
+ return copied;
8741
+ }
8742
+
8743
+ // src/workspace/worktree/root.ts
8744
+ import { existsSync as existsSync4 } from "node:fs";
8745
+ import path55 from "node:path";
8746
+ async function resolveWorkspaceForWorktree(start) {
8747
+ const rootInfo = await resolveRoots(start);
8748
+ if (!rootInfo.isGitRepo) {
8749
+ return { ok: false, reason: "Not inside a git repository." };
8750
+ }
8751
+ const workspaceRoot = rootInfo.mainRoot;
8752
+ const hasYaml = existsSync4(path55.join(workspaceRoot, WORKSPACE_FILE));
8753
+ const hasManifest = existsSync4(path55.join(workspaceRoot, REPOS_MANIFEST_FILE));
8754
+ if (!hasYaml && !hasManifest) {
8755
+ return {
8756
+ ok: false,
8757
+ reason: `No workspace found at ${workspaceRoot} \u2014 expected ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE}. Run \`haus workspace discover --write\` or \`haus workspace init\` first.`
8758
+ };
8759
+ }
8760
+ return { ok: true, rootInfo, workspaceRoot };
7684
8761
  }
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
8762
+
8763
+ // src/workspace/worktree/select-members.ts
8764
+ function selectMembers(members, only) {
8765
+ if (!only || only.length === 0) return { selected: members, unknown: [] };
8766
+ const wanted = new Set(only);
8767
+ const selected = members.filter((m) => wanted.has(m.id) || wanted.has(m.folder));
8768
+ const matchedKeys = /* @__PURE__ */ new Set();
8769
+ for (const m of selected) {
8770
+ if (wanted.has(m.id)) matchedKeys.add(m.id);
8771
+ if (wanted.has(m.folder)) matchedKeys.add(m.folder);
8772
+ }
8773
+ const unknown = only.filter((name) => !matchedKeys.has(name));
8774
+ return { selected, unknown };
8775
+ }
8776
+
8777
+ // src/workspace/worktree/state.ts
8778
+ import path56 from "node:path";
8779
+ var WORKTREE_STATE_FILE = ".haus-worktree.json";
8780
+ function worktreesDir(mainRoot) {
8781
+ return path56.join(mainRoot, ".claude", "worktrees");
8782
+ }
8783
+ function worktreePath(mainRoot, slug) {
8784
+ return path56.join(worktreesDir(mainRoot), slug);
8785
+ }
8786
+ async function readWorktreeState(worktreeRoot) {
8787
+ return readJson(path56.join(worktreeRoot, WORKTREE_STATE_FILE));
8788
+ }
8789
+ async function writeWorktreeState(worktreeRoot, state) {
8790
+ await writeJson(path56.join(worktreeRoot, WORKTREE_STATE_FILE), state);
8791
+ }
8792
+
8793
+ // src/workspace/worktree/add.ts
8794
+ async function runAdd(opts) {
8795
+ const resolved = await resolveWorkspaceForWorktree();
8796
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
8797
+ const { rootInfo, workspaceRoot } = resolved;
8798
+ const allMembers = await readMembers(rootInfo);
8799
+ const { selected, unknown } = selectMembers(allMembers, opts.only);
8800
+ if (unknown.length > 0) {
8801
+ return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
8802
+ }
8803
+ const branch = opts.branch ?? opts.slug;
8804
+ const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
8805
+ if (await fs40.pathExists(wsWorktreePath)) {
8806
+ return {
8807
+ ok: false,
8808
+ error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate --materialize\` from inside it to check out any missing members (e.g. a worktree created by plain \`git worktree add\`), or \`remove\` first to start over.`
8809
+ };
8810
+ }
8811
+ if (opts.dryRun) {
8812
+ return runDryRun({ workspaceRoot, wsWorktreePath, slug: opts.slug, branch, selected });
8813
+ }
8814
+ const wsOutcome = await addWorktree(workspaceRoot, wsWorktreePath, branch);
8815
+ if (!wsOutcome.ok) {
8816
+ return {
8817
+ ok: false,
8818
+ 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"}`
8819
+ };
8820
+ }
8821
+ const memberResults = [];
8822
+ for (const member of selected) {
8823
+ const start = Date.now();
8824
+ const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
8825
+ const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
8826
+ preferDefaultBranchFrom: true
8827
+ });
8828
+ if (!outcome.ok) {
8829
+ memberResults.push({
8830
+ member: member.id,
8831
+ folder: member.folder,
8832
+ status: "failed",
8833
+ branch,
8834
+ branchAction: outcome.branchAction,
8835
+ error: outcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in ${member.id} \u2014 pick a different --branch.` : outcome.stderr ?? "unknown error",
8836
+ durationMs: Date.now() - start
8837
+ });
8838
+ continue;
8839
+ }
8840
+ memberResults.push({
8841
+ member: member.id,
8842
+ folder: member.folder,
8843
+ status: "ok",
8844
+ branch,
8845
+ branchAction: outcome.branchAction,
8846
+ startPoint: outcome.startPoint,
8847
+ durationMs: Date.now() - start
8848
+ });
8849
+ }
8850
+ const okResults = memberResults.filter((m) => m.status === "ok");
8851
+ await mapWithConcurrency(okResults, async (result) => {
8852
+ const member = selected.find((m) => m.id === result.member);
8853
+ if (!member) return;
8854
+ const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
8855
+ const hydrationStart = Date.now();
8856
+ if (opts.hydrate !== false) {
8857
+ result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
8858
+ }
8859
+ result.localFilesCopied = await copyMachineLocalFiles(member.absPath, memberWorktreePath);
8860
+ result.durationMs += Date.now() - hydrationStart;
7696
8861
  });
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);
8862
+ const stateMembers = okResults.map((m) => ({
8863
+ id: m.member,
8864
+ folder: m.folder,
8865
+ branch: m.branch,
8866
+ absPath: selected.find((s) => s.id === m.member)?.absPath
8867
+ }));
8868
+ await writeWorktreeState(wsWorktreePath, {
8869
+ slug: opts.slug,
8870
+ branch,
8871
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8872
+ members: stateMembers
8873
+ });
8874
+ const anyFailed = memberResults.some((m) => m.status === "failed");
8875
+ return {
8876
+ ok: !anyFailed,
8877
+ dryRun: false,
8878
+ workspaceRoot,
8879
+ slug: opts.slug,
8880
+ branch,
8881
+ worktreePath: wsWorktreePath,
8882
+ workspaceWorktree: { status: "ok", branchAction: wsOutcome.branchAction },
8883
+ members: memberResults
8884
+ };
8885
+ }
8886
+ async function runDryRun(args) {
8887
+ const { workspaceRoot, wsWorktreePath, slug, branch, selected } = args;
8888
+ const wsBranchExists = await branchExists(workspaceRoot, branch);
8889
+ const members = [];
8890
+ for (const member of selected) {
8891
+ const exists = await branchExists(member.absPath, branch);
8892
+ let startPoint;
8893
+ if (!exists) {
8894
+ const def = await resolveDefaultBranch(member.absPath);
8895
+ startPoint = def?.ref;
8896
+ }
8897
+ members.push({
8898
+ member: member.id,
8899
+ folder: member.folder,
8900
+ status: "planned",
8901
+ branch,
8902
+ branchAction: exists ? "checkout" : "create",
8903
+ startPoint,
8904
+ durationMs: 0
8905
+ });
7705
8906
  }
7706
- const repoRoots = [...gitDirs];
7707
- const manifestSorted = [...manifestDirs].sort(
7708
- (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
8907
+ return {
8908
+ ok: true,
8909
+ dryRun: true,
8910
+ workspaceRoot,
8911
+ slug,
8912
+ branch,
8913
+ worktreePath: wsWorktreePath,
8914
+ workspaceWorktree: { status: "planned", branchAction: wsBranchExists ? "checkout" : "create" },
8915
+ members
8916
+ };
8917
+ }
8918
+
8919
+ // src/workspace/worktree/doctor.ts
8920
+ import { existsSync as existsSync5 } from "node:fs";
8921
+ import path58 from "node:path";
8922
+ var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
8923
+ function looksHydrated(memberPath) {
8924
+ return HYDRATION_MARKERS.some((marker) => existsSync5(path58.join(memberPath, marker)));
8925
+ }
8926
+ async function runDoctor2() {
8927
+ const rootInfo = await resolveRoots();
8928
+ if (!rootInfo.isGitRepo) {
8929
+ return {
8930
+ isWorkspace: false,
8931
+ inWorkspaceWorktree: false,
8932
+ members: [],
8933
+ orphans: [],
8934
+ problems: ["Not inside a git repository."]
8935
+ };
8936
+ }
8937
+ const workspaceRoot = rootInfo.mainRoot;
8938
+ const isWorkspace = existsSync5(path58.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path58.join(workspaceRoot, REPOS_MANIFEST_FILE));
8939
+ if (!isWorkspace) {
8940
+ return {
8941
+ isWorkspace: false,
8942
+ inWorkspaceWorktree: false,
8943
+ members: [],
8944
+ orphans: [],
8945
+ problems: [
8946
+ `Not a haus workspace \u2014 no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
8947
+ ]
8948
+ };
8949
+ }
8950
+ const wsWorktreesDir = worktreesDir(workspaceRoot);
8951
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path58.sep}`);
8952
+ const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
8953
+ const members = await readMembers(rootInfo);
8954
+ const problems = [];
8955
+ const memberChecks = [];
8956
+ let slug;
8957
+ if (inWorkspaceWorktree) {
8958
+ const wtPath = rootInfo.repoRoot;
8959
+ slug = rootInfo.worktreeName ?? path58.basename(wtPath);
8960
+ const state = await readWorktreeState(wtPath);
8961
+ for (const member of members) {
8962
+ const memberPath = path58.join(wtPath, member.folder);
8963
+ const materialized = existsSync5(memberPath);
8964
+ const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
8965
+ const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
8966
+ const branchMismatch = Boolean(
8967
+ expectedBranch && actualBranch && expectedBranch !== actualBranch
8968
+ );
8969
+ const hydrated = materialized && looksHydrated(memberPath);
8970
+ memberChecks.push({
8971
+ id: member.id,
8972
+ folder: member.folder,
8973
+ materialized,
8974
+ expectedBranch,
8975
+ actualBranch,
8976
+ branchMismatch,
8977
+ hydrated
8978
+ });
8979
+ if (!materialized) problems.push(`${member.id}: not materialized in this worktree.`);
8980
+ else if (branchMismatch) {
8981
+ problems.push(`${member.id}: on branch "${actualBranch}", expected "${expectedBranch}".`);
8982
+ } else if (!hydrated) {
8983
+ problems.push(
8984
+ `${member.id}: materialized but not hydrated (no node_modules/vendor/obj/bin). Run \`haus workspace worktree hydrate\` to fix.`
8985
+ );
8986
+ }
8987
+ }
8988
+ }
8989
+ const orphans = [];
8990
+ for (const member of members) {
8991
+ const entries = await listWorktrees(member.absPath);
8992
+ for (const entry of entries) {
8993
+ const entryPath = path58.normalize(entry.path);
8994
+ const marker = `${path58.sep}.claude${path58.sep}worktrees${path58.sep}`;
8995
+ const idx = entryPath.indexOf(marker);
8996
+ if (idx === -1) continue;
8997
+ const rest = entryPath.slice(idx + marker.length);
8998
+ const slugName = rest.split(path58.sep)[0];
8999
+ if (!slugName) continue;
9000
+ if (!existsSync5(path58.join(wsWorktreesDir, slugName))) {
9001
+ orphans.push(
9002
+ `${member.id}: worktree registered at ${entry.path} but ${path58.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
9003
+ );
9004
+ }
9005
+ }
9006
+ }
9007
+ if (orphans.length > 0) problems.push(...orphans);
9008
+ return { isWorkspace: true, inWorkspaceWorktree, slug, members: memberChecks, orphans, problems };
9009
+ }
9010
+
9011
+ // src/workspace/worktree/hydrate-command.ts
9012
+ import { existsSync as existsSync6 } from "node:fs";
9013
+ import path59 from "node:path";
9014
+ async function runHydrateWorktree(opts) {
9015
+ const rootInfo = await resolveRoots();
9016
+ if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
9017
+ return {
9018
+ ok: false,
9019
+ error: "Run this from inside a workspace worktree created by `haus workspace worktree add` (.claude/worktrees/<slug>)."
9020
+ };
9021
+ }
9022
+ const workspaceRoot = rootInfo.mainRoot;
9023
+ const wtPath = rootInfo.repoRoot;
9024
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path59.sep}`)) {
9025
+ return {
9026
+ ok: false,
9027
+ error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
9028
+ };
9029
+ }
9030
+ const members = await readMembers(rootInfo);
9031
+ const { selected, unknown } = selectMembers(members, opts.only);
9032
+ if (unknown.length > 0) {
9033
+ return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
9034
+ }
9035
+ const state = await readWorktreeState(wtPath);
9036
+ const slug = state?.slug ?? path59.basename(wtPath);
9037
+ const detectedBranch = await currentBranch(wtPath);
9038
+ const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
9039
+ if (opts.dryRun) {
9040
+ const results2 = await Promise.all(
9041
+ selected.map(async (member) => {
9042
+ const memberPath = path59.join(wtPath, member.folder);
9043
+ if (existsSync6(memberPath)) {
9044
+ return { member: member.id, folder: member.folder, status: "planned" };
9045
+ }
9046
+ if (!opts.materialize) {
9047
+ return {
9048
+ member: member.id,
9049
+ folder: member.folder,
9050
+ status: "skipped",
9051
+ error: "not materialized in this worktree"
9052
+ };
9053
+ }
9054
+ const branchAction = await branchExists(member.absPath, targetBranch) ? "checkout" : "create";
9055
+ return { member: member.id, folder: member.folder, status: "planned", branchAction };
9056
+ })
9057
+ );
9058
+ return { ok: true, dryRun: true, slug, results: results2 };
9059
+ }
9060
+ const results = await mapWithConcurrency(
9061
+ selected,
9062
+ async (member) => {
9063
+ const memberPath = path59.join(wtPath, member.folder);
9064
+ let branchAction;
9065
+ if (!existsSync6(memberPath)) {
9066
+ if (!opts.materialize) {
9067
+ return {
9068
+ member: member.id,
9069
+ folder: member.folder,
9070
+ status: "skipped",
9071
+ error: "not materialized in this worktree"
9072
+ };
9073
+ }
9074
+ const outcome = await addWorktree(member.absPath, memberPath, targetBranch, {
9075
+ preferDefaultBranchFrom: true
9076
+ });
9077
+ if (!outcome.ok) {
9078
+ return {
9079
+ member: member.id,
9080
+ folder: member.folder,
9081
+ status: "failed",
9082
+ error: outcome.checkedOutElsewhere ? `Branch "${targetBranch}" is already checked out elsewhere in ${member.id} \u2014 remove that worktree or resolve the branch conflict first.` : outcome.stderr ?? "unknown error creating the member worktree"
9083
+ };
9084
+ }
9085
+ branchAction = outcome.branchAction;
9086
+ }
9087
+ try {
9088
+ const hydration = await hydrateMember(member.absPath, memberPath, { force: opts.force });
9089
+ return { member: member.id, folder: member.folder, status: "ok", hydration, branchAction };
9090
+ } catch (err) {
9091
+ return {
9092
+ member: member.id,
9093
+ folder: member.folder,
9094
+ status: "failed",
9095
+ error: err instanceof Error ? err.message : String(err),
9096
+ branchAction
9097
+ };
9098
+ }
9099
+ }
7709
9100
  );
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);
9101
+ const newlyMaterialized = results.filter((r) => r.branchAction && r.status === "ok");
9102
+ if (newlyMaterialized.length > 0) {
9103
+ const byId = new Map((state?.members ?? []).map((m) => [m.id, m]));
9104
+ for (const r of newlyMaterialized) {
9105
+ const member = selected.find((m) => m.id === r.member);
9106
+ if (!member) continue;
9107
+ const entry = {
9108
+ id: member.id,
9109
+ folder: member.folder,
9110
+ branch: targetBranch,
9111
+ absPath: member.absPath
9112
+ };
9113
+ byId.set(member.id, entry);
9114
+ }
9115
+ await writeWorktreeState(wtPath, {
9116
+ slug,
9117
+ branch: state?.branch ?? targetBranch,
9118
+ createdAt: state?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
9119
+ members: Array.from(byId.values())
9120
+ });
7714
9121
  }
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";
7721
- try {
7722
- const scan = await scanProject(absDir);
7723
- if (scan.repoRoles[0]) role = scan.repoRoles[0];
7724
- } catch {
9122
+ return { ok: results.every((r) => r.status !== "failed"), dryRun: false, slug, results };
9123
+ }
9124
+
9125
+ // src/workspace/worktree/list.ts
9126
+ import path60 from "node:path";
9127
+ import fs41 from "fs-extra";
9128
+ async function runList2() {
9129
+ const resolved = await resolveWorkspaceForWorktree();
9130
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
9131
+ const { rootInfo, workspaceRoot } = resolved;
9132
+ const dir = worktreesDir(workspaceRoot);
9133
+ if (!await fs41.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
9134
+ const entries = await fs41.readdir(dir, { withFileTypes: true });
9135
+ const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
9136
+ const configuredMembers = await readMembers(rootInfo);
9137
+ const worktrees = [];
9138
+ for (const slug of slugs) {
9139
+ const wtPath = path60.join(dir, slug);
9140
+ const state = await readWorktreeState(wtPath);
9141
+ const members = [];
9142
+ const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
9143
+ for (const m of source) {
9144
+ const memberPath = path60.join(wtPath, m.folder);
9145
+ const materialized = await fs41.pathExists(memberPath);
9146
+ const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
9147
+ members.push({
9148
+ id: m.id,
9149
+ folder: m.folder,
9150
+ expectedBranch: m.branch,
9151
+ materialized,
9152
+ actualBranch
9153
+ });
7725
9154
  }
7726
- return { name, path: relDir === "." ? "." : relDir, role };
7727
- });
9155
+ worktrees.push({
9156
+ slug,
9157
+ branch: state?.branch ?? await currentBranch(wtPath) ?? "unknown",
9158
+ createdAt: state?.createdAt,
9159
+ members
9160
+ });
9161
+ }
9162
+ return { ok: true, workspaceRoot, worktrees };
9163
+ }
9164
+
9165
+ // src/workspace/worktree/remove.ts
9166
+ import path61 from "node:path";
9167
+ import fs42 from "fs-extra";
9168
+ async function runRemove(opts) {
9169
+ const resolved = await resolveWorkspaceForWorktree();
9170
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
9171
+ const { rootInfo, workspaceRoot } = resolved;
9172
+ const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
9173
+ if (!await fs42.pathExists(wsWorktreePath)) {
9174
+ return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
9175
+ }
9176
+ const state = await readWorktreeState(wsWorktreePath);
9177
+ const allMembers = await readMembers(rootInfo);
9178
+ 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 }));
9179
+ const targets = [
9180
+ { repo: "(workspace)", ownerRepo: workspaceRoot, path: wsWorktreePath }
9181
+ ];
9182
+ const unresolvedDroppedMembers = [];
9183
+ for (const stateMember of stateMembers) {
9184
+ const configMember = allMembers.find((m) => m.folder === stateMember.folder);
9185
+ const ownerRepo = configMember?.absPath ?? stateMember.absPath;
9186
+ if (!ownerRepo) {
9187
+ if (!configMember) unresolvedDroppedMembers.push(stateMember.folder);
9188
+ continue;
9189
+ }
9190
+ targets.push({
9191
+ repo: configMember?.id ?? stateMember.id,
9192
+ ownerRepo,
9193
+ path: path61.join(wsWorktreePath, stateMember.folder)
9194
+ });
9195
+ }
9196
+ const existingTargets = [];
9197
+ for (const target of targets) {
9198
+ if (await fs42.pathExists(target.path)) existingTargets.push(target);
9199
+ }
9200
+ if (!opts.force) {
9201
+ const blockers = [];
9202
+ for (const folder of unresolvedDroppedMembers) {
9203
+ blockers.push({
9204
+ repo: folder,
9205
+ 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)"
9206
+ });
9207
+ }
9208
+ for (const target of existingTargets) {
9209
+ const ignorePaths = target.repo === "(workspace)" ? [WORKTREE_STATE_FILE, ...stateMembers.map((m) => m.folder)] : [];
9210
+ if (await hasUncommittedChanges(target.path, ignorePaths)) {
9211
+ blockers.push({ repo: target.repo, reason: "uncommitted changes" });
9212
+ continue;
9213
+ }
9214
+ const unpushed = await hasUnpushedWork(target.path);
9215
+ if (unpushed.unpushed) blockers.push({ repo: target.repo, reason: unpushed.reason });
9216
+ }
9217
+ if (blockers.length > 0) return { ok: false, blocked: true, blockers };
9218
+ }
9219
+ if (opts.dryRun) {
9220
+ return {
9221
+ ok: true,
9222
+ dryRun: true,
9223
+ slug: opts.slug,
9224
+ wouldRemove: existingTargets.map((t) => t.repo)
9225
+ };
9226
+ }
9227
+ const removed = [];
9228
+ const failed = [];
9229
+ for (const folder of unresolvedDroppedMembers) {
9230
+ failed.push({
9231
+ repo: folder,
9232
+ 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"
9233
+ });
9234
+ }
9235
+ const memberTargets = existingTargets.filter((t) => t.repo !== "(workspace)");
9236
+ for (const target of memberTargets) {
9237
+ const result = await removeWorktree(target.ownerRepo, target.path);
9238
+ if (result.ok) removed.push(target.repo);
9239
+ else failed.push({ repo: target.repo, error: result.stderr ?? "unknown error" });
9240
+ await pruneWorktrees(target.ownerRepo);
9241
+ }
9242
+ const wsTarget = existingTargets.find((t) => t.repo === "(workspace)");
9243
+ if (wsTarget) {
9244
+ const wsResult = await removeWorktree(workspaceRoot, wsWorktreePath);
9245
+ if (wsResult.ok) removed.push("(workspace)");
9246
+ else failed.push({ repo: "(workspace)", error: wsResult.stderr ?? "unknown error" });
9247
+ await pruneWorktrees(workspaceRoot);
9248
+ }
9249
+ if (await fs42.pathExists(wsWorktreePath)) {
9250
+ await fs42.remove(wsWorktreePath);
9251
+ }
9252
+ return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
7728
9253
  }
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 });
9254
+
9255
+ // src/commands/workspace/worktree.ts
9256
+ function normalizeOnly(only) {
9257
+ if (!only) return void 0;
9258
+ const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
9259
+ const cleaned = list.map((s) => s.trim()).filter(Boolean);
9260
+ return cleaned.length > 0 ? cleaned : void 0;
9261
+ }
9262
+ function describeInstall(hydration) {
9263
+ if (hydration.install.plan.manager === "none") return "no install step (no recognized lockfile)";
9264
+ if (!hydration.install.ran)
9265
+ return `install skipped (${hydration.install.skippedReason ?? "unavailable"})`;
9266
+ return hydration.install.ok ? `hydrated via ${hydration.install.plan.manager}` : `install FAILED via ${hydration.install.plan.manager}`;
9267
+ }
9268
+ async function handleAdd(opts) {
9269
+ if (!opts.slug) {
9270
+ error("Usage: haus workspace worktree add <slug>");
9271
+ process.exitCode = 1;
9272
+ return;
9273
+ }
9274
+ const addOpts = {
9275
+ slug: opts.slug,
9276
+ branch: opts.branch,
9277
+ only: normalizeOnly(opts.only),
9278
+ hydrate: opts.hydrate !== false,
9279
+ dryRun: Boolean(opts.dryRun)
9280
+ };
9281
+ const result = await runAdd(addOpts);
9282
+ if ("error" in result) {
9283
+ error(result.error);
9284
+ process.exitCode = 1;
9285
+ return;
9286
+ }
9287
+ if (result.dryRun) {
9288
+ log(
9289
+ `[dry-run] Would create the workspace worktree at ${result.worktreePath} on branch "${result.branch}" (${result.workspaceWorktree.branchAction}).`
9290
+ );
9291
+ for (const m of result.members) {
9292
+ log(
9293
+ `[dry-run] ${m.member} (${m.folder}): ${m.branchAction}${m.startPoint ? ` from ${m.startPoint}` : ""}`
9294
+ );
7735
9295
  }
9296
+ return;
7736
9297
  }
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);
9298
+ log(
9299
+ `Workspace worktree "${result.slug}" ready at ${result.worktreePath} (branch "${result.branch}", ${result.workspaceWorktree.branchAction}).`
9300
+ );
9301
+ for (const m of result.members) {
9302
+ if (m.status === "ok") {
9303
+ const hydrationNote = m.hydration ? describeInstall(m.hydration) : "not hydrated (--no-hydrate)";
9304
+ log(` ${m.member}: ${m.branchAction} "${m.branch}" \u2014 ${hydrationNote} (${m.durationMs}ms)`);
9305
+ } else {
9306
+ warn(` ${m.member}: FAILED \u2014 ${m.error}`);
9307
+ }
9308
+ }
9309
+ if (!result.ok) {
9310
+ const failedCount = result.members.filter((m) => m.status === "failed").length;
9311
+ error(
9312
+ `${failedCount} member(s) failed to materialize \u2014 the worktree is partially built. See the per-member detail above for exactly what exists.`
9313
+ );
9314
+ process.exitCode = 1;
9315
+ }
9316
+ }
9317
+ async function handleHydrate(opts) {
9318
+ const hydrateOpts = {
9319
+ only: normalizeOnly(opts.only),
9320
+ force: Boolean(opts.force),
9321
+ dryRun: Boolean(opts.dryRun),
9322
+ materialize: Boolean(opts.materialize)
9323
+ };
9324
+ const result = await runHydrateWorktree(hydrateOpts);
9325
+ if (!result.ok && "error" in result) {
9326
+ error(result.error);
9327
+ process.exitCode = 1;
9328
+ return;
9329
+ }
9330
+ if (result.dryRun) {
9331
+ log(`[dry-run] Would hydrate "${result.slug}":`);
9332
+ for (const r of result.results) {
9333
+ const action = r.branchAction ? ` (${r.branchAction} member worktree first)` : "";
9334
+ log(`[dry-run] ${r.member}: ${r.status}${action}${r.error ? ` (${r.error})` : ""}`);
9335
+ }
9336
+ return;
9337
+ }
9338
+ log(`Hydrated "${result.slug}":`);
9339
+ for (const r of result.results) {
9340
+ if (r.status === "ok" && r.hydration) {
9341
+ const materializedNote = r.branchAction ? `materialized (${r.branchAction}), ` : "";
9342
+ log(` ${r.member}: ${materializedNote}${describeInstall(r.hydration)}`);
9343
+ } else {
9344
+ warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
9345
+ }
7742
9346
  }
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);
9347
+ if (!result.ok) process.exitCode = 1;
9348
+ }
9349
+ async function handleRemove(opts) {
9350
+ if (!opts.slug) {
9351
+ error("Usage: haus workspace worktree remove <slug>");
9352
+ process.exitCode = 1;
9353
+ return;
7747
9354
  }
7748
- return {
7749
- client: opts.client ?? existing?.client ?? "unknown",
7750
- repos: ordered,
7751
- relationships: existing?.relationships ?? []
9355
+ const removeOpts = {
9356
+ slug: opts.slug,
9357
+ force: Boolean(opts.force),
9358
+ dryRun: Boolean(opts.dryRun)
7752
9359
  };
7753
- }
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
- });
7760
- }
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) {
7766
- error(
7767
- "Existing haus.workspace.yaml is malformed \u2014 fix or remove it before running discover (refusing to overwrite)."
7768
- );
9360
+ const result = await runRemove(removeOpts);
9361
+ if (!result.ok && "error" in result) {
9362
+ error(result.error);
7769
9363
  process.exitCode = 1;
7770
9364
  return;
7771
9365
  }
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.");
9366
+ if (!result.ok && "blocked" in result && result.blocked) {
9367
+ error(`Refusing to remove "${opts.slug}" \u2014 uncommitted or unpushed work found:`);
9368
+ for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
9369
+ error("Re-run with --force to remove anyway.");
7775
9370
  process.exitCode = 1;
7776
9371
  return;
7777
9372
  }
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`);
9373
+ if ("dryRun" in result && result.dryRun) {
9374
+ log(`[dry-run] Would remove: ${result.wouldRemove.join(", ") || "(nothing)"}`);
9375
+ return;
9376
+ }
9377
+ if ("removed" in result) {
9378
+ log(`Removed: ${result.removed.join(", ") || "(nothing)"}`);
9379
+ if (result.failed.length > 0) {
9380
+ for (const f of result.failed) error(` Failed to remove ${f.repo}: ${f.error}`);
9381
+ process.exitCode = 1;
7786
9382
  }
9383
+ }
9384
+ }
9385
+ async function handleList() {
9386
+ const result = await runList2();
9387
+ if (!result.ok) {
9388
+ error(result.error);
9389
+ process.exitCode = 1;
7787
9390
  return;
7788
9391
  }
7789
- if (opts.json) {
7790
- log(JSON.stringify({ discovered, config: merged }, null, 2));
9392
+ if (result.worktrees.length === 0) {
9393
+ log("No workspace worktrees under .claude/worktrees/.");
7791
9394
  return;
7792
9395
  }
7793
- log("Proposed haus.workspace.yaml (run with --write to persist):\n");
7794
- log(yamlText);
9396
+ for (const wt of result.worktrees) {
9397
+ log(`${wt.slug} (branch: ${wt.branch})`);
9398
+ for (const m of wt.members) {
9399
+ const status = m.materialized ? `ok, branch ${m.actualBranch ?? "unknown"}` : "MISSING";
9400
+ log(` ${m.id}: ${status}`);
9401
+ }
9402
+ }
9403
+ }
9404
+ function emitFromHookContext(additionalContext) {
9405
+ log(
9406
+ JSON.stringify({
9407
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext }
9408
+ })
9409
+ );
9410
+ }
9411
+ async function handleDoctor(opts) {
9412
+ try {
9413
+ const report = await runDoctor2();
9414
+ if (!report.isWorkspace) {
9415
+ if (!opts.fromHook) {
9416
+ warn(report.problems[0] ?? "Not a haus workspace.");
9417
+ process.exitCode = 1;
9418
+ }
9419
+ return;
9420
+ }
9421
+ if (report.problems.length === 0) {
9422
+ if (!opts.fromHook) {
9423
+ log(
9424
+ report.inWorkspaceWorktree ? `Workspace worktree "${report.slug}" looks healthy.` : "No issues found."
9425
+ );
9426
+ }
9427
+ return;
9428
+ }
9429
+ if (opts.fromHook) {
9430
+ const scope = report.slug ? `Workspace worktree "${report.slug}"` : "This haus workspace";
9431
+ emitFromHookContext(
9432
+ `${scope} has ${report.problems.length} issue(s): ${report.problems.join("; ")}`
9433
+ );
9434
+ return;
9435
+ }
9436
+ for (const p of report.problems) warn(p);
9437
+ process.exitCode = 1;
9438
+ } catch (err) {
9439
+ const message = err instanceof Error ? err.message : String(err);
9440
+ if (opts.fromHook) {
9441
+ emitFromHookContext(`\`haus workspace worktree doctor\` failed: ${message}`);
9442
+ } else {
9443
+ error(message);
9444
+ process.exitCode = 1;
9445
+ }
9446
+ }
9447
+ }
9448
+ async function runWorktree(action, opts = {}) {
9449
+ switch (action) {
9450
+ case "add":
9451
+ return handleAdd(opts);
9452
+ case "hydrate":
9453
+ return handleHydrate(opts);
9454
+ case "remove":
9455
+ return handleRemove(opts);
9456
+ case "list":
9457
+ return handleList();
9458
+ case "doctor":
9459
+ return handleDoctor(opts);
9460
+ }
9461
+ }
9462
+
9463
+ // src/commands/workspace.ts
9464
+ import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
9465
+ import path70 from "node:path";
9466
+
9467
+ // src/commands/workspace/aggregate.ts
9468
+ async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
9469
+ const summaries = repos.map((repo) => ({
9470
+ name: repo.name,
9471
+ path: repo.path,
9472
+ roles: repo.context.repoRoles ?? [],
9473
+ packageManager: repo.context.packageManager,
9474
+ deps: repo.context.dependencies ?? []
9475
+ }));
9476
+ const ownership = {};
9477
+ for (const repo of summaries) {
9478
+ for (const dep2 of repo.deps) {
9479
+ ownership[dep2] ??= [];
9480
+ ownership[dep2].push(repo.name);
9481
+ }
9482
+ }
9483
+ const roles = [...new Set(summaries.flatMap((r) => r.roles))].sort();
9484
+ const crossRepoHints = [...new Set(repos.flatMap((r) => r.context.crossRepoHints ?? []))].sort();
9485
+ const summaryPath = hausPath(workspaceRoot, "workspace-summary.json");
9486
+ const ownershipPath = hausPath(workspaceRoot, "dependency-ownership-map.json");
9487
+ const crossRepoPath = hausPath(workspaceRoot, "cross-repo-summary.md");
9488
+ const contextMapPath = hausPath(workspaceRoot, "workspace-context-map.json");
9489
+ await writeJson(summaryPath, {
9490
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9491
+ repos: summaries
9492
+ });
9493
+ await writeJson(ownershipPath, ownership);
9494
+ await writeText(
9495
+ crossRepoPath,
9496
+ `# Cross Repo Summary
9497
+
9498
+ ${summaries.map(
9499
+ (repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
9500
+ ).join("\n")}
9501
+ `
9502
+ );
9503
+ await writeJson(contextMapPath, {
9504
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9505
+ roles,
9506
+ crossRepoHints,
9507
+ repos: summaries.map((r) => ({
9508
+ name: r.name,
9509
+ path: r.path,
9510
+ roles: r.roles,
9511
+ packageManager: r.packageManager
9512
+ })),
9513
+ relationships
9514
+ });
9515
+ return [summaryPath, ownershipPath, crossRepoPath, contextMapPath];
7795
9516
  }
7796
9517
 
7797
9518
  // src/commands/workspace/doctor.ts
7798
- import { existsSync as existsSync3 } from "node:fs";
7799
- import path48 from "node:path";
9519
+ import { existsSync as existsSync7 } from "node:fs";
9520
+ import path63 from "node:path";
7800
9521
 
7801
9522
  // src/commands/workspace/manifest.ts
7802
- import { readFileSync as readFileSync3 } from "node:fs";
7803
- import path47 from "node:path";
9523
+ import { readFileSync as readFileSync4 } from "node:fs";
9524
+ import path62 from "node:path";
7804
9525
  var MANIFEST_FILE = "workspace.manifest.json";
7805
9526
  function manifestPath(workspaceRoot) {
7806
9527
  return hausPath(workspaceRoot, MANIFEST_FILE);
7807
9528
  }
7808
9529
  function hausVersion() {
7809
9530
  try {
7810
- const pkg = JSON.parse(readFileSync3(path47.join(packageRoot(), "package.json"), "utf8"));
9531
+ const pkg = JSON.parse(readFileSync4(path62.join(packageRoot(), "package.json"), "utf8"));
7811
9532
  return pkg.version ?? "0.0.0";
7812
9533
  } catch {
7813
9534
  return "0.0.0";
@@ -7831,7 +9552,8 @@ function buildManifest2(opts) {
7831
9552
  catalogRef: repo.catalogRef,
7832
9553
  status: repo.status,
7833
9554
  ...repo.error ? { error: repo.error } : {}
7834
- }))
9555
+ })),
9556
+ ...opts.linkedContext ? { linkedContext: opts.linkedContext } : {}
7835
9557
  };
7836
9558
  }
7837
9559
  async function readManifest2(workspaceRoot) {
@@ -7842,6 +9564,22 @@ async function writeWorkspaceManifest(workspaceRoot, manifest) {
7842
9564
  await writeJson(target, manifest);
7843
9565
  return target;
7844
9566
  }
9567
+ async function readLinkedContext(workspaceRoot) {
9568
+ const manifest = await readManifest2(workspaceRoot);
9569
+ return manifest?.linkedContext ?? [];
9570
+ }
9571
+ async function writeLinkedContext(workspaceRoot, linkedContext) {
9572
+ const prior = await readManifest2(workspaceRoot);
9573
+ const next = prior ? { ...prior, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), linkedContext } : {
9574
+ version: 1,
9575
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9576
+ hausVersion: hausVersion(),
9577
+ client: "unknown",
9578
+ repos: [],
9579
+ linkedContext
9580
+ };
9581
+ return writeWorkspaceManifest(workspaceRoot, next);
9582
+ }
7845
9583
 
7846
9584
  // src/commands/workspace/doctor.ts
7847
9585
  async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
@@ -7874,7 +9612,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
7874
9612
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
7875
9613
  const catalogRefByRepo = [];
7876
9614
  for (const repo of config.repos) {
7877
- const repoRoot = path48.resolve(workspaceRoot, repo.path);
9615
+ const repoRoot = path63.resolve(workspaceRoot, repo.path);
7878
9616
  const entry = manifestByName.get(repo.name);
7879
9617
  if (!entry) {
7880
9618
  flag({
@@ -7899,7 +9637,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
7899
9637
  detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
7900
9638
  });
7901
9639
  }
7902
- if (!existsSync3(claudePath(repoRoot))) {
9640
+ if (!existsSync7(claudePath(repoRoot))) {
7903
9641
  flag({
7904
9642
  repo: repo.name,
7905
9643
  kind: "missing-claude",
@@ -7908,7 +9646,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
7908
9646
  }
7909
9647
  const lock = await checkLock(repoRoot);
7910
9648
  if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
7911
- if (!existsSync3(hausPath(repoRoot, "haus.lock.json"))) {
9649
+ if (!existsSync7(hausPath(repoRoot, "haus.lock.json"))) {
7912
9650
  flag({
7913
9651
  repo: repo.name,
7914
9652
  kind: "missing-lock",
@@ -7933,6 +9671,40 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
7933
9671
  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
9672
  });
7935
9673
  }
9674
+ if (manifest.linkedContext && manifest.linkedContext.length > 0) {
9675
+ const rootInfo = await resolveRoots(workspaceRoot);
9676
+ const members = await readMembers(rootInfo);
9677
+ const memberById = new Map(members.map((m) => [m.id, m]));
9678
+ for (const entry of manifest.linkedContext) {
9679
+ const member = memberById.get(entry.repo);
9680
+ if (!member || !existsSync7(member.absPath)) {
9681
+ flag({
9682
+ repo: entry.repo,
9683
+ kind: "missing-linked-context-source",
9684
+ 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.`
9685
+ });
9686
+ continue;
9687
+ }
9688
+ if (!existsSync7(path63.join(workspaceRoot, entry.path))) {
9689
+ flag({
9690
+ repo: entry.repo,
9691
+ kind: "missing-linked-context-copy",
9692
+ 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.`
9693
+ });
9694
+ continue;
9695
+ }
9696
+ const liveHash = await hashInstalledPaths(member.absPath, [entry.sourceRelPath], {
9697
+ followSymlinks: false
9698
+ });
9699
+ if (liveHash !== entry.sourceHash) {
9700
+ flag({
9701
+ repo: entry.repo,
9702
+ kind: "stale-linked-context",
9703
+ 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\`.`
9704
+ });
9705
+ }
9706
+ }
9707
+ }
7936
9708
  return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
7937
9709
  }
7938
9710
  function emit(args) {
@@ -7955,13 +9727,256 @@ function emit(args) {
7955
9727
  return { workspaceRoot, manifest, drift };
7956
9728
  }
7957
9729
 
9730
+ // src/workspace/link-context/link.ts
9731
+ import path66 from "node:path";
9732
+ import fs45 from "fs-extra";
9733
+
9734
+ // src/workspace/link-context/plan.ts
9735
+ import path65 from "node:path";
9736
+ import fs44 from "fs-extra";
9737
+
9738
+ // src/workspace/link-context/scan-source.ts
9739
+ import path64 from "node:path";
9740
+ import fs43 from "fs-extra";
9741
+ var ASSET_SUBDIR = {
9742
+ skill: "skills",
9743
+ agent: "agents",
9744
+ command: "commands"
9745
+ };
9746
+ async function hasLinkableContext(member) {
9747
+ for (const subdir of Object.values(ASSET_SUBDIR)) {
9748
+ if (await fs43.pathExists(path64.join(member.absPath, ".claude", subdir))) return true;
9749
+ }
9750
+ return false;
9751
+ }
9752
+ async function collectSkills(member) {
9753
+ const dir = path64.join(member.absPath, ".claude", "skills");
9754
+ if (!await fs43.pathExists(dir)) return [];
9755
+ const names = await fs43.readdir(dir);
9756
+ const out = [];
9757
+ for (const name of [...names].sort()) {
9758
+ const abs = path64.join(dir, name);
9759
+ const stat = await fs43.lstat(abs);
9760
+ if (!stat.isDirectory()) continue;
9761
+ if (!await fs43.pathExists(path64.join(abs, "SKILL.md"))) continue;
9762
+ out.push({ type: "skill", name, sourceRelPath: path64.posix.join(".claude", "skills", name) });
9763
+ }
9764
+ return out;
9765
+ }
9766
+ async function collectFlatMarkdown(member, type) {
9767
+ const subdir = ASSET_SUBDIR[type];
9768
+ const dir = path64.join(member.absPath, ".claude", subdir);
9769
+ if (!await fs43.pathExists(dir)) return [];
9770
+ const names = await fs43.readdir(dir);
9771
+ const out = [];
9772
+ for (const fileName of [...names].sort()) {
9773
+ if (!fileName.endsWith(".md")) continue;
9774
+ const abs = path64.join(dir, fileName);
9775
+ if (!(await fs43.lstat(abs)).isFile()) continue;
9776
+ const name = fileName.slice(0, -".md".length);
9777
+ out.push({ type, name, sourceRelPath: path64.posix.join(".claude", subdir, fileName) });
9778
+ }
9779
+ return out;
9780
+ }
9781
+ async function collectSourceAssets(member) {
9782
+ const [skills, agents, commands] = await Promise.all([
9783
+ collectSkills(member),
9784
+ collectFlatMarkdown(member, "agent"),
9785
+ collectFlatMarkdown(member, "command")
9786
+ ]);
9787
+ return [...skills, ...agents, ...commands];
9788
+ }
9789
+
9790
+ // src/workspace/link-context/plan.ts
9791
+ function repoFolderPrefix(member) {
9792
+ const base = path65.posix.basename(member.folder);
9793
+ if (base && base !== "." && base !== "/") return base;
9794
+ const fromAbs = path65.basename(member.absPath);
9795
+ return fromAbs || member.id;
9796
+ }
9797
+ function assetKey(type, destKey) {
9798
+ return `${type}:${destKey}`;
9799
+ }
9800
+ function splitAssetKey(key) {
9801
+ const idx = key.indexOf(":");
9802
+ return { type: key.slice(0, idx), destKey: key.slice(idx + 1) };
9803
+ }
9804
+ async function buildLinkPlan(members) {
9805
+ const skipped = [];
9806
+ const byKey = /* @__PURE__ */ new Map();
9807
+ const membersById = new Map(members.map((m) => [m.id, m]));
9808
+ for (const member of members) {
9809
+ if (!await fs44.pathExists(member.absPath)) {
9810
+ skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
9811
+ continue;
9812
+ }
9813
+ if (!await hasLinkableContext(member)) {
9814
+ skipped.push({
9815
+ repo: member.id,
9816
+ reason: "no .claude/skills, .claude/agents, or .claude/commands \u2014 nothing to link"
9817
+ });
9818
+ continue;
9819
+ }
9820
+ const assets = await collectSourceAssets(member);
9821
+ const prefix = repoFolderPrefix(member);
9822
+ for (const asset of assets) {
9823
+ const destKey = `${prefix}--${asset.name}`;
9824
+ const key = assetKey(asset.type, destKey);
9825
+ const list = byKey.get(key) ?? [];
9826
+ list.push({ repo: member.id, asset });
9827
+ byKey.set(key, list);
9828
+ }
9829
+ }
9830
+ const collisions = [];
9831
+ const entries = [];
9832
+ for (const [key, list] of byKey) {
9833
+ const { type, destKey } = splitAssetKey(key);
9834
+ const distinctRepos = [...new Set(list.map((l) => l.repo))];
9835
+ if (distinctRepos.length > 1) {
9836
+ collisions.push({ type, destKey, repos: distinctRepos });
9837
+ continue;
9838
+ }
9839
+ const { repo, asset } = list[0];
9840
+ const member = membersById.get(repo);
9841
+ if (!member) continue;
9842
+ const sourceHash = await hashInstalledPaths(member.absPath, [asset.sourceRelPath], {
9843
+ followSymlinks: false
9844
+ });
9845
+ entries.push({
9846
+ repo,
9847
+ type,
9848
+ name: asset.name,
9849
+ destKey,
9850
+ sourceRelPath: asset.sourceRelPath,
9851
+ sourceAbsPath: path65.join(member.absPath, asset.sourceRelPath),
9852
+ sourceHash
9853
+ });
9854
+ }
9855
+ return { entries, skipped, collisions };
9856
+ }
9857
+
9858
+ // src/workspace/link-context/link.ts
9859
+ function destRelPathFor(type, destKey) {
9860
+ const subdir = `${type}s`;
9861
+ const leaf = type === "skill" ? destKey : `${destKey}.md`;
9862
+ return path66.posix.join(".claude", subdir, leaf);
9863
+ }
9864
+ async function runLinkContext(workspaceRoot, opts = {}) {
9865
+ const write = opts.write ?? false;
9866
+ const say = opts.json ? () => {
9867
+ } : log;
9868
+ const rootInfo = await resolveRoots(workspaceRoot);
9869
+ const members = await readMembers(rootInfo);
9870
+ const plan = await buildLinkPlan(members);
9871
+ if (plan.collisions.length > 0) {
9872
+ const detail = plan.collisions.map((c) => `${c.type} "${c.destKey}" claimed by: ${c.repos.join(", ")}`).join("; ");
9873
+ return {
9874
+ ok: false,
9875
+ 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}`,
9876
+ collisions: plan.collisions
9877
+ };
9878
+ }
9879
+ for (const s of plan.skipped) say(` skip ${s.repo}: ${s.reason}`);
9880
+ const priorManifest = await readManifest2(workspaceRoot);
9881
+ const prior = priorManifest?.linkedContext ?? [];
9882
+ const priorPaths = new Set(prior.map((e) => e.path));
9883
+ const nextEntries = [];
9884
+ const added = [];
9885
+ const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
9886
+ for (const entry of plan.entries) {
9887
+ const destRel = destRelPathFor(entry.type, entry.destKey);
9888
+ const destAbs = path66.join(workspaceRoot, destRel);
9889
+ if (write) {
9890
+ await fs45.ensureDir(path66.dirname(destAbs));
9891
+ if (await fs45.pathExists(destAbs)) await fs45.remove(destAbs);
9892
+ await fs45.copy(entry.sourceAbsPath, destAbs, {
9893
+ overwrite: true,
9894
+ errorOnExist: false,
9895
+ dereference: false,
9896
+ filter: (s) => {
9897
+ try {
9898
+ return !fs45.lstatSync(s).isSymbolicLink();
9899
+ } catch {
9900
+ return false;
9901
+ }
9902
+ }
9903
+ });
9904
+ }
9905
+ if (!priorPaths.has(destRel)) added.push(destRel);
9906
+ nextEntries.push({
9907
+ repo: entry.repo,
9908
+ type: entry.type,
9909
+ name: entry.name,
9910
+ path: destRel,
9911
+ sourceRelPath: entry.sourceRelPath,
9912
+ sourceHash: entry.sourceHash,
9913
+ linkedAt
9914
+ });
9915
+ }
9916
+ const nextPaths = new Set(nextEntries.map((e) => e.path));
9917
+ const removed = [];
9918
+ for (const old of prior) {
9919
+ if (nextPaths.has(old.path)) continue;
9920
+ removed.push(old.path);
9921
+ if (write) {
9922
+ const abs = path66.join(workspaceRoot, old.path);
9923
+ if (await fs45.pathExists(abs)) await fs45.remove(abs);
9924
+ }
9925
+ }
9926
+ if (write) {
9927
+ await writeLinkedContext(workspaceRoot, nextEntries);
9928
+ await writeLinkContextGitignore(workspaceRoot, false);
9929
+ }
9930
+ return {
9931
+ ok: true,
9932
+ workspaceRoot,
9933
+ dryRun: !write,
9934
+ linked: nextEntries,
9935
+ added,
9936
+ removed,
9937
+ skipped: plan.skipped
9938
+ };
9939
+ }
9940
+
9941
+ // src/commands/workspace/link-context.ts
9942
+ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
9943
+ const options = { write: opts.write, json: opts.json };
9944
+ const result = await runLinkContext(workspaceRoot, options);
9945
+ if (!result.ok) {
9946
+ if (opts.json) {
9947
+ log(JSON.stringify(result, null, 2));
9948
+ } else {
9949
+ error(result.error);
9950
+ }
9951
+ process.exitCode = 1;
9952
+ return;
9953
+ }
9954
+ if (opts.json) {
9955
+ log(JSON.stringify(result, null, 2));
9956
+ return;
9957
+ }
9958
+ for (const s of result.skipped) warn(` skip ${s.repo}: ${s.reason}`);
9959
+ if (result.dryRun) {
9960
+ log(
9961
+ `[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.`
9962
+ );
9963
+ return;
9964
+ }
9965
+ log(
9966
+ `Linked ${result.linked.length} skill/agent/command copy(ies) into .claude/ (${result.added.length} new, ${result.removed.length} removed).`
9967
+ );
9968
+ log(
9969
+ "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."
9970
+ );
9971
+ }
9972
+
7958
9973
  // src/commands/workspace/setup.ts
7959
- import { existsSync as existsSync4, statSync } from "node:fs";
7960
- import path50 from "node:path";
9974
+ import { existsSync as existsSync8, statSync } from "node:fs";
9975
+ import path68 from "node:path";
7961
9976
 
7962
9977
  // src/claude/write-workspace-claude-md.ts
7963
- import path49 from "node:path";
7964
- import fs36 from "fs-extra";
9978
+ import path67 from "node:path";
9979
+ import fs46 from "fs-extra";
7965
9980
  function buildWorkspaceImportBlock(client, members) {
7966
9981
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
7967
9982
  const body = [
@@ -7979,11 +9994,11 @@ ${BLOCK_END}`;
7979
9994
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
7980
9995
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
7981
9996
  const dryRun = opts.dryRun ?? false;
7982
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path49.join(workspaceRoot, "CLAUDE.md");
9997
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path67.join(workspaceRoot, "CLAUDE.md");
7983
9998
  const say = opts.quiet ? () => {
7984
9999
  } : log;
7985
- const existed = await fs36.pathExists(filePath);
7986
- const prev = existed ? await fs36.readFile(filePath, "utf8") : "";
10000
+ const existed = await fs46.pathExists(filePath);
10001
+ const prev = existed ? await fs46.readFile(filePath, "utf8") : "";
7987
10002
  const next = opts.collision ? `${block}
7988
10003
  ` : injectHausBlock(prev, block);
7989
10004
  const printable = displayPath(workspaceRoot, filePath);
@@ -8010,20 +10025,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
8010
10025
 
8011
10026
  // src/commands/workspace/setup.ts
8012
10027
  function resolveWorkspaceRoot(start = process.cwd()) {
8013
- let dir = path50.resolve(start);
10028
+ let dir = path68.resolve(start);
8014
10029
  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);
10030
+ if (existsSync8(path68.join(dir, WORKSPACE_FILE))) return dir;
10031
+ const parent = path68.dirname(dir);
10032
+ if (parent === dir) return path68.resolve(start);
8018
10033
  dir = parent;
8019
10034
  }
8020
10035
  }
8021
10036
  function isRootRepo(workspaceRoot, repoPath) {
8022
- return path50.resolve(workspaceRoot, repoPath) === path50.resolve(workspaceRoot);
10037
+ return path68.resolve(workspaceRoot, repoPath) === path68.resolve(workspaceRoot);
8023
10038
  }
8024
10039
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
8025
10040
  const apply = options.write ?? false;
8026
- const configText = await readText(path50.join(workspaceRoot, WORKSPACE_FILE));
10041
+ const configText = await readText(path68.join(workspaceRoot, WORKSPACE_FILE));
8027
10042
  if (!configText) {
8028
10043
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
8029
10044
  process.exitCode = 1;
@@ -8050,11 +10065,11 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
8050
10065
  const statuses = [];
8051
10066
  const aggregateInputs = [];
8052
10067
  for (const repo of repos) {
8053
- const repoRoot = path50.resolve(workspaceRoot, repo.path);
10068
+ const repoRoot = path68.resolve(workspaceRoot, repo.path);
8054
10069
  say(`
8055
10070
  \u2192 ${repo.name} (${repo.path})`);
8056
10071
  try {
8057
- if (!existsSync4(repoRoot) || !statSync(repoRoot).isDirectory()) {
10072
+ if (!existsSync8(repoRoot) || !statSync(repoRoot).isDirectory()) {
8058
10073
  throw new Error(`Repo path is not a directory: ${repo.path}`);
8059
10074
  }
8060
10075
  const res = await runSetupCore(repoRoot, {
@@ -8110,7 +10125,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
8110
10125
  if (apply && !options.dryRun) {
8111
10126
  const statusByName = new Map(statuses.map((s) => [s.name, s]));
8112
10127
  const prior = await readManifest2(workspaceRoot);
8113
- if (!prior && existsSync4(manifestPath(workspaceRoot))) {
10128
+ if (!prior && existsSync8(manifestPath(workspaceRoot))) {
8114
10129
  warn(
8115
10130
  "Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
8116
10131
  );
@@ -8121,7 +10136,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
8121
10136
  const status = statusByName.get(repo.name);
8122
10137
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
8123
10138
  if (status?.status === "ok") {
8124
- const lock = await checkLock(path50.resolve(workspaceRoot, repo.path));
10139
+ const lock = await checkLock(path68.resolve(workspaceRoot, repo.path));
8125
10140
  manifestRepos.push({
8126
10141
  name: repo.name,
8127
10142
  path: repo.path,
@@ -8164,9 +10179,33 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
8164
10179
  );
8165
10180
  }
8166
10181
  }
8167
- const manifest = buildManifest2({ client: config.client, repos: manifestRepos });
10182
+ const manifest = buildManifest2({
10183
+ client: config.client,
10184
+ repos: manifestRepos,
10185
+ // buildManifest rebuilds the manifest from scratch each run — carry the prior
10186
+ // `linkedContext` section forward so a plain `setup --write` never silently
10187
+ // drops entries `link-context` wrote on an earlier run.
10188
+ linkedContext: prior?.linkedContext
10189
+ });
8168
10190
  const manifestFile = await writeWorkspaceManifest(workspaceRoot, manifest);
8169
10191
  written.push(manifestFile);
10192
+ try {
10193
+ const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
10194
+ if (linkResult.ok) {
10195
+ written.push(...linkResult.added.map((rel) => path68.join(workspaceRoot, rel)));
10196
+ if (!options.json) {
10197
+ say(
10198
+ `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` : ""}.`
10199
+ );
10200
+ }
10201
+ } else {
10202
+ warn(`Skipping workspace link-context: ${linkResult.error}`);
10203
+ }
10204
+ } catch (err) {
10205
+ warn(
10206
+ `Workspace link-context failed: ${err instanceof Error ? err.message : String(err)} \u2014 run \`haus workspace link-context\` directly to retry.`
10207
+ );
10208
+ }
8170
10209
  }
8171
10210
  const ok = statuses.filter((s) => s.status === "ok").length;
8172
10211
  const failed = statuses.length - ok;
@@ -8184,9 +10223,9 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
8184
10223
  }
8185
10224
 
8186
10225
  // 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";
10226
+ import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
10227
+ import path69 from "node:path";
10228
+ import fs47 from "fs-extra";
8190
10229
  var WORKSPACE_AGGREGATE_FILES = [
8191
10230
  "workspace-summary.json",
8192
10231
  "dependency-ownership-map.json",
@@ -8214,11 +10253,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
8214
10253
  }
8215
10254
  let anyRepoFailed = false;
8216
10255
  for (const repo of config.repos) {
8217
- const repoRoot = path51.resolve(workspaceRoot, repo.path);
10256
+ const repoRoot = path69.resolve(workspaceRoot, repo.path);
8218
10257
  log(`
8219
10258
  \u2192 ${repo.name} (${repo.path})`);
8220
10259
  try {
8221
- if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
10260
+ if (!existsSync9(repoRoot) || !statSync2(repoRoot).isDirectory()) {
8222
10261
  throw new Error(`Repo path is not a directory: ${repo.path}`);
8223
10262
  }
8224
10263
  await runUndo({ yes: true, root: repoRoot });
@@ -8227,33 +10266,41 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
8227
10266
  warn(`Undo failed for ${repo.name}: ${err instanceof Error ? err.message : String(err)}`);
8228
10267
  }
8229
10268
  }
10269
+ const linkedContext = await readLinkedContext(workspaceRoot);
10270
+ for (const entry of linkedContext) {
10271
+ const abs = path69.join(workspaceRoot, entry.path);
10272
+ if (await fs47.pathExists(abs)) {
10273
+ await fs47.remove(abs);
10274
+ log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
10275
+ }
10276
+ }
8230
10277
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
8231
10278
  const abs = hausPath(workspaceRoot, rel);
8232
- if (await fs37.pathExists(abs)) {
8233
- await fs37.remove(abs);
8234
- log(`Removed ${path51.relative(workspaceRoot, abs)}`);
10279
+ if (await fs47.pathExists(abs)) {
10280
+ await fs47.remove(abs);
10281
+ log(`Removed ${path69.relative(workspaceRoot, abs)}`);
8235
10282
  }
8236
10283
  }
8237
10284
  const manifestFile = manifestPath(workspaceRoot);
8238
- if (await fs37.pathExists(manifestFile)) {
8239
- await fs37.remove(manifestFile);
8240
- log(`Removed ${path51.relative(workspaceRoot, manifestFile)}`);
10285
+ if (await fs47.pathExists(manifestFile)) {
10286
+ await fs47.remove(manifestFile);
10287
+ log(`Removed ${path69.relative(workspaceRoot, manifestFile)}`);
8241
10288
  }
8242
10289
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
8243
- if (await fs37.pathExists(workspaceMdPath)) {
8244
- await fs37.remove(workspaceMdPath);
8245
- log(`Removed ${path51.relative(workspaceRoot, workspaceMdPath)}`);
10290
+ if (await fs47.pathExists(workspaceMdPath)) {
10291
+ await fs47.remove(workspaceMdPath);
10292
+ log(`Removed ${path69.relative(workspaceRoot, workspaceMdPath)}`);
8246
10293
  } else {
8247
- const claudeMdPath = path51.join(workspaceRoot, "CLAUDE.md");
8248
- if (existsSync5(claudeMdPath)) {
10294
+ const claudeMdPath = path69.join(workspaceRoot, "CLAUDE.md");
10295
+ if (existsSync9(claudeMdPath)) {
8249
10296
  const prev = await readText(claudeMdPath);
8250
10297
  if (prev?.includes(BLOCK_BEGIN)) {
8251
10298
  const next = stripHausBlock(prev);
8252
10299
  if (next.length === 0) {
8253
- await fs37.remove(claudeMdPath);
10300
+ await fs47.remove(claudeMdPath);
8254
10301
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
8255
10302
  } else {
8256
- await fs37.writeFile(claudeMdPath, next, "utf8");
10303
+ await fs47.writeFile(claudeMdPath, next, "utf8");
8257
10304
  log("Removed haus import block from CLAUDE.md (user content preserved).");
8258
10305
  }
8259
10306
  }
@@ -8269,7 +10316,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
8269
10316
  }
8270
10317
 
8271
10318
  // src/commands/workspace.ts
8272
- function normalizeOnly(only) {
10319
+ function normalizeOnly2(only) {
8273
10320
  if (!only) return void 0;
8274
10321
  const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
8275
10322
  const cleaned = list.map((s) => s.trim()).filter(Boolean);
@@ -8296,7 +10343,7 @@ relationships: []
8296
10343
  log("Workspace initialized.");
8297
10344
  }
8298
10345
  async function scanWorkspace(workspaceRoot, opts) {
8299
- const configText = await readText(path52.join(workspaceRoot, WORKSPACE_FILE));
10346
+ const configText = await readText(path70.join(workspaceRoot, WORKSPACE_FILE));
8300
10347
  if (!configText) {
8301
10348
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
8302
10349
  process.exitCode = 1;
@@ -8318,9 +10365,9 @@ async function scanWorkspace(workspaceRoot, opts) {
8318
10365
  const inputs = [];
8319
10366
  const failures = [];
8320
10367
  for (const repo of config.repos) {
8321
- const repoRoot = path52.resolve(workspaceRoot, repo.path);
10368
+ const repoRoot = path70.resolve(workspaceRoot, repo.path);
8322
10369
  try {
8323
- if (!existsSync6(repoRoot) || !statSync3(repoRoot).isDirectory()) {
10370
+ if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
8324
10371
  throw new Error("path is not a directory");
8325
10372
  }
8326
10373
  const result = await scanProject(repoRoot);
@@ -8366,7 +10413,7 @@ async function runWorkspace(action, options = {}) {
8366
10413
  dryRun: options.dryRun,
8367
10414
  json: options.json,
8368
10415
  continueOnError: options.continueOnError,
8369
- only: normalizeOnly(options.only)
10416
+ only: normalizeOnly2(options.only)
8370
10417
  });
8371
10418
  return;
8372
10419
  case "doctor":
@@ -8375,14 +10422,17 @@ async function runWorkspace(action, options = {}) {
8375
10422
  case "undo":
8376
10423
  await runWorkspaceUndo(workspaceRoot, { yes: options.yes });
8377
10424
  return;
10425
+ case "link-context":
10426
+ await runWorkspaceLinkContext(workspaceRoot, { write: options.write, json: options.json });
10427
+ return;
8378
10428
  }
8379
10429
  }
8380
10430
 
8381
10431
  // src/cli.ts
8382
10432
  function cliVersion() {
8383
10433
  try {
8384
- const pkgPath = path53.join(packageRoot(), "package.json");
8385
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
10434
+ const pkgPath = path71.join(packageRoot(), "package.json");
10435
+ const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
8386
10436
  return pkg.version ?? "0.0.0";
8387
10437
  } catch {
8388
10438
  return "0.0.0";
@@ -8391,8 +10441,8 @@ function cliVersion() {
8391
10441
  var program = new Command();
8392
10442
  function validateRuntimeNodeVersion() {
8393
10443
  try {
8394
- const pkgPath = path53.join(packageRoot(), "package.json");
8395
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
10444
+ const pkgPath = path71.join(packageRoot(), "package.json");
10445
+ const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
8396
10446
  const requiredRange = pkg.engines?.node;
8397
10447
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
8398
10448
  throw new Error(`Node ${process.version} does not satisfy required range ${requiredRange}`);
@@ -8410,7 +10460,10 @@ program.command("recommend").option("--json").option(
8410
10460
  "--include <ids...>",
8411
10461
  "Force opt-in catalog items into the recommendation as manual selections (space- or comma-separated ids)"
8412
10462
  ).action(runRecommend);
8413
- program.command("setup-project").option("--json").action(runSetupProject);
10463
+ program.command("setup-project").option("--json").option(
10464
+ "--force",
10465
+ "Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
10466
+ ).action(runSetupProject);
8414
10467
  program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
8415
10468
  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
10469
  program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
@@ -8472,6 +10525,39 @@ workspace.command("scan").description("Aggregate a cross-repo summary from a fas
8472
10525
  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
10526
  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
10527
  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));
10528
+ workspace.command("link-context").description(
10529
+ "Copy each member repo's skill/agent/command into the workspace root's .claude/, prefixed to avoid collisions"
10530
+ ).option("--write", "Persist copies + manifest + gitignore (default previews only)").option("--json", "Output the link result as JSON").action((opts) => runWorkspace("link-context", opts));
10531
+ var worktree = workspace.command("worktree").description(
10532
+ "Materialize workspace member repos as real git worktrees (per docs/plans/workspace-worktree-materialization.md, Task 4)"
10533
+ );
10534
+ worktree.command("add <slug>").description(
10535
+ "Create a workspace worktree plus one member worktree per repo, on a mirrored branch"
10536
+ ).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(
10537
+ (slug, opts) => runWorktree("add", {
10538
+ slug,
10539
+ branch: opts.branch,
10540
+ only: opts.only,
10541
+ hydrate: opts.hydrate,
10542
+ dryRun: opts.dryRun
10543
+ })
10544
+ );
10545
+ 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(
10546
+ "--materialize",
10547
+ "Also `git worktree add` any member missing entirely (e.g. a worktree created by plain `git worktree add` outside haus) before hydrating it"
10548
+ ).option("--dry-run", "Preview without writing or running anything").action(
10549
+ (opts) => runWorktree("hydrate", {
10550
+ only: opts.only,
10551
+ force: opts.force,
10552
+ materialize: opts.materialize,
10553
+ dryRun: opts.dryRun
10554
+ })
10555
+ );
10556
+ worktree.command("list").description("List materialized workspace worktrees and their member status").action(() => runWorktree("list"));
10557
+ 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(
10558
+ (slug, opts) => runWorktree("remove", { slug, force: opts.force, dryRun: opts.dryRun })
10559
+ );
10560
+ 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
10561
  program.parseAsync(process.argv).catch((err) => {
8476
10562
  const message = err instanceof Error ? err.message : String(err);
8477
10563
  error(message);