@haus-tech/haus-workflow 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -324,7 +324,8 @@ var init_path_safety = __esm({
324
324
  // src/utils/exec.ts
325
325
  var exec_exports = {};
326
326
  __export(exec_exports, {
327
- commandExists: () => commandExists,
327
+ assertNeverCommandProbe: () => assertNeverCommandProbe,
328
+ probeCommand: () => probeCommand,
328
329
  runCommand: () => runCommand,
329
330
  runGit: () => runGit
330
331
  });
@@ -337,14 +338,17 @@ async function runCommand(command, args = [], options = {}) {
337
338
  ...options
338
339
  });
339
340
  const killed = result.timedOut === true || result.isCanceled === true || Boolean(result.signal);
341
+ const capturedStderr = String(result.stderr ?? "");
342
+ const timeoutReason = result.timedOut === true ? `timed out after ${options.timeout}ms` : void 0;
343
+ const stderr = timeoutReason === void 0 ? capturedStderr : capturedStderr.length > 0 ? `${capturedStderr}
344
+ ${timeoutReason}` : timeoutReason;
340
345
  return {
341
346
  command,
342
347
  args,
343
348
  stdout: String(result.stdout ?? ""),
344
- stderr: String(
345
- result.stderr ?? (result.timedOut === true ? `timed out after ${options.timeout}ms` : "")
346
- ),
347
- exitCode: result.exitCode ?? (killed || result.failed === true ? 1 : 0)
349
+ stderr,
350
+ exitCode: result.exitCode ?? (killed || result.failed === true ? 1 : 0),
351
+ killed
348
352
  };
349
353
  } catch (error2) {
350
354
  const message2 = error2 instanceof Error ? error2.message : String(error2);
@@ -354,14 +358,30 @@ async function runCommand(command, args = [], options = {}) {
354
358
  async function runGit(args, options = {}) {
355
359
  return runCommand("git", args, options);
356
360
  }
357
- async function commandExists(command, timeoutMs = 1e4) {
361
+ function assertNeverCommandProbe(probe) {
362
+ throw new Error(`Unhandled CommandProbe: ${JSON.stringify(probe)}`);
363
+ }
364
+ async function probeOnce(command, timeoutMs, run) {
358
365
  try {
359
- const result = await runCommand(command, ["--version"], { timeout: timeoutMs });
360
- return result.exitCode === 0;
361
- } catch {
362
- return false;
366
+ const result = await run(command, ["--version"], { timeout: timeoutMs });
367
+ if (result.exitCode === 0) return { status: "available" };
368
+ if (result.killed) {
369
+ return {
370
+ status: "inconclusive",
371
+ reason: result.stderr.length > 0 ? result.stderr : `probe for "${command} --version" did not complete within ${timeoutMs}ms`
372
+ };
373
+ }
374
+ return { status: "absent" };
375
+ } catch (error2) {
376
+ const message2 = error2 instanceof Error ? error2.message : String(error2);
377
+ return { status: "inconclusive", reason: `probing "${command}" threw: ${message2}` };
363
378
  }
364
379
  }
380
+ async function probeCommand(command, timeoutMs = 1e4, run = runCommand) {
381
+ const first = await probeOnce(command, timeoutMs, run);
382
+ if (first.status !== "inconclusive") return first;
383
+ return probeOnce(command, timeoutMs, run);
384
+ }
365
385
  var init_exec = __esm({
366
386
  "src/utils/exec.ts"() {
367
387
  "use strict";
@@ -481,9 +501,9 @@ var init_github_rate_limit = __esm({
481
501
  // src/catalog/remote-catalog/http.ts
482
502
  import path4 from "node:path";
483
503
  import fs2 from "fs-extra";
484
- async function fetchGuarded(url, extract) {
504
+ async function fetchGuarded(url, extract, headers = {}) {
485
505
  try {
486
- const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
506
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
487
507
  if (!res.ok) {
488
508
  warn(`Catalog fetch HTTP ${res.status}: ${url}`);
489
509
  return null;
@@ -494,11 +514,11 @@ async function fetchGuarded(url, extract) {
494
514
  return null;
495
515
  }
496
516
  }
497
- async function fetchText(url) {
498
- return fetchGuarded(url, (res) => res.text());
517
+ async function fetchText(url, headers = {}) {
518
+ return fetchGuarded(url, (res) => res.text(), headers);
499
519
  }
500
- async function fetchBytes(url) {
501
- return fetchGuarded(url, async (res) => Buffer.from(await res.arrayBuffer()));
520
+ async function fetchBytes(url, headers = {}) {
521
+ return fetchGuarded(url, async (res) => Buffer.from(await res.arrayBuffer()), headers);
502
522
  }
503
523
  async function writeTextIfChanged(dest, text) {
504
524
  if (await fs2.pathExists(dest)) {
@@ -728,6 +748,18 @@ async function resolveCatalogRef(opts) {
728
748
  );
729
749
  return "main";
730
750
  }
751
+ async function catalogContentRequest(base, rel) {
752
+ if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
753
+ return { url: `${base}/${rel}`, headers: {} };
754
+ }
755
+ const ref = base.startsWith(`${CATALOG_REPO_URL}/`) ? base.slice(CATALOG_REPO_URL.length + 1) : getResolvedCatalogRef();
756
+ const headers = await getGithubApiHeaders();
757
+ headers["Accept"] = "application/vnd.github.raw";
758
+ return {
759
+ url: `${CATALOG_GITHUB_API_URL}/contents/${rel}?ref=${encodeURIComponent(ref)}`,
760
+ headers
761
+ };
762
+ }
731
763
  async function remoteBase(projectRoot) {
732
764
  if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
733
765
  return process.env["HAUS_CATALOG_REMOTE_BASE"];
@@ -1147,7 +1179,8 @@ var init_manifest_schema = __esm({
1147
1179
  // src/catalog/remote-catalog/manifest.ts
1148
1180
  async function fetchRemoteManifest(projectRoot) {
1149
1181
  const base = await remoteBase(projectRoot);
1150
- const text = await fetchText(`${base}/manifest.json`);
1182
+ const { url, headers } = await catalogContentRequest(base, "manifest.json");
1183
+ const text = await fetchText(url, headers);
1151
1184
  if (!text) return { ok: false, reason: "unavailable" };
1152
1185
  const parsed = parseManifest(text);
1153
1186
  if (!parsed.ok) {
@@ -1533,16 +1566,16 @@ function isMarkdownPath(rel) {
1533
1566
  async function fetchPrefixFiles(catalogPrefix, relFiles, base, label) {
1534
1567
  const fetched = [];
1535
1568
  for (const rel of relFiles) {
1536
- const url = `${base}/${catalogPrefix}/${rel}`;
1569
+ const { url, headers } = await catalogContentRequest(base, `${catalogPrefix}/${rel}`);
1537
1570
  if (isMarkdownPath(rel)) {
1538
- const text = await fetchText(url);
1571
+ const text = await fetchText(url, headers);
1539
1572
  if (text === null) {
1540
1573
  warn(`Failed to fetch ${rel} for ${label}`);
1541
1574
  return null;
1542
1575
  }
1543
1576
  fetched.push({ rel, kind: "text", body: text });
1544
1577
  } else {
1545
- const bytes = await fetchBytes(url);
1578
+ const bytes = await fetchBytes(url, headers);
1546
1579
  if (bytes === null) {
1547
1580
  warn(`Failed to fetch ${rel} for ${label}`);
1548
1581
  return null;
@@ -1681,7 +1714,8 @@ async function syncConfigItem(item, base) {
1681
1714
  return "failed";
1682
1715
  }
1683
1716
  }
1684
- const text = await fetchText(`${base}/${item.path}`);
1717
+ const configReq = await catalogContentRequest(base, item.path);
1718
+ const text = await fetchText(configReq.url, configReq.headers);
1685
1719
  if (text === null) {
1686
1720
  warn(`Failed to fetch content for ${item.id}`);
1687
1721
  return "failed";
@@ -1739,7 +1773,8 @@ async function syncOneItem(item, base) {
1739
1773
  warn(`Skipping ${item.id}: path traversal detected`);
1740
1774
  return "failed";
1741
1775
  }
1742
- const text = await fetchText(`${base}/${item.path}`);
1776
+ const itemReq = await catalogContentRequest(base, item.path);
1777
+ const text = await fetchText(itemReq.url, itemReq.headers);
1743
1778
  if (text === null) {
1744
1779
  warn(`Failed to fetch content for ${item.id}`);
1745
1780
  return "failed";
@@ -1878,7 +1913,7 @@ var init_sync = __esm({
1878
1913
 
1879
1914
  // src/cli.ts
1880
1915
  import { readFileSync as readFileSync5 } from "node:fs";
1881
- import path78 from "node:path";
1916
+ import path79 from "node:path";
1882
1917
  import { Command } from "commander";
1883
1918
 
1884
1919
  // src/commands/apply.ts
@@ -1908,7 +1943,8 @@ var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
1908
1943
  async function readWorkflowTemplate(opts = {}) {
1909
1944
  const dest = path7.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
1910
1945
  const base = await remoteBase(opts.projectRoot);
1911
- const text = await fetchText(`${base}/${WORKFLOW_TEMPLATE_REL}`);
1946
+ const { url, headers } = await catalogContentRequest(base, WORKFLOW_TEMPLATE_REL);
1947
+ const text = await fetchText(url, headers);
1912
1948
  if (text === null) {
1913
1949
  if (await fs6.pathExists(dest)) return fs6.readFile(dest, "utf8");
1914
1950
  return null;
@@ -3014,8 +3050,8 @@ function buildDenyRules() {
3014
3050
  for (const command of DENY_COMMANDS) {
3015
3051
  rules.push(`Bash(${command}:*)`);
3016
3052
  }
3017
- for (const path79 of DENY_PATHS) {
3018
- const pattern = DENY_DIRS.has(path79) ? `${path79}/**` : path79;
3053
+ for (const path80 of DENY_PATHS) {
3054
+ const pattern = DENY_DIRS.has(path80) ? `${path80}/**` : path80;
3019
3055
  for (const tool of FILE_TOOLS) {
3020
3056
  rules.push(`${tool}(${pattern})`);
3021
3057
  }
@@ -3228,9 +3264,20 @@ async function installSkillDependencies(opts, deps = {}) {
3228
3264
  if (!planned.ok) return { status: "skipped", reason: planned.reason };
3229
3265
  const { command, args } = planned.command;
3230
3266
  if (opts.dryRun) return { status: "would-install", command, args };
3231
- const exists = deps.exists ?? commandExists;
3232
- if (!await exists(command)) {
3233
- return { status: "skipped", reason: `${command} not found on PATH` };
3267
+ const exists = deps.exists ?? probeCommand;
3268
+ const probe = await exists(command);
3269
+ switch (probe.status) {
3270
+ case "available":
3271
+ break;
3272
+ case "absent":
3273
+ return { status: "skipped", reason: `${command} not found on PATH` };
3274
+ case "inconclusive":
3275
+ return {
3276
+ status: "skipped",
3277
+ reason: `could not determine whether ${command} is on PATH (${probe.reason}) \u2014 dependency install skipped`
3278
+ };
3279
+ default:
3280
+ return assertNeverCommandProbe(probe);
3234
3281
  }
3235
3282
  if (!await fs14.pathExists(contained.dir)) {
3236
3283
  return { status: "skipped", reason: `install directory disappeared (${contained.dir})` };
@@ -4962,8 +5009,24 @@ async function parseMarketplaces(stdout2, configDir) {
4962
5009
  }
4963
5010
  async function probePluginEnv(deps = {}) {
4964
5011
  const exec = deps.exec ?? realExec;
4965
- const isAvailable = deps.isAvailable ?? (() => commandExists("claude"));
4966
5012
  const warn2 = deps.warn ?? warn;
5013
+ const probeClaude = deps.probeClaude ?? (() => probeCommand("claude"));
5014
+ const isAvailable = deps.isAvailable ?? (async () => {
5015
+ const probe = await probeClaude();
5016
+ switch (probe.status) {
5017
+ case "available":
5018
+ return true;
5019
+ case "absent":
5020
+ return false;
5021
+ case "inconclusive":
5022
+ warn2(
5023
+ `Could not determine whether the \`claude\` CLI is on PATH (${probe.reason}) \u2014 leaving global plugins untouched this run.`
5024
+ );
5025
+ return false;
5026
+ default:
5027
+ return assertNeverCommandProbe(probe);
5028
+ }
5029
+ });
4967
5030
  if (globalPluginsDisabled() && deps.exec === void 0) {
4968
5031
  return { available: false, installed: [], marketplaces: [] };
4969
5032
  }
@@ -6772,10 +6835,13 @@ function parseWorkspaceConfig(text) {
6772
6835
  const repos = Array.isArray(obj.repos) ? obj.repos.filter(
6773
6836
  (r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
6774
6837
  ) : [];
6838
+ const worktreeRaw = parsed.worktree;
6839
+ const worktree2 = typeof worktreeRaw === "object" && worktreeRaw !== null && !Array.isArray(worktreeRaw) ? { autoHydrate: worktreeRaw.autoHydrate === true } : void 0;
6775
6840
  return {
6776
6841
  client: typeof obj.client === "string" ? obj.client : "unknown",
6777
6842
  repos,
6778
- relationships: Array.isArray(obj.relationships) ? obj.relationships : []
6843
+ relationships: Array.isArray(obj.relationships) ? obj.relationships : [],
6844
+ ...worktree2 ? { worktree: worktree2 } : {}
6779
6845
  };
6780
6846
  }
6781
6847
  async function readWorkspaceConfig(workspaceRoot) {
@@ -9948,6 +10014,9 @@ async function hasMultipleSiblingRepos(root) {
9948
10014
  init_fs();
9949
10015
  init_logger();
9950
10016
  init_paths();
10017
+ function emitJsonEnvelope(json, quiet, envelope) {
10018
+ if (json && !quiet) log(JSON.stringify(envelope, null, 2));
10019
+ }
9951
10020
  async function readPriorManualIncludes(root) {
9952
10021
  const prior = await readJson(hausPath(root, "recommendation.json"));
9953
10022
  if (!prior?.recommended) return [];
@@ -9955,16 +10024,12 @@ async function readPriorManualIncludes(root) {
9955
10024
  }
9956
10025
  async function runSetupCore(root, opts) {
9957
10026
  const { json, apply, dryRun, quiet, confirm: confirm2, force } = opts;
9958
- const say = quiet ? () => {
10027
+ const say = quiet || json ? () => {
9959
10028
  } : log;
9960
10029
  const scanResult = await scanProject(root);
9961
- if (json) {
9962
- say(JSON.stringify(scanResult, null, 2));
9963
- } else {
9964
- say("Haus scan complete");
9965
- say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
9966
- say(`Package manager: ${scanResult.packageManager}`);
9967
- }
10030
+ say("Haus scan complete");
10031
+ say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
10032
+ say(`Package manager: ${scanResult.packageManager}`);
9968
10033
  const siblingWarning = await hasMultipleSiblingRepos(root) ? [SIBLING_REPO_WARNING] : [];
9969
10034
  const context = await readContextOrScan(root);
9970
10035
  const priorManualIncludes = await readPriorManualIncludes(root);
@@ -9977,39 +10042,39 @@ async function runSetupCore(root, opts) {
9977
10042
  const warningLines2 = [
9978
10043
  .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
9979
10044
  ];
9980
- if (json) {
9981
- say(JSON.stringify(recommendation, null, 2));
9982
- } else {
9983
- say("Haus recommendation ready");
9984
- say("Recommended: 0");
9985
- say(`Skipped: ${recommendation.skipped.length}`);
9986
- say(`Repo: ${context.repoName}`);
9987
- for (const warning of warningLines2) say(`- WARN: ${warning}`);
9988
- }
10045
+ say("Haus recommendation ready");
10046
+ say("Recommended: 0");
10047
+ say(`Skipped: ${recommendation.skipped.length}`);
10048
+ say(`Repo: ${context.repoName}`);
10049
+ for (const warning of warningLines2) say(`- WARN: ${warning}`);
9989
10050
  const supersededCount = recommendation.skipped.filter(
9990
10051
  (s) => s.skipReasons?.some((r) => r.code === "superseded-by-global-plugin")
9991
10052
  ).length;
9992
10053
  const message2 = supersededCount > 0 ? `No catalog items matched this project after ${supersededCount} item(s) were gated as already provided by an installed global plugin \u2014 refusing to write recommendation.json/haus.lock.json. Those skills are available globally; re-run with \`haus init --force\` to write anyway.` : "No catalog items matched this project at all (zero signal) \u2014 refusing to write recommendation.json/haus.lock.json. Re-run with `haus init --force` to write anyway.";
9993
10054
  warn(message2);
9994
10055
  process.exitCode = 1;
10056
+ const warnings = [...warningLines2, message2];
10057
+ emitJsonEnvelope(json, quiet, {
10058
+ scan: scanResult,
10059
+ recommendation,
10060
+ hooks: null,
10061
+ repoName: context.repoName,
10062
+ warnings
10063
+ });
9995
10064
  return {
9996
10065
  root,
9997
10066
  repoName: context.repoName,
9998
10067
  roles: scanResult.repoRoles,
9999
10068
  recommendedCount: 0,
10000
- warnings: [...warningLines2, message2],
10069
+ warnings,
10001
10070
  hooksOk: false,
10002
10071
  written: []
10003
10072
  };
10004
10073
  }
10005
10074
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
10006
- if (json) {
10007
- say(JSON.stringify(recommendation, null, 2));
10008
- } else {
10009
- say("Haus recommendation ready");
10010
- say(`Recommended: ${recommendation.recommended.length}`);
10011
- say(`Skipped: ${recommendation.skipped.length}`);
10012
- }
10075
+ say("Haus recommendation ready");
10076
+ say(`Recommended: ${recommendation.recommended.length}`);
10077
+ say(`Skipped: ${recommendation.skipped.length}`);
10013
10078
  const hooks = await verifyProjectSettingsHooksContract(root);
10014
10079
  const warningLines = [
10015
10080
  .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
@@ -10034,12 +10099,23 @@ async function runSetupCore(root, opts) {
10034
10099
  hooksOk,
10035
10100
  written: []
10036
10101
  };
10037
- if (!apply) return baseResult;
10102
+ const envelope = (hooksResult) => ({
10103
+ scan: scanResult,
10104
+ recommendation,
10105
+ hooks: hooksResult,
10106
+ repoName: context.repoName,
10107
+ warnings: warningLines
10108
+ });
10109
+ if (!apply) {
10110
+ emitJsonEnvelope(json, quiet, envelope(hooks));
10111
+ return baseResult;
10112
+ }
10038
10113
  if (confirm2) {
10039
10114
  const approved = await confirm2();
10040
10115
  if (!approved) {
10041
10116
  say("Setup reviewed. No files written.");
10042
10117
  say("Next step: run `haus apply --write` when ready.");
10118
+ emitJsonEnvelope(json, quiet, envelope(hooks));
10043
10119
  return baseResult;
10044
10120
  }
10045
10121
  }
@@ -10049,6 +10125,7 @@ async function runSetupCore(root, opts) {
10049
10125
  if (sync.formatRefusal) {
10050
10126
  error(sync.formatRefusal);
10051
10127
  process.exitCode = 1;
10128
+ emitJsonEnvelope(json, quiet, envelope(hooks));
10052
10129
  return baseResult;
10053
10130
  }
10054
10131
  if (sync.newItems.length > 0) {
@@ -10076,6 +10153,7 @@ async function runSetupCore(root, opts) {
10076
10153
  } else {
10077
10154
  say(`- HOOKS OK: ${hooksAfter.message}`);
10078
10155
  }
10156
+ emitJsonEnvelope(json, quiet, envelope(hooksAfter));
10079
10157
  return { ...baseResult, hooksOk: hooksOkAfter, written: files };
10080
10158
  }
10081
10159
 
@@ -10139,7 +10217,7 @@ function buildCompanionSuggestions(tools, isInstalled) {
10139
10217
  }
10140
10218
  return lines;
10141
10219
  }
10142
- function commandExists2(bin) {
10220
+ function commandExists(bin) {
10143
10221
  try {
10144
10222
  const r = execaSync("sh", ["-c", 'command -v -- "$1"', "probe", bin], { reject: false });
10145
10223
  return r.exitCode === 0;
@@ -10148,7 +10226,7 @@ function commandExists2(bin) {
10148
10226
  }
10149
10227
  }
10150
10228
  function printCompanionToolSuggestions(deps = {}) {
10151
- const isInstalled = deps.isInstalled ?? commandExists2;
10229
+ const isInstalled = deps.isInstalled ?? commandExists;
10152
10230
  const emit2 = deps.log ?? log;
10153
10231
  const lines = buildCompanionSuggestions(COMPANION_TOOLS, isInstalled);
10154
10232
  for (const line2 of lines) emit2(line2);
@@ -10239,7 +10317,7 @@ async function safeExec(exec, command, args, options) {
10239
10317
  return await exec(command, args, options);
10240
10318
  } catch (error2) {
10241
10319
  const message2 = error2 instanceof Error ? error2.message : String(error2);
10242
- return { command, args, stdout: "", stderr: message2, exitCode: 1 };
10320
+ return { command, args, stdout: "", stderr: message2, exitCode: 1, killed: false };
10243
10321
  }
10244
10322
  }
10245
10323
  async function checkGh(exec = runCommand) {
@@ -10347,7 +10425,7 @@ var SCAFFOLD_TIMEOUT_MS = 3e5;
10347
10425
  var defaultDeps = {
10348
10426
  exec: runCommand,
10349
10427
  git: runGit,
10350
- commandExists,
10428
+ commandExists: probeCommand,
10351
10429
  checkGh: () => checkGh(),
10352
10430
  checkOrg: (org) => checkOrg(org),
10353
10431
  createRepo,
@@ -10405,8 +10483,32 @@ async function createProject(opts, deps = defaultDeps) {
10405
10483
  });
10406
10484
  return { ok: true, projectPath, installHint, steps };
10407
10485
  }
10408
- if (!await deps.commandExists("npx") || !await deps.commandExists(opts.pm)) {
10409
- return fail("preflight", `Required tools missing: need npx and ${opts.pm} on PATH.`);
10486
+ const requiredTools = ["npx", opts.pm];
10487
+ const missingTools = [];
10488
+ const inconclusiveTools = [];
10489
+ for (const tool of requiredTools) {
10490
+ const probe = await deps.commandExists(tool);
10491
+ switch (probe.status) {
10492
+ case "available":
10493
+ break;
10494
+ case "absent":
10495
+ missingTools.push(tool);
10496
+ break;
10497
+ case "inconclusive":
10498
+ inconclusiveTools.push(`${tool} (${probe.reason})`);
10499
+ break;
10500
+ default:
10501
+ return assertNeverCommandProbe(probe);
10502
+ }
10503
+ }
10504
+ if (missingTools.length > 0) {
10505
+ return fail("preflight", `Required tools missing: need ${missingTools.join(" and ")} on PATH.`);
10506
+ }
10507
+ if (inconclusiveTools.length > 0) {
10508
+ return fail(
10509
+ "preflight",
10510
+ `Could not confirm tool availability on PATH: ${inconclusiveTools.join("; ")} \u2014 the probe did not complete (likely transient load). Try again.`
10511
+ );
10410
10512
  }
10411
10513
  if (opts.github) {
10412
10514
  const gh = await deps.checkGh();
@@ -11657,7 +11759,8 @@ async function removeWorktree(ownerRepo, worktreePath2, force = true) {
11657
11759
  return { ok: result.exitCode === 0, stderr: result.exitCode === 0 ? void 0 : result.stderr };
11658
11760
  }
11659
11761
  async function pruneWorktrees(ownerRepo) {
11660
- await runGit(["worktree", "prune"], { cwd: ownerRepo });
11762
+ const result = await runGit(["worktree", "prune"], { cwd: ownerRepo });
11763
+ return { ok: result.exitCode === 0, stderr: result.exitCode === 0 ? void 0 : result.stderr };
11661
11764
  }
11662
11765
  async function listWorktrees(ownerRepo) {
11663
11766
  const result = await runGit(["worktree", "list", "--porcelain"], { cwd: ownerRepo });
@@ -11835,22 +11938,114 @@ async function computeLockfileSignals(dir) {
11835
11938
  hasDotnetProject
11836
11939
  };
11837
11940
  }
11838
- async function runInstallPlan(dir, plan) {
11941
+ var INSTALL_OUTPUT_CAP = 8192;
11942
+ var INSTALL_TAIL_LINES = 8;
11943
+ function capTail(text, max = INSTALL_OUTPUT_CAP) {
11944
+ return text.length <= max ? text : text.slice(-max);
11945
+ }
11946
+ function nonemptyLines(text) {
11947
+ return text.split(/\r?\n/).filter((line2) => line2.trim().length > 0);
11948
+ }
11949
+ function findRequiredYarnrcEnvVars(content2) {
11950
+ const required = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
11951
+ const names = /* @__PURE__ */ new Set();
11952
+ for (const line2 of content2.split(/\r?\n/)) {
11953
+ if (line2.trimStart().startsWith("#")) continue;
11954
+ required.lastIndex = 0;
11955
+ let match;
11956
+ while ((match = required.exec(line2)) !== null) {
11957
+ const name = match[1];
11958
+ if (name !== void 0) names.add(name);
11959
+ }
11960
+ }
11961
+ return [...names];
11962
+ }
11963
+ function missingYarnrcEnvVars(content2, env) {
11964
+ return findRequiredYarnrcEnvVars(content2).filter((name) => env[name] === void 0);
11965
+ }
11966
+ function missingYarnrcEnvVarsInDir(dir, env) {
11967
+ const yarnrcPath = path59.join(dir, ".yarnrc.yml");
11968
+ if (!existsSync3(yarnrcPath)) return [];
11969
+ try {
11970
+ return missingYarnrcEnvVars(readFileSync3(yarnrcPath, "utf8"), env);
11971
+ } catch {
11972
+ return [];
11973
+ }
11974
+ }
11975
+ function tailInstallOutput(stdout2, stderr) {
11976
+ const errLines = nonemptyLines(stderr).slice(-INSTALL_TAIL_LINES);
11977
+ const remaining = INSTALL_TAIL_LINES - errLines.length;
11978
+ const outLines = remaining > 0 ? nonemptyLines(stdout2).slice(-remaining) : [];
11979
+ return [...errLines, ...outLines].join("\n");
11980
+ }
11981
+ function summarizeInstall(install) {
11982
+ if (install.plan.manager === "none") return "no install step (no recognized lockfile)";
11983
+ const envNotes = (install.missingEnvVars ?? []).map(
11984
+ (name) => `${name} not set in environment \u2014 yarn install will fail`
11985
+ );
11986
+ if (!install.ran) {
11987
+ const skip = `install skipped (${install.skippedReason ?? "unavailable"})`;
11988
+ return envNotes.length > 0 ? [skip, ...envNotes].join("\n ") : skip;
11989
+ }
11990
+ if (install.ok) return `hydrated via ${install.plan.manager}`;
11991
+ const head = `install FAILED via ${install.plan.manager}`;
11992
+ const tail = tailInstallOutput(install.stdout ?? "", install.stderr ?? "");
11993
+ const details = [...envNotes, ...tail.length > 0 ? tail.split("\n") : []];
11994
+ return details.length > 0 ? [head, ...details].join("\n ") : head;
11995
+ }
11996
+ function withMissingEnv(result, missingEnvVars) {
11997
+ return missingEnvVars.length > 0 ? { ...result, missingEnvVars } : result;
11998
+ }
11999
+ async function runInstallPlan(dir, plan, deps = {}) {
12000
+ const env = deps.env ?? process.env;
12001
+ const missingEnvVars = plan.manager === "yarn" ? missingYarnrcEnvVarsInDir(dir, env) : [];
11839
12002
  if (plan.manager === "none") {
11840
- return { plan, ran: false, ok: true, skippedReason: "no recognized lockfile" };
12003
+ return withMissingEnv(
12004
+ { plan, ran: false, ok: true, skippedReason: "no recognized lockfile" },
12005
+ missingEnvVars
12006
+ );
11841
12007
  }
11842
- const available = await commandExists(plan.command);
11843
- if (!available) {
11844
- return { plan, ran: false, ok: true, skippedReason: `${plan.command} not found on PATH` };
12008
+ const probe = deps.probe ?? probeCommand;
12009
+ const probed = await probe(plan.command);
12010
+ switch (probed.status) {
12011
+ case "available":
12012
+ break;
12013
+ case "absent":
12014
+ return withMissingEnv(
12015
+ { plan, ran: false, ok: true, skippedReason: `${plan.command} not found on PATH` },
12016
+ missingEnvVars
12017
+ );
12018
+ case "inconclusive":
12019
+ return withMissingEnv(
12020
+ {
12021
+ plan,
12022
+ ran: false,
12023
+ ok: true,
12024
+ skippedReason: `could not determine whether ${plan.command} is on PATH (${probed.reason})`
12025
+ },
12026
+ missingEnvVars
12027
+ );
12028
+ default:
12029
+ return assertNeverCommandProbe(probed);
11845
12030
  }
11846
- const result = await runCommand(plan.command, plan.args, { cwd: dir });
11847
- return {
11848
- plan,
11849
- ran: true,
11850
- ok: result.exitCode === 0,
11851
- exitCode: result.exitCode,
11852
- stderr: result.exitCode === 0 ? void 0 : result.stderr
11853
- };
12031
+ const run = deps.run ?? runCommand;
12032
+ const result = await run(plan.command, plan.args, { cwd: dir });
12033
+ if (result.exitCode === 0) {
12034
+ return withMissingEnv({ plan, ran: true, ok: true, exitCode: 0 }, missingEnvVars);
12035
+ }
12036
+ const stdout2 = capTail(result.stdout);
12037
+ const stderr = capTail(result.stderr);
12038
+ return withMissingEnv(
12039
+ {
12040
+ plan,
12041
+ ran: true,
12042
+ ok: false,
12043
+ exitCode: result.exitCode,
12044
+ stdout: stdout2.length > 0 ? stdout2 : void 0,
12045
+ stderr: stderr.length > 0 ? stderr : void 0
12046
+ },
12047
+ missingEnvVars
12048
+ );
11854
12049
  }
11855
12050
 
11856
12051
  // src/workspace/worktree/hydrate.ts
@@ -12093,11 +12288,41 @@ async function runDryRun(args) {
12093
12288
  }
12094
12289
 
12095
12290
  // src/workspace/worktree/doctor.ts
12291
+ import { existsSync as existsSync6 } from "node:fs";
12292
+ import path66 from "node:path";
12293
+
12294
+ // src/workspace/worktree/orphans.ts
12096
12295
  import { existsSync as existsSync5 } from "node:fs";
12097
12296
  import path65 from "node:path";
12297
+ var WORKSPACE_REPO_ID = "(workspace)";
12298
+ async function findOrphanedRegistrations(repos, wsWorktreesDir) {
12299
+ const orphans = [];
12300
+ for (const repo of repos) {
12301
+ const entries = await listWorktrees(repo.absPath);
12302
+ for (const entry of entries) {
12303
+ const entryPath = path65.normalize(entry.path);
12304
+ const marker = `${path65.sep}.claude${path65.sep}worktrees${path65.sep}`;
12305
+ const idx = entryPath.indexOf(marker);
12306
+ if (idx === -1) continue;
12307
+ const rest = entryPath.slice(idx + marker.length);
12308
+ const slug = rest.split(path65.sep)[0];
12309
+ if (!slug) continue;
12310
+ if (existsSync5(path65.join(wsWorktreesDir, slug))) continue;
12311
+ orphans.push({
12312
+ repoId: repo.id,
12313
+ ownerRepo: repo.absPath,
12314
+ registeredPath: entry.path,
12315
+ slug
12316
+ });
12317
+ }
12318
+ }
12319
+ return orphans;
12320
+ }
12321
+
12322
+ // src/workspace/worktree/doctor.ts
12098
12323
  var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
12099
12324
  function looksHydrated(memberPath) {
12100
- return HYDRATION_MARKERS.some((marker) => existsSync5(path65.join(memberPath, marker)));
12325
+ return HYDRATION_MARKERS.some((marker) => existsSync6(path66.join(memberPath, marker)));
12101
12326
  }
12102
12327
  async function runDoctor2() {
12103
12328
  const rootInfo = await resolveRoots();
@@ -12107,11 +12332,13 @@ async function runDoctor2() {
12107
12332
  inWorkspaceWorktree: false,
12108
12333
  members: [],
12109
12334
  orphans: [],
12110
- problems: ["Not inside a git repository."]
12335
+ problems: ["Not inside a git repository."],
12336
+ remedies: [],
12337
+ autoHydrate: false
12111
12338
  };
12112
12339
  }
12113
12340
  const workspaceRoot = rootInfo.mainRoot;
12114
- const isWorkspace = existsSync5(path65.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path65.join(workspaceRoot, REPOS_MANIFEST_FILE));
12341
+ const isWorkspace = existsSync6(path66.join(workspaceRoot, WORKSPACE_FILE)) || existsSync6(path66.join(workspaceRoot, REPOS_MANIFEST_FILE));
12115
12342
  if (!isWorkspace) {
12116
12343
  return {
12117
12344
  isWorkspace: false,
@@ -12120,23 +12347,28 @@ async function runDoctor2() {
12120
12347
  orphans: [],
12121
12348
  problems: [
12122
12349
  `Not a haus workspace \u2014 no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
12123
- ]
12350
+ ],
12351
+ remedies: [],
12352
+ autoHydrate: false
12124
12353
  };
12125
12354
  }
12126
12355
  const wsWorktreesDir = worktreesDir(workspaceRoot);
12127
- const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path65.sep}`);
12356
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path66.sep}`);
12128
12357
  const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
12358
+ const workspaceConfig = await readWorkspaceConfig(workspaceRoot);
12359
+ const autoHydrate = workspaceConfig?.worktree?.autoHydrate === true;
12129
12360
  const members = await readMembers(rootInfo);
12130
12361
  const problems = [];
12362
+ const remedies = [];
12131
12363
  const memberChecks = [];
12132
12364
  let slug;
12133
12365
  if (inWorkspaceWorktree) {
12134
12366
  const wtPath = rootInfo.repoRoot;
12135
- slug = rootInfo.worktreeName ?? path65.basename(wtPath);
12367
+ slug = rootInfo.worktreeName ?? path66.basename(wtPath);
12136
12368
  const state = await readWorktreeState(wtPath);
12137
12369
  for (const member of members) {
12138
- const memberPath = path65.join(wtPath, member.folder);
12139
- const materialized = existsSync5(memberPath);
12370
+ const memberPath = path66.join(wtPath, member.folder);
12371
+ const materialized = existsSync6(memberPath);
12140
12372
  const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
12141
12373
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
12142
12374
  const branchMismatch = Boolean(
@@ -12161,33 +12393,42 @@ async function runDoctor2() {
12161
12393
  );
12162
12394
  }
12163
12395
  }
12164
- }
12165
- const orphans = [];
12166
- for (const member of members) {
12167
- const entries = await listWorktrees(member.absPath);
12168
- for (const entry of entries) {
12169
- const entryPath = path65.normalize(entry.path);
12170
- const marker = `${path65.sep}.claude${path65.sep}worktrees${path65.sep}`;
12171
- const idx = entryPath.indexOf(marker);
12172
- if (idx === -1) continue;
12173
- const rest = entryPath.slice(idx + marker.length);
12174
- const slugName = rest.split(path65.sep)[0];
12175
- if (!slugName) continue;
12176
- if (!existsSync5(path65.join(wsWorktreesDir, slugName))) {
12177
- orphans.push(
12178
- `${member.id}: worktree registered at ${entry.path} but ${path65.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
12179
- );
12180
- }
12396
+ if (memberChecks.some((m) => !m.materialized)) {
12397
+ remedies.push(
12398
+ "Run `haus workspace worktree hydrate --materialize` to materialize the missing members."
12399
+ );
12181
12400
  }
12182
12401
  }
12183
- if (orphans.length > 0) problems.push(...orphans);
12184
- return { isWorkspace: true, inWorkspaceWorktree, slug, members: memberChecks, orphans, problems };
12402
+ const orphanScan = await findOrphanedRegistrations(
12403
+ [
12404
+ { id: WORKSPACE_REPO_ID, absPath: workspaceRoot },
12405
+ ...members.map((m) => ({ id: m.id, absPath: m.absPath }))
12406
+ ],
12407
+ wsWorktreesDir
12408
+ );
12409
+ const orphans = orphanScan.map(
12410
+ (o) => `${o.repoId}: worktree registered at ${o.registeredPath} but ${path66.join(wsWorktreesDir, o.slug)} no longer exists.`
12411
+ );
12412
+ if (orphans.length > 0) {
12413
+ problems.push(...orphans);
12414
+ remedies.push("Run `haus workspace worktree prune` to clean up stale worktree registrations.");
12415
+ }
12416
+ return {
12417
+ isWorkspace: true,
12418
+ inWorkspaceWorktree,
12419
+ slug,
12420
+ members: memberChecks,
12421
+ orphans,
12422
+ problems,
12423
+ remedies,
12424
+ autoHydrate
12425
+ };
12185
12426
  }
12186
12427
 
12187
12428
  // src/workspace/worktree/hydrate-command.ts
12188
12429
  init_fs();
12189
- import { existsSync as existsSync6 } from "node:fs";
12190
- import path66 from "node:path";
12430
+ import { existsSync as existsSync7 } from "node:fs";
12431
+ import path67 from "node:path";
12191
12432
  async function runHydrateWorktree(opts) {
12192
12433
  const rootInfo = await resolveRoots();
12193
12434
  if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
@@ -12198,7 +12439,7 @@ async function runHydrateWorktree(opts) {
12198
12439
  }
12199
12440
  const workspaceRoot = rootInfo.mainRoot;
12200
12441
  const wtPath = rootInfo.repoRoot;
12201
- if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path66.sep}`)) {
12442
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path67.sep}`)) {
12202
12443
  return {
12203
12444
  ok: false,
12204
12445
  error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
@@ -12210,14 +12451,14 @@ async function runHydrateWorktree(opts) {
12210
12451
  return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
12211
12452
  }
12212
12453
  const state = await readWorktreeState(wtPath);
12213
- const slug = state?.slug ?? path66.basename(wtPath);
12454
+ const slug = state?.slug ?? path67.basename(wtPath);
12214
12455
  const detectedBranch = await currentBranch(wtPath);
12215
12456
  const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
12216
12457
  if (opts.dryRun) {
12217
12458
  const results2 = await Promise.all(
12218
12459
  selected.map(async (member) => {
12219
- const memberPath = path66.join(wtPath, member.folder);
12220
- if (existsSync6(memberPath)) {
12460
+ const memberPath = path67.join(wtPath, member.folder);
12461
+ if (existsSync7(memberPath)) {
12221
12462
  return { member: member.id, folder: member.folder, status: "planned" };
12222
12463
  }
12223
12464
  if (!opts.materialize) {
@@ -12225,7 +12466,7 @@ async function runHydrateWorktree(opts) {
12225
12466
  member: member.id,
12226
12467
  folder: member.folder,
12227
12468
  status: "skipped",
12228
- error: "not materialized in this worktree"
12469
+ error: "not materialized in this worktree \u2014 re-run with --materialize to create it"
12229
12470
  };
12230
12471
  }
12231
12472
  const branchAction = await branchExists(member.absPath, targetBranch) ? "checkout" : "create";
@@ -12237,15 +12478,15 @@ async function runHydrateWorktree(opts) {
12237
12478
  const results = await mapWithConcurrency(
12238
12479
  selected,
12239
12480
  async (member) => {
12240
- const memberPath = path66.join(wtPath, member.folder);
12481
+ const memberPath = path67.join(wtPath, member.folder);
12241
12482
  let branchAction;
12242
- if (!existsSync6(memberPath)) {
12483
+ if (!existsSync7(memberPath)) {
12243
12484
  if (!opts.materialize) {
12244
12485
  return {
12245
12486
  member: member.id,
12246
12487
  folder: member.folder,
12247
12488
  status: "skipped",
12248
- error: "not materialized in this worktree"
12489
+ error: "not materialized in this worktree \u2014 re-run with --materialize to create it"
12249
12490
  };
12250
12491
  }
12251
12492
  const outcome = await addWorktree(member.absPath, memberPath, targetBranch, {
@@ -12300,7 +12541,7 @@ async function runHydrateWorktree(opts) {
12300
12541
  }
12301
12542
 
12302
12543
  // src/workspace/worktree/list.ts
12303
- import path67 from "node:path";
12544
+ import path68 from "node:path";
12304
12545
  import fs47 from "fs-extra";
12305
12546
  async function runList2() {
12306
12547
  const resolved = await resolveWorkspaceForWorktree();
@@ -12313,12 +12554,12 @@ async function runList2() {
12313
12554
  const configuredMembers = await readMembers(rootInfo);
12314
12555
  const worktrees = [];
12315
12556
  for (const slug of slugs) {
12316
- const wtPath = path67.join(dir, slug);
12557
+ const wtPath = path68.join(dir, slug);
12317
12558
  const state = await readWorktreeState(wtPath);
12318
12559
  const members = [];
12319
12560
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
12320
12561
  for (const m of source) {
12321
- const memberPath = path67.join(wtPath, m.folder);
12562
+ const memberPath = path68.join(wtPath, m.folder);
12322
12563
  const materialized = await fs47.pathExists(memberPath);
12323
12564
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
12324
12565
  members.push({
@@ -12339,8 +12580,48 @@ async function runList2() {
12339
12580
  return { ok: true, workspaceRoot, worktrees };
12340
12581
  }
12341
12582
 
12583
+ // src/workspace/worktree/prune.ts
12584
+ async function runPrune2(opts) {
12585
+ const resolved = await resolveWorkspaceForWorktree();
12586
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
12587
+ const { rootInfo, workspaceRoot } = resolved;
12588
+ const members = await readMembers(rootInfo);
12589
+ const orphanScan = await findOrphanedRegistrations(
12590
+ [
12591
+ { id: WORKSPACE_REPO_ID, absPath: workspaceRoot },
12592
+ ...members.map((m) => ({ id: m.id, absPath: m.absPath }))
12593
+ ],
12594
+ worktreesDir(workspaceRoot)
12595
+ );
12596
+ const byRepo = /* @__PURE__ */ new Map();
12597
+ for (const orphan of orphanScan) {
12598
+ const bucket = byRepo.get(orphan.repoId) ?? { ownerRepo: orphan.ownerRepo, orphans: [] };
12599
+ bucket.orphans.push(orphan);
12600
+ byRepo.set(orphan.repoId, bucket);
12601
+ }
12602
+ if (opts.dryRun) {
12603
+ const results2 = Array.from(byRepo, ([repo, { orphans }]) => ({
12604
+ repo,
12605
+ orphans: orphans.map((o) => o.registeredPath),
12606
+ pruned: false
12607
+ }));
12608
+ return { ok: true, dryRun: true, results: results2 };
12609
+ }
12610
+ const results = [];
12611
+ for (const [repo, { ownerRepo, orphans }] of byRepo) {
12612
+ const outcome = await pruneWorktrees(ownerRepo);
12613
+ results.push({
12614
+ repo,
12615
+ orphans: orphans.map((o) => o.registeredPath),
12616
+ pruned: outcome.ok,
12617
+ error: outcome.ok ? void 0 : outcome.stderr ?? "git worktree prune failed"
12618
+ });
12619
+ }
12620
+ return { ok: results.every((r) => r.pruned), dryRun: false, results };
12621
+ }
12622
+
12342
12623
  // src/workspace/worktree/remove.ts
12343
- import path68 from "node:path";
12624
+ import path69 from "node:path";
12344
12625
  import fs48 from "fs-extra";
12345
12626
  async function runRemove2(opts) {
12346
12627
  const resolved = await resolveWorkspaceForWorktree();
@@ -12367,7 +12648,7 @@ async function runRemove2(opts) {
12367
12648
  targets.push({
12368
12649
  repo: configMember?.id ?? stateMember.id,
12369
12650
  ownerRepo,
12370
- path: path68.join(wsWorktreePath, stateMember.folder)
12651
+ path: path69.join(wsWorktreePath, stateMember.folder)
12371
12652
  });
12372
12653
  }
12373
12654
  const existingTargets = [];
@@ -12439,10 +12720,11 @@ function emitJson(result) {
12439
12720
  log(JSON.stringify(result, null, 2));
12440
12721
  }
12441
12722
  function describeInstall(hydration) {
12442
- if (hydration.install.plan.manager === "none") return "no install step (no recognized lockfile)";
12443
- if (!hydration.install.ran)
12444
- return `install skipped (${hydration.install.skippedReason ?? "unavailable"})`;
12445
- return hydration.install.ok ? `hydrated via ${hydration.install.plan.manager}` : `install FAILED via ${hydration.install.plan.manager}`;
12723
+ return summarizeInstall(hydration.install);
12724
+ }
12725
+ function emitMemberLine(hydration, line2) {
12726
+ if (hydration?.install.ran && !hydration.install.ok) warn(line2);
12727
+ else log(line2);
12446
12728
  }
12447
12729
  async function handleAdd(opts) {
12448
12730
  if (!opts.slug) {
@@ -12487,7 +12769,10 @@ async function handleAdd(opts) {
12487
12769
  for (const m of result.members) {
12488
12770
  if (m.status === "ok") {
12489
12771
  const hydrationNote = m.hydration ? describeInstall(m.hydration) : "not hydrated (--no-hydrate)";
12490
- log(` ${m.member}: ${m.branchAction} "${m.branch}" \u2014 ${hydrationNote} (${m.durationMs}ms)`);
12772
+ emitMemberLine(
12773
+ m.hydration,
12774
+ ` ${m.member}: ${m.branchAction} "${m.branch}" \u2014 ${hydrationNote} (${m.durationMs}ms)`
12775
+ );
12491
12776
  } else {
12492
12777
  warn(` ${m.member}: FAILED \u2014 ${m.error}`);
12493
12778
  }
@@ -12531,7 +12816,10 @@ async function handleHydrate(opts) {
12531
12816
  for (const r of result.results) {
12532
12817
  if (r.status === "ok" && r.hydration) {
12533
12818
  const materializedNote = r.branchAction ? `materialized (${r.branchAction}), ` : "";
12534
- log(` ${r.member}: ${materializedNote}${describeInstall(r.hydration)}`);
12819
+ emitMemberLine(
12820
+ r.hydration,
12821
+ ` ${r.member}: ${materializedNote}${describeInstall(r.hydration)}`
12822
+ );
12535
12823
  } else {
12536
12824
  warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
12537
12825
  }
@@ -12580,6 +12868,32 @@ async function handleRemove(opts) {
12580
12868
  }
12581
12869
  }
12582
12870
  }
12871
+ async function handlePrune(opts) {
12872
+ const result = await runPrune2({
12873
+ dryRun: resolveWriteMode(opts, "mutate-by-default").kind === "preview"
12874
+ });
12875
+ if (!result.ok && "error" in result) {
12876
+ if (opts.json) emitJson(result);
12877
+ else error(result.error);
12878
+ process.exitCode = 1;
12879
+ return;
12880
+ }
12881
+ if (opts.json) {
12882
+ emitJson(result);
12883
+ if (!result.dryRun && !result.ok) process.exitCode = 1;
12884
+ return;
12885
+ }
12886
+ if (result.results.length === 0) {
12887
+ log("No stale worktree registrations found.");
12888
+ return;
12889
+ }
12890
+ for (const r of result.results) {
12891
+ const prefix = result.dryRun ? "[dry-run] Would prune" : r.pruned ? "Pruned" : "FAILED to prune";
12892
+ const detail = r.error ? ` \u2014 ${r.error}` : "";
12893
+ log(`${prefix} ${r.repo}: ${r.orphans.join(", ")}${detail}`);
12894
+ }
12895
+ if (!result.dryRun && !result.ok) process.exitCode = 1;
12896
+ }
12583
12897
  async function handleList(opts) {
12584
12898
  const result = await runList2();
12585
12899
  if (opts.json) {
@@ -12631,12 +12945,17 @@ async function handleDoctor(opts) {
12631
12945
  }
12632
12946
  if (opts.fromHook) {
12633
12947
  const scope = report.slug ? `Workspace worktree "${report.slug}"` : "This haus workspace";
12948
+ const needsMaterialize = report.members.some((m) => !m.materialized || !m.hydrated);
12949
+ const directive = report.autoHydrate && needsMaterialize ? "autoHydrate is enabled for this workspace: run `haus workspace worktree hydrate --materialize` now, before any other work, to materialize and hydrate the missing members. " : "";
12950
+ const remedies = directive ? report.remedies.filter((r) => !r.includes("--materialize")) : report.remedies;
12951
+ const remedySuffix = remedies.length > 0 ? ` ${remedies.join(" ")}` : "";
12634
12952
  emitFromHookContext(
12635
- `${scope} has ${report.problems.length} issue(s): ${report.problems.join("; ")}`
12953
+ `${directive}${scope} has ${report.problems.length} issue(s): ${report.problems.join("; ")}${remedySuffix}`
12636
12954
  );
12637
12955
  return;
12638
12956
  }
12639
12957
  for (const p of report.problems) warn(p);
12958
+ for (const r of report.remedies) log(r);
12640
12959
  process.exitCode = 1;
12641
12960
  } catch (err) {
12642
12961
  const message2 = err instanceof Error ? err.message : String(err);
@@ -12660,12 +12979,14 @@ async function runWorktree(action, opts = {}) {
12660
12979
  return handleList(opts);
12661
12980
  case "doctor":
12662
12981
  return handleDoctor(opts);
12982
+ case "prune":
12983
+ return handlePrune(opts);
12663
12984
  }
12664
12985
  }
12665
12986
 
12666
12987
  // src/commands/workspace.ts
12667
- import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
12668
- import path77 from "node:path";
12988
+ import { existsSync as existsSync11, statSync as statSync3 } from "node:fs";
12989
+ import path78 from "node:path";
12669
12990
  init_fs();
12670
12991
  init_logger();
12671
12992
 
@@ -12723,8 +13044,8 @@ ${summaries.map(
12723
13044
  }
12724
13045
 
12725
13046
  // src/commands/workspace/doctor.ts
12726
- import { existsSync as existsSync7 } from "node:fs";
12727
- import path70 from "node:path";
13047
+ import { existsSync as existsSync8 } from "node:fs";
13048
+ import path71 from "node:path";
12728
13049
  init_logger();
12729
13050
  init_paths();
12730
13051
 
@@ -12732,14 +13053,14 @@ init_paths();
12732
13053
  init_fs();
12733
13054
  init_paths();
12734
13055
  import { readFileSync as readFileSync4 } from "node:fs";
12735
- import path69 from "node:path";
13056
+ import path70 from "node:path";
12736
13057
  var MANIFEST_FILE = "workspace.manifest.json";
12737
13058
  function manifestPath(workspaceRoot) {
12738
13059
  return hausPath(workspaceRoot, MANIFEST_FILE);
12739
13060
  }
12740
13061
  function hausVersion() {
12741
13062
  try {
12742
- const pkg = JSON.parse(readFileSync4(path69.join(packageRoot(), "package.json"), "utf8"));
13063
+ const pkg = JSON.parse(readFileSync4(path70.join(packageRoot(), "package.json"), "utf8"));
12743
13064
  return pkg.version ?? "0.0.0";
12744
13065
  } catch {
12745
13066
  return "0.0.0";
@@ -12831,7 +13152,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
12831
13152
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
12832
13153
  const catalogRefByRepo = [];
12833
13154
  for (const repo of config.repos) {
12834
- const repoRoot = path70.resolve(workspaceRoot, repo.path);
13155
+ const repoRoot = path71.resolve(workspaceRoot, repo.path);
12835
13156
  const entry = manifestByName.get(repo.name);
12836
13157
  if (!entry) {
12837
13158
  flag({
@@ -12856,7 +13177,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
12856
13177
  detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
12857
13178
  });
12858
13179
  }
12859
- if (!existsSync7(claudePath(repoRoot))) {
13180
+ if (!existsSync8(claudePath(repoRoot))) {
12860
13181
  flag({
12861
13182
  repo: repo.name,
12862
13183
  kind: "missing-claude",
@@ -12865,7 +13186,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
12865
13186
  }
12866
13187
  const lock = await checkLock(repoRoot);
12867
13188
  if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
12868
- if (!existsSync7(hausPath(repoRoot, "haus.lock.json"))) {
13189
+ if (!existsSync8(hausPath(repoRoot, "haus.lock.json"))) {
12869
13190
  flag({
12870
13191
  repo: repo.name,
12871
13192
  kind: "missing-lock",
@@ -12896,7 +13217,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
12896
13217
  const memberById = new Map(members.map((m) => [m.id, m]));
12897
13218
  for (const entry of manifest.linkedContext) {
12898
13219
  const member = memberById.get(entry.repo);
12899
- if (!member || !existsSync7(member.absPath)) {
13220
+ if (!member || !existsSync8(member.absPath)) {
12900
13221
  flag({
12901
13222
  repo: entry.repo,
12902
13223
  kind: "missing-linked-context-source",
@@ -12904,7 +13225,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
12904
13225
  });
12905
13226
  continue;
12906
13227
  }
12907
- if (!existsSync7(path70.join(workspaceRoot, entry.path))) {
13228
+ if (!existsSync8(path71.join(workspaceRoot, entry.path))) {
12908
13229
  flag({
12909
13230
  repo: entry.repo,
12910
13231
  kind: "missing-linked-context-copy",
@@ -12950,17 +13271,17 @@ function emit(args) {
12950
13271
  init_logger();
12951
13272
 
12952
13273
  // src/workspace/link-context/link.ts
12953
- import path73 from "node:path";
13274
+ import path74 from "node:path";
12954
13275
  import fs51 from "fs-extra";
12955
13276
  init_fs();
12956
13277
  init_logger();
12957
13278
 
12958
13279
  // src/workspace/link-context/plan.ts
12959
- import path72 from "node:path";
13280
+ import path73 from "node:path";
12960
13281
  import fs50 from "fs-extra";
12961
13282
 
12962
13283
  // src/workspace/link-context/scan-source.ts
12963
- import path71 from "node:path";
13284
+ import path72 from "node:path";
12964
13285
  import fs49 from "fs-extra";
12965
13286
  var ASSET_SUBDIR = {
12966
13287
  skill: "skills",
@@ -12969,36 +13290,36 @@ var ASSET_SUBDIR = {
12969
13290
  };
12970
13291
  async function hasLinkableContext(member) {
12971
13292
  for (const subdir of Object.values(ASSET_SUBDIR)) {
12972
- if (await fs49.pathExists(path71.join(member.absPath, ".claude", subdir))) return true;
13293
+ if (await fs49.pathExists(path72.join(member.absPath, ".claude", subdir))) return true;
12973
13294
  }
12974
13295
  return false;
12975
13296
  }
12976
13297
  async function collectSkills(member) {
12977
- const dir = path71.join(member.absPath, ".claude", "skills");
13298
+ const dir = path72.join(member.absPath, ".claude", "skills");
12978
13299
  if (!await fs49.pathExists(dir)) return [];
12979
13300
  const names = await fs49.readdir(dir);
12980
13301
  const out = [];
12981
13302
  for (const name of [...names].sort()) {
12982
- const abs = path71.join(dir, name);
13303
+ const abs = path72.join(dir, name);
12983
13304
  const stat = await fs49.lstat(abs);
12984
13305
  if (!stat.isDirectory()) continue;
12985
- if (!await fs49.pathExists(path71.join(abs, "SKILL.md"))) continue;
12986
- out.push({ type: "skill", name, sourceRelPath: path71.posix.join(".claude", "skills", name) });
13306
+ if (!await fs49.pathExists(path72.join(abs, "SKILL.md"))) continue;
13307
+ out.push({ type: "skill", name, sourceRelPath: path72.posix.join(".claude", "skills", name) });
12987
13308
  }
12988
13309
  return out;
12989
13310
  }
12990
13311
  async function collectFlatMarkdown(member, type) {
12991
13312
  const subdir = ASSET_SUBDIR[type];
12992
- const dir = path71.join(member.absPath, ".claude", subdir);
13313
+ const dir = path72.join(member.absPath, ".claude", subdir);
12993
13314
  if (!await fs49.pathExists(dir)) return [];
12994
13315
  const names = await fs49.readdir(dir);
12995
13316
  const out = [];
12996
13317
  for (const fileName of [...names].sort()) {
12997
13318
  if (!fileName.endsWith(".md")) continue;
12998
- const abs = path71.join(dir, fileName);
13319
+ const abs = path72.join(dir, fileName);
12999
13320
  if (!(await fs49.lstat(abs)).isFile()) continue;
13000
13321
  const name = fileName.slice(0, -".md".length);
13001
- out.push({ type, name, sourceRelPath: path71.posix.join(".claude", subdir, fileName) });
13322
+ out.push({ type, name, sourceRelPath: path72.posix.join(".claude", subdir, fileName) });
13002
13323
  }
13003
13324
  return out;
13004
13325
  }
@@ -13013,9 +13334,9 @@ async function collectSourceAssets(member) {
13013
13334
 
13014
13335
  // src/workspace/link-context/plan.ts
13015
13336
  function repoFolderPrefix(member) {
13016
- const base = path72.posix.basename(member.folder);
13337
+ const base = path73.posix.basename(member.folder);
13017
13338
  if (base && base !== "." && base !== "/") return base;
13018
- const fromAbs = path72.basename(member.absPath);
13339
+ const fromAbs = path73.basename(member.absPath);
13019
13340
  return fromAbs || member.id;
13020
13341
  }
13021
13342
  function assetKey(type, destKey) {
@@ -13072,7 +13393,7 @@ async function buildLinkPlan(members) {
13072
13393
  name: asset.name,
13073
13394
  destKey,
13074
13395
  sourceRelPath: asset.sourceRelPath,
13075
- sourceAbsPath: path72.join(member.absPath, asset.sourceRelPath),
13396
+ sourceAbsPath: path73.join(member.absPath, asset.sourceRelPath),
13076
13397
  sourceHash
13077
13398
  });
13078
13399
  }
@@ -13083,7 +13404,7 @@ async function buildLinkPlan(members) {
13083
13404
  function destRelPathFor(type, destKey) {
13084
13405
  const subdir = `${type}s`;
13085
13406
  const leaf = type === "skill" ? destKey : `${destKey}.md`;
13086
- return path73.posix.join(".claude", subdir, leaf);
13407
+ return path74.posix.join(".claude", subdir, leaf);
13087
13408
  }
13088
13409
  async function runLinkContext(workspaceRoot, opts = {}) {
13089
13410
  const write = resolveWriteMode(opts, "preview-by-default").kind === "write";
@@ -13109,9 +13430,9 @@ async function runLinkContext(workspaceRoot, opts = {}) {
13109
13430
  const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
13110
13431
  for (const entry of plan.entries) {
13111
13432
  const destRel = destRelPathFor(entry.type, entry.destKey);
13112
- const destAbs = path73.join(workspaceRoot, destRel);
13433
+ const destAbs = path74.join(workspaceRoot, destRel);
13113
13434
  if (write) {
13114
- await fs51.ensureDir(path73.dirname(destAbs));
13435
+ await fs51.ensureDir(path74.dirname(destAbs));
13115
13436
  if (await fs51.pathExists(destAbs)) await fs51.remove(destAbs);
13116
13437
  await fs51.copy(entry.sourceAbsPath, destAbs, {
13117
13438
  overwrite: true,
@@ -13199,11 +13520,11 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
13199
13520
  }
13200
13521
 
13201
13522
  // src/commands/workspace/setup.ts
13202
- import { existsSync as existsSync8, statSync } from "node:fs";
13203
- import path75 from "node:path";
13523
+ import { existsSync as existsSync9, statSync } from "node:fs";
13524
+ import path76 from "node:path";
13204
13525
 
13205
13526
  // src/claude/write-workspace-claude-md.ts
13206
- import path74 from "node:path";
13527
+ import path75 from "node:path";
13207
13528
  import fs52 from "fs-extra";
13208
13529
  init_fs();
13209
13530
  init_logger();
@@ -13226,7 +13547,7 @@ ${WORKSPACE_BLOCK_END}`;
13226
13547
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
13227
13548
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
13228
13549
  const dryRun = opts.dryRun ?? false;
13229
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path74.join(workspaceRoot, "CLAUDE.md");
13550
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path75.join(workspaceRoot, "CLAUDE.md");
13230
13551
  const say = opts.quiet ? () => {
13231
13552
  } : log;
13232
13553
  const existed = await fs52.pathExists(filePath);
@@ -13259,20 +13580,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
13259
13580
  init_fs();
13260
13581
  init_logger();
13261
13582
  function resolveWorkspaceRoot(start = process.cwd()) {
13262
- let dir = path75.resolve(start);
13583
+ let dir = path76.resolve(start);
13263
13584
  for (; ; ) {
13264
- if (existsSync8(path75.join(dir, WORKSPACE_FILE))) return dir;
13265
- const parent = path75.dirname(dir);
13266
- if (parent === dir) return path75.resolve(start);
13585
+ if (existsSync9(path76.join(dir, WORKSPACE_FILE))) return dir;
13586
+ const parent = path76.dirname(dir);
13587
+ if (parent === dir) return path76.resolve(start);
13267
13588
  dir = parent;
13268
13589
  }
13269
13590
  }
13270
13591
  function isRootRepo(workspaceRoot, repoPath) {
13271
- return path75.resolve(workspaceRoot, repoPath) === path75.resolve(workspaceRoot);
13592
+ return path76.resolve(workspaceRoot, repoPath) === path76.resolve(workspaceRoot);
13272
13593
  }
13273
13594
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
13274
13595
  const apply = options.write ?? false;
13275
- const configText = await readText(path75.join(workspaceRoot, WORKSPACE_FILE));
13596
+ const configText = await readText(path76.join(workspaceRoot, WORKSPACE_FILE));
13276
13597
  if (!configText) {
13277
13598
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
13278
13599
  process.exitCode = 1;
@@ -13299,11 +13620,11 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13299
13620
  const statuses = [];
13300
13621
  const aggregateInputs = [];
13301
13622
  for (const repo of repos) {
13302
- const repoRoot = path75.resolve(workspaceRoot, repo.path);
13623
+ const repoRoot = path76.resolve(workspaceRoot, repo.path);
13303
13624
  say(`
13304
13625
  \u2192 ${repo.name} (${repo.path})`);
13305
13626
  try {
13306
- if (!existsSync8(repoRoot) || !statSync(repoRoot).isDirectory()) {
13627
+ if (!existsSync9(repoRoot) || !statSync(repoRoot).isDirectory()) {
13307
13628
  throw new Error(`Repo path is not a directory: ${repo.path}`);
13308
13629
  }
13309
13630
  const res = await runSetupCore(repoRoot, {
@@ -13378,7 +13699,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13378
13699
  if (apply && !options.dryRun) {
13379
13700
  const statusByName = new Map(statuses.map((s) => [s.name, s]));
13380
13701
  const prior = await readManifest2(workspaceRoot);
13381
- if (!prior && existsSync8(manifestPath(workspaceRoot))) {
13702
+ if (!prior && existsSync9(manifestPath(workspaceRoot))) {
13382
13703
  warn(
13383
13704
  "Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
13384
13705
  );
@@ -13389,7 +13710,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13389
13710
  const status = statusByName.get(repo.name);
13390
13711
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
13391
13712
  if (status?.status === "ok") {
13392
- const lock = await checkLock(path75.resolve(workspaceRoot, repo.path));
13713
+ const lock = await checkLock(path76.resolve(workspaceRoot, repo.path));
13393
13714
  manifestRepos.push({
13394
13715
  name: repo.name,
13395
13716
  path: repo.path,
@@ -13445,7 +13766,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13445
13766
  try {
13446
13767
  const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
13447
13768
  if (linkResult.ok) {
13448
- written.push(...linkResult.added.map((rel) => path75.join(workspaceRoot, rel)));
13769
+ written.push(...linkResult.added.map((rel) => path76.join(workspaceRoot, rel)));
13449
13770
  if (!options.json) {
13450
13771
  say(
13451
13772
  `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` : ""}.`
@@ -13476,8 +13797,8 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
13476
13797
  }
13477
13798
 
13478
13799
  // src/commands/workspace/undo.ts
13479
- import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
13480
- import path76 from "node:path";
13800
+ import { existsSync as existsSync10, statSync as statSync2 } from "node:fs";
13801
+ import path77 from "node:path";
13481
13802
  import fs53 from "fs-extra";
13482
13803
  init_fs();
13483
13804
  init_logger();
@@ -13508,11 +13829,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13508
13829
  }
13509
13830
  if (options.dryRun) {
13510
13831
  for (const repo of config.repos) {
13511
- const repoRoot = path76.resolve(workspaceRoot, repo.path);
13832
+ const repoRoot = path77.resolve(workspaceRoot, repo.path);
13512
13833
  log(`
13513
13834
  \u2192 ${repo.name} (${repo.path})`);
13514
13835
  try {
13515
- if (!existsSync9(repoRoot) || !statSync2(repoRoot).isDirectory()) {
13836
+ if (!existsSync10(repoRoot) || !statSync2(repoRoot).isDirectory()) {
13516
13837
  throw new Error(`Repo path is not a directory: ${repo.path}`);
13517
13838
  }
13518
13839
  await runUndo({ dryRun: true, root: repoRoot });
@@ -13540,11 +13861,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13540
13861
  ];
13541
13862
  for (const abs of workspaceArtifacts) {
13542
13863
  if (await fs53.pathExists(abs)) {
13543
- log(`[dry-run] Would remove ${path76.relative(workspaceRoot, abs)}`);
13864
+ log(`[dry-run] Would remove ${path77.relative(workspaceRoot, abs)}`);
13544
13865
  }
13545
13866
  }
13546
13867
  if (!await fs53.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
13547
- const claudeMd = await readText(path76.join(workspaceRoot, "CLAUDE.md"));
13868
+ const claudeMd = await readText(path77.join(workspaceRoot, "CLAUDE.md"));
13548
13869
  if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
13549
13870
  log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
13550
13871
  }
@@ -13563,11 +13884,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13563
13884
  }
13564
13885
  let anyRepoFailed = false;
13565
13886
  for (const repo of config.repos) {
13566
- const repoRoot = path76.resolve(workspaceRoot, repo.path);
13887
+ const repoRoot = path77.resolve(workspaceRoot, repo.path);
13567
13888
  log(`
13568
13889
  \u2192 ${repo.name} (${repo.path})`);
13569
13890
  try {
13570
- if (!existsSync9(repoRoot) || !statSync2(repoRoot).isDirectory()) {
13891
+ if (!existsSync10(repoRoot) || !statSync2(repoRoot).isDirectory()) {
13571
13892
  throw new Error(`Repo path is not a directory: ${repo.path}`);
13572
13893
  }
13573
13894
  await runUndo({ yes: true, root: repoRoot });
@@ -13592,21 +13913,21 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13592
13913
  const abs = hausPath(workspaceRoot, rel);
13593
13914
  if (await fs53.pathExists(abs)) {
13594
13915
  await fs53.remove(abs);
13595
- log(`Removed ${path76.relative(workspaceRoot, abs)}`);
13916
+ log(`Removed ${path77.relative(workspaceRoot, abs)}`);
13596
13917
  }
13597
13918
  }
13598
13919
  const manifestFile = manifestPath(workspaceRoot);
13599
13920
  if (await fs53.pathExists(manifestFile)) {
13600
13921
  await fs53.remove(manifestFile);
13601
- log(`Removed ${path76.relative(workspaceRoot, manifestFile)}`);
13922
+ log(`Removed ${path77.relative(workspaceRoot, manifestFile)}`);
13602
13923
  }
13603
13924
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
13604
13925
  if (await fs53.pathExists(workspaceMdPath)) {
13605
13926
  await fs53.remove(workspaceMdPath);
13606
- log(`Removed ${path76.relative(workspaceRoot, workspaceMdPath)}`);
13927
+ log(`Removed ${path77.relative(workspaceRoot, workspaceMdPath)}`);
13607
13928
  } else {
13608
- const claudeMdPath = path76.join(workspaceRoot, "CLAUDE.md");
13609
- if (existsSync9(claudeMdPath)) {
13929
+ const claudeMdPath = path77.join(workspaceRoot, "CLAUDE.md");
13930
+ if (existsSync10(claudeMdPath)) {
13610
13931
  const prev = await readText(claudeMdPath);
13611
13932
  if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
13612
13933
  const next = stripHausBlock(prev, {
@@ -13652,7 +13973,7 @@ function normalizeMaxDepth(maxDepth) {
13652
13973
  return void 0;
13653
13974
  }
13654
13975
  async function initWorkspace(options = {}) {
13655
- if (existsSync10(WORKSPACE_FILE) && !options.yes) {
13976
+ if (existsSync11(WORKSPACE_FILE) && !options.yes) {
13656
13977
  const ok = await confirm(
13657
13978
  `${WORKSPACE_FILE} already exists \u2014 overwrite with a fresh placeholder? Existing client/repos/relationships will be lost. Run \`haus workspace discover\` instead to merge in newly found repos.`
13658
13979
  );
@@ -13674,7 +13995,7 @@ relationships: []
13674
13995
  log("Workspace initialized.");
13675
13996
  }
13676
13997
  async function scanWorkspace(workspaceRoot, opts) {
13677
- const configText = await readText(path77.join(workspaceRoot, WORKSPACE_FILE));
13998
+ const configText = await readText(path78.join(workspaceRoot, WORKSPACE_FILE));
13678
13999
  if (!configText) {
13679
14000
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
13680
14001
  process.exitCode = 1;
@@ -13696,9 +14017,9 @@ async function scanWorkspace(workspaceRoot, opts) {
13696
14017
  const inputs = [];
13697
14018
  const failures = [];
13698
14019
  for (const repo of config.repos) {
13699
- const repoRoot = path77.resolve(workspaceRoot, repo.path);
14020
+ const repoRoot = path78.resolve(workspaceRoot, repo.path);
13700
14021
  try {
13701
- if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
14022
+ if (!existsSync11(repoRoot) || !statSync3(repoRoot).isDirectory()) {
13702
14023
  throw new Error("path is not a directory");
13703
14024
  }
13704
14025
  const result = await scanProject(repoRoot);
@@ -13764,7 +14085,7 @@ init_logger();
13764
14085
  init_paths();
13765
14086
  function cliVersion() {
13766
14087
  try {
13767
- const pkgPath = path78.join(packageRoot(), "package.json");
14088
+ const pkgPath = path79.join(packageRoot(), "package.json");
13768
14089
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
13769
14090
  return pkg.version ?? "0.0.0";
13770
14091
  } catch {
@@ -13774,7 +14095,7 @@ function cliVersion() {
13774
14095
  var program = new Command();
13775
14096
  function validateRuntimeNodeVersion() {
13776
14097
  try {
13777
- const pkgPath = path78.join(packageRoot(), "package.json");
14098
+ const pkgPath = path79.join(packageRoot(), "package.json");
13778
14099
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
13779
14100
  const requiredRange = pkg.engines?.node;
13780
14101
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -13835,7 +14156,7 @@ backups.command("prune").description("Delete old backups by age and/or count (re
13835
14156
  })
13836
14157
  );
13837
14158
  program.command("explain-recommendation").description("Print a per-item breakdown of the current recommendation, with skip reasons").option("--json").action(runExplainRecommendation);
13838
- program.command("init").description("Full setup: scan, recommend, and optionally apply Claude files (safe to re-run)").option("--dry-run", "Preview the `.claude/` write; scan artifacts are still written").option("--json", "Machine-readable scan + recommendation documents (requires --dry-run)").option(
14159
+ program.command("init").description("Full setup: scan, recommend, and optionally apply Claude files (safe to re-run)").option("--dry-run", "Preview the `.claude/` write; scan artifacts are still written").option("--json", "Emit one JSON envelope on stdout (requires --dry-run)").option(
13839
14160
  "--force",
13840
14161
  "Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
13841
14162
  ).action(runInit);
@@ -13911,6 +14232,11 @@ worktree.command("list").description("List materialized workspace worktrees and
13911
14232
  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").option("--json", "Emit the structured remove result as JSON").action(
13912
14233
  (slug, opts) => runWorktree("remove", { slug, force: opts.force, dryRun: opts.dryRun, json: opts.json })
13913
14234
  );
14235
+ worktree.command("prune").description(
14236
+ "Remove stale worktree registrations left by a workspace worktree deleted without `haus workspace worktree remove` (e.g. a natively deleted Claude Code worktree)"
14237
+ ).option("--dry-run", "Preview which registrations would be pruned").option("--json", "Emit the structured prune result as JSON").action(
14238
+ (opts) => runWorktree("prune", { dryRun: opts.dryRun, json: opts.json })
14239
+ );
13914
14240
  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 }));
13915
14241
  program.parseAsync(process.argv).catch((err) => {
13916
14242
  const message2 = err instanceof Error ? err.message : String(err);