@haus-tech/haus-workflow 1.5.8 → 1.7.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
@@ -29,12 +29,15 @@ async function runCommand(command, args = [], options = {}) {
29
29
  // non-zero exits are returned, not thrown
30
30
  ...options
31
31
  });
32
+ const killed = result.timedOut === true || result.isCanceled === true || Boolean(result.signal);
32
33
  return {
33
34
  command,
34
35
  args,
35
36
  stdout: String(result.stdout ?? ""),
36
- stderr: String(result.stderr ?? ""),
37
- exitCode: result.exitCode ?? 0
37
+ stderr: String(
38
+ result.stderr ?? (result.timedOut === true ? `timed out after ${options.timeout}ms` : "")
39
+ ),
40
+ exitCode: result.exitCode ?? (killed || result.failed === true ? 1 : 0)
38
41
  };
39
42
  } catch (error2) {
40
43
  const message = error2 instanceof Error ? error2.message : String(error2);
@@ -44,9 +47,9 @@ async function runCommand(command, args = [], options = {}) {
44
47
  async function runGit(args, options = {}) {
45
48
  return runCommand("git", args, options);
46
49
  }
47
- async function commandExists(command) {
50
+ async function commandExists(command, timeoutMs = 1e4) {
48
51
  try {
49
- const result = await runCommand(command, ["--version"]);
52
+ const result = await runCommand(command, ["--version"], { timeout: timeoutMs });
50
53
  return result.exitCode === 0;
51
54
  } catch {
52
55
  return false;
@@ -60,11 +63,11 @@ var init_exec = __esm({
60
63
 
61
64
  // src/cli.ts
62
65
  import { readFileSync as readFileSync5 } from "node:fs";
63
- import path72 from "node:path";
66
+ import path73 from "node:path";
64
67
  import { Command } from "commander";
65
68
 
66
69
  // src/commands/apply.ts
67
- import path24 from "node:path";
70
+ import path25 from "node:path";
68
71
  import checkbox from "@inquirer/checkbox";
69
72
 
70
73
  // src/catalog/load-catalog.ts
@@ -216,6 +219,11 @@ function hausPath(root, ...parts) {
216
219
  function claudePath(root, ...parts) {
217
220
  return path2.join(root, ".claude", ...parts);
218
221
  }
222
+ function claudeConfigDir() {
223
+ const override = process.env.CLAUDE_CONFIG_DIR;
224
+ if (override && override.trim().length > 0) return override;
225
+ return path2.join(os.homedir(), ".claude");
226
+ }
219
227
  function displayPath(root, targetPath) {
220
228
  const rel = path2.relative(root, targetPath).replace(/\\/g, "/");
221
229
  if (rel && !rel.startsWith("../") && rel !== "..") {
@@ -894,7 +902,15 @@ function parseManifest(json) {
894
902
  if (formerIdFailures.length > 0) {
895
903
  return { ok: false, error: formerIdFailures[0] };
896
904
  }
897
- return { ok: true, manifest: { version: root.version, items }, warnings };
905
+ return {
906
+ ok: true,
907
+ manifest: {
908
+ version: root.version,
909
+ items,
910
+ ...root.globalPlugins !== void 0 ? { globalPlugins: root.globalPlugins } : {}
911
+ },
912
+ warnings
913
+ };
898
914
  }
899
915
 
900
916
  // src/catalog/remote-catalog/manifest.ts
@@ -1484,13 +1500,16 @@ async function syncRemoteCatalog() {
1484
1500
  warn("Remote catalog fetch failed \u2014 using bundled catalog");
1485
1501
  return { newItems: [], refreshed: [], unchanged: 0, failed: [] };
1486
1502
  }
1487
- const { version, items } = manifest;
1503
+ const { version, items, globalPlugins } = manifest;
1488
1504
  const cacheDir = getCacheDir();
1489
1505
  try {
1490
1506
  await fs6.ensureDir(cacheDir);
1491
1507
  await fs6.writeFile(
1492
1508
  path8.join(cacheDir, "manifest.json"),
1493
- `${JSON.stringify({ version, items }, null, 2)}
1509
+ // globalPlugins must round-trip into the cache: the cache is what `haus update`
1510
+ // and `haus refresh` read pins from, so dropping it here would make every
1511
+ // catalog pin bump invisible and pin resolution fall back to the bundled default.
1512
+ `${JSON.stringify({ version, items, ...globalPlugins !== void 0 ? { globalPlugins } : {} }, null, 2)}
1494
1513
  `,
1495
1514
  "utf8"
1496
1515
  );
@@ -1544,14 +1563,20 @@ async function loadCatalogContext(root) {
1544
1563
  return {
1545
1564
  items: data2?.items ?? [],
1546
1565
  contentRoot: path9.dirname(envPath),
1547
- source: "fixture"
1566
+ source: "fixture",
1567
+ globalPlugins: data2?.globalPlugins
1548
1568
  };
1549
1569
  }
1550
1570
  const cacheDir = getCacheDir();
1551
1571
  const cacheManifestPath = path9.join(cacheDir, "manifest.json");
1552
1572
  const cacheData = await readJson(cacheManifestPath);
1553
1573
  if (cacheData?.items?.length) {
1554
- return { items: cacheData.items, contentRoot: cacheDir, source: "cache" };
1574
+ return {
1575
+ items: cacheData.items,
1576
+ contentRoot: cacheDir,
1577
+ source: "cache",
1578
+ globalPlugins: cacheData.globalPlugins
1579
+ };
1555
1580
  }
1556
1581
  const localManifest = path9.join(root, "library/catalog/manifest.json");
1557
1582
  const localData = await readJson(localManifest);
@@ -1559,7 +1584,8 @@ async function loadCatalogContext(root) {
1559
1584
  return {
1560
1585
  items: localData.items,
1561
1586
  contentRoot: path9.dirname(localManifest),
1562
- source: "local"
1587
+ source: "local",
1588
+ globalPlugins: localData.globalPlugins
1563
1589
  };
1564
1590
  }
1565
1591
  const packageManifest = path9.join(packageRoot(), "library/catalog/manifest.json");
@@ -1567,7 +1593,8 @@ async function loadCatalogContext(root) {
1567
1593
  return {
1568
1594
  items: data?.items ?? [],
1569
1595
  contentRoot: path9.dirname(packageManifest),
1570
- source: "bundled"
1596
+ source: "bundled",
1597
+ globalPlugins: data?.globalPlugins
1571
1598
  };
1572
1599
  }
1573
1600
  async function loadCatalog(root) {
@@ -1857,6 +1884,26 @@ function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
1857
1884
  return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
1858
1885
  }
1859
1886
 
1887
+ // src/recommender/superseded-by-plugin.ts
1888
+ var RULES = [
1889
+ {
1890
+ pinId: "superpowers",
1891
+ originSourceId: "superpowers-pcvelz",
1892
+ idPrefix: "haus.superpowers-"
1893
+ }
1894
+ ];
1895
+ function isItemSupersededByPlugin(item, activePinIds) {
1896
+ if (item.originSourceId === void 0) return false;
1897
+ return RULES.some(
1898
+ (rule) => activePinIds.includes(rule.pinId) && rule.originSourceId === item.originSourceId
1899
+ );
1900
+ }
1901
+ function isSupersededByPlugin(lockId, activePinIds) {
1902
+ return RULES.some(
1903
+ (rule) => (activePinIds === void 0 || activePinIds.includes(rule.pinId)) && lockId.startsWith(rule.idPrefix)
1904
+ );
1905
+ }
1906
+
1860
1907
  // src/update/hash-installed.ts
1861
1908
  import path11 from "node:path";
1862
1909
  import fg2 from "fast-glob";
@@ -2025,13 +2072,22 @@ async function readManifest() {
2025
2072
  async function writeManifest(manifest) {
2026
2073
  await writeJson(hausManifestPath(), manifest);
2027
2074
  }
2028
- function buildManifest(source, files, hooks) {
2075
+ async function recordManifestPlugins(plugins) {
2076
+ if (plugins.length === 0) return;
2077
+ const manifest = await readManifest();
2078
+ if (!manifest) return;
2079
+ const byId = new Map((manifest.plugins ?? []).map((p) => [p.id, p]));
2080
+ for (const plugin of plugins) byId.set(plugin.id, plugin);
2081
+ await writeManifest({ ...manifest, plugins: [...byId.values()] });
2082
+ }
2083
+ function buildManifest(source, files, hooks, plugins) {
2029
2084
  return {
2030
2085
  _schema: MANIFEST_SCHEMA,
2031
2086
  source,
2032
2087
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2033
2088
  files,
2034
- hooks
2089
+ hooks,
2090
+ ...plugins && plugins.length > 0 ? { plugins } : {}
2035
2091
  };
2036
2092
  }
2037
2093
 
@@ -2287,8 +2343,8 @@ function buildDenyRules() {
2287
2343
  for (const command of DENY_COMMANDS) {
2288
2344
  rules.push(`Bash(${command}:*)`);
2289
2345
  }
2290
- for (const path73 of DENY_PATHS) {
2291
- const pattern = DENY_DIRS.has(path73) ? `${path73}/**` : path73;
2346
+ for (const path74 of DENY_PATHS) {
2347
+ const pattern = DENY_DIRS.has(path74) ? `${path74}/**` : path74;
2292
2348
  for (const tool of FILE_TOOLS) {
2293
2349
  rules.push(`${tool}(${pattern})`);
2294
2350
  }
@@ -2371,6 +2427,7 @@ import path14 from "node:path";
2371
2427
  import fg3 from "fast-glob";
2372
2428
  import fs10 from "fs-extra";
2373
2429
  var SUPERPOWERS_ORIGIN_SOURCE_ID = "superpowers-pcvelz";
2430
+ var SUPERPOWERS_SHARED_INSTALL_REL = ".claude/skills/shared";
2374
2431
  var PATH_REWRITES = [
2375
2432
  ["skills/shared/", ".claude/skills/shared/"]
2376
2433
  ];
@@ -3246,6 +3303,10 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3246
3303
  warn(`Skipping ${item.id}: reviewStatus is deprecated`);
3247
3304
  continue;
3248
3305
  }
3306
+ if (isItemSupersededByPlugin(manifestItem, opts.activePluginIds ?? [])) {
3307
+ warn(`Skipping ${item.id}: provided by an installed global plugin`);
3308
+ continue;
3309
+ }
3249
3310
  const manifestItemTags = (manifestItem.tags ?? []).map((t) => t.toLowerCase());
3250
3311
  if (UNSUPPORTED.some((x) => manifestItemTags.includes(x))) {
3251
3312
  warn(`Skipping ${item.id}: unsupported stack policy`);
@@ -3371,7 +3432,13 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3371
3432
  `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
3372
3433
  );
3373
3434
  }
3374
- await cleanupStaleCatalogItems(root, cleanupManifestById, dryRun, opts.quiet);
3435
+ await cleanupStaleCatalogItems(
3436
+ root,
3437
+ cleanupManifestById,
3438
+ dryRun,
3439
+ opts.quiet,
3440
+ opts.activePluginIds ?? []
3441
+ );
3375
3442
  const refuseDestructiveLockChange = recommendationUnusable && prevLock.length > 0 && !opts.force;
3376
3443
  if (opts.prune) {
3377
3444
  if (refuseDestructiveLockChange) {
@@ -3515,25 +3582,46 @@ async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn) {
3515
3582
  }
3516
3583
  return existing;
3517
3584
  }
3518
- async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
3585
+ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet, activePluginIds = []) {
3519
3586
  const say = quiet ? () => {
3520
3587
  } : log;
3521
3588
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
3522
3589
  if (!prevLock?.length) return;
3590
+ const supersededEntries = prevLock.filter(
3591
+ (entry) => entry.id && isSupersededByPlugin(entry.id, activePluginIds)
3592
+ );
3593
+ let keepShared = false;
3594
+ for (const entry of supersededEntries) {
3595
+ const relPaths = entry.paths ?? [];
3596
+ if (relPaths.length === 0) continue;
3597
+ const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
3598
+ if (existing.length === 0) continue;
3599
+ if (entry.hash === void 0) {
3600
+ keepShared = true;
3601
+ continue;
3602
+ }
3603
+ const currentHash = await hashInstalledPaths(
3604
+ root,
3605
+ existing.map((e) => e.rel)
3606
+ );
3607
+ if (currentHash !== entry.hash) keepShared = true;
3608
+ }
3523
3609
  for (const entry of prevLock) {
3524
3610
  if (!entry.id) continue;
3525
3611
  const manifestItem = manifestById.get(entry.id);
3526
3612
  const removedFromManifest = manifestItem === void 0;
3527
3613
  const deprecated = manifestItem?.reviewStatus === "deprecated";
3528
- if (!removedFromManifest && !deprecated) continue;
3614
+ const superseded = isSupersededByPlugin(entry.id, activePluginIds);
3615
+ if (!removedFromManifest && !deprecated && !superseded) continue;
3529
3616
  const relPaths = entry.paths ?? [];
3530
3617
  if (relPaths.length === 0) continue;
3531
3618
  const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
3532
3619
  if (existing.length === 0) continue;
3533
- const pruneReason = deprecated ? "deprecated" : "stale";
3620
+ const pruneReason = superseded ? "superseded" : deprecated ? "deprecated" : "stale";
3621
+ const label = superseded ? "Superseded" : deprecated ? "Deprecated" : "Stale";
3534
3622
  if (entry.hash === void 0) {
3535
3623
  warn(
3536
- `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3624
+ `${label} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3537
3625
  );
3538
3626
  continue;
3539
3627
  }
@@ -3543,11 +3631,28 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
3543
3631
  );
3544
3632
  if (currentHash !== entry.hash) {
3545
3633
  warn(
3546
- `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3634
+ `${label} catalog item ${entry.id} was modified locally \u2014 leaving in place${superseded ? " (the global plugin provides it; delete the copy manually if you no longer want it)" : ""}: ${existing.map((e) => e.rel).join(", ")}`
3547
3635
  );
3548
3636
  continue;
3549
3637
  }
3550
- for (const { abs } of existing) {
3638
+ const removable = superseded ? existing.filter(({ rel }) => {
3639
+ const isShared = rel.split(path20.sep).join("/") === SUPERPOWERS_SHARED_INSTALL_REL;
3640
+ if (isShared && keepShared) {
3641
+ say(
3642
+ `Keeping ${displayPath(root, path20.join(root, rel))} \u2014 a locally-edited superpowers skill still uses it`
3643
+ );
3644
+ return false;
3645
+ }
3646
+ return true;
3647
+ }) : existing;
3648
+ if (superseded && !dryRun && removable.length > 0) {
3649
+ await backupBeforePrune(
3650
+ root,
3651
+ removable.map((e) => e.abs),
3652
+ say
3653
+ );
3654
+ }
3655
+ for (const { abs } of removable) {
3551
3656
  if (dryRun) {
3552
3657
  say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
3553
3658
  continue;
@@ -3786,16 +3891,431 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
3786
3891
  return untracked;
3787
3892
  }
3788
3893
 
3894
+ // src/install/ensure-global-plugins.ts
3895
+ import path22 from "node:path";
3896
+ init_exec();
3897
+
3898
+ // src/install/global-plugins.ts
3899
+ var DEFAULT_GLOBAL_PLUGINS = [
3900
+ {
3901
+ id: "superpowers",
3902
+ plugin: "superpowers-extended-cc",
3903
+ marketplace: "superpowers-extended-cc-marketplace",
3904
+ url: "https://github.com/pcvelz/superpowers.git",
3905
+ ref: "v6.4.0"
3906
+ }
3907
+ ];
3908
+ var MANAGED_SCOPE = "user";
3909
+ function pluginId(pin) {
3910
+ return `${pin.plugin}@${pin.marketplace}`;
3911
+ }
3912
+ function canonicalUrl(url) {
3913
+ return url.trim().toLowerCase().replace(/\.git$/, "").replace(/\/+$/, "");
3914
+ }
3915
+ function isNonEmptyString3(value) {
3916
+ return typeof value === "string" && value.trim().length > 0;
3917
+ }
3918
+ function normalizeMarketplaceSource(marketplace) {
3919
+ if (marketplace.source === "git" && isNonEmptyString3(marketplace.url)) {
3920
+ return { url: canonicalUrl(marketplace.url), ref: marketplace.ref };
3921
+ }
3922
+ if (marketplace.source === "github" && isNonEmptyString3(marketplace.repo)) {
3923
+ return { url: canonicalUrl(`https://github.com/${marketplace.repo}`), ref: marketplace.ref };
3924
+ }
3925
+ return null;
3926
+ }
3927
+ function resolveGlobalPlugins(manifest, deps = {}) {
3928
+ const warn2 = deps.warn ?? warn;
3929
+ const declared = manifest?.globalPlugins;
3930
+ if (declared === void 0) return [...DEFAULT_GLOBAL_PLUGINS];
3931
+ if (!Array.isArray(declared)) {
3932
+ warn2("Catalog `globalPlugins` is not an array \u2014 falling back to the bundled pin.");
3933
+ return [...DEFAULT_GLOBAL_PLUGINS];
3934
+ }
3935
+ const pins = [];
3936
+ for (const entry of declared) {
3937
+ if (!isValidPin(entry)) {
3938
+ const label = entry !== null && typeof entry === "object" && isNonEmptyString3(entry.id) ? entry.id : JSON.stringify(entry);
3939
+ warn2(`Skipping malformed catalog globalPlugins entry: ${label}`);
3940
+ continue;
3941
+ }
3942
+ if (!isImmutableRef(entry.ref)) {
3943
+ warn2(
3944
+ `Refusing catalog pin ${entry.id}: ref "${entry.ref}" is not a release tag \u2014 haus only installs immutable, reviewed plugin content.`
3945
+ );
3946
+ continue;
3947
+ }
3948
+ pins.push({
3949
+ id: entry.id,
3950
+ plugin: entry.plugin,
3951
+ marketplace: entry.marketplace,
3952
+ url: entry.url,
3953
+ ref: entry.ref
3954
+ });
3955
+ }
3956
+ return pins;
3957
+ }
3958
+ function isValidPin(entry) {
3959
+ if (entry === null || typeof entry !== "object") return false;
3960
+ const candidate = entry;
3961
+ return isNonEmptyString3(candidate.id) && isNonEmptyString3(candidate.plugin) && isNonEmptyString3(candidate.marketplace) && isNonEmptyString3(candidate.url) && isNonEmptyString3(candidate.ref);
3962
+ }
3963
+ var RELEASE_TAG = /^v?\d+(\.\d+)*([-+][0-9A-Za-z.-]+)?$/;
3964
+ function isImmutableRef(ref) {
3965
+ return RELEASE_TAG.test(ref.trim());
3966
+ }
3967
+ function findManagedInstall(state, pin) {
3968
+ const id = pluginId(pin);
3969
+ return state.installed.find((p) => p.id === id && (p.scope ?? MANAGED_SCOPE) === MANAGED_SCOPE);
3970
+ }
3971
+ function isPluginActive(state, pin) {
3972
+ if (!state.available) return false;
3973
+ const id = pluginId(pin);
3974
+ return state.installed.some((p) => p.id === id && p.enabled !== false);
3975
+ }
3976
+ function planPluginActions(desired, state) {
3977
+ return desired.map((pin) => planOne(pin, state));
3978
+ }
3979
+ function planOne(pin, state) {
3980
+ if (!state.available) {
3981
+ return {
3982
+ pin,
3983
+ kind: "unavailable",
3984
+ steps: [],
3985
+ reason: "the `claude` CLI is not on PATH \u2014 plugin state is unknown"
3986
+ };
3987
+ }
3988
+ const marketplace = state.marketplaces.find((m) => m.name === pin.marketplace);
3989
+ const normalized = marketplace ? normalizeMarketplaceSource(marketplace) : null;
3990
+ const marketplaceMatches = normalized !== null && normalized.url === canonicalUrl(pin.url) && normalized.ref === pin.ref;
3991
+ const installed = findManagedInstall(state, pin);
3992
+ const disabled = installed?.enabled === false;
3993
+ const enableStep = disabled ? ["plugin-enable"] : [];
3994
+ if (!installed) {
3995
+ const steps = marketplaceMatches ? ["plugin-install"] : ["marketplace-add", "plugin-install"];
3996
+ return {
3997
+ pin,
3998
+ kind: "install",
3999
+ steps,
4000
+ reason: marketplace ? `plugin ${pluginId(pin)} is not installed at ${MANAGED_SCOPE} scope` : `marketplace ${pin.marketplace} is not configured`
4001
+ };
4002
+ }
4003
+ if (!marketplaceMatches) {
4004
+ return {
4005
+ pin,
4006
+ kind: "repin",
4007
+ steps: [
4008
+ "marketplace-add",
4009
+ "marketplace-update",
4010
+ "plugin-uninstall",
4011
+ "plugin-install",
4012
+ ...enableStep
4013
+ ],
4014
+ reason: `marketplace ${pin.marketplace} does not track the pinned ref ${pin.ref}`,
4015
+ ...normalized?.ref !== void 0 ? { overriddenRef: normalized.ref } : {}
4016
+ };
4017
+ }
4018
+ const versionsDiffer = isNonEmptyString3(marketplace?.pluginVersion) && isNonEmptyString3(installed.version) && marketplace.pluginVersion !== installed.version;
4019
+ if (versionsDiffer) {
4020
+ return {
4021
+ pin,
4022
+ kind: "repin",
4023
+ steps: ["marketplace-update", "plugin-uninstall", "plugin-install", ...enableStep],
4024
+ reason: `installed ${installed.version} does not match marketplace ${marketplace?.pluginVersion} at ref ${pin.ref}`
4025
+ };
4026
+ }
4027
+ if (disabled) {
4028
+ return {
4029
+ pin,
4030
+ kind: "re-enable",
4031
+ steps: ["plugin-enable"],
4032
+ reason: `plugin ${pluginId(pin)} is disabled \u2014 haus workflows depend on it`
4033
+ };
4034
+ }
4035
+ return { pin, kind: "up-to-date", steps: [], reason: `pinned to ${pin.ref}` };
4036
+ }
4037
+ function pluginDriftStatus(actions, opts) {
4038
+ if (actions.length === 0) return false;
4039
+ if (actions.some((a) => a.kind === "unavailable")) return null;
4040
+ return actions.some((a) => {
4041
+ if (a.kind === "install" || a.kind === "re-enable") return true;
4042
+ if (a.kind === "repin") return opts.matchRef;
4043
+ return false;
4044
+ });
4045
+ }
4046
+
4047
+ // src/install/ensure-global-plugins.ts
4048
+ async function loadCatalogPluginPins(root) {
4049
+ const ctx = await loadCatalogContext(root);
4050
+ return resolveGlobalPlugins({ globalPlugins: ctx.globalPlugins });
4051
+ }
4052
+ async function activeGlobalPluginIds(root, deps = {}) {
4053
+ try {
4054
+ const [pins, state] = await Promise.all([loadCatalogPluginPins(root), probePluginEnv(deps)]);
4055
+ if (!state.available) return [];
4056
+ return pins.filter((pin) => isPluginActive(state, pin)).map((pin) => pin.id);
4057
+ } catch {
4058
+ return [];
4059
+ }
4060
+ }
4061
+ var EXEC_TIMEOUT_MS = 12e4;
4062
+ var EXEC_ENV = { GIT_TERMINAL_PROMPT: "0" };
4063
+ function globalPluginsDisabled() {
4064
+ return process.env.HAUS_NO_GLOBAL_PLUGINS === "1";
4065
+ }
4066
+ var realExec = (args) => runCommand("claude", args, {
4067
+ timeout: EXEC_TIMEOUT_MS,
4068
+ env: EXEC_ENV,
4069
+ extendEnv: true
4070
+ });
4071
+ function parseInstalled(stdout) {
4072
+ try {
4073
+ const parsed = JSON.parse(stdout);
4074
+ const list = Array.isArray(parsed) ? parsed : parsed !== null && typeof parsed === "object" ? parsed.installed : void 0;
4075
+ if (!Array.isArray(list)) return null;
4076
+ return list.flatMap((entry) => {
4077
+ const item = entry;
4078
+ if (typeof item.id !== "string") return [];
4079
+ return [
4080
+ {
4081
+ id: item.id,
4082
+ ...typeof item.version === "string" ? { version: item.version } : {},
4083
+ ...typeof item.scope === "string" ? { scope: item.scope } : {},
4084
+ ...typeof item.enabled === "boolean" ? { enabled: item.enabled } : {}
4085
+ }
4086
+ ];
4087
+ });
4088
+ } catch {
4089
+ return null;
4090
+ }
4091
+ }
4092
+ async function readMarketplaceRefs(configDir) {
4093
+ const settings = await readJson(path22.join(configDir, "settings.json"));
4094
+ const out = {};
4095
+ for (const [name, entry] of Object.entries(settings?.extraKnownMarketplaces ?? {})) {
4096
+ const ref = entry?.source?.ref;
4097
+ out[name] = typeof ref === "string" ? ref : void 0;
4098
+ }
4099
+ return out;
4100
+ }
4101
+ async function readClonedPluginVersion(installLocation) {
4102
+ const manifest = await readJson(
4103
+ path22.join(installLocation, ".claude-plugin", "plugin.json")
4104
+ );
4105
+ return typeof manifest?.version === "string" ? manifest.version : void 0;
4106
+ }
4107
+ async function parseMarketplaces(stdout, configDir) {
4108
+ let rows = [];
4109
+ try {
4110
+ const parsed = JSON.parse(stdout);
4111
+ if (!Array.isArray(parsed)) return null;
4112
+ rows = parsed;
4113
+ } catch {
4114
+ return null;
4115
+ }
4116
+ const refs = await readMarketplaceRefs(configDir);
4117
+ const out = [];
4118
+ for (const row of rows) {
4119
+ if (typeof row.name !== "string") continue;
4120
+ const installLocation = typeof row.installLocation === "string" ? row.installLocation : void 0;
4121
+ out.push({
4122
+ name: row.name,
4123
+ ...typeof row.source === "string" ? { source: row.source } : {},
4124
+ ...typeof row.url === "string" ? { url: row.url } : {},
4125
+ ...typeof row.repo === "string" ? { repo: row.repo } : {},
4126
+ ...refs[row.name] !== void 0 ? { ref: refs[row.name] } : {},
4127
+ ...installLocation ? { pluginVersion: await readClonedPluginVersion(installLocation) } : {}
4128
+ });
4129
+ }
4130
+ return out;
4131
+ }
4132
+ async function probePluginEnv(deps = {}) {
4133
+ const exec = deps.exec ?? realExec;
4134
+ const isAvailable = deps.isAvailable ?? (() => commandExists("claude"));
4135
+ const warn2 = deps.warn ?? warn;
4136
+ if (globalPluginsDisabled() && deps.exec === void 0) {
4137
+ return { available: false, installed: [], marketplaces: [] };
4138
+ }
4139
+ if (!await isAvailable()) return { available: false, installed: [], marketplaces: [] };
4140
+ const [listed, markets] = await Promise.all([
4141
+ exec(["plugin", "list", "--json"]),
4142
+ exec(["plugin", "marketplace", "list", "--json"])
4143
+ ]);
4144
+ if (listed.exitCode !== 0 || markets.exitCode !== 0) {
4145
+ warn2(
4146
+ `Could not read Claude Code plugin state (${listed.exitCode !== 0 ? "plugin list" : "marketplace list"} exited ${listed.exitCode !== 0 ? listed.exitCode : markets.exitCode}) \u2014 leaving global plugins untouched.`
4147
+ );
4148
+ return { available: false, installed: [], marketplaces: [] };
4149
+ }
4150
+ const configDir = deps.configDir ?? claudeConfigDir();
4151
+ const installed = parseInstalled(listed.stdout);
4152
+ const marketplaces = await parseMarketplaces(markets.stdout, configDir);
4153
+ if (installed === null || marketplaces === null) {
4154
+ warn2("Claude Code returned unreadable plugin state \u2014 leaving global plugins untouched.");
4155
+ return { available: false, installed: [], marketplaces: [] };
4156
+ }
4157
+ return { available: true, installed, marketplaces };
4158
+ }
4159
+ function stepArgs(step, pin) {
4160
+ const id = pluginId(pin);
4161
+ switch (step) {
4162
+ case "marketplace-add":
4163
+ return ["plugin", "marketplace", "add", `${pin.url}#${pin.ref}`, "--scope", "user"];
4164
+ case "marketplace-update":
4165
+ return ["plugin", "marketplace", "update", pin.marketplace];
4166
+ case "plugin-uninstall":
4167
+ return ["plugin", "uninstall", id, "--scope", "user"];
4168
+ case "plugin-install":
4169
+ return ["plugin", "install", id, "--scope", "user"];
4170
+ case "plugin-update":
4171
+ return ["plugin", "update", id, "--scope", "user"];
4172
+ case "plugin-enable":
4173
+ return ["plugin", "enable", id, "--scope", "user"];
4174
+ }
4175
+ }
4176
+ var RESTART_STEPS = /* @__PURE__ */ new Set([
4177
+ "plugin-uninstall",
4178
+ "plugin-install",
4179
+ "plugin-update",
4180
+ "plugin-enable"
4181
+ ]);
4182
+ function manualCommands(pin) {
4183
+ return [
4184
+ `claude plugin marketplace add ${pin.url}#${pin.ref}`,
4185
+ `claude plugin install ${pluginId(pin)}`
4186
+ ];
4187
+ }
4188
+ async function ensureGlobalPlugins(pins, deps = {}) {
4189
+ const exec = deps.exec ?? realExec;
4190
+ const log2 = deps.log ?? log;
4191
+ const warn2 = deps.warn ?? warn;
4192
+ const dryRun = deps.dryRun ?? false;
4193
+ if (globalPluginsDisabled() && deps.exec === void 0) {
4194
+ return { actions: [], ensured: [], failed: [], unavailable: true, restartRequired: false };
4195
+ }
4196
+ const state = await probePluginEnv(deps);
4197
+ const actions = planPluginActions(pins, state);
4198
+ const result = {
4199
+ actions,
4200
+ ensured: [],
4201
+ failed: [],
4202
+ unavailable: !state.available,
4203
+ restartRequired: false
4204
+ };
4205
+ for (const action of actions) {
4206
+ if (action.kind === "unavailable") {
4207
+ warn2(
4208
+ `Claude Code CLI not found \u2014 cannot manage the ${action.pin.id} plugin. Install it manually:`
4209
+ );
4210
+ manualCommands(action.pin).forEach((cmd) => warn2(` ${cmd}`));
4211
+ continue;
4212
+ }
4213
+ if (action.kind === "up-to-date") {
4214
+ result.ensured.push(action.pin.id);
4215
+ continue;
4216
+ }
4217
+ if (action.overriddenRef !== void 0 && action.overriddenRef !== action.pin.ref) {
4218
+ warn2(
4219
+ `Re-pointing marketplace ${action.pin.marketplace} from ${action.overriddenRef} to the haus-pinned ${action.pin.ref} \u2014 haus manages this plugin's version.`
4220
+ );
4221
+ }
4222
+ if (dryRun) {
4223
+ log2(`[dry-run] would ${action.kind} ${action.pin.id}: ${action.reason}`);
4224
+ action.steps.forEach(
4225
+ (step) => log2(`[dry-run] claude ${stepArgs(step, action.pin).join(" ")}`)
4226
+ );
4227
+ continue;
4228
+ }
4229
+ let ok = true;
4230
+ for (const step of action.steps) {
4231
+ const args = stepArgs(step, action.pin);
4232
+ const run = await exec(args);
4233
+ if (run.exitCode !== 0) {
4234
+ ok = false;
4235
+ warn2(`\`claude ${args.join(" ")}\` failed: ${run.stderr.trim() || `exit ${run.exitCode}`}`);
4236
+ break;
4237
+ }
4238
+ if (RESTART_STEPS.has(step)) result.restartRequired = true;
4239
+ }
4240
+ if (ok) {
4241
+ result.ensured.push(action.pin.id);
4242
+ log2(
4243
+ `Global plugin ${action.pin.id} ${action.kind === "install" ? "installed" : "updated"} (${action.pin.ref}).`
4244
+ );
4245
+ } else {
4246
+ result.failed.push(action.pin.id);
4247
+ warn2(`Could not set up the ${action.pin.id} plugin. Run manually:`);
4248
+ manualCommands(action.pin).forEach((cmd) => warn2(` ${cmd}`));
4249
+ }
4250
+ }
4251
+ if (result.restartRequired && !dryRun) {
4252
+ log2("Restart Claude Code to load the updated plugin.");
4253
+ }
4254
+ return result;
4255
+ }
4256
+ async function removeGlobalPlugins(plugins, deps = {}) {
4257
+ const exec = deps.exec ?? realExec;
4258
+ const warn2 = deps.warn ?? warn;
4259
+ const log2 = deps.log ?? log;
4260
+ const result = { removed: [], failed: [], unavailable: false };
4261
+ if (plugins.length === 0) return result;
4262
+ if (globalPluginsDisabled() && deps.exec === void 0) {
4263
+ return { ...result, unavailable: true };
4264
+ }
4265
+ const state = await probePluginEnv(deps);
4266
+ if (!state.available) {
4267
+ result.unavailable = true;
4268
+ warn2("Claude Code CLI not found \u2014 leaving the haus-managed global plugin(s) in place.");
4269
+ return result;
4270
+ }
4271
+ const scopeKey = (id, scope) => `${id}@@${scope ?? "user"}`;
4272
+ const stillInstalled = new Set(state.installed.map((p) => scopeKey(p.id, p.scope)));
4273
+ for (const pin of plugins) {
4274
+ const id = pluginId(pin);
4275
+ const managedKey = scopeKey(id, "user");
4276
+ if (deps.dryRun) {
4277
+ if (stillInstalled.has(managedKey))
4278
+ log2(`[dry-run] would remove global plugin ${pin.id} (${id})`);
4279
+ continue;
4280
+ }
4281
+ if (stillInstalled.has(managedKey)) {
4282
+ const uninstalled = await exec(["plugin", "uninstall", id, "--scope", "user"]);
4283
+ if (uninstalled.exitCode !== 0) {
4284
+ result.failed.push(pin.id);
4285
+ warn2(
4286
+ `Could not remove plugin ${id}: ${uninstalled.stderr.trim() || `exit ${uninstalled.exitCode}`}`
4287
+ );
4288
+ continue;
4289
+ }
4290
+ stillInstalled.delete(managedKey);
4291
+ result.removed.push(pin.id);
4292
+ }
4293
+ const marketplaceStillConfigured = state.marketplaces.some((m) => m.name === pin.marketplace);
4294
+ const othersFromMarketplace = [...stillInstalled].some(
4295
+ (key) => key.split("@@")[0]?.endsWith(`@${pin.marketplace}`)
4296
+ );
4297
+ if (marketplaceStillConfigured && !othersFromMarketplace) {
4298
+ const removedMarket = await exec(["plugin", "marketplace", "remove", pin.marketplace]);
4299
+ if (removedMarket.exitCode !== 0) {
4300
+ warn2(
4301
+ `Could not remove marketplace ${pin.marketplace}: ${removedMarket.stderr.trim() || `exit ${removedMarket.exitCode}`}`
4302
+ );
4303
+ }
4304
+ }
4305
+ }
4306
+ return result;
4307
+ }
4308
+
3789
4309
  // src/refs/fetch-refs.ts
3790
- import path23 from "node:path";
4310
+ import path24 from "node:path";
3791
4311
  import fs21 from "fs-extra";
3792
4312
 
3793
4313
  // src/refs/cache-meta.ts
3794
- import path22 from "node:path";
4314
+ import path23 from "node:path";
3795
4315
  import fs20 from "fs-extra";
3796
4316
  var META_FILENAME = "cache-meta.json";
3797
4317
  async function readCacheMeta(cacheDir) {
3798
- const metaPath = path22.join(cacheDir, META_FILENAME);
4318
+ const metaPath = path23.join(cacheDir, META_FILENAME);
3799
4319
  try {
3800
4320
  const raw = await fs20.readFile(metaPath, "utf8");
3801
4321
  return JSON.parse(raw);
@@ -3805,7 +4325,7 @@ async function readCacheMeta(cacheDir) {
3805
4325
  }
3806
4326
  async function writeCacheMeta(cacheDir, meta) {
3807
4327
  await fs20.ensureDir(cacheDir);
3808
- const metaPath = path22.join(cacheDir, META_FILENAME);
4328
+ const metaPath = path23.join(cacheDir, META_FILENAME);
3809
4329
  await fs20.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
3810
4330
  }
3811
4331
 
@@ -3826,8 +4346,8 @@ function isLlmsTxtUrl(ref) {
3826
4346
  async function fetchSingleRef(url, cacheDir, meta) {
3827
4347
  const existing = meta[url];
3828
4348
  const headers = {};
3829
- const safeFile = existing?.file && existing.file === path23.basename(existing.file);
3830
- const cachedFileExists = safeFile && await fs21.pathExists(path23.join(cacheDir, existing.file));
4349
+ const safeFile = existing?.file && existing.file === path24.basename(existing.file);
4350
+ const cachedFileExists = safeFile && await fs21.pathExists(path24.join(cacheDir, existing.file));
3831
4351
  if (cachedFileExists) {
3832
4352
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
3833
4353
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -3839,7 +4359,7 @@ async function fetchSingleRef(url, cacheDir, meta) {
3839
4359
  const text = await res.text();
3840
4360
  const file = `${urlToSlug(url)}.md`;
3841
4361
  await fs21.ensureDir(cacheDir);
3842
- await fs21.writeFile(path23.join(cacheDir, file), text, "utf8");
4362
+ await fs21.writeFile(path24.join(cacheDir, file), text, "utf8");
3843
4363
  meta[url] = {
3844
4364
  url,
3845
4365
  etag: res.headers.get("etag") ?? void 0,
@@ -3862,8 +4382,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
3862
4382
  if (keepUrls.has(url)) continue;
3863
4383
  delete meta[url];
3864
4384
  removed++;
3865
- const safeFile = typeof entry.file === "string" && entry.file === path23.basename(entry.file);
3866
- if (safeFile) await fs21.remove(path23.join(cacheDir, entry.file)).catch(() => {
4385
+ const safeFile = typeof entry.file === "string" && entry.file === path24.basename(entry.file);
4386
+ if (safeFile) await fs21.remove(path24.join(cacheDir, entry.file)).catch(() => {
3867
4387
  });
3868
4388
  }
3869
4389
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -3885,10 +4405,10 @@ async function fetchRefsForItems(items, cacheDir) {
3885
4405
  const outcome = await fetchSingleRef(url, cacheDir, meta);
3886
4406
  if (outcome.result === "fetched") {
3887
4407
  summary.fetched++;
3888
- summary.cachedFiles[url] = path23.join(cacheDir, outcome.file);
4408
+ summary.cachedFiles[url] = path24.join(cacheDir, outcome.file);
3889
4409
  } else if (outcome.result === "unchanged") {
3890
4410
  summary.unchanged++;
3891
- if (meta[url]?.file) summary.cachedFiles[url] = path23.join(cacheDir, meta[url].file);
4411
+ if (meta[url]?.file) summary.cachedFiles[url] = path24.join(cacheDir, meta[url].file);
3892
4412
  } else {
3893
4413
  summary.failed++;
3894
4414
  summary.failedUrls.push(url);
@@ -3907,11 +4427,17 @@ function parseIdList(value) {
3907
4427
  const raw = Array.isArray(value) ? value : [value];
3908
4428
  return raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0);
3909
4429
  }
4430
+ function normalizeOnly(only) {
4431
+ if (!only) return void 0;
4432
+ const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
4433
+ const cleaned = list.map((s) => s.trim()).filter(Boolean);
4434
+ return cleaned.length > 0 ? cleaned : void 0;
4435
+ }
3910
4436
 
3911
4437
  // src/commands/apply.ts
3912
4438
  var STALE_CACHE_THRESHOLD_DAYS = 7;
3913
4439
  async function cacheHasItems() {
3914
- const data = await readJson(path24.join(getCacheDir(), "manifest.json"));
4440
+ const data = await readJson(path25.join(getCacheDir(), "manifest.json"));
3915
4441
  return Array.isArray(data?.items) && data.items.length > 0;
3916
4442
  }
3917
4443
  function installableRecommendedItems(recommended) {
@@ -4015,7 +4541,9 @@ async function runApply(options) {
4015
4541
  const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
4016
4542
  refillConfig: options.refillConfig,
4017
4543
  force: options.force,
4018
- prune: options.prune
4544
+ prune: options.prune,
4545
+ // Copies a global plugin already provides are removed here (issue #272).
4546
+ activePluginIds: await activeGlobalPluginIds(root)
4019
4547
  });
4020
4548
  await writeGitignore(root, isDryRun);
4021
4549
  await untrackMachineLocalArtifacts(root, isDryRun, warn);
@@ -4062,7 +4590,7 @@ async function runApply(options) {
4062
4590
  }
4063
4591
 
4064
4592
  // src/commands/backups.ts
4065
- import path25 from "node:path";
4593
+ import path26 from "node:path";
4066
4594
  import fs22 from "fs-extra";
4067
4595
 
4068
4596
  // src/utils/prompts.ts
@@ -4099,7 +4627,7 @@ async function listBackups(root) {
4099
4627
  for (const name of names) {
4100
4628
  const kind = classify(name);
4101
4629
  if (!kind) continue;
4102
- const absPath = path25.join(dir, name);
4630
+ const absPath = path26.join(dir, name);
4103
4631
  const stat = await fs22.lstat(absPath);
4104
4632
  if (stat.isSymbolicLink()) continue;
4105
4633
  const expectDirectory = kind !== "lock";
@@ -4116,23 +4644,33 @@ function formatAge(mtimeMs, now) {
4116
4644
  if (hours < 48) return `${hours}h ago`;
4117
4645
  return `${Math.round(hours / 24)}d ago`;
4118
4646
  }
4119
- async function runList(root) {
4647
+ async function runList(root, json) {
4120
4648
  const entries = await listBackups(root);
4649
+ const newestFirst = [...entries].reverse();
4650
+ if (json) {
4651
+ log(
4652
+ JSON.stringify(
4653
+ newestFirst.map(({ id, kind, mtimeMs, absPath }) => ({ id, kind, mtimeMs, absPath })),
4654
+ null,
4655
+ 2
4656
+ )
4657
+ );
4658
+ return;
4659
+ }
4121
4660
  if (entries.length === 0) {
4122
4661
  log("No backups found under .haus-workflow/backups/.");
4123
4662
  return;
4124
4663
  }
4125
4664
  const now = Date.now();
4126
- const newestFirst = [...entries].reverse();
4127
4665
  for (const entry of newestFirst) {
4128
4666
  log(`${entry.id} [${entry.kind}] ${formatAge(entry.mtimeMs, now)}`);
4129
4667
  }
4130
4668
  }
4131
4669
  async function isUnsafeWriteTarget(root, target) {
4132
- const segments = path25.relative(root, target).split(path25.sep).filter(Boolean);
4670
+ const segments = path26.relative(root, target).split(path26.sep).filter(Boolean);
4133
4671
  let current = root;
4134
4672
  for (let i = 0; i < segments.length; i++) {
4135
- current = path25.join(current, segments[i]);
4673
+ current = path26.join(current, segments[i]);
4136
4674
  const isLastSegment = i === segments.length - 1;
4137
4675
  try {
4138
4676
  const stat = await fs22.lstat(current);
@@ -4157,13 +4695,13 @@ async function lstatIfRegularFile(abs) {
4157
4695
  throw err;
4158
4696
  }
4159
4697
  }
4160
- async function restoreLockBackup(root, entry, yes) {
4698
+ async function restoreLockBackup(root, entry, yes, json, dryRun) {
4161
4699
  const target = hausPath(root, "haus.lock.json");
4162
4700
  if (await isUnsafeWriteTarget(root, target)) {
4163
4701
  error(
4164
4702
  `Refusing to restore: ${target} (or a parent directory) is a symlink or other non-regular file \u2014 never written through.`
4165
4703
  );
4166
- return false;
4704
+ return { ok: false, restored: 0 };
4167
4705
  }
4168
4706
  if (await fs22.pathExists(target)) {
4169
4707
  const targetStat = await fs22.stat(target);
@@ -4173,22 +4711,30 @@ async function restoreLockBackup(root, entry, yes) {
4173
4711
  );
4174
4712
  }
4175
4713
  }
4714
+ if (dryRun) {
4715
+ if (!json) {
4716
+ log(
4717
+ `[dry-run] Would restore .haus-workflow/haus.lock.json from ${entry.id}. Nothing was changed.`
4718
+ );
4719
+ }
4720
+ return { ok: true, restored: 0, dryRun: true, wouldRestore: [".haus-workflow/haus.lock.json"] };
4721
+ }
4176
4722
  if (!yes) {
4177
4723
  const ok = await confirm(
4178
4724
  `Restore .haus-workflow/haus.lock.json from backup ${entry.id}? This overwrites the current lockfile.`
4179
4725
  );
4180
4726
  if (!ok) {
4181
- log("Cancelled.");
4182
- return false;
4727
+ if (!json) log("Cancelled.");
4728
+ return { ok: false, restored: 0 };
4183
4729
  }
4184
4730
  }
4185
4731
  if (!await lstatIfRegularFile(entry.absPath) || await isUnsafeWriteTarget(root, target)) {
4186
4732
  error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
4187
- return false;
4733
+ return { ok: false, restored: 0 };
4188
4734
  }
4189
4735
  await fs22.copy(entry.absPath, target, { overwrite: true });
4190
- log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
4191
- return true;
4736
+ if (!json) log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
4737
+ return { ok: true, restored: 1 };
4192
4738
  }
4193
4739
  async function collectFilesRecursive(dir) {
4194
4740
  const files = [];
@@ -4196,7 +4742,7 @@ async function collectFilesRecursive(dir) {
4196
4742
  const skippedOther = [];
4197
4743
  const names = await fs22.readdir(dir);
4198
4744
  for (const name of names) {
4199
- const abs = path25.join(dir, name);
4745
+ const abs = path26.join(dir, name);
4200
4746
  const stat = await fs22.lstat(abs);
4201
4747
  if (stat.isSymbolicLink()) {
4202
4748
  skippedLinks.push(abs);
@@ -4215,27 +4761,27 @@ async function collectFilesRecursive(dir) {
4215
4761
  }
4216
4762
  return { files, skippedLinks, skippedOther };
4217
4763
  }
4218
- async function restoreDirBackup(root, entry, yes) {
4764
+ async function restoreDirBackup(root, entry, yes, json, dryRun) {
4219
4765
  const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
4220
4766
  if (skippedLinks.length > 0) {
4221
4767
  warn(
4222
- `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path25.relative(entry.absPath, abs)).join(", ")}`
4768
+ `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path26.relative(entry.absPath, abs)).join(", ")}`
4223
4769
  );
4224
4770
  }
4225
4771
  if (skippedOther.length > 0) {
4226
4772
  warn(
4227
- `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path25.relative(entry.absPath, abs)).join(", ")}`
4773
+ `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path26.relative(entry.absPath, abs)).join(", ")}`
4228
4774
  );
4229
4775
  }
4230
4776
  if (files.length === 0) {
4231
- log(`Backup ${entry.id} contains no files.`);
4232
- return false;
4777
+ if (!json) log(`Backup ${entry.id} contains no files.`);
4778
+ return { ok: false, restored: 0 };
4233
4779
  }
4234
4780
  const safeFiles = [];
4235
4781
  const unsafeTargets = [];
4236
4782
  for (const abs of files) {
4237
- const rel = path25.relative(entry.absPath, abs);
4238
- const target = path25.join(root, rel);
4783
+ const rel = path26.relative(entry.absPath, abs);
4784
+ const target = path26.join(root, rel);
4239
4785
  if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
4240
4786
  else safeFiles.push(abs);
4241
4787
  }
@@ -4245,21 +4791,21 @@ async function restoreDirBackup(root, entry, yes) {
4245
4791
  );
4246
4792
  }
4247
4793
  if (safeFiles.length === 0) {
4248
- log(`Backup ${entry.id} has no restorable targets.`);
4249
- return false;
4794
+ if (!json) log(`Backup ${entry.id} has no restorable targets.`);
4795
+ return { ok: false, restored: 0 };
4250
4796
  }
4251
4797
  const stale = [];
4252
4798
  const vanished = [];
4253
4799
  const toRestore = [];
4254
4800
  for (const abs of safeFiles) {
4255
- const rel = path25.relative(entry.absPath, abs);
4801
+ const rel = path26.relative(entry.absPath, abs);
4256
4802
  const backupFileStat = await lstatIfRegularFile(abs);
4257
4803
  if (!backupFileStat) {
4258
4804
  vanished.push(rel);
4259
4805
  continue;
4260
4806
  }
4261
4807
  toRestore.push(abs);
4262
- const target = path25.join(root, rel);
4808
+ const target = path26.join(root, rel);
4263
4809
  if (await fs22.pathExists(target)) {
4264
4810
  const targetStat = await fs22.stat(target);
4265
4811
  if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
@@ -4277,8 +4823,8 @@ async function restoreDirBackup(root, entry, yes) {
4277
4823
  );
4278
4824
  }
4279
4825
  if (toRestore.length === 0) {
4280
- log(`Backup ${entry.id} has no restorable files left after re-validation.`);
4281
- return false;
4826
+ if (!json) log(`Backup ${entry.id} has no restorable files left after re-validation.`);
4827
+ return { ok: false, restored: 0 };
4282
4828
  }
4283
4829
  if (stale.length > 0) {
4284
4830
  const detail = stale.map(
@@ -4288,25 +4834,34 @@ async function restoreDirBackup(root, entry, yes) {
4288
4834
  `${stale.length} file(s) at the restore destination are newer than backup ${entry.id} and will be overwritten: ${detail}`
4289
4835
  );
4290
4836
  }
4837
+ if (dryRun) {
4838
+ const rels = toRestore.map((abs) => path26.relative(entry.absPath, abs));
4839
+ if (!json) {
4840
+ log(`[dry-run] Would restore ${rels.length} file(s) from ${entry.id}:`);
4841
+ for (const rel of rels) log(` ${rel}`);
4842
+ log("[dry-run] Nothing was changed.");
4843
+ }
4844
+ return { ok: true, restored: 0, dryRun: true, wouldRestore: rels };
4845
+ }
4291
4846
  if (!yes) {
4292
4847
  const ok = await confirm(
4293
4848
  `Restore ${toRestore.length} file(s) from backup ${entry.id} to their original paths?`
4294
4849
  );
4295
4850
  if (!ok) {
4296
- log("Cancelled.");
4297
- return false;
4851
+ if (!json) log("Cancelled.");
4852
+ return { ok: false, restored: 0 };
4298
4853
  }
4299
4854
  }
4300
4855
  let restoredCount = 0;
4301
4856
  const skippedAtWrite = [];
4302
4857
  for (const abs of toRestore) {
4303
- const rel = path25.relative(entry.absPath, abs);
4304
- const target = path25.join(root, rel);
4858
+ const rel = path26.relative(entry.absPath, abs);
4859
+ const target = path26.join(root, rel);
4305
4860
  if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
4306
4861
  skippedAtWrite.push(rel);
4307
4862
  continue;
4308
4863
  }
4309
- await fs22.ensureDir(path25.dirname(target));
4864
+ await fs22.ensureDir(path26.dirname(target));
4310
4865
  await fs22.copy(abs, target, { overwrite: true });
4311
4866
  restoredCount++;
4312
4867
  }
@@ -4315,23 +4870,20 @@ async function restoreDirBackup(root, entry, yes) {
4315
4870
  `Skipped ${skippedAtWrite.length} file(s) that changed since being checked, right before restoring: ${skippedAtWrite.join(", ")}`
4316
4871
  );
4317
4872
  }
4318
- log(`Restored ${restoredCount} file(s) from ${entry.id}.`);
4319
- return restoredCount > 0;
4873
+ if (!json) log(`Restored ${restoredCount} file(s) from ${entry.id}.`);
4874
+ return { ok: restoredCount > 0, restored: restoredCount };
4320
4875
  }
4321
- async function runRestore(root, id, yes) {
4876
+ async function runRestore(root, id, yes, json, dryRun) {
4322
4877
  const entries = await listBackups(root);
4323
4878
  const entry = entries.find((e) => e.id === id);
4324
4879
  if (!entry) {
4325
4880
  error(`No backup found with id "${id}". Run \`haus backups list\` to see available backups.`);
4326
- return false;
4881
+ return { ok: false, restored: 0 };
4327
4882
  }
4328
- return entry.kind === "lock" ? restoreLockBackup(root, entry, yes) : restoreDirBackup(root, entry, yes);
4883
+ return entry.kind === "lock" ? restoreLockBackup(root, entry, yes, json, dryRun) : restoreDirBackup(root, entry, yes, json, dryRun);
4329
4884
  }
4330
4885
  async function runPrune(root, opts) {
4331
- if (opts.olderThanDays === void 0 && opts.keep === void 0) {
4332
- error("Specify --older-than <days> or --keep <n> \u2014 refusing to prune with no bound.");
4333
- return false;
4334
- }
4886
+ const json = Boolean(opts.json);
4335
4887
  const entries = await listBackups(root);
4336
4888
  const now = Date.now();
4337
4889
  const toRemove = /* @__PURE__ */ new Set();
@@ -4344,11 +4896,32 @@ async function runPrune(root, opts) {
4344
4896
  if (excess > 0) for (const entry of entries.slice(0, excess)) toRemove.add(entry.id);
4345
4897
  }
4346
4898
  if (toRemove.size === 0) {
4347
- log("Nothing to prune.");
4348
- return true;
4899
+ if (!json) log("Nothing to prune.");
4900
+ return {
4901
+ ok: true,
4902
+ removed: [],
4903
+ failed: [],
4904
+ kept: entries.length,
4905
+ ...opts.dryRun ? { dryRun: true, wouldRemove: [] } : {}
4906
+ };
4349
4907
  }
4350
4908
  const removeList = entries.filter((e) => toRemove.has(e.id));
4351
4909
  const keepCount = entries.length - removeList.length;
4910
+ if (opts.dryRun) {
4911
+ if (!json) {
4912
+ log(`[dry-run] Would remove ${removeList.length} backup(s), keeping ${keepCount}:`);
4913
+ for (const e of removeList) log(` ${e.id}`);
4914
+ log("[dry-run] Nothing was changed.");
4915
+ }
4916
+ return {
4917
+ ok: true,
4918
+ removed: [],
4919
+ failed: [],
4920
+ kept: keepCount,
4921
+ dryRun: true,
4922
+ wouldRemove: removeList.map((e) => e.id)
4923
+ };
4924
+ }
4352
4925
  if (!opts.yes) {
4353
4926
  const ok = await confirm(
4354
4927
  `Remove ${removeList.length} backup(s)?
@@ -4356,8 +4929,8 @@ async function runPrune(root, opts) {
4356
4929
  Keeping ${keepCount}.`
4357
4930
  );
4358
4931
  if (!ok) {
4359
- log("Cancelled.");
4360
- return false;
4932
+ if (!json) log("Cancelled.");
4933
+ return { ok: false, removed: [], failed: [], kept: entries.length, cancelled: true };
4361
4934
  }
4362
4935
  }
4363
4936
  const removed = [];
@@ -4370,37 +4943,52 @@ Keeping ${keepCount}.`
4370
4943
  failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
4371
4944
  }
4372
4945
  }
4373
- log(`Removed ${removed.length} backup(s), kept ${entries.length - removed.length}.`);
4946
+ if (!json) log(`Removed ${removed.length} backup(s), kept ${entries.length - removed.length}.`);
4374
4947
  if (failed.length > 0) {
4375
4948
  warn(
4376
4949
  `Failed to remove ${failed.length} backup(s): ${failed.map((f) => `${f.id} (${f.message})`).join(", ")}`
4377
4950
  );
4378
- return false;
4379
4951
  }
4380
- return true;
4952
+ return {
4953
+ ok: failed.length === 0,
4954
+ removed,
4955
+ failed,
4956
+ kept: entries.length - removed.length
4957
+ };
4381
4958
  }
4382
4959
  async function runBackups(action, opts = {}) {
4383
4960
  const root = opts.root ?? process.cwd();
4961
+ const json = Boolean(opts.json);
4384
4962
  if (action === "list") {
4385
- await runList(root);
4963
+ await runList(root, json);
4386
4964
  return;
4387
4965
  }
4388
4966
  if (action === "restore") {
4389
4967
  if (!opts.id) {
4968
+ if (json) log(JSON.stringify({ ok: false, error: "Usage: haus backups restore <id>" }));
4390
4969
  error("Usage: haus backups restore <id>");
4391
4970
  process.exitCode = 1;
4392
4971
  return;
4393
4972
  }
4394
- if (!await runRestore(root, opts.id, Boolean(opts.yes))) process.exitCode = 1;
4973
+ const result2 = await runRestore(root, opts.id, Boolean(opts.yes), json, Boolean(opts.dryRun));
4974
+ if (json) log(JSON.stringify({ id: opts.id, ...result2 }, null, 2));
4975
+ if (!result2.ok) process.exitCode = 1;
4976
+ return;
4977
+ }
4978
+ if (opts.olderThan === void 0 && opts.keep === void 0) {
4979
+ const message = "Specify --older-than <days> or --keep <n> \u2014 refusing to prune with no bound.";
4980
+ if (json) log(JSON.stringify({ ok: false, error: message }));
4981
+ error(message);
4982
+ process.exitCode = 1;
4395
4983
  return;
4396
4984
  }
4397
4985
  let olderThanDays;
4398
4986
  if (opts.olderThan !== void 0) {
4399
4987
  const n = Number(opts.olderThan);
4400
4988
  if (!Number.isFinite(n) || n < 0) {
4401
- error(
4402
- `Invalid --older-than value "${opts.olderThan}" \u2014 expected a non-negative number of days.`
4403
- );
4989
+ const message = `Invalid --older-than value "${opts.olderThan}" \u2014 expected a non-negative number of days.`;
4990
+ if (json) log(JSON.stringify({ ok: false, error: message }));
4991
+ error(message);
4404
4992
  process.exitCode = 1;
4405
4993
  return;
4406
4994
  }
@@ -4410,13 +4998,23 @@ async function runBackups(action, opts = {}) {
4410
4998
  if (opts.keep !== void 0) {
4411
4999
  const n = Number(opts.keep);
4412
5000
  if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
4413
- error(`Invalid --keep value "${opts.keep}" \u2014 expected a non-negative integer.`);
5001
+ const message = `Invalid --keep value "${opts.keep}" \u2014 expected a non-negative integer.`;
5002
+ if (json) log(JSON.stringify({ ok: false, error: message }));
5003
+ error(message);
4414
5004
  process.exitCode = 1;
4415
5005
  return;
4416
5006
  }
4417
5007
  keep = n;
4418
5008
  }
4419
- if (!await runPrune(root, { olderThanDays, keep, yes: Boolean(opts.yes) })) process.exitCode = 1;
5009
+ const result = await runPrune(root, {
5010
+ olderThanDays,
5011
+ keep,
5012
+ yes: Boolean(opts.yes),
5013
+ json,
5014
+ dryRun: Boolean(opts.dryRun)
5015
+ });
5016
+ if (json) log(JSON.stringify(result, null, 2));
5017
+ if (!result.ok) process.exitCode = 1;
4420
5018
  }
4421
5019
 
4422
5020
  // src/commands/catalog-audit.ts
@@ -4445,18 +5043,19 @@ async function runCatalogAudit() {
4445
5043
  }
4446
5044
 
4447
5045
  // src/commands/doctor.ts
4448
- import path39 from "node:path";
5046
+ import path40 from "node:path";
4449
5047
  import fs30 from "fs-extra";
4450
5048
 
4451
5049
  // src/claude/skill-reference-check.ts
4452
- import path26 from "node:path";
5050
+ import path27 from "node:path";
4453
5051
  import fg4 from "fast-glob";
4454
5052
  import fs23 from "fs-extra";
4455
5053
  var REFERENCE_PATTERN = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+)+):([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b(?!:\/\/)/g;
4456
5054
  function stripFencedCodeBlocks(text) {
4457
5055
  return text.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, " "));
4458
5056
  }
4459
- async function findUnresolvedSkillReferences(root) {
5057
+ async function findUnresolvedSkillReferences(root, options = {}) {
5058
+ const resolvableNamespaces = new Set(options.pluginNamespaces ?? []);
4460
5059
  const skillsRoot = claudePath(root, "skills");
4461
5060
  if (!await fs23.pathExists(skillsRoot)) return [];
4462
5061
  const skillDirs = (await fs23.readdir(skillsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== "shared").map((entry) => entry.name);
@@ -4465,15 +5064,16 @@ async function findUnresolvedSkillReferences(root) {
4465
5064
  const issues = [];
4466
5065
  for (const rel of files) {
4467
5066
  if (rel.startsWith("shared/")) continue;
4468
- const abs = path26.join(skillsRoot, rel);
5067
+ const abs = path27.join(skillsRoot, rel);
4469
5068
  const raw = await fs23.readFile(abs, "utf8");
4470
5069
  const lines = stripFencedCodeBlocks(raw).split("\n");
4471
5070
  lines.forEach((line2, idx) => {
4472
5071
  for (const match of line2.matchAll(REFERENCE_PATTERN)) {
4473
- const [reference, , bareName] = match;
5072
+ const [reference, namespace, bareName] = match;
4474
5073
  if (!reference || !bareName) continue;
5074
+ if (namespace && resolvableNamespaces.has(namespace)) continue;
4475
5075
  issues.push({
4476
- file: path26.join(".claude/skills", rel),
5076
+ file: path27.join(".claude/skills", rel),
4477
5077
  line: idx + 1,
4478
5078
  reference,
4479
5079
  bareName,
@@ -4486,11 +5086,11 @@ async function findUnresolvedSkillReferences(root) {
4486
5086
  }
4487
5087
 
4488
5088
  // src/decisions/doctor.ts
4489
- import path31 from "node:path";
5089
+ import path32 from "node:path";
4490
5090
  import fs27 from "fs-extra";
4491
5091
 
4492
5092
  // src/decisions/check.ts
4493
- import path30 from "node:path";
5093
+ import path31 from "node:path";
4494
5094
  import fs26 from "fs-extra";
4495
5095
 
4496
5096
  // src/decisions/dep-bump.ts
@@ -4550,7 +5150,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
4550
5150
 
4551
5151
  // src/decisions/diff.ts
4552
5152
  init_exec();
4553
- import path27 from "node:path";
5153
+ import path28 from "node:path";
4554
5154
  function isPackageJsonPath(file) {
4555
5155
  return file === "package.json" || file.endsWith("/package.json");
4556
5156
  }
@@ -4611,7 +5211,7 @@ async function resolvePackageJsonPair(root, file, opts) {
4611
5211
  return { before: before2, after: after2 };
4612
5212
  }
4613
5213
  const before = await readBlob(root, `${range}:${file}`) ?? empty;
4614
- const after = await readText(path27.join(root, file)) ?? empty;
5214
+ const after = await readText(path28.join(root, file)) ?? empty;
4615
5215
  return { before, after };
4616
5216
  }
4617
5217
  async function collectDiffStats(root, opts) {
@@ -4682,21 +5282,21 @@ function firstMatchingRegex(filePath, patterns) {
4682
5282
  }
4683
5283
 
4684
5284
  // src/decisions/paths.ts
4685
- import path28 from "node:path";
5285
+ import path29 from "node:path";
4686
5286
  import fs24 from "fs-extra";
4687
5287
  async function resolveDecisionsDir(root) {
4688
- const primary = path28.join(root, DECISIONS_TRIGGERS.decisionsDir);
5288
+ const primary = path29.join(root, DECISIONS_TRIGGERS.decisionsDir);
4689
5289
  if (await fs24.pathExists(primary)) return primary;
4690
- const legacy = path28.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
5290
+ const legacy = path29.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4691
5291
  if (await fs24.pathExists(legacy)) return legacy;
4692
5292
  return primary;
4693
5293
  }
4694
5294
  function relativeDecisionPath(decisionsDir, root) {
4695
- return path28.relative(root, decisionsDir).replace(/\\/g, "/");
5295
+ return path29.relative(root, decisionsDir).replace(/\\/g, "/");
4696
5296
  }
4697
5297
 
4698
5298
  // src/decisions/validate.ts
4699
- import path29 from "node:path";
5299
+ import path30 from "node:path";
4700
5300
  import fs25 from "fs-extra";
4701
5301
  function validateDecisionContent(content2) {
4702
5302
  const errors = [];
@@ -4726,14 +5326,14 @@ function decisionNumberFromFile(name) {
4726
5326
  return match?.[1] ?? null;
4727
5327
  }
4728
5328
  async function validateIndexSync(decisionsDir, decisionFiles) {
4729
- const readmePath = path29.join(decisionsDir, "README.md");
5329
+ const readmePath = path30.join(decisionsDir, "README.md");
4730
5330
  if (!await fs25.pathExists(readmePath)) {
4731
5331
  return ["docs/decisions/README.md is missing"];
4732
5332
  }
4733
5333
  const readme = await readText(readmePath) ?? "";
4734
5334
  const errors = [];
4735
5335
  for (const file of decisionFiles) {
4736
- const num = decisionNumberFromFile(path29.basename(file));
5336
+ const num = decisionNumberFromFile(path30.basename(file));
4737
5337
  if (num && !indexReferencesNumber(readme, num)) {
4738
5338
  errors.push(`README.md missing index row for ADR-${num}`);
4739
5339
  }
@@ -4828,22 +5428,22 @@ async function checkSatisfaction(input2) {
4828
5428
  const errors = [];
4829
5429
  const decisionsPrefix = `${input2.decisionsRel}/`;
4830
5430
  const newDecisionFiles = input2.changedFiles.filter(
4831
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path30.basename(f))
5431
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path31.basename(f))
4832
5432
  );
4833
5433
  if (newDecisionFiles.length === 0) {
4834
5434
  errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
4835
5435
  return errors;
4836
5436
  }
4837
- const decisionsDir = path30.join(input2.root, input2.decisionsRel);
5437
+ const decisionsDir = path31.join(input2.root, input2.decisionsRel);
4838
5438
  for (const rel of newDecisionFiles) {
4839
- const abs = path30.join(input2.root, rel);
5439
+ const abs = path31.join(input2.root, rel);
4840
5440
  const content2 = await readText(abs) ?? "";
4841
5441
  const valid = validateDecisionContent(content2);
4842
5442
  if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
4843
5443
  }
4844
5444
  const indexErrors = await validateIndexSync(
4845
5445
  decisionsDir,
4846
- newDecisionFiles.map((f) => path30.basename(f))
5446
+ newDecisionFiles.map((f) => path31.basename(f))
4847
5447
  );
4848
5448
  errors.push(...indexErrors);
4849
5449
  return errors;
@@ -4899,7 +5499,7 @@ ${opts.commitMessages ?? ""}`;
4899
5499
  changedFiles: stats.files
4900
5500
  });
4901
5501
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
4902
- const legacyExists = await fs26.pathExists(path30.join(root, legacyRel));
5502
+ const legacyExists = await fs26.pathExists(path31.join(root, legacyRel));
4903
5503
  if (legacyExists && !await fs26.pathExists(decisionsDir)) {
4904
5504
  satisfactionErrors = await checkSatisfaction({
4905
5505
  root,
@@ -4930,8 +5530,8 @@ async function listDecisionNumbers(decisionsDir) {
4930
5530
  // src/decisions/doctor.ts
4931
5531
  async function auditDecisionsLayout(root) {
4932
5532
  const findings = [];
4933
- const primary = path31.join(root, DECISIONS_TRIGGERS.decisionsDir);
4934
- const legacy = path31.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
5533
+ const primary = path32.join(root, DECISIONS_TRIGGERS.decisionsDir);
5534
+ const legacy = path32.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4935
5535
  const hasPrimary = await fs27.pathExists(primary);
4936
5536
  const hasLegacy = await fs27.pathExists(legacy);
4937
5537
  if (!hasPrimary && hasLegacy) {
@@ -4946,7 +5546,7 @@ async function auditDecisionsLayout(root) {
4946
5546
  if (!hasPrimary) return findings;
4947
5547
  const decisionsDir = await resolveDecisionsDir(root);
4948
5548
  const rel = relativeDecisionPath(decisionsDir, root);
4949
- const readmePath = path31.join(decisionsDir, "README.md");
5549
+ const readmePath = path32.join(decisionsDir, "README.md");
4950
5550
  if (!await fs27.pathExists(readmePath)) {
4951
5551
  findings.push({
4952
5552
  level: "advisory",
@@ -4971,7 +5571,7 @@ async function auditDecisionsLayout(root) {
4971
5571
  });
4972
5572
  }
4973
5573
  }
4974
- const claude = await readText(path31.join(root, "CLAUDE.md"));
5574
+ const claude = await readText(path32.join(root, "CLAUDE.md"));
4975
5575
  if (claude && !claude.includes(`@${rel}/README.md`)) {
4976
5576
  findings.push({
4977
5577
  level: "advisory",
@@ -5002,11 +5602,11 @@ async function auditDecisionsLayout(root) {
5002
5602
  }
5003
5603
 
5004
5604
  // src/scanner/read-context.ts
5005
- import path38 from "node:path";
5605
+ import path39 from "node:path";
5006
5606
  import fs29 from "fs-extra";
5007
5607
 
5008
5608
  // src/commands/workspace/config.ts
5009
- import path32 from "node:path";
5609
+ import path33 from "node:path";
5010
5610
  import YAML from "yaml";
5011
5611
  var WORKSPACE_FILE = "haus.workspace.yaml";
5012
5612
  function parseWorkspaceConfig(text) {
@@ -5029,11 +5629,11 @@ function parseWorkspaceConfig(text) {
5029
5629
  };
5030
5630
  }
5031
5631
  async function readWorkspaceConfig(workspaceRoot) {
5032
- return parseWorkspaceConfig(await readText(path32.join(workspaceRoot, WORKSPACE_FILE)));
5632
+ return parseWorkspaceConfig(await readText(path33.join(workspaceRoot, WORKSPACE_FILE)));
5033
5633
  }
5034
5634
 
5035
5635
  // src/workspace/members.ts
5036
- import path33 from "node:path";
5636
+ import path34 from "node:path";
5037
5637
  var REPOS_MANIFEST_FILE = "repos.manifest.json";
5038
5638
  var REPOS_LOCAL_FILE = "repos.local.json";
5039
5639
  var MemberConfigError = class extends Error {
@@ -5046,14 +5646,14 @@ var MemberConfigError = class extends Error {
5046
5646
  };
5047
5647
  function resolveAbsPath(workspaceRoot, folder, overrides) {
5048
5648
  const override = overrides[folder];
5049
- if (override) return path33.resolve(workspaceRoot, override);
5050
- return path33.resolve(workspaceRoot, folder);
5649
+ if (override) return path34.resolve(workspaceRoot, override);
5650
+ return path34.resolve(workspaceRoot, folder);
5051
5651
  }
5052
5652
  function isSelfMember(workspaceRoot, member) {
5053
- return path33.resolve(member.absPath) === path33.resolve(workspaceRoot);
5653
+ return path34.resolve(member.absPath) === path34.resolve(workspaceRoot);
5054
5654
  }
5055
5655
  async function readPathOverrides(workspaceRoot) {
5056
- const filePath = path33.join(workspaceRoot, REPOS_LOCAL_FILE);
5656
+ const filePath = path34.join(workspaceRoot, REPOS_LOCAL_FILE);
5057
5657
  const result = await readJsonDetailed(filePath);
5058
5658
  if (result.status === "missing") return {};
5059
5659
  if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
@@ -5069,7 +5669,7 @@ async function readPathOverrides(workspaceRoot) {
5069
5669
  return overrides;
5070
5670
  }
5071
5671
  async function readFromWorkspaceYaml(workspaceRoot, overrides) {
5072
- const filePath = path33.join(workspaceRoot, WORKSPACE_FILE);
5672
+ const filePath = path34.join(workspaceRoot, WORKSPACE_FILE);
5073
5673
  const text = await readText(filePath);
5074
5674
  if (text === void 0) return void 0;
5075
5675
  const config = parseWorkspaceConfig(text);
@@ -5082,7 +5682,7 @@ async function readFromWorkspaceYaml(workspaceRoot, overrides) {
5082
5682
  }));
5083
5683
  }
5084
5684
  async function readFromManifest(workspaceRoot, overrides) {
5085
- const filePath = path33.join(workspaceRoot, REPOS_MANIFEST_FILE);
5685
+ const filePath = path34.join(workspaceRoot, REPOS_MANIFEST_FILE);
5086
5686
  const result = await readJsonDetailed(filePath);
5087
5687
  if (result.status === "missing") return void 0;
5088
5688
  if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
@@ -5124,11 +5724,11 @@ async function readMembers(rootInfo) {
5124
5724
  }
5125
5725
 
5126
5726
  // src/scanner/scan-project.ts
5127
- import path37 from "node:path";
5727
+ import path38 from "node:path";
5128
5728
 
5129
5729
  // src/utils/git-root.ts
5130
5730
  init_exec();
5131
- import path34 from "node:path";
5731
+ import path35 from "node:path";
5132
5732
  function fallback(start) {
5133
5733
  return {
5134
5734
  cwd: start,
@@ -5144,11 +5744,11 @@ function fallback(start) {
5144
5744
  async function resolveAbsoluteGitPath(flag, cwd, repoRoot) {
5145
5745
  const withFormat = await runGit(["rev-parse", "--path-format=absolute", flag], { cwd });
5146
5746
  const raw = withFormat.exitCode === 0 ? withFormat.stdout.trim() : "";
5147
- if (raw && path34.isAbsolute(raw)) return path34.resolve(raw);
5747
+ if (raw && path35.isAbsolute(raw)) return path35.resolve(raw);
5148
5748
  const plain = await runGit(["rev-parse", flag], { cwd });
5149
5749
  const plainRaw = plain.exitCode === 0 ? plain.stdout.trim() : "";
5150
5750
  if (!plainRaw) return "";
5151
- return path34.isAbsolute(plainRaw) ? path34.resolve(plainRaw) : path34.resolve(repoRoot, plainRaw);
5751
+ return path35.isAbsolute(plainRaw) ? path35.resolve(plainRaw) : path35.resolve(repoRoot, plainRaw);
5152
5752
  }
5153
5753
  async function resolveRoots(start) {
5154
5754
  const cwd = start ?? process.cwd();
@@ -5157,15 +5757,15 @@ async function resolveRoots(start) {
5157
5757
  if (toplevel.exitCode !== 0) return fallback(cwd);
5158
5758
  const toplevelRaw = toplevel.stdout.trim();
5159
5759
  if (!toplevelRaw) return fallback(cwd);
5160
- const repoRoot = path34.resolve(toplevelRaw);
5760
+ const repoRoot = path35.resolve(toplevelRaw);
5161
5761
  const [gitDir, gitCommonDir] = await Promise.all([
5162
5762
  resolveAbsoluteGitPath("--git-dir", cwd, repoRoot),
5163
5763
  resolveAbsoluteGitPath("--git-common-dir", cwd, repoRoot)
5164
5764
  ]);
5165
5765
  if (!gitDir || !gitCommonDir) return fallback(cwd);
5166
5766
  const isLinkedWorktree = gitDir !== gitCommonDir;
5167
- const mainRoot = isLinkedWorktree && path34.basename(gitCommonDir) === ".git" ? path34.dirname(gitCommonDir) : repoRoot;
5168
- const worktreeName = isLinkedWorktree ? path34.basename(gitDir) : null;
5767
+ const mainRoot = isLinkedWorktree && path35.basename(gitCommonDir) === ".git" ? path35.dirname(gitCommonDir) : repoRoot;
5768
+ const worktreeName = isLinkedWorktree ? path35.basename(gitDir) : null;
5169
5769
  return {
5170
5770
  cwd,
5171
5771
  repoRoot,
@@ -5210,7 +5810,7 @@ function resolveMajorVersion(range) {
5210
5810
  }
5211
5811
 
5212
5812
  // src/scanner/detect-package-manager.ts
5213
- import path35 from "node:path";
5813
+ import path36 from "node:path";
5214
5814
  import fs28 from "fs-extra";
5215
5815
  function detectPackageManager(root, packageManagerField) {
5216
5816
  const field = String(packageManagerField ?? "").trim();
@@ -5229,9 +5829,9 @@ function detectPackageManager(root, packageManagerField) {
5229
5829
  if (satisfiesVersion(version, ">=9")) return "npm";
5230
5830
  return "unknown";
5231
5831
  }
5232
- if (fs28.existsSync(path35.join(root, "yarn.lock"))) return "yarn";
5233
- if (fs28.existsSync(path35.join(root, "pnpm-lock.yaml"))) return "pnpm";
5234
- if (fs28.existsSync(path35.join(root, "package-lock.json"))) return "npm";
5832
+ if (fs28.existsSync(path36.join(root, "yarn.lock"))) return "yarn";
5833
+ if (fs28.existsSync(path36.join(root, "pnpm-lock.yaml"))) return "pnpm";
5834
+ if (fs28.existsSync(path36.join(root, "package-lock.json"))) return "npm";
5235
5835
  return "unknown";
5236
5836
  }
5237
5837
 
@@ -5456,7 +6056,7 @@ function runDetection(ctx, rules = STACK_RULES) {
5456
6056
 
5457
6057
  // src/scanner/render.ts
5458
6058
  import { readFile } from "node:fs/promises";
5459
- import path36 from "node:path";
6059
+ import path37 from "node:path";
5460
6060
  async function buildContentBlob(root, files) {
5461
6061
  const candidates = files.filter(
5462
6062
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -5464,7 +6064,7 @@ async function buildContentBlob(root, files) {
5464
6064
  const slice = candidates.slice(0, 300);
5465
6065
  const parts = await mapWithConcurrency(slice, async (rel) => {
5466
6066
  try {
5467
- return await readFile(path36.join(root, rel), "utf8");
6067
+ return await readFile(path37.join(root, rel), "utf8");
5468
6068
  } catch {
5469
6069
  return "";
5470
6070
  }
@@ -5556,8 +6156,8 @@ var SAFE_FILES = [
5556
6156
  "Gemfile"
5557
6157
  ];
5558
6158
  async function scanProject(root) {
5559
- const pkg = await readJson(path37.join(root, "package.json"));
5560
- const composer = await readJson(path37.join(root, "composer.json"));
6159
+ const pkg = await readJson(path38.join(root, "package.json"));
6160
+ const composer = await readJson(path38.join(root, "composer.json"));
5561
6161
  const files = await listFiles(root, SAFE_FILES);
5562
6162
  const safeFiles = files.filter((f) => !blocked(f));
5563
6163
  const depVersions = dependencyVersionMap(pkg, composer);
@@ -5587,7 +6187,7 @@ async function scanProject(root) {
5587
6187
  crossRepoHints.push("Monorepo orchestration detected");
5588
6188
  const unsupportedSignals = collectUnsupportedSignals(safeFiles);
5589
6189
  const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
5590
- const repoNameFallback = pkg?.name ? String(pkg.name) : path37.basename((await resolveRoots(root)).mainRoot);
6190
+ const repoNameFallback = pkg?.name ? String(pkg.name) : path38.basename((await resolveRoots(root)).mainRoot);
5591
6191
  const context = {
5592
6192
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5593
6193
  repoName: repoNameFallback,
@@ -5609,10 +6209,10 @@ async function scanProject(root) {
5609
6209
  // src/scanner/read-context.ts
5610
6210
  async function isCacheFresh(root, cachePath) {
5611
6211
  const markerPaths = [
5612
- path38.join(root, "package.json"),
5613
- path38.join(root, "composer.json"),
5614
- path38.join(root, WORKSPACE_FILE),
5615
- path38.join(root, REPOS_MANIFEST_FILE)
6212
+ path39.join(root, "package.json"),
6213
+ path39.join(root, "composer.json"),
6214
+ path39.join(root, WORKSPACE_FILE),
6215
+ path39.join(root, REPOS_MANIFEST_FILE)
5616
6216
  ];
5617
6217
  const cacheStat = await fs29.stat(cachePath);
5618
6218
  for (const markerPath of markerPaths) {
@@ -5664,12 +6264,19 @@ async function runDoctor(options) {
5664
6264
  const root = process.cwd();
5665
6265
  if (options?.hooks) {
5666
6266
  const hooks2 = await verifyProjectSettingsHooksContract(root);
5667
- if (hooks2.skipped) {
5668
- error(`Haus doctor --hooks: ${hooks2.message}`);
5669
- process.exitCode = 1;
6267
+ const hooksOk = !hooks2.skipped && hooks2.ok;
6268
+ if (options.json) {
6269
+ log(
6270
+ JSON.stringify(
6271
+ { ok: hooksOk, skipped: Boolean(hooks2.skipped), message: hooks2.message },
6272
+ null,
6273
+ 2
6274
+ )
6275
+ );
6276
+ if (!hooksOk) process.exitCode = 1;
5670
6277
  return;
5671
6278
  }
5672
- if (!hooks2.ok) {
6279
+ if (!hooksOk) {
5673
6280
  error(`Haus doctor --hooks: ${hooks2.message}`);
5674
6281
  process.exitCode = 1;
5675
6282
  return;
@@ -5718,7 +6325,7 @@ async function runDoctor(options) {
5718
6325
  } else {
5719
6326
  ok(`- HOOKS OK: ${hooks.message}`);
5720
6327
  }
5721
- const rootClaudeMdPath = path39.join(root, "CLAUDE.md");
6328
+ const rootClaudeMdPath = path40.join(root, "CLAUDE.md");
5722
6329
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
5723
6330
  if (!rootClaudeMdContent) {
5724
6331
  flag(
@@ -5761,9 +6368,9 @@ async function runDoctor(options) {
5761
6368
  ok("- CLAUDE.md: import block present");
5762
6369
  }
5763
6370
  for (const target of actualTargets) {
5764
- const resolved = path39.resolve(root, target);
5765
- const relFromRoot = path39.relative(root, resolved);
5766
- const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path39.sep}`) || path39.isAbsolute(relFromRoot);
6371
+ const resolved = path40.resolve(root, target);
6372
+ const relFromRoot = path40.relative(root, resolved);
6373
+ const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path40.sep}`) || path40.isAbsolute(relFromRoot);
5767
6374
  if (escapesRoot || !await fs30.pathExists(resolved)) {
5768
6375
  flag(
5769
6376
  `- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
@@ -5797,8 +6404,8 @@ async function runDoctor(options) {
5797
6404
  } else {
5798
6405
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
5799
6406
  const bodyContent = workflowContent.slice(firstLine.length + 1);
5800
- const cachePath = path39.join(getCacheDir(), "templates/agentic-workflow-standard.md");
5801
- const bundledPath = path39.join(
6407
+ const cachePath = path40.join(getCacheDir(), "templates/agentic-workflow-standard.md");
6408
+ const bundledPath = path40.join(
5802
6409
  packageRoot(),
5803
6410
  "library",
5804
6411
  "global",
@@ -5809,10 +6416,17 @@ async function runDoctor(options) {
5809
6416
  const templateContent = await readText(templatePath);
5810
6417
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
5811
6418
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
6419
+ let diffNote = "";
6420
+ if (templateContent != null) {
6421
+ const summary = summarizeDiff(
6422
+ createUnifiedDiff(".haus-workflow/WORKFLOW.md", bodyContent, templateContent)
6423
+ );
6424
+ diffNote = ` \u2014 restoring would discard ${summary.deletions} local line(s) and add ${summary.additions}`;
6425
+ }
5812
6426
  flag(
5813
- "- .haus-workflow/WORKFLOW.md: modified locally (run `haus apply --write --force` to restore)",
5814
- "The workflow standard file was edited after haus wrote it",
5815
- "haus apply --write --force"
6427
+ `- .haus-workflow/WORKFLOW.md: modified locally${diffNote} (preview: \`haus apply --dry-run\`; restore: \`haus apply --write --force\`)`,
6428
+ `The workflow standard file was edited after haus wrote it${diffNote}`,
6429
+ "preview with `haus apply --dry-run`, then `haus apply --write --force` to restore"
5816
6430
  );
5817
6431
  } else if (verdict.status === "stale") {
5818
6432
  suggest(
@@ -5847,7 +6461,7 @@ async function runDoctor(options) {
5847
6461
  }
5848
6462
  }
5849
6463
  if (workflowExists) {
5850
- const prettierIgnore = await readText(path39.join(root, ".prettierignore")) ?? "";
6464
+ const prettierIgnore = await readText(path40.join(root, ".prettierignore")) ?? "";
5851
6465
  const lines = prettierIgnore.split("\n");
5852
6466
  const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
5853
6467
  if (missing.length > 0) {
@@ -5887,7 +6501,50 @@ async function runDoctor(options) {
5887
6501
  if (finding.level === "ok") ok(finding.line);
5888
6502
  else suggest(finding.line, finding.sentence, finding.fix);
5889
6503
  }
5890
- const skillRefIssues = await findUnresolvedSkillReferences(root);
6504
+ const pluginPins = await loadCatalogPluginPins(root);
6505
+ const pluginState = await probePluginEnv();
6506
+ const activePins = pluginPins.filter((pin) => isPluginActive(pluginState, pin));
6507
+ const activeNamespaces = activePins.map((pin) => pin.plugin);
6508
+ if (pluginPins.length === 0) {
6509
+ } else if (!pluginState.available) {
6510
+ suggest(
6511
+ "- GLOBAL PLUGINS: not checked (the `claude` CLI is not on PATH)",
6512
+ "Claude Code's CLI isn't on PATH, so haus can't verify the global plugin(s) it manages",
6513
+ "install Claude Code, then run `haus install`"
6514
+ );
6515
+ } else {
6516
+ const pending = planPluginActions(pluginPins, pluginState).filter(
6517
+ (action) => action.kind !== "up-to-date"
6518
+ );
6519
+ if (pending.length > 0) {
6520
+ suggest(
6521
+ `- GLOBAL PLUGINS: ${pending.length} plugin(s) not at the pinned state (${pending.map((a) => `${a.pin.id}: ${a.reason}`).join("; ")})`,
6522
+ `${pending.length} haus-managed global plugin(s) are missing, disabled, or off the pinned ref`,
6523
+ "haus update (or `haus install` outside a project)"
6524
+ );
6525
+ } else {
6526
+ ok(`- GLOBAL PLUGINS: OK (${pluginPins.map((p) => `${p.id}@${p.ref}`).join(", ")})`);
6527
+ }
6528
+ }
6529
+ if (activePins.length > 0) {
6530
+ const lockEntries = await readJson(hausPath(root, "haus.lock.json"));
6531
+ const superseded = (lockEntries ?? []).map((entry) => entry.id).filter(
6532
+ (id) => typeof id === "string" && isSupersededByPlugin(
6533
+ id,
6534
+ activePins.map((pin) => pin.id)
6535
+ )
6536
+ );
6537
+ if (superseded.length > 0) {
6538
+ suggest(
6539
+ `- GLOBAL PLUGINS: ${superseded.length} installed item(s) are superseded by the global plugin (${superseded.slice(0, 3).join(", ")}${superseded.length > 3 ? ", \u2026" : ""})`,
6540
+ `${superseded.length} project skill(s) duplicate what the global plugin already provides`,
6541
+ "haus update (removes the superseded copies)"
6542
+ );
6543
+ }
6544
+ }
6545
+ const skillRefIssues = await findUnresolvedSkillReferences(root, {
6546
+ pluginNamespaces: activeNamespaces
6547
+ });
5891
6548
  if (skillRefIssues.length > 0) {
5892
6549
  const prefixBug = skillRefIssues.filter((i) => i.bareInstalled);
5893
6550
  const missingDep = skillRefIssues.filter((i) => !i.bareInstalled);
@@ -5951,7 +6608,41 @@ async function runDoctor(options) {
5951
6608
  }
5952
6609
  }
5953
6610
  }
5954
- const pkgJson = await readJson(path39.join(packageRoot(), "package.json"));
6611
+ {
6612
+ const lockRows = await readJson(
6613
+ hausPath(root, "haus.lock.json")
6614
+ );
6615
+ if (lockRows && lockRows.length > 0) {
6616
+ const catalogItems = await loadCatalog(root);
6617
+ const byId = new Map(catalogItems.map((item) => [item.id, item]));
6618
+ const activePinIds = activePins.map((pin) => pin.id);
6619
+ const lingering = [];
6620
+ for (const row of lockRows) {
6621
+ if (typeof row.id !== "string") continue;
6622
+ if (isSupersededByPlugin(row.id, activePinIds)) continue;
6623
+ const manifestItem = byId.get(row.id);
6624
+ if (manifestItem && manifestItem.reviewStatus !== "deprecated") continue;
6625
+ let onDisk = false;
6626
+ for (const rel of row.paths ?? []) {
6627
+ const abs = resolveContainedPath(root, rel);
6628
+ if (abs !== null && await fs30.pathExists(abs)) {
6629
+ onDisk = true;
6630
+ break;
6631
+ }
6632
+ }
6633
+ if (onDisk) lingering.push(row.id);
6634
+ }
6635
+ if (lingering.length > 0) {
6636
+ const idList = `${lingering.slice(0, 3).join(", ")}${lingering.length > 3 ? ", \u2026" : ""}`;
6637
+ suggest(
6638
+ `- CATALOG ITEMS: ${lingering.length} installed item(s) are removed or deprecated in the current catalog (${idList}) \u2014 pre-0.30.0 CLIs never pruned these`,
6639
+ `${lingering.length} installed item(s) no longer exist in the catalog (removed or deprecated)`,
6640
+ "haus update (prunes them when unmodified; see docs/runbook.md for locally-edited copies)"
6641
+ );
6642
+ }
6643
+ }
6644
+ }
6645
+ const pkgJson = await readJson(path40.join(packageRoot(), "package.json"));
5955
6646
  const currentVersion = pkgJson?.version ?? "0.0.0";
5956
6647
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
5957
6648
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -5966,6 +6657,10 @@ async function runDoctor(options) {
5966
6657
  ok(`- CLI: ${currentVersion} (version check unavailable)`);
5967
6658
  }
5968
6659
  if (attention.length > 0) process.exitCode = 1;
6660
+ if (options?.json) {
6661
+ log(JSON.stringify({ ok: attention.length === 0, attention, suggestions, detail }, null, 2));
6662
+ return;
6663
+ }
5969
6664
  if (attention.length === 0) {
5970
6665
  log("\u2705 Your project is set up and healthy.");
5971
6666
  } else {
@@ -5984,7 +6679,7 @@ async function runDoctor(options) {
5984
6679
  }
5985
6680
 
5986
6681
  // src/commands/update.ts
5987
- import path42 from "node:path";
6682
+ import path43 from "node:path";
5988
6683
 
5989
6684
  // src/claude/refresh-project.ts
5990
6685
  import fs31 from "fs-extra";
@@ -5996,12 +6691,16 @@ async function isHausProject(root) {
5996
6691
  }
5997
6692
  async function refreshProjectApply(root) {
5998
6693
  if (!await isHausProject(root)) return [];
5999
- return writeClaudeFiles(root, false, void 0, { refillConfig: false });
6694
+ return writeClaudeFiles(root, false, void 0, {
6695
+ refillConfig: false,
6696
+ // Superseded copies are removed in the same pass that re-applies the catalog.
6697
+ activePluginIds: await activeGlobalPluginIds(root)
6698
+ });
6000
6699
  }
6001
6700
 
6002
6701
  // src/install/apply.ts
6003
6702
  import crypto2 from "node:crypto";
6004
- import path40 from "node:path";
6703
+ import path41 from "node:path";
6005
6704
  import fs32 from "fs-extra";
6006
6705
 
6007
6706
  // src/install/header.ts
@@ -6095,7 +6794,7 @@ function destFileMissing(destPath) {
6095
6794
  }
6096
6795
  function sourceVersion() {
6097
6796
  try {
6098
- const pkgPath = path40.join(packageRoot(), "package.json");
6797
+ const pkgPath = path41.join(packageRoot(), "package.json");
6099
6798
  const pkg = JSON.parse(fs32.readFileSync(pkgPath, "utf8"));
6100
6799
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
6101
6800
  } catch {
@@ -6103,49 +6802,49 @@ function sourceVersion() {
6103
6802
  }
6104
6803
  }
6105
6804
  function globalSrcDir() {
6106
- return path40.join(packageRoot(), "library", "global");
6805
+ return path41.join(packageRoot(), "library", "global");
6107
6806
  }
6108
6807
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
6109
- const skillDir = path40.join(skillsDir, skillName);
6808
+ const skillDir = path41.join(skillsDir, skillName);
6110
6809
  const entries = [];
6111
6810
  for (const rel of fs32.readdirSync(skillDir, { recursive: true })) {
6112
6811
  if (rel === "SKILL.md") continue;
6113
- const abs = path40.join(skillDir, rel);
6812
+ const abs = path41.join(skillDir, rel);
6114
6813
  if (!fs32.statSync(abs).isFile()) continue;
6115
- const posixRel = rel.split(path40.sep).join("/");
6814
+ const posixRel = rel.split(path41.sep).join("/");
6116
6815
  entries.push({
6117
6816
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
6118
- srcRelPath: path40.join("library", "global", "skills", skillName, rel),
6119
- destPath: path40.join(claudeDir, "skills", skillName, rel)
6817
+ srcRelPath: path41.join("library", "global", "skills", skillName, rel),
6818
+ destPath: path41.join(claudeDir, "skills", skillName, rel)
6120
6819
  });
6121
6820
  }
6122
6821
  return entries;
6123
6822
  }
6124
6823
  function collectSourceFiles(srcDir, claudeDir) {
6125
6824
  const entries = [];
6126
- const skillsDir = path40.join(srcDir, "skills");
6825
+ const skillsDir = path41.join(srcDir, "skills");
6127
6826
  if (fs32.pathExistsSync(skillsDir)) {
6128
6827
  for (const skillName of fs32.readdirSync(skillsDir)) {
6129
- const skillFile = path40.join(skillsDir, skillName, "SKILL.md");
6828
+ const skillFile = path41.join(skillsDir, skillName, "SKILL.md");
6130
6829
  if (fs32.pathExistsSync(skillFile)) {
6131
6830
  entries.push({
6132
6831
  stableId: `skill.${skillName}`,
6133
- srcRelPath: path40.join("library", "global", "skills", skillName, "SKILL.md"),
6134
- destPath: path40.join(claudeDir, "skills", skillName, "SKILL.md")
6832
+ srcRelPath: path41.join("library", "global", "skills", skillName, "SKILL.md"),
6833
+ destPath: path41.join(claudeDir, "skills", skillName, "SKILL.md")
6135
6834
  });
6136
6835
  entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
6137
6836
  }
6138
6837
  }
6139
6838
  }
6140
- const commandsDir = path40.join(srcDir, "commands");
6839
+ const commandsDir = path41.join(srcDir, "commands");
6141
6840
  if (fs32.pathExistsSync(commandsDir)) {
6142
6841
  for (const fileName of fs32.readdirSync(commandsDir)) {
6143
6842
  if (!fileName.endsWith(".md")) continue;
6144
6843
  const commandName = fileName.slice(0, -".md".length);
6145
6844
  entries.push({
6146
6845
  stableId: `command.${commandName}`,
6147
- srcRelPath: path40.join("library", "global", "commands", fileName),
6148
- destPath: path40.join(claudeDir, "commands", fileName)
6846
+ srcRelPath: path41.join("library", "global", "commands", fileName),
6847
+ destPath: path41.join(claudeDir, "commands", fileName)
6149
6848
  });
6150
6849
  }
6151
6850
  }
@@ -6171,7 +6870,7 @@ async function applyInstall(options = {}) {
6171
6870
  };
6172
6871
  const manifestFiles = [];
6173
6872
  for (const entry of sourceFiles) {
6174
- const srcPath = path40.join(packageRoot(), entry.srcRelPath);
6873
+ const srcPath = path41.join(packageRoot(), entry.srcRelPath);
6175
6874
  const rawContent = await readText(srcPath);
6176
6875
  if (rawContent === void 0) {
6177
6876
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -6232,7 +6931,7 @@ async function applyInstall(options = {}) {
6232
6931
  }
6233
6932
  let mergedSettings;
6234
6933
  if (!check) {
6235
- const fragmentPath = path40.join(srcDir, "settings-fragments", "hooks.json");
6934
+ const fragmentPath = path41.join(srcDir, "settings-fragments", "hooks.json");
6236
6935
  const fragments = await loadHooksFragment(fragmentPath);
6237
6936
  const settings = await readSettings();
6238
6937
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -6262,9 +6961,12 @@ async function applyInstall(options = {}) {
6262
6961
  }
6263
6962
  if (!dryRun && !check && mergedSettings) {
6264
6963
  await writeSettings(mergedSettings);
6265
- const manifest = buildManifest(source, manifestFiles, [
6266
- .../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...result.hookIds])
6267
- ]);
6964
+ const manifest = buildManifest(
6965
+ source,
6966
+ manifestFiles,
6967
+ [.../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...result.hookIds])],
6968
+ existingManifest?.plugins
6969
+ );
6268
6970
  await writeManifest(manifest);
6269
6971
  }
6270
6972
  return result;
@@ -6321,7 +7023,7 @@ function summarizeLockDiff(before, after) {
6321
7023
 
6322
7024
  // src/update/lockfile.ts
6323
7025
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
6324
- import path41 from "node:path";
7026
+ import path42 from "node:path";
6325
7027
  async function readLockSummary(root) {
6326
7028
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
6327
7029
  const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
@@ -6359,7 +7061,7 @@ async function applyLock(root) {
6359
7061
  try {
6360
7062
  const backupDir = hausPath(root, "backups");
6361
7063
  await mkdir(backupDir, { recursive: true });
6362
- await copyFile(lockPath, path41.join(backupDir, `haus.lock.${Date.now()}.json`));
7064
+ await copyFile(lockPath, path42.join(backupDir, `haus.lock.${Date.now()}.json`));
6363
7065
  } catch {
6364
7066
  }
6365
7067
  const enriched = await Promise.all(
@@ -6381,13 +7083,38 @@ function diffLock(before, after) {
6381
7083
  }
6382
7084
  async function hasLocalOverrides(root) {
6383
7085
  try {
6384
- await readFile2(path41.join(root, ".claude", "settings.json"), "utf8");
7086
+ await readFile2(path42.join(root, ".claude", "settings.json"), "utf8");
6385
7087
  return true;
6386
7088
  } catch {
6387
7089
  return false;
6388
7090
  }
6389
7091
  }
6390
7092
 
7093
+ // src/update/release-notes.ts
7094
+ var RELEASES_API_BASE = "https://api.github.com/repos/WeAreHausTech/haus-workflow/releases/tags";
7095
+ var MAX_NOTE_LINES = 40;
7096
+ async function fetchReleaseNotes(version) {
7097
+ try {
7098
+ const headers = await getGithubApiHeaders();
7099
+ const res = await fetch(`${RELEASES_API_BASE}/v${version}`, {
7100
+ headers,
7101
+ // Same bound npm-version.ts uses — best-effort must also mean bounded-time.
7102
+ signal: AbortSignal.timeout(8e3)
7103
+ });
7104
+ if (!res.ok) return null;
7105
+ const release = await res.json();
7106
+ if (typeof release.body !== "string" || release.body.trim().length === 0) return null;
7107
+ const lines = release.body.trim().split("\n");
7108
+ if (lines.length <= MAX_NOTE_LINES) return lines.join("\n");
7109
+ return [
7110
+ ...lines.slice(0, MAX_NOTE_LINES),
7111
+ `\u2026 (${lines.length - MAX_NOTE_LINES} more lines)`
7112
+ ].join("\n");
7113
+ } catch {
7114
+ return null;
7115
+ }
7116
+ }
7117
+
6391
7118
  // src/commands/update.ts
6392
7119
  var NPM_PACKAGE_NAME2 = "@haus-tech/haus-workflow";
6393
7120
  async function runUpdate(options) {
@@ -6397,15 +7124,26 @@ async function runUpdate(options) {
6397
7124
  return;
6398
7125
  }
6399
7126
  if (options.check) {
6400
- const pkgJson2 = await readJson(path42.join(packageRoot(), "package.json"));
7127
+ const pkgJson2 = await readJson(path43.join(packageRoot(), "package.json"));
6401
7128
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
6402
- const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
7129
+ const [
7130
+ status,
7131
+ npmVersion,
7132
+ latestCatalogTag,
7133
+ globalInstallDrift,
7134
+ catalogItems,
7135
+ lockItems,
7136
+ globalPluginDrift
7137
+ ] = await Promise.all([
6403
7138
  options.fast ? fastLockStatus(root) : checkLock(root),
6404
7139
  fetchNpmVersionStatus(currentVersion2),
6405
7140
  fetchLatestCatalogTag(),
6406
7141
  options.fast ? Promise.resolve(null) : detectGlobalInstallDrift(),
6407
7142
  loadCatalog(root),
6408
- readJson(hausPath(root, "haus.lock.json"))
7143
+ readJson(hausPath(root, "haus.lock.json")),
7144
+ // Same tier split as globalInstallDrift: --fast is the cheap probe the
7145
+ // SessionStart hook uses and must not shell out to the `claude` CLI.
7146
+ options.fast ? Promise.resolve(null) : detectGlobalPluginDrift(root)
6409
7147
  ]);
6410
7148
  const formerIdMigrations = findFormerIdMigrations(lockItems ?? [], catalogItems);
6411
7149
  const installedRef = options.fast ? status.catalogRef : status.catalogRef ?? "main";
@@ -6420,6 +7158,7 @@ async function runUpdate(options) {
6420
7158
  catalogRefBehind,
6421
7159
  formerIdMigrations,
6422
7160
  globalInstallDrift,
7161
+ globalPluginDrift,
6423
7162
  localOverrides: await hasLocalOverrides(root),
6424
7163
  summary: diffGeneratedFiles(),
6425
7164
  npmVersion
@@ -6429,17 +7168,24 @@ async function runUpdate(options) {
6429
7168
  )
6430
7169
  );
6431
7170
  const hasHashDrift = !options.fast && status.count > 0 && "ok" in status && !status.ok;
6432
- if (hasHashDrift || formerIdMigrations.length > 0 || globalInstallDrift === true) {
7171
+ if (hasHashDrift || formerIdMigrations.length > 0 || globalInstallDrift === true || globalPluginDrift === true) {
6433
7172
  process.exitCode = 1;
6434
7173
  }
6435
7174
  return;
6436
7175
  }
6437
- const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
7176
+ const pkgJson = await readJson(path43.join(packageRoot(), "package.json"));
6438
7177
  const currentVersion = pkgJson?.version ?? "0.0.0";
6439
7178
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
6440
7179
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
6441
7180
  log(`npm update available: ${currentVersion} \u2192 ${npmStatus.latest}`);
6442
7181
  log(`Run: npm install -g ${NPM_PACKAGE_NAME2}`);
7182
+ const notes = await fetchReleaseNotes(npmStatus.latest);
7183
+ if (notes !== null) {
7184
+ log(`
7185
+ What changed in ${npmStatus.latest}:`);
7186
+ log(notes);
7187
+ log("");
7188
+ }
6443
7189
  } else if (npmStatus.latest !== null) {
6444
7190
  log(`npm package up to date: ${currentVersion}`);
6445
7191
  }
@@ -6467,6 +7213,7 @@ async function runUpdate(options) {
6467
7213
  warn(formatGithubRateLimitMessage(sync.rateLimit));
6468
7214
  }
6469
7215
  await refreshGlobalInstall();
7216
+ await ensureCatalogPinnedPlugins(root);
6470
7217
  await refreshProjectFiles(root);
6471
7218
  const { after } = await applyLock(root);
6472
7219
  log(diffLock(lockBefore, after));
@@ -6477,7 +7224,7 @@ async function runFromHookCheck(root) {
6477
7224
  try {
6478
7225
  const summary = await readLockSummary(root);
6479
7226
  if (summary.count === 0) return;
6480
- const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
7227
+ const pkgJson = await readJson(path43.join(packageRoot(), "package.json"));
6481
7228
  const currentVersion = pkgJson?.version ?? "0.0.0";
6482
7229
  const [npmVersion, latestCatalogTag] = await Promise.all([
6483
7230
  fetchNpmVersionStatus(currentVersion),
@@ -6535,6 +7282,26 @@ async function refreshGlobalInstall() {
6535
7282
  warn(`Could not refresh ~/.claude/: ${err instanceof Error ? err.message : String(err)}`);
6536
7283
  }
6537
7284
  }
7285
+ async function ensureCatalogPinnedPlugins(root) {
7286
+ try {
7287
+ const pins = await loadCatalogPluginPins(root);
7288
+ const ensured = await ensureGlobalPlugins(pins);
7289
+ await recordManifestPlugins(
7290
+ pins.filter((pin) => ensured.ensured.includes(pin.id)).map((pin) => ({ ...pin, installedAt: (/* @__PURE__ */ new Date()).toISOString() }))
7291
+ );
7292
+ } catch (err) {
7293
+ warn(`Could not update global plugins: ${err instanceof Error ? err.message : String(err)}`);
7294
+ }
7295
+ }
7296
+ async function detectGlobalPluginDrift(root) {
7297
+ try {
7298
+ const pins = await loadCatalogPluginPins(root);
7299
+ const state = await probePluginEnv();
7300
+ return pluginDriftStatus(planPluginActions(pins, state), { matchRef: true });
7301
+ } catch {
7302
+ return null;
7303
+ }
7304
+ }
6538
7305
  async function detectGlobalInstallDrift() {
6539
7306
  try {
6540
7307
  const result = await applyInstall({ check: true });
@@ -6628,7 +7395,7 @@ async function runCiGate(options = {}) {
6628
7395
  init_exec();
6629
7396
  import { existsSync as existsSync2 } from "node:fs";
6630
7397
  import { readdir } from "node:fs/promises";
6631
- import path43 from "node:path";
7398
+ import path44 from "node:path";
6632
7399
  var GIT_LOCATION_VARS = [
6633
7400
  "GIT_DIR",
6634
7401
  "GIT_WORK_TREE",
@@ -6688,11 +7455,11 @@ async function runClone(url, opts = {}) {
6688
7455
  process.exitCode = 1;
6689
7456
  return;
6690
7457
  }
6691
- const target = path43.resolve(opts.dir?.trim() || repoNameFromUrl(url));
7458
+ const target = path44.resolve(opts.dir?.trim() || repoNameFromUrl(url));
6692
7459
  if (existsSync2(target) && !await isEmptyDirectory(target)) {
6693
7460
  const check = await checkExistingTarget(target, url);
6694
7461
  if (check.kind === "matches") {
6695
- log(`\u2022 ${path43.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
7462
+ log(`\u2022 ${path44.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
6696
7463
  return;
6697
7464
  }
6698
7465
  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].`;
@@ -6801,7 +7568,7 @@ async function runDecisionsGuard(options = {}) {
6801
7568
  }
6802
7569
 
6803
7570
  // src/commands/decisions.ts
6804
- import path44 from "node:path";
7571
+ import path45 from "node:path";
6805
7572
 
6806
7573
  // src/decisions/next-number.ts
6807
7574
  async function nextDecisionNumber(root) {
@@ -6911,7 +7678,7 @@ async function runDecisions(sub, arg, options = {}) {
6911
7678
  return;
6912
7679
  }
6913
7680
  if (sub === "validate") {
6914
- const target = arg ? path44.resolve(root, arg) : void 0;
7681
+ const target = arg ? path45.resolve(root, arg) : void 0;
6915
7682
  if (!target) {
6916
7683
  error("usage: haus decisions validate <path-to-decision.md>");
6917
7684
  process.exitCode = 1;
@@ -7180,7 +7947,7 @@ async function runGuard(kind, _options) {
7180
7947
  }
7181
7948
 
7182
7949
  // src/commands/init.ts
7183
- import path46 from "node:path";
7950
+ import path47 from "node:path";
7184
7951
  import fs33 from "fs-extra";
7185
7952
 
7186
7953
  // src/recommender/git-signal.ts
@@ -7223,6 +7990,7 @@ async function readChangedFiles(root) {
7223
7990
 
7224
7991
  // src/recommender/recommend.ts
7225
7992
  async function recommend(root, context, opts = {}) {
7993
+ const activePluginIds = opts.activePluginIds ?? [];
7226
7994
  const items = await loadCatalog(root);
7227
7995
  const sources = buildSourcesReport(items);
7228
7996
  const deep = await readJson(hausPath(root, "deep-context.json")) ?? {};
@@ -7280,6 +8048,15 @@ async function recommend(root, context, opts = {}) {
7280
8048
  "Catalog item is deprecated",
7281
8049
  "reviewStatus:deprecated"
7282
8050
  );
8051
+ if (isItemSupersededByPlugin(item, activePluginIds)) {
8052
+ add(
8053
+ "superseded-by-global-plugin",
8054
+ false,
8055
+ "superseded-by-global-plugin",
8056
+ "Provided by an installed global Claude Code plugin",
8057
+ `plugin:${activePluginIds.join(",")}`
8058
+ );
8059
+ }
7283
8060
  if (normSource === "curated") {
7284
8061
  const rs = item.reviewStatus;
7285
8062
  add(
@@ -7495,13 +8272,6 @@ function applyCoInstallSuppression(recommended, skipped) {
7495
8272
  message: "Skip TDD superpower when a stack testing skill is selected",
7496
8273
  signal: "co-install:stack-testing"
7497
8274
  },
7498
- {
7499
- suppress: "haus.superpowers-specifying-gates",
7500
- whenAnyRecommended: ["haus.superpowers-checking-gates"],
7501
- code: "co-install-gate-workflow",
7502
- message: "Skip specifying-gates when checking-gates is selected",
7503
- signal: "co-install:checking-gates"
7504
- },
7505
8275
  {
7506
8276
  suppress: "haus.ecc-redis-patterns",
7507
8277
  whenAnyRecommended: ["haus.redis-redis-connections"],
@@ -7619,7 +8389,7 @@ function buildOptInEligible(items, skipped) {
7619
8389
  }
7620
8390
 
7621
8391
  // src/commands/workspace/discover.ts
7622
- import path45 from "node:path";
8392
+ import path46 from "node:path";
7623
8393
  import fg5 from "fast-glob";
7624
8394
  import YAML2 from "yaml";
7625
8395
  var DEFAULT_MAX_DEPTH = 3;
@@ -7660,10 +8430,10 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7660
8430
  const gitDirs = /* @__PURE__ */ new Set();
7661
8431
  const manifestDirs = /* @__PURE__ */ new Set();
7662
8432
  for (const match of gitMatches) {
7663
- gitDirs.add(path45.posix.dirname(match));
8433
+ gitDirs.add(path46.posix.dirname(match));
7664
8434
  }
7665
8435
  for (const match of manifestMatches) {
7666
- manifestDirs.add(path45.posix.dirname(match));
8436
+ manifestDirs.add(path46.posix.dirname(match));
7667
8437
  }
7668
8438
  const repoRoots = [...gitDirs];
7669
8439
  const manifestSorted = [...manifestDirs].sort(
@@ -7680,9 +8450,9 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7680
8450
  async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7681
8451
  const repoRoots = (await findRepoRoots(workspaceRoot, maxDepth)).filter((dir) => dir !== ".");
7682
8452
  return mapWithConcurrency(repoRoots, async (relDir) => {
7683
- const absDir = path45.resolve(workspaceRoot, relDir);
7684
- const pkg = await readJson(path45.join(absDir, "package.json"));
7685
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path45.basename(absDir);
8453
+ const absDir = path46.resolve(workspaceRoot, relDir);
8454
+ const pkg = await readJson(path46.join(absDir, "package.json"));
8455
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path46.basename(absDir);
7686
8456
  let role = "auto";
7687
8457
  try {
7688
8458
  const scan = await scanProject(absDir);
@@ -7725,7 +8495,7 @@ function renderWorkspaceYaml(config) {
7725
8495
  });
7726
8496
  }
7727
8497
  async function runDiscover(workspaceRoot, opts = {}) {
7728
- const yamlPath = path45.join(workspaceRoot, "haus.workspace.yaml");
8498
+ const yamlPath = path46.join(workspaceRoot, "haus.workspace.yaml");
7729
8499
  const existingText = await readText(yamlPath);
7730
8500
  const existing = parseWorkspaceConfig(existingText);
7731
8501
  if (existingText && !existing) {
@@ -7792,7 +8562,11 @@ async function runSetupCore(root, opts) {
7792
8562
  const siblingWarning = await hasMultipleSiblingRepos(root) ? [SIBLING_REPO_WARNING] : [];
7793
8563
  const context = await readContextOrScan(root);
7794
8564
  const priorManualIncludes = await readPriorManualIncludes(root);
7795
- const recommendation = await recommend(root, context, { include: priorManualIncludes });
8565
+ const activePluginIds = await activeGlobalPluginIds(root);
8566
+ const recommendation = await recommend(root, context, {
8567
+ include: priorManualIncludes,
8568
+ activePluginIds
8569
+ });
7796
8570
  if (recommendation.recommended.length === 0 && !force) {
7797
8571
  const warningLines2 = [
7798
8572
  .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
@@ -7806,7 +8580,10 @@ async function runSetupCore(root, opts) {
7806
8580
  say(`Repo: ${context.repoName}`);
7807
8581
  for (const warning of warningLines2) say(`- WARN: ${warning}`);
7808
8582
  }
7809
- 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.";
8583
+ const supersededCount = recommendation.skipped.filter(
8584
+ (s) => s.skipReasons?.some((r) => r.code === "superseded-by-global-plugin")
8585
+ ).length;
8586
+ const message = 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 setup-project --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 setup-project --force` to write anyway.";
7810
8587
  warn(message);
7811
8588
  process.exitCode = 1;
7812
8589
  return {
@@ -7872,7 +8649,10 @@ async function runSetupCore(root, opts) {
7872
8649
  warn(formatGithubRateLimitMessage(sync.rateLimit));
7873
8650
  }
7874
8651
  }
7875
- const files = await writeClaudeFiles(root, dryRun ?? false, void 0, { quiet: opts.quiet });
8652
+ const files = await writeClaudeFiles(root, dryRun ?? false, void 0, {
8653
+ quiet: opts.quiet,
8654
+ activePluginIds
8655
+ });
7876
8656
  say("Applied files:");
7877
8657
  files.forEach((f) => say(`- ${displayPath(root, f)}`));
7878
8658
  const hooksAfter = await verifyProjectSettingsHooksContract(root);
@@ -7903,7 +8683,7 @@ async function runSetupProject(options) {
7903
8683
  // src/commands/init.ts
7904
8684
  async function runInit(options) {
7905
8685
  const root = process.cwd();
7906
- const hausDir = path46.join(root, ".haus-workflow");
8686
+ const hausDir = path47.join(root, ".haus-workflow");
7907
8687
  const alreadyInit = await fs33.pathExists(hausDir);
7908
8688
  if (alreadyInit) {
7909
8689
  log("Haus AI already initialized in this project.");
@@ -7968,7 +8748,19 @@ function printCompanionToolSuggestions(deps = {}) {
7968
8748
  }
7969
8749
 
7970
8750
  // src/commands/install.ts
7971
- async function runInstall(options) {
8751
+ function toManifestPlugin(pin) {
8752
+ return {
8753
+ id: pin.id,
8754
+ plugin: pin.plugin,
8755
+ marketplace: pin.marketplace,
8756
+ url: pin.url,
8757
+ ref: pin.ref,
8758
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
8759
+ };
8760
+ }
8761
+ async function runInstall(options, deps = {}) {
8762
+ const ensure = deps.ensureGlobalPlugins ?? ensureGlobalPlugins;
8763
+ const probe = deps.probePluginEnv ?? probePluginEnv;
7972
8764
  try {
7973
8765
  const result = await applyInstall({
7974
8766
  dryRun: options.dryRun,
@@ -7976,9 +8768,23 @@ async function runInstall(options) {
7976
8768
  check: options.check
7977
8769
  });
7978
8770
  if (!options.postinstall) printApplyResult(result, options.dryRun ?? false);
7979
- if (options.check && result.drift) {
7980
- process.exitCode = 1;
7981
- } else if (!options.check && !options.dryRun) {
8771
+ const pins = [...DEFAULT_GLOBAL_PLUGINS];
8772
+ if (options.check) {
8773
+ const state = await probe();
8774
+ const pluginDrift = pluginDriftStatus(planPluginActions(pins, state), { matchRef: false });
8775
+ if (pluginDrift === true) {
8776
+ log("Global plugin drift: a haus-managed plugin is missing or disabled.");
8777
+ }
8778
+ if (result.drift || pluginDrift === true) process.exitCode = 1;
8779
+ return;
8780
+ }
8781
+ const ensured = await ensure(pins, { dryRun: options.dryRun });
8782
+ if (!options.dryRun) {
8783
+ await recordManifestPlugins(
8784
+ pins.filter((pin) => ensured.ensured.includes(pin.id)).map(toManifestPlugin)
8785
+ );
8786
+ }
8787
+ if (!options.dryRun) {
7982
8788
  printCompanionToolSuggestions();
7983
8789
  const total = result.created.length + result.updated.length;
7984
8790
  if (options.postinstall) {
@@ -7990,6 +8796,9 @@ async function runInstall(options) {
7990
8796
  parts.length ? ` \u2022 ${parts.join(", ")} in ~/.claude (skills, slash commands)` : " \u2022 already up to date \u2014 no files changed"
7991
8797
  );
7992
8798
  log(` \u2022 ensured hooks + security rules are present in ~/.claude/settings.json`);
8799
+ if (ensured.ensured.length) {
8800
+ log(` \u2022 global plugin(s) pinned: ${ensured.ensured.join(", ")}`);
8801
+ }
7993
8802
  log("Undo any time with: haus uninstall");
7994
8803
  log("Disable this on install: HAUS_NO_POSTINSTALL=1");
7995
8804
  } else {
@@ -8009,7 +8818,8 @@ async function runRecommend(options) {
8009
8818
  const root = process.cwd();
8010
8819
  const context = await readContextOrScan(root);
8011
8820
  const include = parseIdList(options.include);
8012
- const result = await recommend(root, context, { include });
8821
+ const activePluginIds = await activeGlobalPluginIds(root);
8822
+ const result = await recommend(root, context, { include, activePluginIds });
8013
8823
  await writeJson(hausPath(root, "recommendation.json"), result);
8014
8824
  if (options.json) {
8015
8825
  log(JSON.stringify(result, null, 2));
@@ -8024,13 +8834,21 @@ async function runRecommend(options) {
8024
8834
  );
8025
8835
  }
8026
8836
  for (const w of result.warnings) log(`- WARN: ${w}`);
8837
+ log("Run `haus explain-recommendation` for per-item reasons and skip explanations.");
8027
8838
  }
8028
8839
 
8029
8840
  // src/commands/refresh.ts
8030
8841
  async function runRefresh() {
8031
8842
  const root = process.cwd();
8032
8843
  const context = await scanProject(root);
8033
- const recommendation = await recommend(root, context);
8844
+ try {
8845
+ await ensureGlobalPlugins(await loadCatalogPluginPins(root));
8846
+ } catch (err) {
8847
+ warn(`Could not refresh global plugins: ${err instanceof Error ? err.message : String(err)}`);
8848
+ }
8849
+ const recommendation = await recommend(root, context, {
8850
+ activePluginIds: await activeGlobalPluginIds(root)
8851
+ });
8034
8852
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
8035
8853
  log("Haus refresh complete");
8036
8854
  log(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
@@ -8039,7 +8857,7 @@ async function runRefresh() {
8039
8857
  }
8040
8858
 
8041
8859
  // src/install/scaffold.ts
8042
- import path47 from "node:path";
8860
+ import path48 from "node:path";
8043
8861
  import fs34 from "fs-extra";
8044
8862
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8045
8863
  const scaffolded = [];
@@ -8056,9 +8874,9 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8056
8874
  const realRoot = await fs34.realpath(catalogRoot).catch(() => null);
8057
8875
  for (const item of items) {
8058
8876
  if (item.type !== "config") continue;
8059
- const sourcePath = path47.resolve(catalogRoot, item.path);
8060
- const rel = path47.relative(catalogRoot, sourcePath);
8061
- const escapes = rel === "" || path47.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
8877
+ const sourcePath = path48.resolve(catalogRoot, item.path);
8878
+ const rel = path48.relative(catalogRoot, sourcePath);
8879
+ const escapes = rel === "" || path48.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
8062
8880
  if (escapes) {
8063
8881
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
8064
8882
  continue;
@@ -8068,8 +8886,8 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8068
8886
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
8069
8887
  continue;
8070
8888
  }
8071
- const realRel = path47.relative(realRoot, realSource);
8072
- if (realRel !== "" && (path47.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
8889
+ const realRel = path48.relative(realRoot, realSource);
8890
+ if (realRel !== "" && (path48.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
8073
8891
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
8074
8892
  continue;
8075
8893
  }
@@ -8083,14 +8901,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8083
8901
  continue;
8084
8902
  }
8085
8903
  if (stat.isFile()) {
8086
- const filename = path47.basename(sourcePath);
8087
- const dest = path47.join(projectRoot, filename);
8904
+ const filename = path48.basename(sourcePath);
8905
+ const dest = path48.join(projectRoot, filename);
8088
8906
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
8089
8907
  } else if (stat.isDirectory()) {
8090
8908
  const entries = await fs34.readdir(sourcePath);
8091
8909
  for (const entry of entries) {
8092
- const src = path47.join(sourcePath, entry);
8093
- const dest = path47.join(projectRoot, entry);
8910
+ const src = path48.join(sourcePath, entry);
8911
+ const dest = path48.join(projectRoot, entry);
8094
8912
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
8095
8913
  }
8096
8914
  }
@@ -8098,14 +8916,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8098
8916
  return { scaffolded, skipped, skippedExisting };
8099
8917
  }
8100
8918
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8101
- const destDisplay = path47.relative(projectRoot, dest) || path47.basename(dest);
8919
+ const destDisplay = path48.relative(projectRoot, dest) || path48.basename(dest);
8102
8920
  const srcStat = await fs34.lstat(src).catch(() => null);
8103
8921
  if (!srcStat) {
8104
- warn(`Skipping ${path47.basename(src)}: source not found`);
8922
+ warn(`Skipping ${path48.basename(src)}: source not found`);
8105
8923
  return "skipped-refused";
8106
8924
  }
8107
8925
  if (srcStat.isSymbolicLink()) {
8108
- warn(`Skipping ${path47.basename(src)}: source is a symlink`);
8926
+ warn(`Skipping ${path48.basename(src)}: source is a symlink`);
8109
8927
  return "skipped-refused";
8110
8928
  }
8111
8929
  const exists = await fs34.pathExists(dest);
@@ -8114,13 +8932,13 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8114
8932
  return "skipped-exists";
8115
8933
  }
8116
8934
  if (opts.dryRun) {
8117
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path47.basename(dest)} (${itemId})`);
8935
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path48.basename(dest)} (${itemId})`);
8118
8936
  return "scaffolded";
8119
8937
  }
8120
8938
  if (exists && opts.force) {
8121
8939
  await fs34.remove(dest);
8122
8940
  }
8123
- await fs34.ensureDir(path47.dirname(dest));
8941
+ await fs34.ensureDir(path48.dirname(dest));
8124
8942
  await fs34.copy(src, dest, {
8125
8943
  overwrite: true,
8126
8944
  dereference: false,
@@ -8133,7 +8951,7 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8133
8951
  }
8134
8952
  }
8135
8953
  });
8136
- log(`${exists ? "Overwrote" : "Created"} ${path47.basename(dest)} (${itemId})`);
8954
+ log(`${exists ? "Overwrote" : "Created"} ${path48.basename(dest)} (${itemId})`);
8137
8955
  return "scaffolded";
8138
8956
  }
8139
8957
 
@@ -8198,7 +9016,7 @@ async function runScan(options) {
8198
9016
  }
8199
9017
 
8200
9018
  // src/commands/undo.ts
8201
- import path48 from "node:path";
9019
+ import path49 from "node:path";
8202
9020
  import fs35 from "fs-extra";
8203
9021
 
8204
9022
  // src/claude/managed-paths.ts
@@ -8226,18 +9044,18 @@ async function collectLockTrackedPaths(root) {
8226
9044
  const relPaths = row.paths ?? [];
8227
9045
  const existingRel = [];
8228
9046
  for (const rel of relPaths) {
8229
- if (await fs35.pathExists(path48.resolve(root, rel))) existingRel.push(rel);
9047
+ if (await fs35.pathExists(path49.resolve(root, rel))) existingRel.push(rel);
8230
9048
  }
8231
9049
  if (existingRel.length === 0) continue;
8232
9050
  if (row.hash === void 0) {
8233
- removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
9051
+ removable.push(...existingRel.map((rel) => path49.resolve(root, rel)));
8234
9052
  continue;
8235
9053
  }
8236
9054
  const currentHash = await hashInstalledPaths(root, existingRel);
8237
9055
  if (currentHash === row.hash) {
8238
- removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
9056
+ removable.push(...existingRel.map((rel) => path49.resolve(root, rel)));
8239
9057
  } else {
8240
- preserved.push(...existingRel.map((rel) => path48.resolve(root, rel)));
9058
+ preserved.push(...existingRel.map((rel) => path49.resolve(root, rel)));
8241
9059
  log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
8242
9060
  }
8243
9061
  }
@@ -8250,7 +9068,7 @@ async function settingsHasHausContent(root) {
8250
9068
  return settings._haus != null;
8251
9069
  }
8252
9070
  async function claudeMdHasHausBlock(root) {
8253
- const filePath = path48.join(root, "CLAUDE.md");
9071
+ const filePath = path49.join(root, "CLAUDE.md");
8254
9072
  if (!await fs35.pathExists(filePath)) return false;
8255
9073
  const text = await fs35.readFile(filePath, "utf8");
8256
9074
  return text.includes(BLOCK_BEGIN);
@@ -8263,15 +9081,15 @@ async function stripProjectSettings(root) {
8263
9081
  const hasContent = Object.keys(settings).length > 0;
8264
9082
  if (hasContent) {
8265
9083
  await writeProjectSettings(root, settings);
8266
- log(`Stripped haus rules from ${path48.relative(root, settingsPath)} (user settings preserved).`);
9084
+ log(`Stripped haus rules from ${path49.relative(root, settingsPath)} (user settings preserved).`);
8267
9085
  return true;
8268
9086
  }
8269
9087
  await fs35.remove(settingsPath);
8270
- log(`Removed ${path48.relative(root, settingsPath)} (no user-owned settings remained).`);
9088
+ log(`Removed ${path49.relative(root, settingsPath)} (no user-owned settings remained).`);
8271
9089
  return true;
8272
9090
  }
8273
9091
  async function stripRootClaudeMd(root) {
8274
- const filePath = path48.join(root, "CLAUDE.md");
9092
+ const filePath = path49.join(root, "CLAUDE.md");
8275
9093
  if (!await fs35.pathExists(filePath)) return false;
8276
9094
  const prev = await fs35.readFile(filePath, "utf8");
8277
9095
  if (!prev.includes(BLOCK_BEGIN)) return false;
@@ -8299,12 +9117,12 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
8299
9117
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
8300
9118
  for (const abs of managedAbsPaths) {
8301
9119
  if (!await fs35.pathExists(abs)) continue;
8302
- const rel = path48.relative(root, abs);
8303
- const backupPath = path48.join(backupRoot, rel);
8304
- await fs35.ensureDir(path48.dirname(backupPath));
9120
+ const rel = path49.relative(root, abs);
9121
+ const backupPath = path49.join(backupRoot, rel);
9122
+ await fs35.ensureDir(path49.dirname(backupPath));
8305
9123
  await fs35.copy(abs, backupPath);
8306
9124
  }
8307
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path48.relative(root, backupRoot)}.`);
9125
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path49.relative(root, backupRoot)}.`);
8308
9126
  }
8309
9127
  async function runUndo(options) {
8310
9128
  const root = options.root ?? process.cwd();
@@ -8317,7 +9135,7 @@ async function runUndo(options) {
8317
9135
  log("Nothing to remove: no haus-managed files found in this directory.");
8318
9136
  return;
8319
9137
  }
8320
- const relTargets = managed.map((p) => path48.relative(root, p));
9138
+ const relTargets = managed.map((p) => path49.relative(root, p));
8321
9139
  const summaryParts = [...relTargets];
8322
9140
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
8323
9141
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -8326,6 +9144,12 @@ async function runUndo(options) {
8326
9144
  `(preserving ${lockPreserved.length} locally-modified catalog item file(s) \u2014 not shown above)`
8327
9145
  );
8328
9146
  }
9147
+ if (options.dryRun) {
9148
+ log(`[dry-run] Would remove:
9149
+ ${summaryParts.join("\n ")}`);
9150
+ log("[dry-run] User-owned .claude/ files would be preserved. Nothing was changed.");
9151
+ return;
9152
+ }
8329
9153
  if (!options.yes) {
8330
9154
  const ok = await confirm(
8331
9155
  `Remove haus-managed files?
@@ -8341,7 +9165,7 @@ User-owned .claude/ files will be preserved.`
8341
9165
  for (const abs of managed) {
8342
9166
  if (!await fs35.pathExists(abs)) continue;
8343
9167
  await fs35.remove(abs);
8344
- log(`Removed ${path48.relative(root, abs)}`);
9168
+ log(`Removed ${path49.relative(root, abs)}`);
8345
9169
  }
8346
9170
  if (stripSettings) await stripProjectSettings(root);
8347
9171
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -8352,16 +9176,43 @@ User-owned .claude/ files will be preserved.`
8352
9176
 
8353
9177
  // src/install/uninstall.ts
8354
9178
  import crypto3 from "node:crypto";
8355
- import path49 from "node:path";
9179
+ import path50 from "node:path";
8356
9180
  import fs36 from "fs-extra";
8357
9181
  async function runUninstall(options = {}) {
8358
- const { force = false } = options;
9182
+ const { force = false, removePlugins = removeGlobalPlugins } = options;
8359
9183
  const manifest = await readManifest();
8360
- const result = { deleted: [], skipped: [], hooksStripped: false };
9184
+ const result = {
9185
+ deleted: [],
9186
+ skipped: [],
9187
+ hooksStripped: false,
9188
+ pluginsRemoved: []
9189
+ };
8361
9190
  if (!manifest) {
8362
9191
  warn("No install manifest found \u2014 nothing to uninstall.");
8363
9192
  return result;
8364
9193
  }
9194
+ const recordedPlugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
9195
+ const malformed = recordedPlugins.filter((entry) => !isValidPin(entry)).length;
9196
+ if (malformed > 0) {
9197
+ warn(
9198
+ `Ignoring ${malformed} malformed plugin record(s) in the install manifest \u2014 continuing with the rest of the uninstall.`
9199
+ );
9200
+ }
9201
+ const managedPlugins = recordedPlugins.filter(isValidPin).map((p) => ({
9202
+ id: p.id,
9203
+ plugin: p.plugin,
9204
+ marketplace: p.marketplace,
9205
+ url: p.url,
9206
+ ref: p.ref
9207
+ }));
9208
+ try {
9209
+ const pluginRemoval = await removePlugins(managedPlugins);
9210
+ result.pluginsRemoved = pluginRemoval.removed;
9211
+ } catch (err) {
9212
+ warn(
9213
+ `Could not remove global plugin(s): ${err instanceof Error ? err.message : String(err)} \u2014 continuing with the rest of the uninstall.`
9214
+ );
9215
+ }
8365
9216
  for (const entry of manifest.files) {
8366
9217
  const exists = fs36.pathExistsSync(entry.destPath);
8367
9218
  if (!exists) continue;
@@ -8382,14 +9233,14 @@ async function runUninstall(options = {}) {
8382
9233
  continue;
8383
9234
  }
8384
9235
  await fs36.remove(entry.destPath);
8385
- await pruneEmptyDir(path49.dirname(entry.destPath));
9236
+ await pruneEmptyDir(path50.dirname(entry.destPath));
8386
9237
  result.deleted.push(entry.destPath);
8387
9238
  }
8388
9239
  const settings = await readSettings();
8389
9240
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
8390
9241
  await writeSettings(stripped);
8391
9242
  result.hooksStripped = true;
8392
- const hausDir = path49.join(globalClaudeDir(), "haus");
9243
+ const hausDir = path50.join(globalClaudeDir(), "haus");
8393
9244
  const manifestPath2 = hausManifestPath();
8394
9245
  if (fs36.pathExistsSync(manifestPath2)) {
8395
9246
  await fs36.remove(manifestPath2);
@@ -8415,11 +9266,30 @@ function printUninstallResult(result) {
8415
9266
  if (result.hooksStripped) {
8416
9267
  log("Haus hook entries removed from ~/.claude/settings.json");
8417
9268
  }
9269
+ if (result.pluginsRemoved.length) {
9270
+ log(`Global plugin(s) removed: ${result.pluginsRemoved.join(", ")}`);
9271
+ log("Projects relying on those plugin skills get them back by reinstalling haus.");
9272
+ }
8418
9273
  }
8419
9274
 
8420
9275
  // src/commands/uninstall.ts
8421
9276
  async function runUninstallCommand(options) {
8422
9277
  try {
9278
+ if (options.dryRun) {
9279
+ const manifest = await readManifest();
9280
+ if (!manifest) {
9281
+ log("[dry-run] No install manifest found \u2014 nothing would be removed.");
9282
+ return;
9283
+ }
9284
+ if (manifest.files.length > 0) {
9285
+ log(`[dry-run] Would remove ${manifest.files.length} haus-managed file(s) from ~/.claude/:`);
9286
+ for (const f of manifest.files) log(` ${f.destPath}`);
9287
+ }
9288
+ log(
9289
+ "[dry-run] Would strip haus hook entries from ~/.claude/settings.json and remove the install manifest. Nothing was changed."
9290
+ );
9291
+ return;
9292
+ }
8423
9293
  if (!options.yes) {
8424
9294
  const manifest = await readManifest();
8425
9295
  if (manifest) {
@@ -8443,11 +9313,11 @@ async function runUninstallCommand(options) {
8443
9313
  }
8444
9314
 
8445
9315
  // src/commands/validate-catalog.ts
8446
- import path51 from "node:path";
9316
+ import path52 from "node:path";
8447
9317
 
8448
9318
  // src/catalog/validate-core.ts
8449
9319
  import fs37 from "node:fs";
8450
- import path50 from "node:path";
9320
+ import path51 from "node:path";
8451
9321
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
8452
9322
  var ITEM_ID_PATTERN = /^haus\./;
8453
9323
  function auditForbiddenStacks(items) {
@@ -8596,7 +9466,7 @@ function readForAudit(absPath, label, failures) {
8596
9466
  }
8597
9467
  }
8598
9468
  function auditTemplateContent(manifestDir, absPath, itemId) {
8599
- const rel = path50.relative(manifestDir, absPath);
9469
+ const rel = path51.relative(manifestDir, absPath);
8600
9470
  const failures = [];
8601
9471
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
8602
9472
  if (text === null) return failures;
@@ -8618,25 +9488,25 @@ function auditShippedFiles(manifestDir, items) {
8618
9488
  const failures = [];
8619
9489
  for (const item of items) {
8620
9490
  if (!item.path || !isSafeCatalogPath(item.path)) continue;
8621
- const absPath = path50.join(manifestDir, item.path);
9491
+ const absPath = path51.join(manifestDir, item.path);
8622
9492
  if (item.type === "skill") {
8623
- const skillMd = path50.join(absPath, "SKILL.md");
9493
+ const skillMd = path51.join(absPath, "SKILL.md");
8624
9494
  if (!fs37.existsSync(skillMd)) {
8625
- failures.push(`${item.id}: missing ${path50.relative(manifestDir, skillMd)}`);
9495
+ failures.push(`${item.id}: missing ${path51.relative(manifestDir, skillMd)}`);
8626
9496
  continue;
8627
9497
  }
8628
9498
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
8629
9499
  if (text === null) continue;
8630
9500
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
8631
9501
  failures.push(
8632
- ...auditForbiddenTagsInText(text, `${item.id}: ${path50.relative(manifestDir, skillMd)}`)
9502
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path51.relative(manifestDir, skillMd)}`)
8633
9503
  );
8634
9504
  } else if (item.type === "agent") {
8635
9505
  if (!fs37.existsSync(absPath)) {
8636
9506
  failures.push(`${item.id}: missing agent file ${item.path}`);
8637
9507
  continue;
8638
9508
  }
8639
- const rel = path50.relative(manifestDir, absPath);
9509
+ const rel = path51.relative(manifestDir, absPath);
8640
9510
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
8641
9511
  if (text === null) continue;
8642
9512
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
@@ -8652,7 +9522,7 @@ function auditShippedFiles(manifestDir, items) {
8652
9522
  failures.push(`${item.id}: missing command file ${item.path}`);
8653
9523
  continue;
8654
9524
  }
8655
- const rel = path50.relative(manifestDir, absPath);
9525
+ const rel = path51.relative(manifestDir, absPath);
8656
9526
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
8657
9527
  if (text === null) continue;
8658
9528
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
@@ -8684,7 +9554,7 @@ function walkAllFiles(dir, fn, failures) {
8684
9554
  }
8685
9555
  for (const entry of entries) {
8686
9556
  if (entry.name === ".DS_Store") continue;
8687
- const full = path50.join(dir, entry.name);
9557
+ const full = path51.join(dir, entry.name);
8688
9558
  if (entry.isDirectory()) walkAllFiles(full, fn, failures);
8689
9559
  else fn(full);
8690
9560
  }
@@ -8702,12 +9572,12 @@ function auditDiskOrphans(manifestDir, items) {
8702
9572
  const failures = [];
8703
9573
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
8704
9574
  for (const dir of CONTENT_ROOTS) {
8705
- const abs = path50.join(manifestDir, dir);
9575
+ const abs = path51.join(manifestDir, dir);
8706
9576
  if (!fs37.existsSync(abs)) continue;
8707
9577
  walkAllFiles(
8708
9578
  abs,
8709
9579
  (file) => {
8710
- const relPath = path50.relative(manifestDir, file).replace(/\\/g, "/");
9580
+ const relPath = path51.relative(manifestDir, file).replace(/\\/g, "/");
8711
9581
  if (!isPathClaimed(relPath, claimedPaths)) {
8712
9582
  failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
8713
9583
  }
@@ -8728,7 +9598,7 @@ function walkMd(dir, fn, failures) {
8728
9598
  return;
8729
9599
  }
8730
9600
  for (const entry of entries) {
8731
- const full = path50.join(dir, entry.name);
9601
+ const full = path51.join(dir, entry.name);
8732
9602
  if (entry.isDirectory()) walkMd(full, fn, failures);
8733
9603
  else if (entry.name.endsWith(".md")) fn(full);
8734
9604
  }
@@ -8737,13 +9607,13 @@ function auditMarkdownContent(manifestDir, items) {
8737
9607
  const failures = [];
8738
9608
  const pathSourceMap = buildItemPathSourceMap(items);
8739
9609
  for (const dir of ["skills", "agents", "templates", "commands"]) {
8740
- const abs = path50.join(manifestDir, dir);
9610
+ const abs = path51.join(manifestDir, dir);
8741
9611
  if (!fs37.existsSync(abs)) continue;
8742
9612
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
8743
9613
  walkMd(
8744
9614
  abs,
8745
9615
  (file) => {
8746
- const rel = path50.relative(manifestDir, file);
9616
+ const rel = path51.relative(manifestDir, file);
8747
9617
  const text = readForAudit(file, rel, failures);
8748
9618
  if (text === null) return;
8749
9619
  const norm = rel.replace(/\\/g, "/");
@@ -8788,8 +9658,8 @@ async function runValidateCatalog(manifestPath2) {
8788
9658
  process.exitCode = 1;
8789
9659
  return;
8790
9660
  }
8791
- const abs = path51.resolve(process.cwd(), manifestPath2);
8792
- const manifestDir = path51.dirname(abs);
9661
+ const abs = path52.resolve(process.cwd(), manifestPath2);
9662
+ const manifestDir = path52.dirname(abs);
8793
9663
  const data = await readJson(abs);
8794
9664
  if (!data?.items) {
8795
9665
  error(`Could not read catalog manifest at ${abs}`);
@@ -8806,7 +9676,7 @@ async function runValidateCatalog(manifestPath2) {
8806
9676
  }
8807
9677
 
8808
9678
  // src/workspace/worktree/add.ts
8809
- import path58 from "node:path";
9679
+ import path59 from "node:path";
8810
9680
  import fs41 from "fs-extra";
8811
9681
 
8812
9682
  // src/workspace/worktree/git-worktree.ts
@@ -8926,12 +9796,12 @@ async function hasUnpushedWork(cwd) {
8926
9796
  }
8927
9797
 
8928
9798
  // src/workspace/worktree/hydrate.ts
8929
- import path54 from "node:path";
9799
+ import path55 from "node:path";
8930
9800
  import fs39 from "fs-extra";
8931
9801
 
8932
9802
  // src/workspace/worktree/cow-copy.ts
8933
9803
  init_exec();
8934
- import path52 from "node:path";
9804
+ import path53 from "node:path";
8935
9805
  import fs38 from "fs-extra";
8936
9806
  async function getMacFilesystemType(dir) {
8937
9807
  const df = await runCommand("df", ["-P", dir]);
@@ -8966,11 +9836,11 @@ async function cowCopyDir(src, dest) {
8966
9836
  const srcExists = await fs38.pathExists(src);
8967
9837
  if (!srcExists)
8968
9838
  return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
8969
- const strategy = await detectCowStrategy(path52.dirname(dest));
9839
+ const strategy = await detectCowStrategy(path53.dirname(dest));
8970
9840
  if (strategy === "unsupported" || strategy === "unknown-platform") {
8971
9841
  return { strategy, attempted: false, ok: false };
8972
9842
  }
8973
- await fs38.ensureDir(path52.dirname(dest));
9843
+ await fs38.ensureDir(path53.dirname(dest));
8974
9844
  const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
8975
9845
  const result = await runCommand("cp", args);
8976
9846
  return {
@@ -8984,7 +9854,7 @@ async function cowCopyDir(src, dest) {
8984
9854
  // src/workspace/worktree/install.ts
8985
9855
  init_exec();
8986
9856
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
8987
- import path53 from "node:path";
9857
+ import path54 from "node:path";
8988
9858
  import fg6 from "fast-glob";
8989
9859
  function detectInstallPlan(signals) {
8990
9860
  if (signals.hasYarnLock) {
@@ -9005,10 +9875,10 @@ function detectInstallPlan(signals) {
9005
9875
  return { manager: "none" };
9006
9876
  }
9007
9877
  async function computeLockfileSignals(dir) {
9008
- const hasYarnLock = existsSync3(path53.join(dir, "yarn.lock"));
9009
- const hasPnpmLock = existsSync3(path53.join(dir, "pnpm-lock.yaml"));
9010
- const hasPackageLock = existsSync3(path53.join(dir, "package-lock.json"));
9011
- const hasComposerLock = existsSync3(path53.join(dir, "composer.lock"));
9878
+ const hasYarnLock = existsSync3(path54.join(dir, "yarn.lock"));
9879
+ const hasPnpmLock = existsSync3(path54.join(dir, "pnpm-lock.yaml"));
9880
+ const hasPackageLock = existsSync3(path54.join(dir, "package-lock.json"));
9881
+ const hasComposerLock = existsSync3(path54.join(dir, "composer.lock"));
9012
9882
  const dotnetFiles = await fg6(["**/*.csproj", "**/*.sln"], {
9013
9883
  cwd: dir,
9014
9884
  onlyFiles: true,
@@ -9022,7 +9892,7 @@ async function computeLockfileSignals(dir) {
9022
9892
  });
9023
9893
  const hasDotnetProject = dotnetFiles.length > 0;
9024
9894
  let hasPackageManagerField = false;
9025
- const pkgPath = path53.join(dir, "package.json");
9895
+ const pkgPath = path54.join(dir, "package.json");
9026
9896
  if (existsSync3(pkgPath)) {
9027
9897
  try {
9028
9898
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
@@ -9063,8 +9933,8 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
9063
9933
  const start = Date.now();
9064
9934
  const targets = [];
9065
9935
  for (const target of HYDRATION_TARGETS) {
9066
- const src = path54.join(mainMemberPath, target);
9067
- const dest = path54.join(worktreeMemberPath, target);
9936
+ const src = path55.join(mainMemberPath, target);
9937
+ const dest = path55.join(worktreeMemberPath, target);
9068
9938
  if (!await fs39.pathExists(src)) continue;
9069
9939
  const destExists = await fs39.pathExists(dest);
9070
9940
  if (!opts.force && destExists) continue;
@@ -9082,11 +9952,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
9082
9952
 
9083
9953
  // src/workspace/worktree/local-files.ts
9084
9954
  init_exec();
9085
- import path55 from "node:path";
9955
+ import path56 from "node:path";
9086
9956
  import fs40 from "fs-extra";
9087
9957
  var MACHINE_LOCAL_FILES = [
9088
9958
  "docker-compose.local.yml",
9089
- path55.join(".claude", "settings.local.json")
9959
+ path56.join(".claude", "settings.local.json")
9090
9960
  ];
9091
9961
  async function isTracked(repoRoot, relFile) {
9092
9962
  const gitPath = relFile.replace(/\\/g, "/");
@@ -9096,11 +9966,11 @@ async function isTracked(repoRoot, relFile) {
9096
9966
  async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
9097
9967
  const copied = [];
9098
9968
  for (const rel of MACHINE_LOCAL_FILES) {
9099
- const src = path55.join(mainMemberPath, rel);
9969
+ const src = path56.join(mainMemberPath, rel);
9100
9970
  if (!await fs40.pathExists(src)) continue;
9101
9971
  if (await isTracked(mainMemberPath, rel)) continue;
9102
- const dest = path55.join(worktreeMemberPath, rel);
9103
- await fs40.ensureDir(path55.dirname(dest));
9972
+ const dest = path56.join(worktreeMemberPath, rel);
9973
+ await fs40.ensureDir(path56.dirname(dest));
9104
9974
  await fs40.copy(src, dest, { overwrite: true });
9105
9975
  copied.push(rel);
9106
9976
  }
@@ -9109,15 +9979,15 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
9109
9979
 
9110
9980
  // src/workspace/worktree/root.ts
9111
9981
  import { existsSync as existsSync4 } from "node:fs";
9112
- import path56 from "node:path";
9982
+ import path57 from "node:path";
9113
9983
  async function resolveWorkspaceForWorktree(start) {
9114
9984
  const rootInfo = await resolveRoots(start);
9115
9985
  if (!rootInfo.isGitRepo) {
9116
9986
  return { ok: false, reason: "Not inside a git repository." };
9117
9987
  }
9118
9988
  const workspaceRoot = rootInfo.mainRoot;
9119
- const hasYaml = existsSync4(path56.join(workspaceRoot, WORKSPACE_FILE));
9120
- const hasManifest = existsSync4(path56.join(workspaceRoot, REPOS_MANIFEST_FILE));
9989
+ const hasYaml = existsSync4(path57.join(workspaceRoot, WORKSPACE_FILE));
9990
+ const hasManifest = existsSync4(path57.join(workspaceRoot, REPOS_MANIFEST_FILE));
9121
9991
  if (!hasYaml && !hasManifest) {
9122
9992
  return {
9123
9993
  ok: false,
@@ -9142,10 +10012,10 @@ function selectMembers(members, only) {
9142
10012
  }
9143
10013
 
9144
10014
  // src/workspace/worktree/state.ts
9145
- import path57 from "node:path";
10015
+ import path58 from "node:path";
9146
10016
  var WORKTREE_STATE_FILE = ".haus-worktree.json";
9147
10017
  function worktreesDir(mainRoot) {
9148
- return path57.join(mainRoot, ".claude", "worktrees");
10018
+ return path58.join(mainRoot, ".claude", "worktrees");
9149
10019
  }
9150
10020
  function isValidWorktreeSlug(slug) {
9151
10021
  if (slug === "" || slug === "." || slug === "..") return false;
@@ -9158,15 +10028,15 @@ function worktreePath(mainRoot, slug) {
9158
10028
  `Invalid worktree slug ${JSON.stringify(slug)} \u2014 slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
9159
10029
  );
9160
10030
  }
9161
- return path57.join(worktreesDir(mainRoot), slug);
10031
+ return path58.join(worktreesDir(mainRoot), slug);
9162
10032
  }
9163
10033
  async function readWorktreeState(worktreeRoot) {
9164
- const state = await readJson(path57.join(worktreeRoot, WORKTREE_STATE_FILE));
10034
+ const state = await readJson(path58.join(worktreeRoot, WORKTREE_STATE_FILE));
9165
10035
  if (state == null || typeof state !== "object" || !Array.isArray(state.members)) return void 0;
9166
10036
  return state;
9167
10037
  }
9168
10038
  async function writeWorktreeState(worktreeRoot, state) {
9169
- await writeJson(path57.join(worktreeRoot, WORKTREE_STATE_FILE), state);
10039
+ await writeJson(path58.join(worktreeRoot, WORKTREE_STATE_FILE), state);
9170
10040
  }
9171
10041
 
9172
10042
  // src/workspace/worktree/add.ts
@@ -9200,7 +10070,7 @@ async function runAdd(opts) {
9200
10070
  const memberResults = [];
9201
10071
  for (const member of selected) {
9202
10072
  const start = Date.now();
9203
- const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
10073
+ const memberWorktreePath = path59.join(wsWorktreePath, member.folder);
9204
10074
  const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
9205
10075
  preferDefaultBranchFrom: true
9206
10076
  });
@@ -9230,7 +10100,7 @@ async function runAdd(opts) {
9230
10100
  await mapWithConcurrency(okResults, async (result) => {
9231
10101
  const member = selected.find((m) => m.id === result.member);
9232
10102
  if (!member) return;
9233
- const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
10103
+ const memberWorktreePath = path59.join(wsWorktreePath, member.folder);
9234
10104
  const hydrationStart = Date.now();
9235
10105
  if (opts.hydrate !== false) {
9236
10106
  result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
@@ -9297,10 +10167,10 @@ async function runDryRun(args) {
9297
10167
 
9298
10168
  // src/workspace/worktree/doctor.ts
9299
10169
  import { existsSync as existsSync5 } from "node:fs";
9300
- import path59 from "node:path";
10170
+ import path60 from "node:path";
9301
10171
  var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
9302
10172
  function looksHydrated(memberPath) {
9303
- return HYDRATION_MARKERS.some((marker) => existsSync5(path59.join(memberPath, marker)));
10173
+ return HYDRATION_MARKERS.some((marker) => existsSync5(path60.join(memberPath, marker)));
9304
10174
  }
9305
10175
  async function runDoctor2() {
9306
10176
  const rootInfo = await resolveRoots();
@@ -9314,7 +10184,7 @@ async function runDoctor2() {
9314
10184
  };
9315
10185
  }
9316
10186
  const workspaceRoot = rootInfo.mainRoot;
9317
- const isWorkspace = existsSync5(path59.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path59.join(workspaceRoot, REPOS_MANIFEST_FILE));
10187
+ const isWorkspace = existsSync5(path60.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path60.join(workspaceRoot, REPOS_MANIFEST_FILE));
9318
10188
  if (!isWorkspace) {
9319
10189
  return {
9320
10190
  isWorkspace: false,
@@ -9327,7 +10197,7 @@ async function runDoctor2() {
9327
10197
  };
9328
10198
  }
9329
10199
  const wsWorktreesDir = worktreesDir(workspaceRoot);
9330
- const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path59.sep}`);
10200
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path60.sep}`);
9331
10201
  const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
9332
10202
  const members = await readMembers(rootInfo);
9333
10203
  const problems = [];
@@ -9335,10 +10205,10 @@ async function runDoctor2() {
9335
10205
  let slug;
9336
10206
  if (inWorkspaceWorktree) {
9337
10207
  const wtPath = rootInfo.repoRoot;
9338
- slug = rootInfo.worktreeName ?? path59.basename(wtPath);
10208
+ slug = rootInfo.worktreeName ?? path60.basename(wtPath);
9339
10209
  const state = await readWorktreeState(wtPath);
9340
10210
  for (const member of members) {
9341
- const memberPath = path59.join(wtPath, member.folder);
10211
+ const memberPath = path60.join(wtPath, member.folder);
9342
10212
  const materialized = existsSync5(memberPath);
9343
10213
  const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
9344
10214
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
@@ -9369,16 +10239,16 @@ async function runDoctor2() {
9369
10239
  for (const member of members) {
9370
10240
  const entries = await listWorktrees(member.absPath);
9371
10241
  for (const entry of entries) {
9372
- const entryPath = path59.normalize(entry.path);
9373
- const marker = `${path59.sep}.claude${path59.sep}worktrees${path59.sep}`;
10242
+ const entryPath = path60.normalize(entry.path);
10243
+ const marker = `${path60.sep}.claude${path60.sep}worktrees${path60.sep}`;
9374
10244
  const idx = entryPath.indexOf(marker);
9375
10245
  if (idx === -1) continue;
9376
10246
  const rest = entryPath.slice(idx + marker.length);
9377
- const slugName = rest.split(path59.sep)[0];
10247
+ const slugName = rest.split(path60.sep)[0];
9378
10248
  if (!slugName) continue;
9379
- if (!existsSync5(path59.join(wsWorktreesDir, slugName))) {
10249
+ if (!existsSync5(path60.join(wsWorktreesDir, slugName))) {
9380
10250
  orphans.push(
9381
- `${member.id}: worktree registered at ${entry.path} but ${path59.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
10251
+ `${member.id}: worktree registered at ${entry.path} but ${path60.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
9382
10252
  );
9383
10253
  }
9384
10254
  }
@@ -9389,7 +10259,7 @@ async function runDoctor2() {
9389
10259
 
9390
10260
  // src/workspace/worktree/hydrate-command.ts
9391
10261
  import { existsSync as existsSync6 } from "node:fs";
9392
- import path60 from "node:path";
10262
+ import path61 from "node:path";
9393
10263
  async function runHydrateWorktree(opts) {
9394
10264
  const rootInfo = await resolveRoots();
9395
10265
  if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
@@ -9400,7 +10270,7 @@ async function runHydrateWorktree(opts) {
9400
10270
  }
9401
10271
  const workspaceRoot = rootInfo.mainRoot;
9402
10272
  const wtPath = rootInfo.repoRoot;
9403
- if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path60.sep}`)) {
10273
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path61.sep}`)) {
9404
10274
  return {
9405
10275
  ok: false,
9406
10276
  error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
@@ -9412,13 +10282,13 @@ async function runHydrateWorktree(opts) {
9412
10282
  return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
9413
10283
  }
9414
10284
  const state = await readWorktreeState(wtPath);
9415
- const slug = state?.slug ?? path60.basename(wtPath);
10285
+ const slug = state?.slug ?? path61.basename(wtPath);
9416
10286
  const detectedBranch = await currentBranch(wtPath);
9417
10287
  const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
9418
10288
  if (opts.dryRun) {
9419
10289
  const results2 = await Promise.all(
9420
10290
  selected.map(async (member) => {
9421
- const memberPath = path60.join(wtPath, member.folder);
10291
+ const memberPath = path61.join(wtPath, member.folder);
9422
10292
  if (existsSync6(memberPath)) {
9423
10293
  return { member: member.id, folder: member.folder, status: "planned" };
9424
10294
  }
@@ -9439,7 +10309,7 @@ async function runHydrateWorktree(opts) {
9439
10309
  const results = await mapWithConcurrency(
9440
10310
  selected,
9441
10311
  async (member) => {
9442
- const memberPath = path60.join(wtPath, member.folder);
10312
+ const memberPath = path61.join(wtPath, member.folder);
9443
10313
  let branchAction;
9444
10314
  if (!existsSync6(memberPath)) {
9445
10315
  if (!opts.materialize) {
@@ -9502,7 +10372,7 @@ async function runHydrateWorktree(opts) {
9502
10372
  }
9503
10373
 
9504
10374
  // src/workspace/worktree/list.ts
9505
- import path61 from "node:path";
10375
+ import path62 from "node:path";
9506
10376
  import fs42 from "fs-extra";
9507
10377
  async function runList2() {
9508
10378
  const resolved = await resolveWorkspaceForWorktree();
@@ -9515,12 +10385,12 @@ async function runList2() {
9515
10385
  const configuredMembers = await readMembers(rootInfo);
9516
10386
  const worktrees = [];
9517
10387
  for (const slug of slugs) {
9518
- const wtPath = path61.join(dir, slug);
10388
+ const wtPath = path62.join(dir, slug);
9519
10389
  const state = await readWorktreeState(wtPath);
9520
10390
  const members = [];
9521
10391
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
9522
10392
  for (const m of source) {
9523
- const memberPath = path61.join(wtPath, m.folder);
10393
+ const memberPath = path62.join(wtPath, m.folder);
9524
10394
  const materialized = await fs42.pathExists(memberPath);
9525
10395
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
9526
10396
  members.push({
@@ -9542,7 +10412,7 @@ async function runList2() {
9542
10412
  }
9543
10413
 
9544
10414
  // src/workspace/worktree/remove.ts
9545
- import path62 from "node:path";
10415
+ import path63 from "node:path";
9546
10416
  import fs43 from "fs-extra";
9547
10417
  async function runRemove(opts) {
9548
10418
  const resolved = await resolveWorkspaceForWorktree();
@@ -9569,7 +10439,7 @@ async function runRemove(opts) {
9569
10439
  targets.push({
9570
10440
  repo: configMember?.id ?? stateMember.id,
9571
10441
  ownerRepo,
9572
- path: path62.join(wsWorktreePath, stateMember.folder)
10442
+ path: path63.join(wsWorktreePath, stateMember.folder)
9573
10443
  });
9574
10444
  }
9575
10445
  const existingTargets = [];
@@ -9637,11 +10507,8 @@ async function runRemove(opts) {
9637
10507
  }
9638
10508
 
9639
10509
  // src/commands/workspace/worktree.ts
9640
- function normalizeOnly(only) {
9641
- if (!only) return void 0;
9642
- const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
9643
- const cleaned = list.map((s) => s.trim()).filter(Boolean);
9644
- return cleaned.length > 0 ? cleaned : void 0;
10510
+ function emitJson(result) {
10511
+ log(JSON.stringify(result, null, 2));
9645
10512
  }
9646
10513
  function describeInstall(hydration) {
9647
10514
  if (hydration.install.plan.manager === "none") return "no install step (no recognized lockfile)";
@@ -9651,7 +10518,8 @@ function describeInstall(hydration) {
9651
10518
  }
9652
10519
  async function handleAdd(opts) {
9653
10520
  if (!opts.slug) {
9654
- error("Usage: haus workspace worktree add <slug>");
10521
+ if (opts.json) emitJson({ ok: false, error: "Usage: haus workspace worktree add <slug>" });
10522
+ else error("Usage: haus workspace worktree add <slug>");
9655
10523
  process.exitCode = 1;
9656
10524
  return;
9657
10525
  }
@@ -9664,10 +10532,16 @@ async function handleAdd(opts) {
9664
10532
  };
9665
10533
  const result = await runAdd(addOpts);
9666
10534
  if ("error" in result) {
9667
- error(result.error);
10535
+ if (opts.json) emitJson(result);
10536
+ else error(result.error);
9668
10537
  process.exitCode = 1;
9669
10538
  return;
9670
10539
  }
10540
+ if (opts.json) {
10541
+ emitJson(result);
10542
+ if (!result.dryRun && !result.ok) process.exitCode = 1;
10543
+ return;
10544
+ }
9671
10545
  if (result.dryRun) {
9672
10546
  log(
9673
10547
  `[dry-run] Would create the workspace worktree at ${result.worktreePath} on branch "${result.branch}" (${result.workspaceWorktree.branchAction}).`
@@ -9707,10 +10581,16 @@ async function handleHydrate(opts) {
9707
10581
  };
9708
10582
  const result = await runHydrateWorktree(hydrateOpts);
9709
10583
  if (!result.ok && "error" in result) {
9710
- error(result.error);
10584
+ if (opts.json) emitJson(result);
10585
+ else error(result.error);
9711
10586
  process.exitCode = 1;
9712
10587
  return;
9713
10588
  }
10589
+ if (opts.json) {
10590
+ emitJson(result);
10591
+ if (!result.dryRun && !result.ok) process.exitCode = 1;
10592
+ return;
10593
+ }
9714
10594
  if (result.dryRun) {
9715
10595
  log(`[dry-run] Would hydrate "${result.slug}":`);
9716
10596
  for (const r of result.results) {
@@ -9732,7 +10612,8 @@ async function handleHydrate(opts) {
9732
10612
  }
9733
10613
  async function handleRemove(opts) {
9734
10614
  if (!opts.slug) {
9735
- error("Usage: haus workspace worktree remove <slug>");
10615
+ if (opts.json) emitJson({ ok: false, error: "Usage: haus workspace worktree remove <slug>" });
10616
+ else error("Usage: haus workspace worktree remove <slug>");
9736
10617
  process.exitCode = 1;
9737
10618
  return;
9738
10619
  }
@@ -9742,6 +10623,11 @@ async function handleRemove(opts) {
9742
10623
  dryRun: Boolean(opts.dryRun)
9743
10624
  };
9744
10625
  const result = await runRemove(removeOpts);
10626
+ if (opts.json) {
10627
+ emitJson(result);
10628
+ if (!result.ok || "removed" in result && result.failed.length > 0) process.exitCode = 1;
10629
+ return;
10630
+ }
9745
10631
  if (!result.ok && "error" in result) {
9746
10632
  error(result.error);
9747
10633
  process.exitCode = 1;
@@ -9766,8 +10652,13 @@ async function handleRemove(opts) {
9766
10652
  }
9767
10653
  }
9768
10654
  }
9769
- async function handleList() {
10655
+ async function handleList(opts) {
9770
10656
  const result = await runList2();
10657
+ if (opts.json) {
10658
+ emitJson(result);
10659
+ if (!result.ok) process.exitCode = 1;
10660
+ return;
10661
+ }
9771
10662
  if (!result.ok) {
9772
10663
  error(result.error);
9773
10664
  process.exitCode = 1;
@@ -9838,7 +10729,7 @@ async function runWorktree(action, opts = {}) {
9838
10729
  case "remove":
9839
10730
  return handleRemove(opts);
9840
10731
  case "list":
9841
- return handleList();
10732
+ return handleList(opts);
9842
10733
  case "doctor":
9843
10734
  return handleDoctor(opts);
9844
10735
  }
@@ -9846,7 +10737,7 @@ async function runWorktree(action, opts = {}) {
9846
10737
 
9847
10738
  // src/commands/workspace.ts
9848
10739
  import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
9849
- import path71 from "node:path";
10740
+ import path72 from "node:path";
9850
10741
 
9851
10742
  // src/commands/workspace/aggregate.ts
9852
10743
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -9901,18 +10792,18 @@ ${summaries.map(
9901
10792
 
9902
10793
  // src/commands/workspace/doctor.ts
9903
10794
  import { existsSync as existsSync7 } from "node:fs";
9904
- import path64 from "node:path";
10795
+ import path65 from "node:path";
9905
10796
 
9906
10797
  // src/commands/workspace/manifest.ts
9907
10798
  import { readFileSync as readFileSync4 } from "node:fs";
9908
- import path63 from "node:path";
10799
+ import path64 from "node:path";
9909
10800
  var MANIFEST_FILE = "workspace.manifest.json";
9910
10801
  function manifestPath(workspaceRoot) {
9911
10802
  return hausPath(workspaceRoot, MANIFEST_FILE);
9912
10803
  }
9913
10804
  function hausVersion() {
9914
10805
  try {
9915
- const pkg = JSON.parse(readFileSync4(path63.join(packageRoot(), "package.json"), "utf8"));
10806
+ const pkg = JSON.parse(readFileSync4(path64.join(packageRoot(), "package.json"), "utf8"));
9916
10807
  return pkg.version ?? "0.0.0";
9917
10808
  } catch {
9918
10809
  return "0.0.0";
@@ -10004,7 +10895,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
10004
10895
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
10005
10896
  const catalogRefByRepo = [];
10006
10897
  for (const repo of config.repos) {
10007
- const repoRoot = path64.resolve(workspaceRoot, repo.path);
10898
+ const repoRoot = path65.resolve(workspaceRoot, repo.path);
10008
10899
  const entry = manifestByName.get(repo.name);
10009
10900
  if (!entry) {
10010
10901
  flag({
@@ -10077,7 +10968,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
10077
10968
  });
10078
10969
  continue;
10079
10970
  }
10080
- if (!existsSync7(path64.join(workspaceRoot, entry.path))) {
10971
+ if (!existsSync7(path65.join(workspaceRoot, entry.path))) {
10081
10972
  flag({
10082
10973
  repo: entry.repo,
10083
10974
  kind: "missing-linked-context-copy",
@@ -10120,15 +11011,15 @@ function emit(args) {
10120
11011
  }
10121
11012
 
10122
11013
  // src/workspace/link-context/link.ts
10123
- import path67 from "node:path";
11014
+ import path68 from "node:path";
10124
11015
  import fs46 from "fs-extra";
10125
11016
 
10126
11017
  // src/workspace/link-context/plan.ts
10127
- import path66 from "node:path";
11018
+ import path67 from "node:path";
10128
11019
  import fs45 from "fs-extra";
10129
11020
 
10130
11021
  // src/workspace/link-context/scan-source.ts
10131
- import path65 from "node:path";
11022
+ import path66 from "node:path";
10132
11023
  import fs44 from "fs-extra";
10133
11024
  var ASSET_SUBDIR = {
10134
11025
  skill: "skills",
@@ -10137,36 +11028,36 @@ var ASSET_SUBDIR = {
10137
11028
  };
10138
11029
  async function hasLinkableContext(member) {
10139
11030
  for (const subdir of Object.values(ASSET_SUBDIR)) {
10140
- if (await fs44.pathExists(path65.join(member.absPath, ".claude", subdir))) return true;
11031
+ if (await fs44.pathExists(path66.join(member.absPath, ".claude", subdir))) return true;
10141
11032
  }
10142
11033
  return false;
10143
11034
  }
10144
11035
  async function collectSkills(member) {
10145
- const dir = path65.join(member.absPath, ".claude", "skills");
11036
+ const dir = path66.join(member.absPath, ".claude", "skills");
10146
11037
  if (!await fs44.pathExists(dir)) return [];
10147
11038
  const names = await fs44.readdir(dir);
10148
11039
  const out = [];
10149
11040
  for (const name of [...names].sort()) {
10150
- const abs = path65.join(dir, name);
11041
+ const abs = path66.join(dir, name);
10151
11042
  const stat = await fs44.lstat(abs);
10152
11043
  if (!stat.isDirectory()) continue;
10153
- if (!await fs44.pathExists(path65.join(abs, "SKILL.md"))) continue;
10154
- out.push({ type: "skill", name, sourceRelPath: path65.posix.join(".claude", "skills", name) });
11044
+ if (!await fs44.pathExists(path66.join(abs, "SKILL.md"))) continue;
11045
+ out.push({ type: "skill", name, sourceRelPath: path66.posix.join(".claude", "skills", name) });
10155
11046
  }
10156
11047
  return out;
10157
11048
  }
10158
11049
  async function collectFlatMarkdown(member, type) {
10159
11050
  const subdir = ASSET_SUBDIR[type];
10160
- const dir = path65.join(member.absPath, ".claude", subdir);
11051
+ const dir = path66.join(member.absPath, ".claude", subdir);
10161
11052
  if (!await fs44.pathExists(dir)) return [];
10162
11053
  const names = await fs44.readdir(dir);
10163
11054
  const out = [];
10164
11055
  for (const fileName of [...names].sort()) {
10165
11056
  if (!fileName.endsWith(".md")) continue;
10166
- const abs = path65.join(dir, fileName);
11057
+ const abs = path66.join(dir, fileName);
10167
11058
  if (!(await fs44.lstat(abs)).isFile()) continue;
10168
11059
  const name = fileName.slice(0, -".md".length);
10169
- out.push({ type, name, sourceRelPath: path65.posix.join(".claude", subdir, fileName) });
11060
+ out.push({ type, name, sourceRelPath: path66.posix.join(".claude", subdir, fileName) });
10170
11061
  }
10171
11062
  return out;
10172
11063
  }
@@ -10181,9 +11072,9 @@ async function collectSourceAssets(member) {
10181
11072
 
10182
11073
  // src/workspace/link-context/plan.ts
10183
11074
  function repoFolderPrefix(member) {
10184
- const base = path66.posix.basename(member.folder);
11075
+ const base = path67.posix.basename(member.folder);
10185
11076
  if (base && base !== "." && base !== "/") return base;
10186
- const fromAbs = path66.basename(member.absPath);
11077
+ const fromAbs = path67.basename(member.absPath);
10187
11078
  return fromAbs || member.id;
10188
11079
  }
10189
11080
  function assetKey(type, destKey) {
@@ -10240,7 +11131,7 @@ async function buildLinkPlan(members) {
10240
11131
  name: asset.name,
10241
11132
  destKey,
10242
11133
  sourceRelPath: asset.sourceRelPath,
10243
- sourceAbsPath: path66.join(member.absPath, asset.sourceRelPath),
11134
+ sourceAbsPath: path67.join(member.absPath, asset.sourceRelPath),
10244
11135
  sourceHash
10245
11136
  });
10246
11137
  }
@@ -10251,7 +11142,7 @@ async function buildLinkPlan(members) {
10251
11142
  function destRelPathFor(type, destKey) {
10252
11143
  const subdir = `${type}s`;
10253
11144
  const leaf = type === "skill" ? destKey : `${destKey}.md`;
10254
- return path67.posix.join(".claude", subdir, leaf);
11145
+ return path68.posix.join(".claude", subdir, leaf);
10255
11146
  }
10256
11147
  async function runLinkContext(workspaceRoot, opts = {}) {
10257
11148
  const write = opts.write ?? false;
@@ -10277,9 +11168,9 @@ async function runLinkContext(workspaceRoot, opts = {}) {
10277
11168
  const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
10278
11169
  for (const entry of plan.entries) {
10279
11170
  const destRel = destRelPathFor(entry.type, entry.destKey);
10280
- const destAbs = path67.join(workspaceRoot, destRel);
11171
+ const destAbs = path68.join(workspaceRoot, destRel);
10281
11172
  if (write) {
10282
- await fs46.ensureDir(path67.dirname(destAbs));
11173
+ await fs46.ensureDir(path68.dirname(destAbs));
10283
11174
  if (await fs46.pathExists(destAbs)) await fs46.remove(destAbs);
10284
11175
  await fs46.copy(entry.sourceAbsPath, destAbs, {
10285
11176
  overwrite: true,
@@ -10368,10 +11259,10 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
10368
11259
 
10369
11260
  // src/commands/workspace/setup.ts
10370
11261
  import { existsSync as existsSync8, statSync } from "node:fs";
10371
- import path69 from "node:path";
11262
+ import path70 from "node:path";
10372
11263
 
10373
11264
  // src/claude/write-workspace-claude-md.ts
10374
- import path68 from "node:path";
11265
+ import path69 from "node:path";
10375
11266
  import fs47 from "fs-extra";
10376
11267
  var WORKSPACE_SENTINELS = { begin: WORKSPACE_BLOCK_BEGIN, end: WORKSPACE_BLOCK_END };
10377
11268
  function buildWorkspaceImportBlock(client, members) {
@@ -10391,7 +11282,7 @@ ${WORKSPACE_BLOCK_END}`;
10391
11282
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
10392
11283
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
10393
11284
  const dryRun = opts.dryRun ?? false;
10394
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path68.join(workspaceRoot, "CLAUDE.md");
11285
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path69.join(workspaceRoot, "CLAUDE.md");
10395
11286
  const say = opts.quiet ? () => {
10396
11287
  } : log;
10397
11288
  const existed = await fs47.pathExists(filePath);
@@ -10422,20 +11313,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
10422
11313
 
10423
11314
  // src/commands/workspace/setup.ts
10424
11315
  function resolveWorkspaceRoot(start = process.cwd()) {
10425
- let dir = path69.resolve(start);
11316
+ let dir = path70.resolve(start);
10426
11317
  for (; ; ) {
10427
- if (existsSync8(path69.join(dir, WORKSPACE_FILE))) return dir;
10428
- const parent = path69.dirname(dir);
10429
- if (parent === dir) return path69.resolve(start);
11318
+ if (existsSync8(path70.join(dir, WORKSPACE_FILE))) return dir;
11319
+ const parent = path70.dirname(dir);
11320
+ if (parent === dir) return path70.resolve(start);
10430
11321
  dir = parent;
10431
11322
  }
10432
11323
  }
10433
11324
  function isRootRepo(workspaceRoot, repoPath) {
10434
- return path69.resolve(workspaceRoot, repoPath) === path69.resolve(workspaceRoot);
11325
+ return path70.resolve(workspaceRoot, repoPath) === path70.resolve(workspaceRoot);
10435
11326
  }
10436
11327
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
10437
11328
  const apply = options.write ?? false;
10438
- const configText = await readText(path69.join(workspaceRoot, WORKSPACE_FILE));
11329
+ const configText = await readText(path70.join(workspaceRoot, WORKSPACE_FILE));
10439
11330
  if (!configText) {
10440
11331
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
10441
11332
  process.exitCode = 1;
@@ -10462,7 +11353,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10462
11353
  const statuses = [];
10463
11354
  const aggregateInputs = [];
10464
11355
  for (const repo of repos) {
10465
- const repoRoot = path69.resolve(workspaceRoot, repo.path);
11356
+ const repoRoot = path70.resolve(workspaceRoot, repo.path);
10466
11357
  say(`
10467
11358
  \u2192 ${repo.name} (${repo.path})`);
10468
11359
  try {
@@ -10502,7 +11393,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10502
11393
  process.exitCode = 1;
10503
11394
  return result2;
10504
11395
  }
10505
- throw err;
11396
+ throw new Error(`Setup failed for ${repo.name}: ${message}`, { cause: err });
10506
11397
  }
10507
11398
  error(`Setup failed for ${repo.name}: ${message}`);
10508
11399
  }
@@ -10552,7 +11443,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10552
11443
  const status = statusByName.get(repo.name);
10553
11444
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
10554
11445
  if (status?.status === "ok") {
10555
- const lock = await checkLock(path69.resolve(workspaceRoot, repo.path));
11446
+ const lock = await checkLock(path70.resolve(workspaceRoot, repo.path));
10556
11447
  manifestRepos.push({
10557
11448
  name: repo.name,
10558
11449
  path: repo.path,
@@ -10608,7 +11499,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10608
11499
  try {
10609
11500
  const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
10610
11501
  if (linkResult.ok) {
10611
- written.push(...linkResult.added.map((rel) => path69.join(workspaceRoot, rel)));
11502
+ written.push(...linkResult.added.map((rel) => path70.join(workspaceRoot, rel)));
10612
11503
  if (!options.json) {
10613
11504
  say(
10614
11505
  `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` : ""}.`
@@ -10640,7 +11531,7 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
10640
11531
 
10641
11532
  // src/commands/workspace/undo.ts
10642
11533
  import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
10643
- import path70 from "node:path";
11534
+ import path71 from "node:path";
10644
11535
  import fs48 from "fs-extra";
10645
11536
  var WORKSPACE_AGGREGATE_FILES = [
10646
11537
  "workspace-summary.json",
@@ -10666,6 +11557,52 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10666
11557
  if (config.repos.length === 0) {
10667
11558
  log(`No repos configured in ${WORKSPACE_FILE}.`);
10668
11559
  }
11560
+ if (options.dryRun) {
11561
+ for (const repo of config.repos) {
11562
+ const repoRoot = path71.resolve(workspaceRoot, repo.path);
11563
+ log(`
11564
+ \u2192 ${repo.name} (${repo.path})`);
11565
+ try {
11566
+ if (!existsSync9(repoRoot) || !statSync2(repoRoot).isDirectory()) {
11567
+ throw new Error(`Repo path is not a directory: ${repo.path}`);
11568
+ }
11569
+ await runUndo({ dryRun: true, root: repoRoot });
11570
+ } catch (err) {
11571
+ warn(
11572
+ `[dry-run] Undo preview failed for ${repo.name}: ${err instanceof Error ? err.message : String(err)}`
11573
+ );
11574
+ }
11575
+ }
11576
+ const linkedContext2 = await readLinkedContext(workspaceRoot);
11577
+ for (const entry of linkedContext2) {
11578
+ const abs = resolveContainedPath(workspaceRoot, entry.path);
11579
+ if (abs === null) {
11580
+ warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
11581
+ continue;
11582
+ }
11583
+ if (await fs48.pathExists(abs)) {
11584
+ log(`[dry-run] Would remove ${entry.path} (linked ${entry.type} from ${entry.repo})`);
11585
+ }
11586
+ }
11587
+ const workspaceArtifacts = [
11588
+ ...WORKSPACE_AGGREGATE_FILES.map((rel) => hausPath(workspaceRoot, rel)),
11589
+ manifestPath(workspaceRoot),
11590
+ hausPath(workspaceRoot, "WORKSPACE.md")
11591
+ ];
11592
+ for (const abs of workspaceArtifacts) {
11593
+ if (await fs48.pathExists(abs)) {
11594
+ log(`[dry-run] Would remove ${path71.relative(workspaceRoot, abs)}`);
11595
+ }
11596
+ }
11597
+ if (!await fs48.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
11598
+ const claudeMd = await readText(path71.join(workspaceRoot, "CLAUDE.md"));
11599
+ if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
11600
+ log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
11601
+ }
11602
+ }
11603
+ log(`[dry-run] ${WORKSPACE_FILE} would be left in place. Nothing was changed.`);
11604
+ return;
11605
+ }
10669
11606
  if (!options.yes) {
10670
11607
  const ok = await confirm(
10671
11608
  `Undo haus setup for ${config.repos.length} repo(s) in this workspace, plus workspace-root artifacts?`
@@ -10677,7 +11614,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10677
11614
  }
10678
11615
  let anyRepoFailed = false;
10679
11616
  for (const repo of config.repos) {
10680
- const repoRoot = path70.resolve(workspaceRoot, repo.path);
11617
+ const repoRoot = path71.resolve(workspaceRoot, repo.path);
10681
11618
  log(`
10682
11619
  \u2192 ${repo.name} (${repo.path})`);
10683
11620
  try {
@@ -10706,20 +11643,20 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10706
11643
  const abs = hausPath(workspaceRoot, rel);
10707
11644
  if (await fs48.pathExists(abs)) {
10708
11645
  await fs48.remove(abs);
10709
- log(`Removed ${path70.relative(workspaceRoot, abs)}`);
11646
+ log(`Removed ${path71.relative(workspaceRoot, abs)}`);
10710
11647
  }
10711
11648
  }
10712
11649
  const manifestFile = manifestPath(workspaceRoot);
10713
11650
  if (await fs48.pathExists(manifestFile)) {
10714
11651
  await fs48.remove(manifestFile);
10715
- log(`Removed ${path70.relative(workspaceRoot, manifestFile)}`);
11652
+ log(`Removed ${path71.relative(workspaceRoot, manifestFile)}`);
10716
11653
  }
10717
11654
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
10718
11655
  if (await fs48.pathExists(workspaceMdPath)) {
10719
11656
  await fs48.remove(workspaceMdPath);
10720
- log(`Removed ${path70.relative(workspaceRoot, workspaceMdPath)}`);
11657
+ log(`Removed ${path71.relative(workspaceRoot, workspaceMdPath)}`);
10721
11658
  } else {
10722
- const claudeMdPath = path70.join(workspaceRoot, "CLAUDE.md");
11659
+ const claudeMdPath = path71.join(workspaceRoot, "CLAUDE.md");
10723
11660
  if (existsSync9(claudeMdPath)) {
10724
11661
  const prev = await readText(claudeMdPath);
10725
11662
  if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
@@ -10758,12 +11695,6 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10758
11695
  }
10759
11696
 
10760
11697
  // src/commands/workspace.ts
10761
- function normalizeOnly2(only) {
10762
- if (!only) return void 0;
10763
- const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
10764
- const cleaned = list.map((s) => s.trim()).filter(Boolean);
10765
- return cleaned.length > 0 ? cleaned : void 0;
10766
- }
10767
11698
  function normalizeMaxDepth(maxDepth) {
10768
11699
  if (maxDepth === void 0) return void 0;
10769
11700
  const n = typeof maxDepth === "number" ? maxDepth : Number.parseInt(maxDepth, 10);
@@ -10794,7 +11725,7 @@ relationships: []
10794
11725
  log("Workspace initialized.");
10795
11726
  }
10796
11727
  async function scanWorkspace(workspaceRoot, opts) {
10797
- const configText = await readText(path71.join(workspaceRoot, WORKSPACE_FILE));
11728
+ const configText = await readText(path72.join(workspaceRoot, WORKSPACE_FILE));
10798
11729
  if (!configText) {
10799
11730
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
10800
11731
  process.exitCode = 1;
@@ -10816,7 +11747,7 @@ async function scanWorkspace(workspaceRoot, opts) {
10816
11747
  const inputs = [];
10817
11748
  const failures = [];
10818
11749
  for (const repo of config.repos) {
10819
- const repoRoot = path71.resolve(workspaceRoot, repo.path);
11750
+ const repoRoot = path72.resolve(workspaceRoot, repo.path);
10820
11751
  try {
10821
11752
  if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
10822
11753
  throw new Error("path is not a directory");
@@ -10864,14 +11795,14 @@ async function runWorkspace(action, options = {}) {
10864
11795
  dryRun: options.dryRun,
10865
11796
  json: options.json,
10866
11797
  continueOnError: options.continueOnError,
10867
- only: normalizeOnly2(options.only)
11798
+ only: normalizeOnly(options.only)
10868
11799
  });
10869
11800
  return;
10870
11801
  case "doctor":
10871
11802
  await runWorkspaceDoctor(workspaceRoot, { json: options.json });
10872
11803
  return;
10873
11804
  case "undo":
10874
- await runWorkspaceUndo(workspaceRoot, { yes: options.yes });
11805
+ await runWorkspaceUndo(workspaceRoot, { yes: options.yes, dryRun: options.dryRun });
10875
11806
  return;
10876
11807
  case "link-context":
10877
11808
  await runWorkspaceLinkContext(workspaceRoot, { write: options.write, json: options.json });
@@ -10882,7 +11813,7 @@ async function runWorkspace(action, options = {}) {
10882
11813
  // src/cli.ts
10883
11814
  function cliVersion() {
10884
11815
  try {
10885
- const pkgPath = path72.join(packageRoot(), "package.json");
11816
+ const pkgPath = path73.join(packageRoot(), "package.json");
10886
11817
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
10887
11818
  return pkg.version ?? "0.0.0";
10888
11819
  } catch {
@@ -10892,7 +11823,7 @@ function cliVersion() {
10892
11823
  var program = new Command();
10893
11824
  function validateRuntimeNodeVersion() {
10894
11825
  try {
10895
- const pkgPath = path72.join(packageRoot(), "package.json");
11826
+ const pkgPath = path73.join(packageRoot(), "package.json");
10896
11827
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
10897
11828
  const requiredRange = pkg.engines?.node;
10898
11829
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -10906,18 +11837,22 @@ function validateRuntimeNodeVersion() {
10906
11837
  }
10907
11838
  validateRuntimeNodeVersion();
10908
11839
  program.name("haus").description("Haus AI workflow CLI").version(cliVersion());
10909
- program.command("scan").option("--json").action(runScan);
10910
- program.command("recommend").option("--json").option(
11840
+ program.command("scan").description(
11841
+ "Scan the repo for roles, dependencies, and package manager; writes context-map.json"
11842
+ ).option("--json").action(runScan);
11843
+ program.command("recommend").description("Score catalog items against the scanned context and write recommendation.json").option("--json").option(
10911
11844
  "--include <ids...>",
10912
11845
  "Force opt-in catalog items into the recommendation as manual selections (space- or comma-separated ids)"
10913
11846
  ).action(runRecommend);
10914
- program.command("setup-project").option("--json").option(
11847
+ program.command("setup-project").description("Fast full setup: scan, recommend, and optionally apply Claude files").option("--json").option(
10915
11848
  "--force",
10916
11849
  "Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
10917
11850
  ).action(runSetupProject);
10918
- program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
11851
+ program.command("doctor").description(
11852
+ "Validate project setup: hooks contract, managed files, cache freshness, CLI version"
11853
+ ).option("--hooks", "Verify .claude/settings.json matches the hook contract").option("--json", "Emit all findings as one JSON document on stdout instead of human output").action(runDoctor);
10919
11854
  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);
10920
- program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
11855
+ program.command("apply").description("Write recommended catalog items and core Claude files to .claude/").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
10921
11856
  "--ids <ids...>",
10922
11857
  "Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
10923
11858
  ).option(
@@ -10930,33 +11865,41 @@ program.command("apply").option("--dry-run").option("--write").option("--select"
10930
11865
  "--prune",
10931
11866
  "Remove lock-tracked catalog items no longer in the current recommendation (hash-gated, backed up first)"
10932
11867
  ).action(runApply);
10933
- program.command("undo").option("-y, --yes", "Skip confirmation").action(runUndo);
10934
- var backups = program.command("backups");
10935
- backups.command("list").description("List backups under .haus-workflow/backups/").action(() => runBackups("list"));
10936
- backups.command("restore <id>").description("Restore a specific backup by id (see `haus backups list`)").option("-y, --yes", "Skip confirmation").action((id, opts) => runBackups("restore", { id, yes: opts.yes }));
10937
- backups.command("prune").description("Delete old backups by age and/or count (requires --older-than or --keep)").option("--older-than <days>", "Remove backups older than N days").option("--keep <n>", "Keep only the N most recent backups").option("-y, --yes", "Skip confirmation").action(
10938
- (opts) => runBackups("prune", { olderThan: opts.olderThan, keep: opts.keep, yes: opts.yes })
11868
+ program.command("undo").description("Remove haus-managed project files; preserves user-owned .claude/ content").option("-y, --yes", "Skip confirmation").option("--dry-run", "Print what would be removed without deleting anything").action(runUndo);
11869
+ var backups = program.command("backups").description("List, restore, or prune backups under .haus-workflow/backups/");
11870
+ backups.command("list").description("List backups under .haus-workflow/backups/").option("--json", "Emit the backup entries as JSON (newest first)").action((opts) => runBackups("list", { json: opts.json }));
11871
+ backups.command("restore <id>").description("Restore a specific backup by id (see `haus backups list`)").option("-y, --yes", "Skip confirmation").option("--json", "Emit a machine-readable result (JSON-only stdout; warnings on stderr)").option("--dry-run", "Print what would be restored without writing anything").action(
11872
+ (id, opts) => runBackups("restore", { id, yes: opts.yes, json: opts.json, dryRun: opts.dryRun })
11873
+ );
11874
+ backups.command("prune").description("Delete old backups by age and/or count (requires --older-than or --keep)").option("--older-than <days>", "Remove backups older than N days").option("--keep <n>", "Keep only the N most recent backups").option("-y, --yes", "Skip confirmation").option("--json", "Emit a machine-readable result (JSON-only stdout; warnings on stderr)").option("--dry-run", "Print what would be pruned without deleting anything").action(
11875
+ (opts) => runBackups("prune", {
11876
+ olderThan: opts.olderThan,
11877
+ dryRun: opts.dryRun,
11878
+ keep: opts.keep,
11879
+ yes: opts.yes,
11880
+ json: opts.json
11881
+ })
10939
11882
  );
10940
- program.command("explain-recommendation").option("--json").action(runExplainRecommendation);
10941
- program.command("init").option("--json").action(runInit);
11883
+ program.command("explain-recommendation").description("Print a per-item breakdown of the current recommendation, with skip reasons").option("--json").action(runExplainRecommendation);
11884
+ program.command("init").description("First-run setup; delegates to setup-project if .haus-workflow/ does not exist").option("--json").action(runInit);
10942
11885
  program.command("clone").description("Clone a single git repository by URL").argument("<url>", "Git URL to clone").argument("[dir]", "Target directory (default: repo name derived from the URL)").option("--dry-run", "Print what would happen without cloning").action(
10943
11886
  (url, dir, opts) => runClone(url, { dir, dryRun: opts.dryRun })
10944
11887
  );
10945
- program.command("refresh").action(runRefresh);
10946
- program.command("catalog-audit").action(runCatalogAudit);
10947
- program.command("validate-catalog").argument("[manifest]").action(runValidateCatalog);
10948
- program.command("update").option("--check").option("--fast", "With --check, skip per-item content hashing (cheap count+ref only)").option("--from-hook", "Emit JSON for the Claude Code SessionStart hook (silent if up to date)").action(runUpdate);
11888
+ program.command("refresh").description("Re-scan the project, refresh recommendations, and update scan artifacts").action(runRefresh);
11889
+ program.command("catalog-audit").description("Check the bundled catalog manifest for unsupported or forbidden stacks").action(runCatalogAudit);
11890
+ program.command("validate-catalog").description("Validate a catalog manifest at an explicit path").argument("[manifest]").action(runValidateCatalog);
11891
+ program.command("update").description("Sync the remote catalog cache, refresh the lockfile, and check for CLI updates").option("--check").option("--fast", "With --check, skip per-item content hashing (cheap count+ref only)").option("--from-hook", "Emit JSON for the Claude Code SessionStart hook (silent if up to date)").action(runUpdate);
10949
11892
  program.command("ci-gate").description(
10950
11893
  "Aggregate `doctor` + `decisions check` + `update --check --fast` into one pass/fail gate for CI. Uses the fast/cheap update-check tier (no per-item content hashing) to keep CI runs quick \u2014 run `haus update --check` directly for the fully-hashed tier. Each constituent command remains independently runnable with its own unchanged exit-code contract."
10951
11894
  ).option("--json", "Emit machine-readable {doctor, decisions, update, ok}").action((opts) => runCiGate(opts));
10952
- program.command("install").option("--dry-run").option("--force").option("--check", "Exit non-zero if any HAUS-MANAGED file is out of date").option("--postinstall", "Run by the npm postinstall hook; prints a plain-language change notice").action(runInstall);
11895
+ program.command("install").description("Seed ~/.claude/ with Haus-managed skills, agents, and hook settings").option("--dry-run").option("--force").option("--check", "Exit non-zero if any HAUS-MANAGED file is out of date").option("--postinstall", "Run by the npm postinstall hook; prints a plain-language change notice").action(runInstall);
10953
11896
  program.command("scaffold [ids...]").description(
10954
11897
  "Copy catalog config items (ESLint, Prettier) into the project root. Pass item IDs to scaffold specific items, or omit to scaffold all available config items."
10955
11898
  ).option("--force", "Overwrite existing files").option("--dry-run", "Preview what would be written without changing anything").option("--root <path>", "Project root (defaults to cwd)", process.cwd()).action(runScaffold);
10956
- program.command("uninstall").option("--force").option("-y, --yes", "Skip confirmation").action(runUninstallCommand);
10957
- var guard = program.command("guard");
10958
- guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
10959
- guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
11899
+ program.command("uninstall").description("Remove Haus-managed skills, agents, and hook entries from ~/.claude/").option("--force").option("-y, --yes", "Skip confirmation").option("--dry-run", "Print what would be removed without deleting anything").action(runUninstallCommand);
11900
+ var guard = program.command("guard").description("PreToolUse hook guards: block dangerous bash commands and sensitive file access");
11901
+ guard.command("file-access").description("Block reads/edits of sensitive paths (run by the Claude Code hook)").option("--from-hook").action((opts) => runGuard("file-access", opts));
11902
+ guard.command("bash").description("Block dangerous shell commands (run by the Claude Code hook)").option("--from-hook").action((opts) => runGuard("bash", opts));
10960
11903
  function registerDecisionsCommand(name) {
10961
11904
  const cmd = program.command(name).description("Architecture decision records (ADR) gate and drafts");
10962
11905
  cmd.command("check").option("--staged", "Check staged changes only").option("--range <range>", "Git revision range (e.g. origin/main..HEAD)").action((opts) => runDecisions("check", void 0, opts));
@@ -10969,13 +11912,13 @@ function registerDecisionsCommand(name) {
10969
11912
  }
10970
11913
  registerDecisionsCommand("decisions");
10971
11914
  registerDecisionsCommand("adr");
10972
- var workspace = program.command("workspace");
11915
+ var workspace = program.command("workspace").description("Multi-repo workspace commands: discover, setup, link-context, worktree, doctor");
10973
11916
  workspace.command("init").option("-y, --yes", "Skip confirmation when haus.workspace.yaml already exists").action((opts) => runWorkspace("init", opts));
10974
11917
  workspace.command("discover").description("Auto-find member repos and write/merge haus.workspace.yaml").option("--write", "Persist haus.workspace.yaml (default previews only)").option("--json", "Output the discovered repos and proposed config as JSON").option("--max-depth <n>", "Max directory depth to traverse (default 3)").option("--client <name>", "Set the workspace client name").action((opts) => runWorkspace("discover", opts));
10975
11918
  workspace.command("scan").description("Aggregate a cross-repo summary from a fast scan of each repo").option("--json", "Output the written artifact paths as JSON").action((opts) => runWorkspace("scan", opts));
10976
11919
  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));
10977
11920
  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));
10978
- 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));
11921
+ workspace.command("undo").description("Revert haus setup for every configured repo, plus workspace-root artifacts").option("-y, --yes", "Skip confirmation").option("--dry-run", "Print what would be removed without deleting anything").action((opts) => runWorkspace("undo", opts));
10979
11922
  workspace.command("link-context").description(
10980
11923
  "Copy each member repo's skill/agent/command into the workspace root's .claude/, prefixed to avoid collisions"
10981
11924
  ).option("--write", "Persist copies + manifest + gitignore (default previews only)").option("--json", "Output the link result as JSON").action((opts) => runWorkspace("link-context", opts));
@@ -10984,29 +11927,31 @@ var worktree = workspace.command("worktree").description(
10984
11927
  );
10985
11928
  worktree.command("add <slug>").description(
10986
11929
  "Create a workspace worktree plus one member worktree per repo, on a mirrored branch"
10987
- ).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(
11930
+ ).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").option("--json", "Emit the structured add result as JSON").action(
10988
11931
  (slug, opts) => runWorktree("add", {
10989
11932
  slug,
10990
11933
  branch: opts.branch,
10991
11934
  only: opts.only,
10992
11935
  hydrate: opts.hydrate,
10993
- dryRun: opts.dryRun
11936
+ dryRun: opts.dryRun,
11937
+ json: opts.json
10994
11938
  })
10995
11939
  );
10996
11940
  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(
10997
11941
  "--materialize",
10998
11942
  "Also `git worktree add` any member missing entirely (e.g. a worktree created by plain `git worktree add` outside haus) before hydrating it"
10999
- ).option("--dry-run", "Preview without writing or running anything").action(
11943
+ ).option("--dry-run", "Preview without writing or running anything").option("--json", "Emit the structured hydrate result as JSON").action(
11000
11944
  (opts) => runWorktree("hydrate", {
11001
11945
  only: opts.only,
11002
11946
  force: opts.force,
11003
11947
  materialize: opts.materialize,
11004
- dryRun: opts.dryRun
11948
+ dryRun: opts.dryRun,
11949
+ json: opts.json
11005
11950
  })
11006
11951
  );
11007
- worktree.command("list").description("List materialized workspace worktrees and their member status").action(() => runWorktree("list"));
11008
- 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(
11009
- (slug, opts) => runWorktree("remove", { slug, force: opts.force, dryRun: opts.dryRun })
11952
+ worktree.command("list").description("List materialized workspace worktrees and their member status").option("--json", "Emit the structured list result as JSON").action((opts) => runWorktree("list", { json: opts.json }));
11953
+ 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(
11954
+ (slug, opts) => runWorktree("remove", { slug, force: opts.force, dryRun: opts.dryRun, json: opts.json })
11010
11955
  );
11011
11956
  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 }));
11012
11957
  program.parseAsync(process.argv).catch((err) => {