@haus-tech/haus-workflow 4.0.1 → 4.2.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
@@ -14,9 +14,122 @@ var __export = (target, all) => {
14
14
  __defProp(target, name, { get: all[name], enumerable: true });
15
15
  };
16
16
 
17
+ // src/catalog/offline.ts
18
+ function setOfflineFlag(value) {
19
+ flagged = value;
20
+ }
21
+ function isOffline() {
22
+ return flagged || process.env["HAUS_OFFLINE"] === "1";
23
+ }
24
+ var flagged;
25
+ var init_offline = __esm({
26
+ "src/catalog/offline.ts"() {
27
+ "use strict";
28
+ flagged = false;
29
+ }
30
+ });
31
+
32
+ // src/utils/paths.ts
33
+ import { existsSync, readFileSync } from "node:fs";
34
+ import os from "node:os";
35
+ import path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ function hausPath(root, ...parts) {
38
+ return path.join(root, HAUS_DIR, ...parts);
39
+ }
40
+ function claudePath(root, ...parts) {
41
+ return path.join(root, ".claude", ...parts);
42
+ }
43
+ function claudeConfigDir() {
44
+ const override = process.env.CLAUDE_CONFIG_DIR;
45
+ if (override && override.trim().length > 0) return override;
46
+ return path.join(os.homedir(), ".claude");
47
+ }
48
+ function defaultClaudeConfigDir() {
49
+ return path.join(os.homedir(), ".claude");
50
+ }
51
+ function claudeConfigDirIsRelocated() {
52
+ return path.resolve(claudeConfigDir()) !== path.resolve(defaultClaudeConfigDir());
53
+ }
54
+ function claudeConfigDirLabel() {
55
+ return claudeConfigDirIsRelocated() ? claudeConfigDir().replace(/\\/g, "/") : "~/.claude";
56
+ }
57
+ function displayPath(root, targetPath) {
58
+ const rel = path.relative(root, targetPath).replace(/\\/g, "/");
59
+ if (rel && !rel.startsWith("../") && rel !== "..") {
60
+ return rel.startsWith("./") ? rel : `./${rel}`;
61
+ }
62
+ if (claudeConfigDirIsRelocated()) {
63
+ const configDir = claudeConfigDir();
64
+ const configRel = path.relative(configDir, targetPath).replace(/\\/g, "/");
65
+ if (configRel === "") return "$CLAUDE_CONFIG_DIR";
66
+ if (!configRel.startsWith("../") && configRel !== "..") {
67
+ return `$CLAUDE_CONFIG_DIR/${configRel}`;
68
+ }
69
+ }
70
+ const home = os.homedir();
71
+ const normalized = targetPath.replace(/\\/g, "/");
72
+ if (home && home.trim().length > 0) {
73
+ const homeRel = path.relative(home, targetPath).replace(/\\/g, "/");
74
+ if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
75
+ return `~/${homeRel}`;
76
+ }
77
+ }
78
+ return normalized;
79
+ }
80
+ function packageRoot() {
81
+ let dir = path.dirname(fileURLToPath(import.meta.url));
82
+ for (let i = 0; i < 12; i++) {
83
+ const pkgPath = path.join(dir, "package.json");
84
+ if (existsSync(pkgPath)) {
85
+ try {
86
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
87
+ if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
88
+ } catch {
89
+ }
90
+ }
91
+ const parent = path.dirname(dir);
92
+ if (parent === dir) break;
93
+ dir = parent;
94
+ }
95
+ const file = fileURLToPath(import.meta.url);
96
+ return path.resolve(path.dirname(file), "../..");
97
+ }
98
+ var HAUS_DIR;
99
+ var init_paths = __esm({
100
+ "src/utils/paths.ts"() {
101
+ "use strict";
102
+ HAUS_DIR = ".haus-workflow";
103
+ }
104
+ });
105
+
106
+ // src/catalog/path-safety.ts
107
+ import path2 from "node:path";
108
+ function isSafeCatalogPath(itemPath) {
109
+ if (!itemPath || path2.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
110
+ const normalized = path2.normalize(itemPath);
111
+ return !normalized.startsWith("..") && !normalized.includes("/..");
112
+ }
113
+ function isSafeRelativeFilePath(rel) {
114
+ if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
115
+ if (path2.isAbsolute(rel)) return false;
116
+ const normalized = path2.posix.normalize(rel.replace(/\\/g, "/"));
117
+ return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
118
+ }
119
+ function safeJoin(base, itemPath) {
120
+ if (!isSafeCatalogPath(itemPath)) return null;
121
+ const resolved = path2.resolve(base, itemPath);
122
+ return resolved.startsWith(base + path2.sep) || resolved === base ? resolved : null;
123
+ }
124
+ var init_path_safety = __esm({
125
+ "src/catalog/path-safety.ts"() {
126
+ "use strict";
127
+ }
128
+ });
129
+
17
130
  // src/utils/fs.ts
18
131
  import crypto from "node:crypto";
19
- import path from "node:path";
132
+ import path5 from "node:path";
20
133
  import fg from "fast-glob";
21
134
  import fs from "fs-extra";
22
135
  async function readJsonDetailed(file) {
@@ -66,9 +179,9 @@ async function writeText(file, value) {
66
179
  await atomicWriteText(file, value);
67
180
  }
68
181
  async function atomicWriteText(file, value) {
69
- const dir = path.dirname(file);
182
+ const dir = path5.dirname(file);
70
183
  await fs.ensureDir(dir);
71
- const tempPath = path.join(
184
+ const tempPath = path5.join(
72
185
  dir,
73
186
  `.tmp-haus-write-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
74
187
  );
@@ -86,7 +199,7 @@ async function findNestedRepoDirs(root) {
86
199
  });
87
200
  const owners = /* @__PURE__ */ new Set();
88
201
  for (const match of matches) {
89
- const owner = path.posix.dirname(match);
202
+ const owner = path5.posix.dirname(match);
90
203
  if (owner === "." || owner === "") continue;
91
204
  owners.add(owner);
92
205
  }
@@ -120,10 +233,10 @@ function hashText(value) {
120
233
  return `sha256-${crypto.createHash("sha256").update(value).digest("hex")}`;
121
234
  }
122
235
  function resolveContainedPath(root, rel) {
123
- const resolvedRoot = path.resolve(root);
124
- const resolved = path.resolve(root, rel);
236
+ const resolvedRoot = path5.resolve(root);
237
+ const resolved = path5.resolve(root, rel);
125
238
  if (resolved === resolvedRoot) return null;
126
- const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
239
+ const prefix = resolvedRoot.endsWith(path5.sep) ? resolvedRoot : resolvedRoot + path5.sep;
127
240
  if (!resolved.startsWith(prefix)) return null;
128
241
  return resolved;
129
242
  }
@@ -171,80 +284,6 @@ var init_logger = __esm({
171
284
  }
172
285
  });
173
286
 
174
- // src/utils/paths.ts
175
- import { existsSync, readFileSync } from "node:fs";
176
- import os from "node:os";
177
- import path2 from "node:path";
178
- import { fileURLToPath } from "node:url";
179
- function hausPath(root, ...parts) {
180
- return path2.join(root, HAUS_DIR, ...parts);
181
- }
182
- function claudePath(root, ...parts) {
183
- return path2.join(root, ".claude", ...parts);
184
- }
185
- function claudeConfigDir() {
186
- const override = process.env.CLAUDE_CONFIG_DIR;
187
- if (override && override.trim().length > 0) return override;
188
- return path2.join(os.homedir(), ".claude");
189
- }
190
- function defaultClaudeConfigDir() {
191
- return path2.join(os.homedir(), ".claude");
192
- }
193
- function claudeConfigDirIsRelocated() {
194
- return path2.resolve(claudeConfigDir()) !== path2.resolve(defaultClaudeConfigDir());
195
- }
196
- function claudeConfigDirLabel() {
197
- return claudeConfigDirIsRelocated() ? claudeConfigDir().replace(/\\/g, "/") : "~/.claude";
198
- }
199
- function displayPath(root, targetPath) {
200
- const rel = path2.relative(root, targetPath).replace(/\\/g, "/");
201
- if (rel && !rel.startsWith("../") && rel !== "..") {
202
- return rel.startsWith("./") ? rel : `./${rel}`;
203
- }
204
- if (claudeConfigDirIsRelocated()) {
205
- const configDir = claudeConfigDir();
206
- const configRel = path2.relative(configDir, targetPath).replace(/\\/g, "/");
207
- if (configRel === "") return "$CLAUDE_CONFIG_DIR";
208
- if (!configRel.startsWith("../") && configRel !== "..") {
209
- return `$CLAUDE_CONFIG_DIR/${configRel}`;
210
- }
211
- }
212
- const home = os.homedir();
213
- const normalized = targetPath.replace(/\\/g, "/");
214
- if (home && home.trim().length > 0) {
215
- const homeRel = path2.relative(home, targetPath).replace(/\\/g, "/");
216
- if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
217
- return `~/${homeRel}`;
218
- }
219
- }
220
- return normalized;
221
- }
222
- function packageRoot() {
223
- let dir = path2.dirname(fileURLToPath(import.meta.url));
224
- for (let i = 0; i < 12; i++) {
225
- const pkgPath = path2.join(dir, "package.json");
226
- if (existsSync(pkgPath)) {
227
- try {
228
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
229
- if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
230
- } catch {
231
- }
232
- }
233
- const parent = path2.dirname(dir);
234
- if (parent === dir) break;
235
- dir = parent;
236
- }
237
- const file = fileURLToPath(import.meta.url);
238
- return path2.resolve(path2.dirname(file), "../..");
239
- }
240
- var HAUS_DIR;
241
- var init_paths = __esm({
242
- "src/utils/paths.ts"() {
243
- "use strict";
244
- HAUS_DIR = ".haus-workflow";
245
- }
246
- });
247
-
248
287
  // src/catalog/manifest-format.ts
249
288
  function checkManifestFormat(value, sourceLabel) {
250
289
  if (value === void 0) return { ok: true, format: 1 };
@@ -297,30 +336,6 @@ var init_constants = __esm({
297
336
  }
298
337
  });
299
338
 
300
- // src/catalog/path-safety.ts
301
- import path3 from "node:path";
302
- function isSafeCatalogPath(itemPath) {
303
- if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
304
- const normalized = path3.normalize(itemPath);
305
- return !normalized.startsWith("..") && !normalized.includes("/..");
306
- }
307
- function isSafeRelativeFilePath(rel) {
308
- if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
309
- if (path3.isAbsolute(rel)) return false;
310
- const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
311
- return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
312
- }
313
- function safeJoin(base, itemPath) {
314
- if (!isSafeCatalogPath(itemPath)) return null;
315
- const resolved = path3.resolve(base, itemPath);
316
- return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
317
- }
318
- var init_path_safety = __esm({
319
- "src/catalog/path-safety.ts"() {
320
- "use strict";
321
- }
322
- });
323
-
324
339
  // src/utils/exec.ts
325
340
  var exec_exports = {};
326
341
  __export(exec_exports, {
@@ -499,16 +514,16 @@ var init_github_rate_limit = __esm({
499
514
  });
500
515
 
501
516
  // src/catalog/remote-catalog/http.ts
502
- import path4 from "node:path";
503
- import fs2 from "fs-extra";
504
- async function fetchGuarded(url, extract, headers = {}) {
517
+ import path6 from "node:path";
518
+ import fs3 from "fs-extra";
519
+ async function fetchGuarded(url, extract2, headers = {}) {
505
520
  try {
506
521
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
507
522
  if (!res.ok) {
508
523
  warn(`Catalog fetch HTTP ${res.status}: ${url}`);
509
524
  return null;
510
525
  }
511
- return await extract(res);
526
+ return await extract2(res);
512
527
  } catch (e) {
513
528
  warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
514
529
  return null;
@@ -521,14 +536,14 @@ async function fetchBytes(url, headers = {}) {
521
536
  return fetchGuarded(url, async (res) => Buffer.from(await res.arrayBuffer()), headers);
522
537
  }
523
538
  async function writeTextIfChanged(dest, text) {
524
- if (await fs2.pathExists(dest)) {
525
- const local = await fs2.readFile(dest, "utf8");
539
+ if (await fs3.pathExists(dest)) {
540
+ const local = await fs3.readFile(dest, "utf8");
526
541
  if (local === text) return "unchanged";
527
- await fs2.writeFile(dest, text, "utf8");
542
+ await fs3.writeFile(dest, text, "utf8");
528
543
  return "updated";
529
544
  }
530
- await fs2.ensureDir(path4.dirname(dest));
531
- await fs2.writeFile(dest, text, "utf8");
545
+ await fs3.ensureDir(path6.dirname(dest));
546
+ await fs3.writeFile(dest, text, "utf8");
532
547
  return "created";
533
548
  }
534
549
  var init_http = __esm({
@@ -557,7 +572,7 @@ var init_semver_tag = __esm({
557
572
  });
558
573
 
559
574
  // src/catalog/remote-catalog/project-pin.ts
560
- import fs3 from "fs-extra";
575
+ import fs4 from "fs-extra";
561
576
  function isSafeRef(ref) {
562
577
  return SAFE_REF.test(ref) && !ref.split("/").includes("..");
563
578
  }
@@ -568,7 +583,7 @@ function loadProjectCatalogPin(root) {
568
583
  const file = hausPath(root, PROJECT_CONFIG_FILE);
569
584
  let raw;
570
585
  try {
571
- raw = fs3.readFileSync(file, "utf8");
586
+ raw = fs4.readFileSync(file, "utf8");
572
587
  } catch (err) {
573
588
  if (err.code === "ENOENT") return { kind: "none" };
574
589
  return { kind: "invalid", reason: `could not be read (${message(err)})` };
@@ -638,18 +653,18 @@ var init_project_pin = __esm({
638
653
  });
639
654
 
640
655
  // src/catalog/remote-catalog/ref.ts
641
- import path5 from "node:path";
642
- import fs4 from "fs-extra";
656
+ import path7 from "node:path";
657
+ import fs5 from "fs-extra";
643
658
  function isTestMode() {
644
659
  return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
645
660
  }
646
661
  function getCacheDir() {
647
- return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path5.join(claudeConfigDir(), CATALOG_CACHE_SUBDIR);
662
+ return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path7.join(claudeConfigDir(), CATALOG_CACHE_SUBDIR);
648
663
  }
649
664
  function getBundledCatalogRef() {
650
665
  try {
651
- const manifestPath2 = path5.join(packageRoot(), "library/catalog/manifest.json");
652
- const raw = fs4.readFileSync(manifestPath2, "utf8");
666
+ const manifestPath2 = path7.join(packageRoot(), "library/catalog/manifest.json");
667
+ const raw = fs5.readFileSync(manifestPath2, "utf8");
653
668
  const data = JSON.parse(raw);
654
669
  if (typeof data.version === "string" && data.version) {
655
670
  return data.version.startsWith("v") ? data.version : `v${data.version}`;
@@ -794,8 +809,8 @@ var init_ref = __esm({
794
809
  });
795
810
 
796
811
  // src/catalog/remote-catalog/github-tree.ts
797
- import path6 from "node:path";
798
- import fs5 from "fs-extra";
812
+ import path8 from "node:path";
813
+ import fs6 from "fs-extra";
799
814
  function sanitizeRelativeFilePaths(files, label) {
800
815
  const safe = [];
801
816
  for (const rel of files) {
@@ -811,17 +826,17 @@ async function listFilesRecursive(dir, base = dir) {
811
826
  const out = [];
812
827
  let entries;
813
828
  try {
814
- if (!await fs5.pathExists(dir)) return out;
815
- entries = await fs5.readdir(dir, { withFileTypes: true });
829
+ if (!await fs6.pathExists(dir)) return out;
830
+ entries = await fs6.readdir(dir, { withFileTypes: true });
816
831
  } catch {
817
832
  return out;
818
833
  }
819
834
  for (const entry of entries) {
820
- const full = path6.join(dir, entry.name);
835
+ const full = path8.join(dir, entry.name);
821
836
  if (entry.isDirectory()) {
822
837
  out.push(...await listFilesRecursive(full, base));
823
838
  } else if (entry.isFile()) {
824
- out.push(path6.relative(base, full).replace(/\\/g, "/"));
839
+ out.push(path8.relative(base, full).replace(/\\/g, "/"));
825
840
  }
826
841
  }
827
842
  return out.sort();
@@ -1558,8 +1573,8 @@ __export(sync_exports, {
1558
1573
  getCacheManifestAge: () => getCacheManifestAge,
1559
1574
  syncRemoteCatalog: () => syncRemoteCatalog
1560
1575
  });
1561
- import path8 from "node:path";
1562
- import fs7 from "fs-extra";
1576
+ import path10 from "node:path";
1577
+ import fs8 from "fs-extra";
1563
1578
  function isMarkdownPath(rel) {
1564
1579
  return rel.toLowerCase().endsWith(".md");
1565
1580
  }
@@ -1597,7 +1612,7 @@ function validateMarkdownFiles(item, fetched) {
1597
1612
  return true;
1598
1613
  }
1599
1614
  async function directoryMatchesFetched(destDir, fetched) {
1600
- if (!await fs7.pathExists(destDir)) return false;
1615
+ if (!await fs8.pathExists(destDir)) return false;
1601
1616
  const existing = await listFilesRecursive(destDir);
1602
1617
  const relSet = new Set(fetched.map((f) => f.rel));
1603
1618
  if (existing.length !== fetched.length) return false;
@@ -1605,30 +1620,30 @@ async function directoryMatchesFetched(destDir, fetched) {
1605
1620
  if (!relSet.has(rel)) return false;
1606
1621
  }
1607
1622
  for (const file of fetched) {
1608
- const dest = path8.join(destDir, file.rel);
1609
- if (!await fs7.pathExists(dest)) return false;
1623
+ const dest = path10.join(destDir, file.rel);
1624
+ if (!await fs8.pathExists(dest)) return false;
1610
1625
  if (file.kind === "text") {
1611
- const local = await fs7.readFile(dest, "utf8");
1626
+ const local = await fs8.readFile(dest, "utf8");
1612
1627
  if (local !== file.body) return false;
1613
1628
  } else {
1614
- const local = await fs7.readFile(dest);
1629
+ const local = await fs8.readFile(dest);
1615
1630
  if (!local.equals(file.body)) return false;
1616
1631
  }
1617
1632
  }
1618
1633
  return true;
1619
1634
  }
1620
1635
  async function writeFetchedDirectory(destDir, fetched) {
1621
- if (await fs7.pathExists(destDir)) {
1622
- await fs7.remove(destDir);
1636
+ if (await fs8.pathExists(destDir)) {
1637
+ await fs8.remove(destDir);
1623
1638
  }
1624
- await fs7.ensureDir(destDir);
1639
+ await fs8.ensureDir(destDir);
1625
1640
  for (const file of fetched) {
1626
- const dest = path8.join(destDir, file.rel);
1627
- await fs7.ensureDir(path8.dirname(dest));
1641
+ const dest = path10.join(destDir, file.rel);
1642
+ await fs8.ensureDir(path10.dirname(dest));
1628
1643
  if (file.kind === "text") {
1629
- await fs7.writeFile(dest, file.body, "utf8");
1644
+ await fs8.writeFile(dest, file.body, "utf8");
1630
1645
  } else {
1631
- await fs7.writeFile(dest, file.body);
1646
+ await fs8.writeFile(dest, file.body);
1632
1647
  }
1633
1648
  }
1634
1649
  }
@@ -1670,7 +1685,7 @@ async function syncDirectoryFromPrefix(item, catalogPrefix, destDir, base, opts)
1670
1685
  }
1671
1686
  }
1672
1687
  }
1673
- const existed = await fs7.pathExists(destDir);
1688
+ const existed = await fs8.pathExists(destDir);
1674
1689
  if (await directoryMatchesFetched(destDir, fetched)) {
1675
1690
  return "unchanged";
1676
1691
  }
@@ -1792,6 +1807,10 @@ async function syncOneItem(item, base) {
1792
1807
  }
1793
1808
  }
1794
1809
  async function syncRemoteCatalog(projectRoot) {
1810
+ if (isOffline()) {
1811
+ log("Offline \u2014 skipping catalog sync; using cached or bundled content.");
1812
+ return { newItems: [], refreshed: [], unchanged: 0, failed: [], skipped: true };
1813
+ }
1795
1814
  _resetBlobPathCacheForNewSync();
1796
1815
  clearGithubRateLimitHit();
1797
1816
  const fetched = await fetchRemoteManifest(projectRoot);
@@ -1812,9 +1831,9 @@ async function syncRemoteCatalog(projectRoot) {
1812
1831
  const cacheDir = getCacheDir();
1813
1832
  const catalogRef = expectedCatalogRef(projectRoot);
1814
1833
  try {
1815
- await fs7.ensureDir(cacheDir);
1816
- await fs7.writeFile(
1817
- path8.join(cacheDir, "manifest.json"),
1834
+ await fs8.ensureDir(cacheDir);
1835
+ await fs8.writeFile(
1836
+ path10.join(cacheDir, "manifest.json"),
1818
1837
  // globalPlugins must round-trip into the cache: the cache is what `haus update`
1819
1838
  // and `haus rescan` read pins from, so dropping it here would make every
1820
1839
  // catalog pin bump invisible and pin resolution fall back to the bundled default.
@@ -1879,7 +1898,7 @@ async function syncRemoteCatalog(projectRoot) {
1879
1898
  }
1880
1899
  async function getCacheManifestAge() {
1881
1900
  try {
1882
- const stat = await fs7.stat(path8.join(getCacheDir(), "manifest.json"));
1901
+ const stat = await fs8.stat(path10.join(getCacheDir(), "manifest.json"));
1883
1902
  return Date.now() - stat.mtimeMs;
1884
1903
  } catch {
1885
1904
  return null;
@@ -1893,6 +1912,7 @@ var init_sync = __esm({
1893
1912
  init_logger();
1894
1913
  init_constants();
1895
1914
  init_ingest_catalog();
1915
+ init_offline();
1896
1916
  init_path_safety();
1897
1917
  init_github_rate_limit();
1898
1918
  init_github_tree();
@@ -1912,176 +1932,21 @@ var init_sync = __esm({
1912
1932
  });
1913
1933
 
1914
1934
  // src/cli.ts
1935
+ init_offline();
1915
1936
  import { readFileSync as readFileSync5 } from "node:fs";
1916
1937
  import path80 from "node:path";
1917
1938
  import { Command } from "commander";
1918
1939
 
1919
1940
  // src/commands/apply.ts
1920
- import path30 from "node:path";
1921
1941
  import checkbox from "@inquirer/checkbox";
1922
1942
 
1923
- // src/catalog/load-catalog.ts
1924
- init_fs();
1925
- init_logger();
1926
- init_paths();
1927
- init_manifest_format();
1928
- import path9 from "node:path";
1929
-
1930
- // src/catalog/remote-catalog.ts
1931
- init_github_tree();
1932
- init_ref();
1933
- init_ref();
1934
- init_project_pin();
1935
- init_manifest();
1936
-
1937
- // src/catalog/remote-catalog/workflow-template.ts
1938
- init_http();
1939
- init_ref();
1940
- import path7 from "node:path";
1941
- import fs6 from "fs-extra";
1942
- var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
1943
- async function readWorkflowTemplate(opts = {}) {
1944
- const dest = path7.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
1945
- const base = await remoteBase(opts.projectRoot);
1946
- const { url, headers } = await catalogContentRequest(base, WORKFLOW_TEMPLATE_REL);
1947
- const text = await fetchText(url, headers);
1948
- if (text === null) {
1949
- if (await fs6.pathExists(dest)) return fs6.readFile(dest, "utf8");
1950
- return null;
1951
- }
1952
- if (!opts.dryRun) {
1953
- await writeTextIfChanged(dest, text);
1954
- }
1955
- return text;
1956
- }
1957
-
1958
- // src/catalog/remote-catalog.ts
1959
- init_github_tree();
1960
- init_sync();
1961
-
1962
- // src/catalog/load-catalog.ts
1963
- function cacheRefMatches(cached, expected) {
1964
- if (expected === null) return true;
1965
- if (typeof cached !== "string" || cached === "") return true;
1966
- return cached === expected;
1967
- }
1968
- async function loadCatalogContext(root) {
1969
- const envPath = process.env["HAUS_FIXTURE_CATALOG"];
1970
- if (envPath) {
1971
- const data2 = await readJson(envPath);
1972
- return {
1973
- items: data2?.items ?? [],
1974
- contentRoot: path9.dirname(envPath),
1975
- source: "fixture",
1976
- manifestFormat: assertSupportedManifestFormat(data2?.manifestFormat, "The fixture catalog"),
1977
- globalPlugins: data2?.globalPlugins
1978
- };
1979
- }
1980
- const cacheDir = getCacheDir();
1981
- const cacheManifestPath = path9.join(cacheDir, "manifest.json");
1982
- const cacheData = await readJson(cacheManifestPath);
1983
- if (cacheData?.items?.length) {
1984
- const wantedRef = expectedCatalogRef(root);
1985
- if (!cacheRefMatches(cacheData.catalogRef, wantedRef)) {
1986
- warn(
1987
- `Catalog cache was populated from ${String(cacheData.catalogRef)} but this project resolves to ${wantedRef} \u2014 ignoring the cache. Run \`haus update\` to re-sync it.`
1988
- );
1989
- } else {
1990
- return {
1991
- items: cacheData.items,
1992
- contentRoot: cacheDir,
1993
- source: "cache",
1994
- manifestFormat: assertSupportedManifestFormat(
1995
- cacheData.manifestFormat,
1996
- "The cached catalog"
1997
- ),
1998
- globalPlugins: cacheData.globalPlugins
1999
- };
2000
- }
2001
- }
2002
- const localManifest = path9.join(root, "library/catalog/manifest.json");
2003
- const localData = await readJson(localManifest);
2004
- if (localData?.items?.length) {
2005
- return {
2006
- items: localData.items,
2007
- contentRoot: path9.dirname(localManifest),
2008
- source: "local",
2009
- manifestFormat: assertSupportedManifestFormat(
2010
- localData.manifestFormat,
2011
- "The project-local catalog"
2012
- ),
2013
- globalPlugins: localData.globalPlugins
2014
- };
2015
- }
2016
- const packageManifest = path9.join(packageRoot(), "library/catalog/manifest.json");
2017
- const data = await readJson(packageManifest);
2018
- return {
2019
- items: data?.items ?? [],
2020
- contentRoot: path9.dirname(packageManifest),
2021
- source: "bundled",
2022
- manifestFormat: assertSupportedManifestFormat(data?.manifestFormat, "The bundled catalog"),
2023
- globalPlugins: data?.globalPlugins
2024
- };
2025
- }
2026
- async function catalogFormatRefusal(root) {
2027
- try {
2028
- await loadCatalogContext(root);
2029
- return null;
2030
- } catch (err) {
2031
- if (err instanceof UnsupportedManifestFormatError) return err.message;
2032
- throw err;
2033
- }
2034
- }
2035
- async function loadCatalog(root) {
2036
- const ctx = await loadCatalogContext(root);
2037
- return ctx.items;
2038
- }
2039
- function catalogItemContentPath(contentRoot, item) {
2040
- return path9.join(contentRoot, item.path);
2041
- }
2042
-
2043
- // src/claude/gitignore-owned-check.ts
2044
- init_exec();
2045
- var HAUS_OWNED_TRACKED_PATHS = [
2046
- ".claude",
2047
- ".claude/skills",
2048
- ".claude/agents",
2049
- ".claude/commands",
2050
- ".haus-workflow"
2051
- ];
2052
- async function findGitignoredHausPaths(root) {
2053
- try {
2054
- const result = await runGit(["check-ignore", "--", ...HAUS_OWNED_TRACKED_PATHS], {
2055
- cwd: root,
2056
- timeout: 5e3
2057
- });
2058
- if (result.exitCode !== 0 && result.exitCode !== 1) return [];
2059
- return [
2060
- ...new Set(
2061
- result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean)
2062
- )
2063
- ];
2064
- } catch {
2065
- return [];
2066
- }
2067
- }
2068
- function summarizeGitignoredHausDirs(paths) {
2069
- const dirs = /* @__PURE__ */ new Set();
2070
- for (const p of paths) dirs.add(p === ".haus-workflow" ? ".haus-workflow/" : ".claude/");
2071
- return [...dirs];
2072
- }
2073
-
2074
- // src/claude/write-claude-files.ts
2075
- import path25 from "node:path";
2076
- import fs22 from "fs-extra";
2077
-
2078
1943
  // src/catalog/former-ids.ts
2079
- import path11 from "node:path";
1944
+ import path4 from "node:path";
2080
1945
 
2081
1946
  // src/catalog/install-mapping.ts
2082
1947
  init_paths();
2083
1948
  init_path_safety();
2084
- import path10 from "node:path";
1949
+ import path3 from "node:path";
2085
1950
  var FLATTEN_DIR_BY_TYPE = {
2086
1951
  skill: "skills",
2087
1952
  agent: "agents",
@@ -2123,7 +1988,7 @@ function installTargetFor(item) {
2123
1988
  return {
2124
1989
  ok: true,
2125
1990
  layout: "flatten",
2126
- relPath: `${flattenDir}/${path10.posix.basename(itemPath)}`
1991
+ relPath: `${flattenDir}/${path3.posix.basename(itemPath)}`
2127
1992
  };
2128
1993
  }
2129
1994
  const rel = upstreamRelative(itemPath);
@@ -2193,39 +2058,241 @@ function buildFormerIdMap(items) {
2193
2058
  map.set(formerId, item.id);
2194
2059
  }
2195
2060
  }
2196
- return map;
2061
+ return map;
2062
+ }
2063
+ function findFormerIdMigrations(lockItems, manifestItems) {
2064
+ const formerIdMap = buildFormerIdMap(manifestItems);
2065
+ return lockItems.flatMap(({ id }) => {
2066
+ const newId = formerIdMap.get(id);
2067
+ return newId === void 0 ? [] : [{ oldId: id, newId }];
2068
+ });
2069
+ }
2070
+ function buildFormerInstallNames(items) {
2071
+ const names = [];
2072
+ for (const item of items) {
2073
+ const formerIds = normalizeFormerIds(item.formerIds);
2074
+ if (formerIds.length === 0) continue;
2075
+ const target = installTargetFor({ type: item.type, path: item.path });
2076
+ if (!target.ok || target.layout !== "flatten") continue;
2077
+ const dir = path4.posix.dirname(target.relPath);
2078
+ const base = path4.posix.basename(target.relPath);
2079
+ const ext = path4.posix.extname(base);
2080
+ const currentName = ext.length > 0 ? base.slice(0, -ext.length) : base;
2081
+ if (currentName.length === 0 || !item.id.endsWith(currentName)) continue;
2082
+ const prefix = item.id.slice(0, item.id.length - currentName.length);
2083
+ for (const formerId of formerIds) {
2084
+ if (!formerId.startsWith(prefix)) continue;
2085
+ const candidate = formerId.slice(prefix.length);
2086
+ if (candidate.length === 0 || candidate === currentName) continue;
2087
+ names.push({ formerId, currentId: item.id, dir, ext, candidate });
2088
+ }
2089
+ }
2090
+ return names;
2091
+ }
2092
+
2093
+ // src/catalog/installable.ts
2094
+ init_fs();
2095
+ init_paths();
2096
+ init_path_safety();
2097
+ import fs2 from "fs-extra";
2098
+ function installableRecommendedItems(recommended) {
2099
+ return recommended.filter((item) => item.install !== false);
2100
+ }
2101
+ async function countInstallableItems(ctx, ids) {
2102
+ const wanted = ids === void 0 ? ctx.items : ctx.items.filter((item) => ids.includes(item.id));
2103
+ const byId = new Map(ctx.items.map((item) => [item.id, item]));
2104
+ const missing = [];
2105
+ let installable = 0;
2106
+ for (const item of wanted) {
2107
+ const contentPath = typeof item.path === "string" ? safeJoin(ctx.contentRoot, item.path) : null;
2108
+ if (contentPath !== null && await fs2.pathExists(contentPath)) {
2109
+ installable += 1;
2110
+ } else {
2111
+ missing.push(item.id);
2112
+ }
2113
+ }
2114
+ if (ids !== void 0) {
2115
+ for (const id of ids) if (!byId.has(id)) missing.push(id);
2116
+ }
2117
+ return { installable, missing };
2118
+ }
2119
+ async function recountInstalledAgainstLock(root, wantedIds) {
2120
+ const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
2121
+ const installedIds = new Set(lock.map((entry) => entry.id));
2122
+ const installedCount = wantedIds.filter((id) => installedIds.has(id)).length;
2123
+ const missingWantedIds = wantedIds.filter((id) => !installedIds.has(id));
2124
+ return { installedCount, missingWantedIds };
2125
+ }
2126
+
2127
+ // src/catalog/load-catalog.ts
2128
+ init_fs();
2129
+ init_logger();
2130
+ init_paths();
2131
+ init_manifest_format();
2132
+ import path11 from "node:path";
2133
+
2134
+ // src/catalog/remote-catalog.ts
2135
+ init_github_tree();
2136
+ init_ref();
2137
+ init_ref();
2138
+ init_project_pin();
2139
+ init_manifest();
2140
+
2141
+ // src/catalog/remote-catalog/workflow-template.ts
2142
+ init_offline();
2143
+ init_http();
2144
+ init_ref();
2145
+ import path9 from "node:path";
2146
+ import fs7 from "fs-extra";
2147
+ var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
2148
+ async function readWorkflowTemplate(opts = {}) {
2149
+ const dest = path9.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
2150
+ if (isOffline()) {
2151
+ if (await fs7.pathExists(dest)) return fs7.readFile(dest, "utf8");
2152
+ return null;
2153
+ }
2154
+ const base = await remoteBase(opts.projectRoot);
2155
+ const { url, headers } = await catalogContentRequest(base, WORKFLOW_TEMPLATE_REL);
2156
+ const text = await fetchText(url, headers);
2157
+ if (text === null) {
2158
+ if (await fs7.pathExists(dest)) return fs7.readFile(dest, "utf8");
2159
+ return null;
2160
+ }
2161
+ if (!opts.dryRun) {
2162
+ await writeTextIfChanged(dest, text);
2163
+ }
2164
+ return text;
2165
+ }
2166
+
2167
+ // src/catalog/remote-catalog.ts
2168
+ init_github_tree();
2169
+ init_sync();
2170
+
2171
+ // src/catalog/load-catalog.ts
2172
+ function manifestVersion(raw) {
2173
+ return typeof raw === "string" && raw !== "" ? raw : null;
2174
+ }
2175
+ function cacheRefMatches(cached, expected) {
2176
+ if (expected === null) return true;
2177
+ if (typeof cached !== "string" || cached === "") return true;
2178
+ return cached === expected;
2179
+ }
2180
+ async function loadCatalogContext(root) {
2181
+ const envPath = process.env["HAUS_FIXTURE_CATALOG"];
2182
+ if (envPath) {
2183
+ const data2 = await readJson(envPath);
2184
+ return {
2185
+ items: data2?.items ?? [],
2186
+ contentRoot: path11.dirname(envPath),
2187
+ source: "fixture",
2188
+ manifestFormat: assertSupportedManifestFormat(data2?.manifestFormat, "The fixture catalog"),
2189
+ version: manifestVersion(data2?.version),
2190
+ globalPlugins: data2?.globalPlugins
2191
+ };
2192
+ }
2193
+ const cacheDir = getCacheDir();
2194
+ const cacheManifestPath = path11.join(cacheDir, "manifest.json");
2195
+ const cacheData = await readJson(cacheManifestPath);
2196
+ if (cacheData?.items?.length) {
2197
+ const wantedRef = expectedCatalogRef(root);
2198
+ if (!cacheRefMatches(cacheData.catalogRef, wantedRef)) {
2199
+ warn(
2200
+ `Catalog cache was populated from ${String(cacheData.catalogRef)} but this project resolves to ${wantedRef} \u2014 ignoring the cache. Run \`haus update\` to re-sync it.`
2201
+ );
2202
+ } else {
2203
+ return {
2204
+ items: cacheData.items,
2205
+ contentRoot: cacheDir,
2206
+ source: "cache",
2207
+ manifestFormat: assertSupportedManifestFormat(
2208
+ cacheData.manifestFormat,
2209
+ "The cached catalog"
2210
+ ),
2211
+ version: manifestVersion(cacheData.version),
2212
+ globalPlugins: cacheData.globalPlugins
2213
+ };
2214
+ }
2215
+ }
2216
+ const localManifest = path11.join(root, "library/catalog/manifest.json");
2217
+ const localData = await readJson(localManifest);
2218
+ if (localData?.items?.length) {
2219
+ return {
2220
+ items: localData.items,
2221
+ contentRoot: path11.dirname(localManifest),
2222
+ source: "local",
2223
+ manifestFormat: assertSupportedManifestFormat(
2224
+ localData.manifestFormat,
2225
+ "The project-local catalog"
2226
+ ),
2227
+ version: manifestVersion(localData.version),
2228
+ globalPlugins: localData.globalPlugins
2229
+ };
2230
+ }
2231
+ const packageManifest = path11.join(packageRoot(), "library/catalog/manifest.json");
2232
+ const data = await readJson(packageManifest);
2233
+ return {
2234
+ items: data?.items ?? [],
2235
+ contentRoot: path11.dirname(packageManifest),
2236
+ source: "bundled",
2237
+ manifestFormat: assertSupportedManifestFormat(data?.manifestFormat, "The bundled catalog"),
2238
+ version: manifestVersion(data?.version),
2239
+ globalPlugins: data?.globalPlugins
2240
+ };
2241
+ }
2242
+ async function catalogFormatRefusal(root) {
2243
+ try {
2244
+ await loadCatalogContext(root);
2245
+ return null;
2246
+ } catch (err) {
2247
+ if (err instanceof UnsupportedManifestFormatError) return err.message;
2248
+ throw err;
2249
+ }
2197
2250
  }
2198
- function findFormerIdMigrations(lockItems, manifestItems) {
2199
- const formerIdMap = buildFormerIdMap(manifestItems);
2200
- return lockItems.flatMap(({ id }) => {
2201
- const newId = formerIdMap.get(id);
2202
- return newId === void 0 ? [] : [{ oldId: id, newId }];
2203
- });
2251
+ async function loadCatalog(root) {
2252
+ const ctx = await loadCatalogContext(root);
2253
+ return ctx.items;
2204
2254
  }
2205
- function buildFormerInstallNames(items) {
2206
- const names = [];
2207
- for (const item of items) {
2208
- const formerIds = normalizeFormerIds(item.formerIds);
2209
- if (formerIds.length === 0) continue;
2210
- const target = installTargetFor({ type: item.type, path: item.path });
2211
- if (!target.ok || target.layout !== "flatten") continue;
2212
- const dir = path11.posix.dirname(target.relPath);
2213
- const base = path11.posix.basename(target.relPath);
2214
- const ext = path11.posix.extname(base);
2215
- const currentName = ext.length > 0 ? base.slice(0, -ext.length) : base;
2216
- if (currentName.length === 0 || !item.id.endsWith(currentName)) continue;
2217
- const prefix = item.id.slice(0, item.id.length - currentName.length);
2218
- for (const formerId of formerIds) {
2219
- if (!formerId.startsWith(prefix)) continue;
2220
- const candidate = formerId.slice(prefix.length);
2221
- if (candidate.length === 0 || candidate === currentName) continue;
2222
- names.push({ formerId, currentId: item.id, dir, ext, candidate });
2223
- }
2255
+ function catalogItemContentPath(contentRoot, item) {
2256
+ return path11.join(contentRoot, item.path);
2257
+ }
2258
+
2259
+ // src/commands/apply.ts
2260
+ init_offline();
2261
+
2262
+ // src/claude/gitignore-owned-check.ts
2263
+ init_exec();
2264
+ var HAUS_OWNED_TRACKED_PATHS = [
2265
+ ".claude",
2266
+ ".claude/skills",
2267
+ ".claude/agents",
2268
+ ".claude/commands",
2269
+ ".haus-workflow"
2270
+ ];
2271
+ async function findGitignoredHausPaths(root) {
2272
+ try {
2273
+ const result = await runGit(["check-ignore", "--", ...HAUS_OWNED_TRACKED_PATHS], {
2274
+ cwd: root,
2275
+ timeout: 5e3
2276
+ });
2277
+ if (result.exitCode !== 0 && result.exitCode !== 1) return [];
2278
+ return [
2279
+ ...new Set(
2280
+ result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean)
2281
+ )
2282
+ ];
2283
+ } catch {
2284
+ return [];
2224
2285
  }
2225
- return names;
2286
+ }
2287
+ function summarizeGitignoredHausDirs(paths) {
2288
+ const dirs = /* @__PURE__ */ new Set();
2289
+ for (const p of paths) dirs.add(p === ".haus-workflow" ? ".haus-workflow/" : ".claude/");
2290
+ return [...dirs];
2226
2291
  }
2227
2292
 
2228
2293
  // src/claude/write-claude-files.ts
2294
+ import path25 from "node:path";
2295
+ import fs23 from "fs-extra";
2229
2296
  init_ingest_catalog();
2230
2297
 
2231
2298
  // src/recommender/orphaned-items.ts
@@ -2451,7 +2518,7 @@ function isSupersededByPlugin(lockId, activePinIds) {
2451
2518
 
2452
2519
  // src/scanner/detect-package-manager.ts
2453
2520
  import path13 from "node:path";
2454
- import fs8 from "fs-extra";
2521
+ import fs9 from "fs-extra";
2455
2522
 
2456
2523
  // src/utils/versions.ts
2457
2524
  import semver from "semver";
@@ -2499,16 +2566,16 @@ function detectPackageManager(root, packageManagerField) {
2499
2566
  if (satisfiesVersion(version, ">=9")) return "npm";
2500
2567
  return "unknown";
2501
2568
  }
2502
- if (fs8.existsSync(path13.join(root, "yarn.lock"))) return "yarn";
2503
- if (fs8.existsSync(path13.join(root, "pnpm-lock.yaml"))) return "pnpm";
2504
- if (fs8.existsSync(path13.join(root, "package-lock.json"))) return "npm";
2569
+ if (fs9.existsSync(path13.join(root, "yarn.lock"))) return "yarn";
2570
+ if (fs9.existsSync(path13.join(root, "pnpm-lock.yaml"))) return "pnpm";
2571
+ if (fs9.existsSync(path13.join(root, "package-lock.json"))) return "npm";
2505
2572
  return "unknown";
2506
2573
  }
2507
2574
 
2508
2575
  // src/update/hash-installed.ts
2509
2576
  import path15 from "node:path";
2510
2577
  import fg3 from "fast-glob";
2511
- import fs10 from "fs-extra";
2578
+ import fs11 from "fs-extra";
2512
2579
 
2513
2580
  // src/claude/haus-managed-header.ts
2514
2581
  function parseHausManagedAttrs(line2) {
@@ -2551,7 +2618,7 @@ init_logger();
2551
2618
  init_paths();
2552
2619
  import path14 from "node:path";
2553
2620
  import fg2 from "fast-glob";
2554
- import fs9 from "fs-extra";
2621
+ import fs10 from "fs-extra";
2555
2622
  var SUPERPOWERS_ORIGIN_SOURCE_ID = "superpowers-pcvelz";
2556
2623
  var SUPERPOWERS_SHARED_INSTALL_REL = ".claude/skills/shared";
2557
2624
  var PATH_REWRITES = [
@@ -2574,10 +2641,10 @@ async function rewriteMarkdownTree(dir) {
2574
2641
  const files = await fg2("**/*.md", { cwd: dir, onlyFiles: true, dot: true });
2575
2642
  for (const rel of files) {
2576
2643
  const abs = path14.join(dir, rel);
2577
- const text = await fs9.readFile(abs, "utf8");
2644
+ const text = await fs10.readFile(abs, "utf8");
2578
2645
  const rewritten = rewriteSuperpowersMarkdown(text);
2579
2646
  if (rewritten !== text) {
2580
- await fs9.writeFile(abs, rewritten, "utf8");
2647
+ await fs10.writeFile(abs, rewritten, "utf8");
2581
2648
  }
2582
2649
  }
2583
2650
  }
@@ -2591,40 +2658,40 @@ async function extraDestFiles(sourcePath, destination) {
2591
2658
  }
2592
2659
  async function installCatalogSkill(sourcePath, destination, opts) {
2593
2660
  if (opts.dryRun) return;
2594
- await fs9.ensureDir(path14.dirname(destination));
2595
- if (await fs9.pathExists(destination)) {
2661
+ await fs10.ensureDir(path14.dirname(destination));
2662
+ if (await fs10.pathExists(destination)) {
2596
2663
  const extra = await extraDestFiles(sourcePath, destination);
2597
2664
  if (extra.length > 0) {
2598
2665
  warn(
2599
2666
  `${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
2600
2667
  );
2601
2668
  }
2602
- for (const entry of await fs9.readdir(destination)) {
2669
+ for (const entry of await fs10.readdir(destination)) {
2603
2670
  if (SKILL_DEP_ARTIFACT_DIRS.has(entry)) continue;
2604
- await fs9.remove(path14.join(destination, entry));
2671
+ await fs10.remove(path14.join(destination, entry));
2605
2672
  }
2606
2673
  }
2607
- await fs9.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2674
+ await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2608
2675
  if (opts.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID) {
2609
2676
  await rewriteMarkdownTree(destination);
2610
2677
  }
2611
2678
  }
2612
2679
  async function installSuperpowersShared(contentRoot, projectRoot, dryRun) {
2613
2680
  const source = path14.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
2614
- if (!await fs9.pathExists(source)) return null;
2681
+ if (!await fs10.pathExists(source)) return null;
2615
2682
  const destination = claudePath(projectRoot, "skills", "shared");
2616
2683
  if (dryRun) return path14.relative(projectRoot, destination);
2617
- await fs9.ensureDir(path14.dirname(destination));
2618
- if (await fs9.pathExists(destination)) {
2684
+ await fs10.ensureDir(path14.dirname(destination));
2685
+ if (await fs10.pathExists(destination)) {
2619
2686
  const extra = await extraDestFiles(source, destination);
2620
2687
  if (extra.length > 0) {
2621
2688
  warn(
2622
2689
  `${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
2623
2690
  );
2624
2691
  }
2625
- await fs9.remove(destination);
2692
+ await fs10.remove(destination);
2626
2693
  }
2627
- await fs9.copy(source, destination, { overwrite: true, errorOnExist: false });
2694
+ await fs10.copy(source, destination, { overwrite: true, errorOnExist: false });
2628
2695
  await rewriteMarkdownTree(destination);
2629
2696
  return path14.relative(projectRoot, destination);
2630
2697
  }
@@ -2649,11 +2716,11 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
2649
2716
  const fileDigests = [];
2650
2717
  for (const rel of normalized) {
2651
2718
  const abs = path15.join(root, rel);
2652
- if (!await fs10.pathExists(abs)) continue;
2653
- const stat = followSymlinks ? await fs10.stat(abs) : await fs10.lstat(abs);
2719
+ if (!await fs11.pathExists(abs)) continue;
2720
+ const stat = followSymlinks ? await fs11.stat(abs) : await fs11.lstat(abs);
2654
2721
  if (!followSymlinks && stat.isSymbolicLink()) continue;
2655
2722
  if (stat.isFile()) {
2656
- const body = await fs10.readFile(abs);
2723
+ const body = await fs11.readFile(abs);
2657
2724
  fileDigests.push({ rel, digest: digestFileContent(body) });
2658
2725
  continue;
2659
2726
  }
@@ -2674,9 +2741,9 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
2674
2741
  });
2675
2742
  for (const sub of inner.sort()) {
2676
2743
  const absFile = path15.join(abs, sub);
2677
- if (!followSymlinks && (await fs10.lstat(absFile)).isSymbolicLink()) continue;
2744
+ if (!followSymlinks && (await fs11.lstat(absFile)).isSymbolicLink()) continue;
2678
2745
  const relFile = path15.join(rel, sub).replace(/\\/g, "/");
2679
- const body = await fs10.readFile(absFile);
2746
+ const body = await fs11.readFile(absFile);
2680
2747
  fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
2681
2748
  }
2682
2749
  }
@@ -2715,15 +2782,15 @@ init_logger();
2715
2782
  init_paths();
2716
2783
 
2717
2784
  // src/claude/managed-write.ts
2718
- import fs11 from "fs-extra";
2785
+ import fs12 from "fs-extra";
2719
2786
  init_fs();
2720
2787
  init_logger();
2721
2788
  init_paths();
2722
2789
  async function writeManagedText(root, filePath, nextText, dryRun, quiet = false) {
2723
2790
  const say = quiet ? () => {
2724
2791
  } : log;
2725
- const existed = await fs11.pathExists(filePath);
2726
- const prev = existed ? await fs11.readFile(filePath, "utf8") : "";
2792
+ const existed = await fs12.pathExists(filePath);
2793
+ const prev = existed ? await fs12.readFile(filePath, "utf8") : "";
2727
2794
  const printable = displayPath(root, filePath);
2728
2795
  if (dryRun) {
2729
2796
  if (!existed) {
@@ -2759,7 +2826,7 @@ function buildAllowRules() {
2759
2826
  // src/install/settings-merge.ts
2760
2827
  init_fs();
2761
2828
  import path17 from "node:path";
2762
- import fs12 from "fs-extra";
2829
+ import fs13 from "fs-extra";
2763
2830
 
2764
2831
  // src/install/manifest.ts
2765
2832
  init_fs();
@@ -3000,7 +3067,7 @@ function stripHausAsk(settings) {
3000
3067
  async function loadHooksFragment(fragmentPath) {
3001
3068
  let raw;
3002
3069
  try {
3003
- raw = await fs12.readJson(fragmentPath);
3070
+ raw = await fs13.readJson(fragmentPath);
3004
3071
  } catch {
3005
3072
  return [];
3006
3073
  }
@@ -3134,9 +3201,33 @@ async function applyProjectSettingsMerge(root) {
3134
3201
 
3135
3202
  // src/claude/remove-items.ts
3136
3203
  import path18 from "node:path";
3137
- import fs13 from "fs-extra";
3204
+ import fs14 from "fs-extra";
3138
3205
  init_fs();
3139
3206
  init_paths();
3207
+
3208
+ // src/claude/backup-kinds.ts
3209
+ var DIR_BACKUP_PREFIXES = {
3210
+ undo: "undo",
3211
+ prune: "prune",
3212
+ remove: "remove",
3213
+ migrate: "migrate"
3214
+ };
3215
+ var LOCK_BACKUP_RE = /^haus\.lock\.\d+\.json$/;
3216
+ function dirBackupDirName(kind, stamp) {
3217
+ return `${DIR_BACKUP_PREFIXES[kind]}-${stamp}`;
3218
+ }
3219
+ function lockBackupFileName(epochMs) {
3220
+ return `haus.lock.${epochMs}.json`;
3221
+ }
3222
+ function classifyBackupName(name) {
3223
+ if (LOCK_BACKUP_RE.test(name)) return "lock";
3224
+ for (const kind of Object.keys(DIR_BACKUP_PREFIXES)) {
3225
+ if (name.startsWith(`${DIR_BACKUP_PREFIXES[kind]}-`)) return kind;
3226
+ }
3227
+ return void 0;
3228
+ }
3229
+
3230
+ // src/claude/remove-items.ts
3140
3231
  async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn) {
3141
3232
  const existing = [];
3142
3233
  for (const rel of relPaths) {
@@ -3145,7 +3236,7 @@ async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn) {
3145
3236
  warnFn(`Refusing to touch ${itemId}'s recorded path outside the project root: ${rel}`);
3146
3237
  continue;
3147
3238
  }
3148
- if (await fs13.pathExists(abs)) existing.push({ rel, abs });
3239
+ if (await fs14.pathExists(abs)) existing.push({ rel, abs });
3149
3240
  }
3150
3241
  return existing;
3151
3242
  }
@@ -3161,13 +3252,13 @@ async function hashGateEntry(root, entry, existing) {
3161
3252
  async function backupTrackedPaths(root, absPaths, say, prefix) {
3162
3253
  if (absPaths.length === 0) return null;
3163
3254
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3164
- const backupRoot = hausPath(root, "backups", `${prefix}-${stamp}`);
3255
+ const backupRoot = hausPath(root, "backups", dirBackupDirName(prefix, stamp));
3165
3256
  for (const abs of absPaths) {
3166
- if (!await fs13.pathExists(abs)) continue;
3257
+ if (!await fs14.pathExists(abs)) continue;
3167
3258
  const rel = path18.relative(root, abs);
3168
3259
  const backupPath = path18.join(backupRoot, rel);
3169
- await fs13.ensureDir(path18.dirname(backupPath));
3170
- await fs13.copy(abs, backupPath);
3260
+ await fs14.ensureDir(path18.dirname(backupPath));
3261
+ await fs14.copy(abs, backupPath);
3171
3262
  }
3172
3263
  say(
3173
3264
  `Backed up ${absPaths.length} path(s) to ${path18.relative(root, backupRoot)} before deleting them.`
@@ -3176,7 +3267,7 @@ async function backupTrackedPaths(root, absPaths, say, prefix) {
3176
3267
  }
3177
3268
  async function deleteTrackedPaths(root, itemId, existing, { verb, reason }, say) {
3178
3269
  for (const { abs } of existing) {
3179
- await fs13.remove(abs);
3270
+ await fs14.remove(abs);
3180
3271
  await pruneEmptyDir(path18.dirname(abs));
3181
3272
  say([verb, reason, displayPath(root, abs)].filter(Boolean).join(" ") + ` (${itemId})`);
3182
3273
  }
@@ -3185,7 +3276,7 @@ async function deleteTrackedPaths(root, itemId, existing, { verb, reason }, say)
3185
3276
  // src/claude/skill-dep-install.ts
3186
3277
  init_exec();
3187
3278
  import path19 from "node:path";
3188
- import fs14 from "fs-extra";
3279
+ import fs15 from "fs-extra";
3189
3280
  function skillDepInstallCommand(manager) {
3190
3281
  switch (manager) {
3191
3282
  case "npm":
@@ -3222,7 +3313,7 @@ function skillDepInstallCommand(manager) {
3222
3313
  }
3223
3314
  async function hasDeclaredDependencies(dir) {
3224
3315
  try {
3225
- const raw = await fs14.readFile(path19.join(dir, "package.json"), "utf8");
3316
+ const raw = await fs15.readFile(path19.join(dir, "package.json"), "utf8");
3226
3317
  const parsed = JSON.parse(raw);
3227
3318
  if (typeof parsed !== "object" || parsed === null) return false;
3228
3319
  const deps = parsed.dependencies;
@@ -3234,7 +3325,7 @@ async function hasDeclaredDependencies(dir) {
3234
3325
  }
3235
3326
  async function realpathOrSelf(target) {
3236
3327
  try {
3237
- return await fs14.realpath(target);
3328
+ return await fs15.realpath(target);
3238
3329
  } catch {
3239
3330
  return path19.resolve(target);
3240
3331
  }
@@ -3279,7 +3370,7 @@ async function installSkillDependencies(opts, deps = {}) {
3279
3370
  default:
3280
3371
  return assertNeverCommandProbe(probe);
3281
3372
  }
3282
- if (!await fs14.pathExists(contained.dir)) {
3373
+ if (!await fs15.pathExists(contained.dir)) {
3283
3374
  return { status: "skipped", reason: `install directory disappeared (${contained.dir})` };
3284
3375
  }
3285
3376
  const run = deps.run ?? defaultRun;
@@ -3293,7 +3384,7 @@ async function installSkillDependencies(opts, deps = {}) {
3293
3384
  // src/claude/verify-hooks-contract.ts
3294
3385
  init_fs();
3295
3386
  init_paths();
3296
- import fs15 from "fs-extra";
3387
+ import fs16 from "fs-extra";
3297
3388
 
3298
3389
  // src/claude/load-hooks.ts
3299
3390
  var CANONICAL_HOOKS = {
@@ -3377,7 +3468,7 @@ async function assertPostApplySettingsHausContract(root) {
3377
3468
  }
3378
3469
  async function verifyProjectSettingsHooksContract(root) {
3379
3470
  const settingsPath = claudePath(root, "settings.json");
3380
- if (!await fs15.pathExists(settingsPath)) {
3471
+ if (!await fs16.pathExists(settingsPath)) {
3381
3472
  return {
3382
3473
  ok: true,
3383
3474
  skipped: true,
@@ -3405,7 +3496,7 @@ async function verifyProjectSettingsHooksContract(root) {
3405
3496
 
3406
3497
  // src/claude/write-decisions-seed.ts
3407
3498
  import path20 from "node:path";
3408
- import fs16 from "fs-extra";
3499
+ import fs17 from "fs-extra";
3409
3500
 
3410
3501
  // library/catalog/decisions-triggers.json
3411
3502
  var decisions_triggers_default = {
@@ -3450,15 +3541,15 @@ Why we chose X \u2014 short, dated, write-once decision log for humans and agent
3450
3541
  async function writeDecisionsSeed(root, dryRun, quiet = false) {
3451
3542
  const decisionsDir = path20.join(root, DECISIONS_TRIGGERS.decisionsDir);
3452
3543
  const readmePath = path20.join(decisionsDir, "README.md");
3453
- if (await fs16.pathExists(readmePath)) return null;
3454
- if (!dryRun) await fs16.ensureDir(decisionsDir);
3544
+ if (await fs17.pathExists(readmePath)) return null;
3545
+ if (!dryRun) await fs17.ensureDir(decisionsDir);
3455
3546
  await writeManagedText(root, readmePath, SEED_README, dryRun, quiet);
3456
3547
  return readmePath;
3457
3548
  }
3458
3549
 
3459
3550
  // src/claude/write-prettierignore.ts
3460
3551
  import path21 from "node:path";
3461
- import fs17 from "fs-extra";
3552
+ import fs18 from "fs-extra";
3462
3553
  var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
3463
3554
  var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
3464
3555
  var IGNORED_PATHS = [".haus-workflow/", ".claude/"];
@@ -3527,7 +3618,7 @@ ${block}
3527
3618
  }
3528
3619
  async function writePrettierIgnore(root, dryRun, quiet = false) {
3529
3620
  const filePath = path21.join(root, ".prettierignore");
3530
- const prev = await fs17.pathExists(filePath) ? await fs17.readFile(filePath, "utf8") : "";
3621
+ const prev = await fs18.pathExists(filePath) ? await fs18.readFile(filePath, "utf8") : "";
3531
3622
  const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
3532
3623
  await writeManagedText(root, filePath, next, dryRun, quiet);
3533
3624
  return filePath;
@@ -3535,7 +3626,7 @@ async function writePrettierIgnore(root, dryRun, quiet = false) {
3535
3626
 
3536
3627
  // src/claude/write-root-claude-md.ts
3537
3628
  import path22 from "node:path";
3538
- import fs18 from "fs-extra";
3629
+ import fs19 from "fs-extra";
3539
3630
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
3540
3631
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
3541
3632
  var WORKSPACE_BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-workspace-imports v=1 -->";
@@ -3614,7 +3705,7 @@ ${block}
3614
3705
  async function writeRootClaudeMd(root, dryRun, quiet = false) {
3615
3706
  const filePath = path22.join(root, "CLAUDE.md");
3616
3707
  const block = buildImportBlock();
3617
- const prev = await fs18.pathExists(filePath) ? await fs18.readFile(filePath, "utf8") : "";
3708
+ const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3618
3709
  const next = injectHausBlock(prev, block);
3619
3710
  await writeManagedText(root, filePath, next, dryRun, quiet);
3620
3711
  return filePath;
@@ -3625,12 +3716,12 @@ init_fs();
3625
3716
  init_logger();
3626
3717
  init_paths();
3627
3718
  import path24 from "node:path";
3628
- import fs20 from "fs-extra";
3719
+ import fs21 from "fs-extra";
3629
3720
 
3630
3721
  // src/claude/derive-workflow-config.ts
3631
3722
  init_fs();
3632
3723
  import path23 from "node:path";
3633
- import fs19 from "fs-extra";
3724
+ import fs20 from "fs-extra";
3634
3725
  function binCmd(pm, bin, args) {
3635
3726
  const tail = args ? ` ${args}` : "";
3636
3727
  if (pm === "yarn") return `yarn ${bin}${tail}`;
@@ -3649,7 +3740,7 @@ async function deriveWorkflowConfig(root, ctx) {
3649
3740
  return null;
3650
3741
  };
3651
3742
  const hasDep = (name) => deps.has(name);
3652
- const exists = (rel) => fs19.pathExistsSync(path23.join(root, rel));
3743
+ const exists = (rel) => fs20.pathExistsSync(path23.join(root, rel));
3653
3744
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
3654
3745
  const hasCypress = hasDep("cypress");
3655
3746
  const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
@@ -3718,7 +3809,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
3718
3809
  } : log;
3719
3810
  const destPath = hausPath(root, "workflow-config.md");
3720
3811
  const printable = displayPath(root, destPath);
3721
- const exists = await fs20.pathExists(destPath);
3812
+ const exists = await fs21.pathExists(destPath);
3722
3813
  if (exists && !opts.refill) {
3723
3814
  if (dryRun) say(printable + ": exists (project-owned, skipping)");
3724
3815
  return null;
@@ -3730,7 +3821,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
3730
3821
  };
3731
3822
  const values = await deriveWorkflowConfig(root, ctx);
3732
3823
  if (exists) {
3733
- const current = await fs20.readFile(destPath, "utf8");
3824
+ const current = await fs21.readFile(destPath, "utf8");
3734
3825
  const refilled = refillContent(current, values);
3735
3826
  if (refilled === current) {
3736
3827
  if (dryRun) say(printable + ": no blank fields to refill");
@@ -3752,7 +3843,8 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
3752
3843
  }
3753
3844
 
3754
3845
  // src/claude/write-workflow.ts
3755
- import fs21 from "fs-extra";
3846
+ init_offline();
3847
+ import fs22 from "fs-extra";
3756
3848
  init_fs();
3757
3849
  init_logger();
3758
3850
  init_paths();
@@ -3784,7 +3876,7 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false, quiet = fa
3784
3876
  const templateContent = await readWorkflowTemplate({ dryRun, projectRoot: root });
3785
3877
  if (templateContent === null) {
3786
3878
  warn(
3787
- `Workflow template could not be fetched from the catalog \u2014 check your network, then re-run \`haus apply --write\` (or \`haus update\`)`
3879
+ isOffline() ? `Workflow template is not in the local catalog cache and this run is offline \u2014 run \`haus catalog bundle import <file>\` (or drop \`--offline\`), then re-run \`haus apply --write\`` : `Workflow template could not be fetched from the catalog \u2014 check your network, then re-run \`haus apply --write\` (or \`haus update\`)`
3788
3880
  );
3789
3881
  return null;
3790
3882
  }
@@ -3794,8 +3886,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false, quiet = fa
3794
3886
  ${templateContent}`;
3795
3887
  const destPath = hausPath(root, "WORKFLOW.md");
3796
3888
  const printable = displayPath(root, destPath);
3797
- if (await fs21.pathExists(destPath)) {
3798
- const existing = await fs21.readFile(destPath, "utf8");
3889
+ if (await fs22.pathExists(destPath)) {
3890
+ const existing = await fs22.readFile(destPath, "utf8");
3799
3891
  const firstLine = existing.split("\n")[0] ?? "";
3800
3892
  const parsed = parseHausManagedHeader(firstLine);
3801
3893
  if (!parsed) {
@@ -3839,7 +3931,7 @@ ${existingContent}`;
3839
3931
  }
3840
3932
  }
3841
3933
  if (dryRun) {
3842
- const prev = await fs21.pathExists(destPath) ? await fs21.readFile(destPath, "utf8") : "";
3934
+ const prev = await fs22.pathExists(destPath) ? await fs22.readFile(destPath, "utf8") : "";
3843
3935
  if (!prev) {
3844
3936
  say(createUnifiedDiff(printable, "", next));
3845
3937
  } else {
@@ -3894,14 +3986,14 @@ function decodeIfText(buf) {
3894
3986
  return Buffer.from(asText, "utf8").equals(buf) ? asText : null;
3895
3987
  }
3896
3988
  async function listFilesRecursive2(dir) {
3897
- if (!await fs22.pathExists(dir)) return [];
3989
+ if (!await fs23.pathExists(dir)) return [];
3898
3990
  const files = [];
3899
3991
  const walk = async (current, relPrefix) => {
3900
- const names = await fs22.readdir(current);
3992
+ const names = await fs23.readdir(current);
3901
3993
  for (const name of names) {
3902
3994
  const abs = path25.join(current, name);
3903
3995
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
3904
- const stat = await fs22.lstat(abs);
3996
+ const stat = await fs23.lstat(abs);
3905
3997
  if (stat.isSymbolicLink()) {
3906
3998
  warn(`Skipping symlink in dry-run diff preview: ${rel}`);
3907
3999
  continue;
@@ -3918,14 +4010,14 @@ async function listFilesRecursive2(dir) {
3918
4010
  }
3919
4011
  async function logFileDryRunDiff(root, srcFile, destFile, itemId, say) {
3920
4012
  const printable = displayPath(root, destFile);
3921
- const nextBuf = await fs22.readFile(srcFile);
3922
- if (!await fs22.pathExists(destFile)) {
4013
+ const nextBuf = await fs23.readFile(srcFile);
4014
+ if (!await fs23.pathExists(destFile)) {
3923
4015
  say(`${printable}: would create (${itemId})`);
3924
4016
  const nextText2 = decodeIfText(nextBuf);
3925
4017
  if (nextText2 !== null) say(createUnifiedDiff(printable, "", nextText2));
3926
4018
  return;
3927
4019
  }
3928
- const prevBuf = await fs22.readFile(destFile);
4020
+ const prevBuf = await fs23.readFile(destFile);
3929
4021
  if (prevBuf.equals(nextBuf)) {
3930
4022
  say(`${printable}: unchanged (${itemId})`);
3931
4023
  return;
@@ -3958,15 +4050,15 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
3958
4050
  }
3959
4051
  async function removeLegacyManagedStub(root, relPathSegments, stub, dryRun, say) {
3960
4052
  const target = claudePath(root, ...relPathSegments);
3961
- if (!await fs22.pathExists(target)) return;
3962
- const content2 = await fs22.readFile(target, "utf8");
4053
+ if (!await fs23.pathExists(target)) return;
4054
+ const content2 = await fs23.readFile(target, "utf8");
3963
4055
  if (content2 !== stub && content2 !== `${stub}
3964
4056
  ` && content2 !== `${stub}\r
3965
4057
  `) return;
3966
4058
  if (dryRun) {
3967
4059
  say(`[dry-run] would remove stale ${displayPath(root, target)}`);
3968
4060
  } else {
3969
- await fs22.remove(target);
4061
+ await fs23.remove(target);
3970
4062
  }
3971
4063
  }
3972
4064
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
@@ -4072,11 +4164,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4072
4164
  ];
4073
4165
  for (const rel of LEGACY_PRUNED_ARTIFACTS) {
4074
4166
  const legacyPath = hausPath(root, rel);
4075
- if (await fs22.pathExists(legacyPath)) {
4167
+ if (await fs23.pathExists(legacyPath)) {
4076
4168
  if (dryRun) {
4077
4169
  say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
4078
4170
  } else {
4079
- await fs22.remove(legacyPath);
4171
+ await fs23.remove(legacyPath);
4080
4172
  }
4081
4173
  }
4082
4174
  }
@@ -4194,7 +4286,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4194
4286
  }
4195
4287
  const sourcePath = catalogItemContentPath(contentRoot, manifestItem);
4196
4288
  const destination = placement.destination;
4197
- if (await fs22.pathExists(sourcePath)) {
4289
+ if (await fs23.pathExists(sourcePath)) {
4198
4290
  if (dryRun) {
4199
4291
  if (item.type === "skill") {
4200
4292
  await logSkillDryRunDiff(root, sourcePath, destination, item.id, say);
@@ -4221,13 +4313,13 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4221
4313
  )) {
4222
4314
  continue;
4223
4315
  } else if (item.type === "skill") {
4224
- const skillFiles = (await fs22.readdir(sourcePath, { recursive: true })).filter(
4316
+ const skillFiles = (await fs23.readdir(sourcePath, { recursive: true })).filter(
4225
4317
  (f) => typeof f === "string" && f.endsWith(".md")
4226
4318
  );
4227
4319
  let skillValid = true;
4228
4320
  for (const relFile of skillFiles) {
4229
4321
  const mdPath = path25.join(sourcePath, relFile);
4230
- const mdContent = await fs22.readFile(mdPath, "utf8");
4322
+ const mdContent = await fs23.readFile(mdPath, "utf8");
4231
4323
  const mdValidation = validateCatalogItem(
4232
4324
  {
4233
4325
  id: manifestItem.id,
@@ -4260,7 +4352,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4260
4352
  say
4261
4353
  );
4262
4354
  } else {
4263
- const fileContent = await fs22.readFile(sourcePath, "utf8");
4355
+ const fileContent = await fs23.readFile(sourcePath, "utf8");
4264
4356
  const validation = validateCatalogItem(
4265
4357
  {
4266
4358
  id: manifestItem.id,
@@ -4274,8 +4366,8 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4274
4366
  warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
4275
4367
  continue;
4276
4368
  }
4277
- await fs22.ensureDir(path25.dirname(destination));
4278
- await fs22.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
4369
+ await fs23.ensureDir(path25.dirname(destination));
4370
+ await fs23.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
4279
4371
  }
4280
4372
  files.push(destination);
4281
4373
  const relPaths = [path25.relative(root, destination)];
@@ -4407,7 +4499,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4407
4499
  async function hasProtectedUnsafePath(root, relPaths) {
4408
4500
  for (const rel of relPaths) {
4409
4501
  if (resolveContainedPath(root, rel) !== null) continue;
4410
- if (await fs22.pathExists(path25.resolve(root, rel))) return true;
4502
+ if (await fs23.pathExists(path25.resolve(root, rel))) return true;
4411
4503
  }
4412
4504
  return false;
4413
4505
  }
@@ -4471,7 +4563,7 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
4471
4563
  void await backupTrackedPaths(root, allAbsPaths, say, "migrate");
4472
4564
  for (const { existing } of toRemove) {
4473
4565
  for (const { abs } of existing) {
4474
- await fs22.remove(abs);
4566
+ await fs23.remove(abs);
4475
4567
  await pruneEmptyDir(path25.dirname(abs));
4476
4568
  }
4477
4569
  }
@@ -4550,7 +4642,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet, activ
4550
4642
  say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
4551
4643
  continue;
4552
4644
  }
4553
- await fs22.remove(abs);
4645
+ await fs23.remove(abs);
4554
4646
  await pruneEmptyDir(path25.dirname(abs));
4555
4647
  say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
4556
4648
  }
@@ -4605,7 +4697,7 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
4605
4697
  // src/claude/write-gitignore.ts
4606
4698
  init_exec();
4607
4699
  import path26 from "node:path";
4608
- import fs23 from "fs-extra";
4700
+ import fs24 from "fs-extra";
4609
4701
  var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
4610
4702
  var GITIGNORE_END = "# HAUS:END haus-managed";
4611
4703
  var GITIGNORED_ARTIFACT_PATHS = [
@@ -4721,14 +4813,14 @@ function injectLinkContextGitignoreBlock(existing) {
4721
4813
  }
4722
4814
  async function writeGitignore(root, dryRun, quiet = false) {
4723
4815
  const filePath = path26.join(root, ".gitignore");
4724
- const prev = await fs23.pathExists(filePath) ? await fs23.readFile(filePath, "utf8") : "";
4816
+ const prev = await fs24.pathExists(filePath) ? await fs24.readFile(filePath, "utf8") : "";
4725
4817
  const next = injectGitignoreBlock(prev);
4726
4818
  await writeManagedText(root, filePath, next, dryRun, quiet);
4727
4819
  return filePath;
4728
4820
  }
4729
4821
  async function writeLinkContextGitignore(root, dryRun, quiet = false) {
4730
4822
  const filePath = path26.join(root, ".gitignore");
4731
- const prev = await fs23.pathExists(filePath) ? await fs23.readFile(filePath, "utf8") : "";
4823
+ const prev = await fs24.pathExists(filePath) ? await fs24.readFile(filePath, "utf8") : "";
4732
4824
  const next = injectLinkContextGitignoreBlock(prev);
4733
4825
  await writeManagedText(root, filePath, next, dryRun, quiet);
4734
4826
  return filePath;
@@ -4770,6 +4862,7 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
4770
4862
 
4771
4863
  // src/install/ensure-global-plugins.ts
4772
4864
  import path27 from "node:path";
4865
+ init_offline();
4773
4866
  init_exec();
4774
4867
  init_fs();
4775
4868
  init_logger();
@@ -4950,6 +5043,9 @@ var EXEC_ENV = { GIT_TERMINAL_PROMPT: "0" };
4950
5043
  function globalPluginsDisabled() {
4951
5044
  return process.env.HAUS_NO_GLOBAL_PLUGINS === "1";
4952
5045
  }
5046
+ function globalPluginMutationsDisabled() {
5047
+ return globalPluginsDisabled() || isOffline();
5048
+ }
4953
5049
  var realExec = (args) => runCommand("claude", args, {
4954
5050
  timeout: EXEC_TIMEOUT_MS,
4955
5051
  env: EXEC_ENV,
@@ -5093,7 +5189,7 @@ async function ensureGlobalPlugins(pins, deps = {}) {
5093
5189
  const log2 = deps.log ?? log;
5094
5190
  const warn2 = deps.warn ?? warn;
5095
5191
  const dryRun = deps.dryRun ?? false;
5096
- if (globalPluginsDisabled() && deps.exec === void 0) {
5192
+ if (globalPluginMutationsDisabled() && deps.exec === void 0) {
5097
5193
  return { actions: [], ensured: [], failed: [], unavailable: true, restartRequired: false };
5098
5194
  }
5099
5195
  const state = await probePluginEnv(deps);
@@ -5162,7 +5258,7 @@ async function removeGlobalPlugins(plugins, deps = {}) {
5162
5258
  const log2 = deps.log ?? log;
5163
5259
  const result = { removed: [], failed: [], unavailable: false };
5164
5260
  if (plugins.length === 0) return result;
5165
- if (globalPluginsDisabled() && deps.exec === void 0) {
5261
+ if (globalPluginMutationsDisabled() && deps.exec === void 0) {
5166
5262
  return { ...result, unavailable: true };
5167
5263
  }
5168
5264
  const state = await probePluginEnv(deps);
@@ -5211,25 +5307,25 @@ async function removeGlobalPlugins(plugins, deps = {}) {
5211
5307
 
5212
5308
  // src/refs/fetch-refs.ts
5213
5309
  import path29 from "node:path";
5214
- import fs25 from "fs-extra";
5310
+ import fs26 from "fs-extra";
5215
5311
 
5216
5312
  // src/refs/cache-meta.ts
5217
5313
  import path28 from "node:path";
5218
- import fs24 from "fs-extra";
5314
+ import fs25 from "fs-extra";
5219
5315
  var META_FILENAME = "cache-meta.json";
5220
5316
  async function readCacheMeta(cacheDir) {
5221
5317
  const metaPath = path28.join(cacheDir, META_FILENAME);
5222
5318
  try {
5223
- const raw = await fs24.readFile(metaPath, "utf8");
5319
+ const raw = await fs25.readFile(metaPath, "utf8");
5224
5320
  return JSON.parse(raw);
5225
5321
  } catch {
5226
5322
  return {};
5227
5323
  }
5228
5324
  }
5229
5325
  async function writeCacheMeta(cacheDir, meta) {
5230
- await fs24.ensureDir(cacheDir);
5326
+ await fs25.ensureDir(cacheDir);
5231
5327
  const metaPath = path28.join(cacheDir, META_FILENAME);
5232
- await fs24.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
5328
+ await fs25.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
5233
5329
  }
5234
5330
 
5235
5331
  // src/refs/fetch-refs.ts
@@ -5250,7 +5346,7 @@ async function fetchSingleRef(url, cacheDir, meta) {
5250
5346
  const existing = meta[url];
5251
5347
  const headers = {};
5252
5348
  const safeFile = existing?.file && existing.file === path29.basename(existing.file);
5253
- const cachedFileExists = safeFile && await fs25.pathExists(path29.join(cacheDir, existing.file));
5349
+ const cachedFileExists = safeFile && await fs26.pathExists(path29.join(cacheDir, existing.file));
5254
5350
  if (cachedFileExists) {
5255
5351
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
5256
5352
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -5261,8 +5357,8 @@ async function fetchSingleRef(url, cacheDir, meta) {
5261
5357
  if (!res.ok) return { result: "failed" };
5262
5358
  const text = await res.text();
5263
5359
  const file = `${urlToSlug(url)}.md`;
5264
- await fs25.ensureDir(cacheDir);
5265
- await fs25.writeFile(path29.join(cacheDir, file), text, "utf8");
5360
+ await fs26.ensureDir(cacheDir);
5361
+ await fs26.writeFile(path29.join(cacheDir, file), text, "utf8");
5266
5362
  meta[url] = {
5267
5363
  url,
5268
5364
  etag: res.headers.get("etag") ?? void 0,
@@ -5286,7 +5382,7 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
5286
5382
  delete meta[url];
5287
5383
  removed++;
5288
5384
  const safeFile = typeof entry.file === "string" && entry.file === path29.basename(entry.file);
5289
- if (safeFile) await fs25.remove(path29.join(cacheDir, entry.file)).catch(() => {
5385
+ if (safeFile) await fs26.remove(path29.join(cacheDir, entry.file)).catch(() => {
5290
5386
  });
5291
5387
  }
5292
5388
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -5360,13 +5456,6 @@ function dryRunPrefix(dryRun, text) {
5360
5456
 
5361
5457
  // src/commands/apply.ts
5362
5458
  var STALE_CACHE_THRESHOLD_DAYS = 7;
5363
- async function cacheHasItems() {
5364
- const data = await readJson(path30.join(getCacheDir(), "manifest.json"));
5365
- return Array.isArray(data?.items) && data.items.length > 0;
5366
- }
5367
- function installableRecommendedItems(recommended) {
5368
- return recommended.filter((item) => item.install !== false);
5369
- }
5370
5459
  async function runApply(options) {
5371
5460
  const mode = resolveWriteMode(options, "explicit-choice");
5372
5461
  if (mode.kind === "unspecified") {
@@ -5383,11 +5472,11 @@ async function runApply(options) {
5383
5472
  process.exitCode = 1;
5384
5473
  return;
5385
5474
  }
5386
- const rec = await readJson(hausPath(root, "recommendation.json"));
5387
- const recommended = rec?.recommended ?? [];
5475
+ const rec2 = await readJson(hausPath(root, "recommendation.json"));
5476
+ const recommended = rec2?.recommended ?? [];
5388
5477
  const installableIds = new Set(installableRecommendedItems(recommended).map((i) => i.id));
5389
5478
  const configIds = new Set(recommended.filter((i) => i.install === false).map((i) => i.id));
5390
- const skippedIds = new Set((rec?.skipped ?? []).map((s) => s.id));
5479
+ const skippedIds = new Set((rec2?.skipped ?? []).map((s) => s.id));
5391
5480
  for (const id of explicitIds.filter((id2) => !installableIds.has(id2))) {
5392
5481
  if (configIds.has(id)) {
5393
5482
  warn(`--ids: "${id}" is a config item \u2014 install it with \`haus configs ${id}\`. Ignoring.`);
@@ -5410,12 +5499,12 @@ async function runApply(options) {
5410
5499
  process.exitCode = 1;
5411
5500
  return;
5412
5501
  }
5413
- const rec = await readJson(hausPath(root, "recommendation.json"));
5414
- if (!rec) {
5502
+ const rec2 = await readJson(hausPath(root, "recommendation.json"));
5503
+ if (!rec2) {
5415
5504
  log("No recommendation.json found \u2014 run `haus recommend` first. Writing core files only.");
5416
5505
  selectedIds = [];
5417
5506
  } else {
5418
- const items = installableRecommendedItems(rec.recommended);
5507
+ const items = installableRecommendedItems(rec2.recommended);
5419
5508
  if (items.length === 0) {
5420
5509
  log("Recommendation contains no catalog items. Writing core files only.");
5421
5510
  selectedIds = [];
@@ -5435,14 +5524,31 @@ async function runApply(options) {
5435
5524
  }
5436
5525
  }
5437
5526
  }
5438
- if (!process.env["HAUS_FIXTURE_CATALOG"]) {
5439
- const rec = await readJson(hausPath(root, "recommendation.json"));
5440
- const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
5441
- const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : installableRecommended.length;
5442
- if (catalogItemCount > 0) {
5443
- const hasItems = await cacheHasItems();
5444
- if (!hasItems && !options.allowEmptyCache) {
5445
- const message2 = "No catalog content found. Run `haus update` first.";
5527
+ const formatRefusal = await catalogFormatRefusal(root);
5528
+ if (formatRefusal) {
5529
+ error(formatRefusal);
5530
+ process.exitCode = 1;
5531
+ return;
5532
+ }
5533
+ const rec = await readJson(hausPath(root, "recommendation.json"));
5534
+ const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
5535
+ const wantedIds = selectedIds ?? installableRecommended.map((item) => item.id);
5536
+ let catalogCtx = null;
5537
+ let resolvedWantedIds = wantedIds;
5538
+ if (wantedIds.length > 0) {
5539
+ catalogCtx = await loadCatalogContext(root);
5540
+ const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
5541
+ const migrations = findFormerIdMigrations(
5542
+ prevLock.filter((entry) => Boolean(entry.id)),
5543
+ catalogCtx.items
5544
+ );
5545
+ const migrationByOldId = new Map(migrations.map((m) => [m.oldId, m.newId]));
5546
+ resolvedWantedIds = wantedIds.map((id) => migrationByOldId.get(id) ?? id);
5547
+ const shortfall = await countInstallableItems(catalogCtx, resolvedWantedIds);
5548
+ const tier = `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}`;
5549
+ if (shortfall.installable === 0) {
5550
+ const message2 = "No catalog content found. Run `haus update` first.";
5551
+ if (!options.allowEmptyCache) {
5446
5552
  if (isDryRun) {
5447
5553
  warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message2}`);
5448
5554
  } else {
@@ -5450,25 +5556,32 @@ async function runApply(options) {
5450
5556
  process.exitCode = 1;
5451
5557
  return;
5452
5558
  }
5453
- } else if (hasItems) {
5454
- const cacheAgeMs = await getCacheManifestAge();
5455
- if (cacheAgeMs !== null) {
5456
- const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
5457
- if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
5458
- warn(
5459
- `Catalog cache is ${cacheAgeDays} days old \u2014 run \`haus update\` for the latest content.`
5460
- );
5461
- }
5559
+ } else {
5560
+ warn(
5561
+ `Serving catalog metadata from the ${tier} tier, which has no installable content. Core files will be written; all ${wantedIds.length} catalog item(s) will be skipped.`
5562
+ );
5563
+ }
5564
+ } else if (shortfall.missing.length > 0) {
5565
+ warn(
5566
+ `The ${tier} catalog tier is missing content for ${shortfall.missing.length} item(s): ${shortfall.missing.join(", ")}. They will be skipped.`
5567
+ );
5568
+ }
5569
+ if (catalogCtx.source === "cache") {
5570
+ const cacheAgeMs = await getCacheManifestAge();
5571
+ if (cacheAgeMs !== null) {
5572
+ const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
5573
+ if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
5574
+ warn(
5575
+ `Catalog cache is ${cacheAgeDays} days old \u2014 run \`haus update\` for the latest content.`
5576
+ );
5462
5577
  }
5463
5578
  }
5579
+ } else if (catalogCtx.source === "bundled") {
5580
+ warn(
5581
+ `Catalog is being served from the snapshot bundled with this CLI${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}, frozen at publish time. Run \`haus update\` when you have network access.`
5582
+ );
5464
5583
  }
5465
5584
  }
5466
- const formatRefusal = await catalogFormatRefusal(root);
5467
- if (formatRefusal) {
5468
- error(formatRefusal);
5469
- process.exitCode = 1;
5470
- return;
5471
- }
5472
5585
  const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
5473
5586
  refillConfig: options.refillConfig,
5474
5587
  force: options.force,
@@ -5485,13 +5598,19 @@ async function runApply(options) {
5485
5598
  const installedIds = new Set(lock.map((entry) => entry.id));
5486
5599
  const allItems = await loadCatalog(root);
5487
5600
  const installedItems = allItems.filter((item) => installedIds.has(item.id));
5488
- const summary = await fetchRefsForItems(installedItems, cacheDir);
5489
- if (summary.fetched > 0)
5490
- log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
5491
- if (summary.failed > 0)
5492
- warn(
5493
- `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
5494
- );
5601
+ if (isOffline()) {
5602
+ const refCount = collectLlmsTxtUrls(installedItems).length;
5603
+ if (refCount > 0)
5604
+ log(`Offline \u2014 skipping ${refCount} llms.txt reference fetch(es); using the cache as-is.`);
5605
+ } else {
5606
+ const summary = await fetchRefsForItems(installedItems, cacheDir);
5607
+ if (summary.fetched > 0)
5608
+ log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
5609
+ if (summary.failed > 0)
5610
+ warn(
5611
+ `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
5612
+ );
5613
+ }
5495
5614
  const pruned = await pruneOrphanedRefs(cacheDir, new Set(collectLlmsTxtUrls(installedItems)));
5496
5615
  if (pruned > 0)
5497
5616
  log(
@@ -5514,17 +5633,32 @@ async function runApply(options) {
5514
5633
  }
5515
5634
  if (isDryRun) {
5516
5635
  log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
5517
- } else {
5518
- log("Applied files:");
5519
- files.forEach((f) => log(`- ${displayPath(root, f)}`));
5636
+ return;
5637
+ }
5638
+ log("Applied files:");
5639
+ files.forEach((f) => log(`- ${displayPath(root, f)}`));
5640
+ if (resolvedWantedIds.length > 0) {
5641
+ const { installedCount: installedWantedCount, missingWantedIds } = await recountInstalledAgainstLock(root, resolvedWantedIds);
5642
+ if (missingWantedIds.length > 0) {
5643
+ const tier = catalogCtx ? `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}` : "unknown";
5644
+ log(
5645
+ `${installedWantedCount} of ${resolvedWantedIds.length} catalog items installed (${tier} tier).`
5646
+ );
5647
+ if (installedWantedCount === 0) {
5648
+ error(
5649
+ "No catalog items were installed \u2014 only core files were written. Run `haus update` with network access, or import a catalog bundle."
5650
+ );
5651
+ process.exitCode = 1;
5652
+ }
5653
+ }
5520
5654
  }
5521
5655
  }
5522
5656
 
5523
5657
  // src/commands/backups.ts
5658
+ import path30 from "node:path";
5659
+ import fs27 from "fs-extra";
5524
5660
  init_logger();
5525
5661
  init_paths();
5526
- import path31 from "node:path";
5527
- import fs26 from "fs-extra";
5528
5662
 
5529
5663
  // src/utils/prompts.ts
5530
5664
  import { stdin, stdout } from "node:process";
@@ -5587,25 +5721,16 @@ Enter a number (1-${choices.length})`, io);
5587
5721
  }
5588
5722
 
5589
5723
  // src/commands/backups.ts
5590
- var LOCK_BACKUP_RE = /^haus\.lock\.\d+\.json$/;
5591
- function classify(name) {
5592
- if (LOCK_BACKUP_RE.test(name)) return "lock";
5593
- if (name.startsWith("undo-")) return "undo";
5594
- if (name.startsWith("prune-")) return "prune";
5595
- if (name.startsWith("remove-")) return "remove";
5596
- if (name.startsWith("migrate-")) return "migrate";
5597
- return void 0;
5598
- }
5599
5724
  async function listBackups(root) {
5600
5725
  const dir = hausPath(root, "backups");
5601
- if (!await fs26.pathExists(dir)) return [];
5602
- const names = await fs26.readdir(dir);
5726
+ if (!await fs27.pathExists(dir)) return [];
5727
+ const names = await fs27.readdir(dir);
5603
5728
  const entries = [];
5604
5729
  for (const name of names) {
5605
- const kind = classify(name);
5730
+ const kind = classifyBackupName(name);
5606
5731
  if (!kind) continue;
5607
- const absPath = path31.join(dir, name);
5608
- const stat = await fs26.lstat(absPath);
5732
+ const absPath = path30.join(dir, name);
5733
+ const stat = await fs27.lstat(absPath);
5609
5734
  if (stat.isSymbolicLink()) continue;
5610
5735
  const expectDirectory = kind !== "lock";
5611
5736
  if (stat.isDirectory() !== expectDirectory) continue;
@@ -5644,13 +5769,13 @@ async function runList(root, json) {
5644
5769
  }
5645
5770
  }
5646
5771
  async function isUnsafeWriteTarget(root, target) {
5647
- const segments = path31.relative(root, target).split(path31.sep).filter(Boolean);
5772
+ const segments = path30.relative(root, target).split(path30.sep).filter(Boolean);
5648
5773
  let current = root;
5649
5774
  for (let i = 0; i < segments.length; i++) {
5650
- current = path31.join(current, segments[i]);
5775
+ current = path30.join(current, segments[i]);
5651
5776
  const isLastSegment = i === segments.length - 1;
5652
5777
  try {
5653
- const stat = await fs26.lstat(current);
5778
+ const stat = await fs27.lstat(current);
5654
5779
  if (isLastSegment) {
5655
5780
  if (!stat.isFile()) return true;
5656
5781
  } else if (!stat.isDirectory()) {
@@ -5665,7 +5790,7 @@ async function isUnsafeWriteTarget(root, target) {
5665
5790
  }
5666
5791
  async function lstatIfRegularFile(abs) {
5667
5792
  try {
5668
- const stat = await fs26.lstat(abs);
5793
+ const stat = await fs27.lstat(abs);
5669
5794
  return stat.isFile() ? stat : null;
5670
5795
  } catch (err) {
5671
5796
  if (err.code === "ENOENT") return null;
@@ -5680,8 +5805,8 @@ async function restoreLockBackup(root, entry, yes, json, dryRun) {
5680
5805
  );
5681
5806
  return { ok: false, restored: 0 };
5682
5807
  }
5683
- if (await fs26.pathExists(target)) {
5684
- const targetStat = await fs26.stat(target);
5808
+ if (await fs27.pathExists(target)) {
5809
+ const targetStat = await fs27.stat(target);
5685
5810
  if (targetStat.mtimeMs > entry.mtimeMs) {
5686
5811
  warn(
5687
5812
  `Current haus.lock.json (${new Date(targetStat.mtimeMs).toISOString()}) is newer than backup ${entry.id} (${new Date(entry.mtimeMs).toISOString()}) \u2014 it will be overwritten.`
@@ -5715,7 +5840,7 @@ async function restoreLockBackup(root, entry, yes, json, dryRun) {
5715
5840
  error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
5716
5841
  return { ok: false, restored: 0 };
5717
5842
  }
5718
- await fs26.copy(entry.absPath, target, { overwrite: true });
5843
+ await fs27.copy(entry.absPath, target, { overwrite: true });
5719
5844
  if (!json) log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
5720
5845
  return { ok: true, restored: 1 };
5721
5846
  }
@@ -5723,10 +5848,10 @@ async function collectFilesRecursive(dir) {
5723
5848
  const files = [];
5724
5849
  const skippedLinks = [];
5725
5850
  const skippedOther = [];
5726
- const names = await fs26.readdir(dir);
5851
+ const names = await fs27.readdir(dir);
5727
5852
  for (const name of names) {
5728
- const abs = path31.join(dir, name);
5729
- const stat = await fs26.lstat(abs);
5853
+ const abs = path30.join(dir, name);
5854
+ const stat = await fs27.lstat(abs);
5730
5855
  if (stat.isSymbolicLink()) {
5731
5856
  skippedLinks.push(abs);
5732
5857
  continue;
@@ -5748,12 +5873,12 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5748
5873
  const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
5749
5874
  if (skippedLinks.length > 0) {
5750
5875
  warn(
5751
- `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path31.relative(entry.absPath, abs)).join(", ")}`
5876
+ `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path30.relative(entry.absPath, abs)).join(", ")}`
5752
5877
  );
5753
5878
  }
5754
5879
  if (skippedOther.length > 0) {
5755
5880
  warn(
5756
- `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path31.relative(entry.absPath, abs)).join(", ")}`
5881
+ `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path30.relative(entry.absPath, abs)).join(", ")}`
5757
5882
  );
5758
5883
  }
5759
5884
  if (files.length === 0) {
@@ -5763,8 +5888,8 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5763
5888
  const safeFiles = [];
5764
5889
  const unsafeTargets = [];
5765
5890
  for (const abs of files) {
5766
- const rel = path31.relative(entry.absPath, abs);
5767
- const target = path31.join(root, rel);
5891
+ const rel = path30.relative(entry.absPath, abs);
5892
+ const target = path30.join(root, rel);
5768
5893
  if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
5769
5894
  else safeFiles.push(abs);
5770
5895
  }
@@ -5781,16 +5906,16 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5781
5906
  const vanished = [];
5782
5907
  const toRestore = [];
5783
5908
  for (const abs of safeFiles) {
5784
- const rel = path31.relative(entry.absPath, abs);
5909
+ const rel = path30.relative(entry.absPath, abs);
5785
5910
  const backupFileStat = await lstatIfRegularFile(abs);
5786
5911
  if (!backupFileStat) {
5787
5912
  vanished.push(rel);
5788
5913
  continue;
5789
5914
  }
5790
5915
  toRestore.push(abs);
5791
- const target = path31.join(root, rel);
5792
- if (await fs26.pathExists(target)) {
5793
- const targetStat = await fs26.stat(target);
5916
+ const target = path30.join(root, rel);
5917
+ if (await fs27.pathExists(target)) {
5918
+ const targetStat = await fs27.stat(target);
5794
5919
  if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
5795
5920
  stale.push({
5796
5921
  rel,
@@ -5818,7 +5943,7 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5818
5943
  );
5819
5944
  }
5820
5945
  if (dryRun) {
5821
- const rels = toRestore.map((abs) => path31.relative(entry.absPath, abs));
5946
+ const rels = toRestore.map((abs) => path30.relative(entry.absPath, abs));
5822
5947
  if (!json) {
5823
5948
  log(`[dry-run] Would restore ${rels.length} file(s) from ${entry.id}:`);
5824
5949
  for (const rel of rels) log(` ${rel}`);
@@ -5844,14 +5969,14 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5844
5969
  let restoredCount = 0;
5845
5970
  const skippedAtWrite = [];
5846
5971
  for (const abs of toRestore) {
5847
- const rel = path31.relative(entry.absPath, abs);
5848
- const target = path31.join(root, rel);
5972
+ const rel = path30.relative(entry.absPath, abs);
5973
+ const target = path30.join(root, rel);
5849
5974
  if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
5850
5975
  skippedAtWrite.push(rel);
5851
5976
  continue;
5852
5977
  }
5853
- await fs26.ensureDir(path31.dirname(target));
5854
- await fs26.copy(abs, target, { overwrite: true });
5978
+ await fs27.ensureDir(path30.dirname(target));
5979
+ await fs27.copy(abs, target, { overwrite: true });
5855
5980
  restoredCount++;
5856
5981
  }
5857
5982
  if (skippedAtWrite.length > 0) {
@@ -5932,7 +6057,7 @@ Keeping ${keepCount}.`
5932
6057
  const failed = [];
5933
6058
  for (const entry of removeList) {
5934
6059
  try {
5935
- await fs26.remove(entry.absPath);
6060
+ await fs27.remove(entry.absPath);
5936
6061
  removed.push(entry.id);
5937
6062
  } catch (err) {
5938
6063
  failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
@@ -5999,53 +6124,299 @@ async function runBackups(action, opts = {}) {
5999
6124
  process.exitCode = 1;
6000
6125
  return;
6001
6126
  }
6002
- keep = n;
6127
+ keep = n;
6128
+ }
6129
+ const result = await runPrune(root, {
6130
+ olderThanDays,
6131
+ keep,
6132
+ yes: Boolean(opts.yes),
6133
+ json,
6134
+ dryRun: Boolean(opts.dryRun)
6135
+ });
6136
+ if (json && !result.refused) log(JSON.stringify(result, null, 2));
6137
+ if (!result.ok) process.exitCode = 1;
6138
+ }
6139
+
6140
+ // src/commands/catalog-audit.ts
6141
+ init_validation_rules2();
6142
+ init_logger();
6143
+ function auditForbiddenTags(items) {
6144
+ const failures = [];
6145
+ const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
6146
+ for (const item of items) {
6147
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
6148
+ if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
6149
+ }
6150
+ for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
6151
+ if (forbidden.has(token)) failures.push(`${item.id} has unsupported tag ${token}`);
6152
+ }
6153
+ }
6154
+ return failures;
6155
+ }
6156
+ async function runCatalogAudit() {
6157
+ const items = await loadCatalog(process.cwd());
6158
+ const failures = auditForbiddenTags(items);
6159
+ if (failures.length) {
6160
+ failures.forEach((f) => error(f));
6161
+ process.exitCode = 1;
6162
+ return;
6163
+ }
6164
+ log("Catalog audit passed.");
6165
+ }
6166
+
6167
+ // src/commands/catalog-bundle.ts
6168
+ init_constants();
6169
+ init_ingest_catalog();
6170
+ init_manifest_format();
6171
+ init_path_safety();
6172
+ import os2 from "node:os";
6173
+ import path31 from "node:path";
6174
+ import fs28 from "fs-extra";
6175
+ import * as tar from "tar";
6176
+ init_fs();
6177
+ init_logger();
6178
+ var BUNDLE_META_FILE = "bundle.json";
6179
+ var MANIFEST_FILE = "manifest.json";
6180
+ var SUPERPOWERS_SHARED_ITEM = {
6181
+ id: "haus.superpowers-shared",
6182
+ type: "skill",
6183
+ path: SUPERPOWERS_SHARED_CATALOG_REL
6184
+ };
6185
+ async function runBundleExport(options) {
6186
+ const out = options.out;
6187
+ if (!out) {
6188
+ error("Specify a destination with --out <file.tgz>");
6189
+ process.exitCode = 1;
6190
+ return;
6191
+ }
6192
+ const cacheDir = getCacheDir();
6193
+ const manifest = await readJson(path31.join(cacheDir, MANIFEST_FILE));
6194
+ if (!manifest?.items?.length) {
6195
+ error("Catalog cache is empty \u2014 run `haus update` on a connected machine first.");
6196
+ process.exitCode = 1;
6197
+ return;
6198
+ }
6199
+ const meta = {
6200
+ version: typeof manifest.version === "string" ? manifest.version : null,
6201
+ catalogRef: typeof manifest.catalogRef === "string" ? manifest.catalogRef : null,
6202
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
6203
+ };
6204
+ await writeBundleAsTar(cacheDir, meta, path31.resolve(out));
6205
+ log(
6206
+ `Exported ${manifest.items.length} catalog item(s) to ${out}${meta.catalogRef ? ` (${meta.catalogRef})` : ""}.`
6207
+ );
6208
+ log("Move it to the disconnected machine and run `haus catalog bundle import <file>`.");
6209
+ }
6210
+ async function writeBundleAsTar(cacheDir, meta, dest) {
6211
+ const bundleMetaPath = path31.join(cacheDir, BUNDLE_META_FILE);
6212
+ const previousBundleMeta = await fs28.pathExists(bundleMetaPath) ? await fs28.readFile(bundleMetaPath, "utf8") : null;
6213
+ await writeJson(bundleMetaPath, meta);
6214
+ try {
6215
+ await fs28.ensureDir(path31.dirname(dest));
6216
+ await tar.create({ gzip: true, cwd: cacheDir, file: dest }, ["."]);
6217
+ } finally {
6218
+ if (previousBundleMeta === null) await fs28.remove(bundleMetaPath);
6219
+ else await fs28.writeFile(bundleMetaPath, previousBundleMeta, "utf8");
6220
+ }
6221
+ }
6222
+ function isMarkdownFile(relOrAbsPath) {
6223
+ return relOrAbsPath.toLowerCase().endsWith(".md");
6224
+ }
6225
+ async function listAllFilesUnfiltered(root, current = root) {
6226
+ const entries = await fs28.readdir(current, { withFileTypes: true });
6227
+ const out = [];
6228
+ for (const entry of entries) {
6229
+ if (entry.isSymbolicLink()) continue;
6230
+ const abs = path31.join(current, entry.name);
6231
+ if (entry.isDirectory()) {
6232
+ out.push(...await listAllFilesUnfiltered(root, abs));
6233
+ } else if (entry.isFile()) {
6234
+ out.push(path31.relative(root, abs).split(path31.sep).join("/"));
6235
+ }
6236
+ }
6237
+ return out;
6238
+ }
6239
+ async function validateStagedItem(item, absPath) {
6240
+ const stat = await fs28.stat(absPath).catch(() => null);
6241
+ if (!stat) return { ok: true };
6242
+ if (stat.isFile()) {
6243
+ const content2 = await fs28.readFile(absPath, "utf8");
6244
+ return validateCatalogItem(item, content2);
6245
+ }
6246
+ const files = await listAllFilesUnfiltered(absPath);
6247
+ for (const rel of files) {
6248
+ if (!isMarkdownFile(rel)) continue;
6249
+ const content2 = await fs28.readFile(path31.join(absPath, rel), "utf8");
6250
+ const verdict = validateCatalogItem(item, content2);
6251
+ if (!verdict.ok) return verdict;
6252
+ }
6253
+ return { ok: true };
6254
+ }
6255
+ async function validateStagedItems(staging, items) {
6256
+ let rejected = 0;
6257
+ const accepted = [];
6258
+ const contentlessIds = /* @__PURE__ */ new Set();
6259
+ const coveredPaths = [];
6260
+ for (const item of [...items, SUPERPOWERS_SHARED_ITEM]) {
6261
+ const isSynthetic = item === SUPERPOWERS_SHARED_ITEM;
6262
+ const contentPath = safeJoin(staging, item.path);
6263
+ if (!contentPath) {
6264
+ if (!isSynthetic) {
6265
+ warn(`Refusing item with an unsafe path: ${item.id} (${item.path})`);
6266
+ rejected += 1;
6267
+ }
6268
+ continue;
6269
+ }
6270
+ coveredPaths.push(item.path);
6271
+ if (!await fs28.pathExists(contentPath)) {
6272
+ if (!isSynthetic) {
6273
+ accepted.push(item.id);
6274
+ contentlessIds.add(item.id);
6275
+ }
6276
+ continue;
6277
+ }
6278
+ const verdict = await validateStagedItem(item, contentPath);
6279
+ if (!verdict.ok) {
6280
+ warn(`Rejected ${item.id}: ${verdict.reason}`);
6281
+ await fs28.remove(contentPath);
6282
+ if (!isSynthetic) rejected += 1;
6283
+ continue;
6284
+ }
6285
+ if (!isSynthetic) accepted.push(item.id);
6003
6286
  }
6004
- const result = await runPrune(root, {
6005
- olderThanDays,
6006
- keep,
6007
- yes: Boolean(opts.yes),
6008
- json,
6009
- dryRun: Boolean(opts.dryRun)
6010
- });
6011
- if (json && !result.refused) log(JSON.stringify(result, null, 2));
6012
- if (!result.ok) process.exitCode = 1;
6287
+ const stagedFiles = await listAllFilesUnfiltered(staging);
6288
+ for (const rel of stagedFiles) {
6289
+ if (rel === MANIFEST_FILE || rel === BUNDLE_META_FILE) continue;
6290
+ const covered = coveredPaths.some((p) => rel === p || rel.startsWith(`${p}/`));
6291
+ if (!covered) {
6292
+ warn(`Refusing staged file outside any catalog item's path: ${rel}`);
6293
+ await fs28.remove(path31.join(staging, rel));
6294
+ }
6295
+ }
6296
+ await pruneEmptyDirs(staging);
6297
+ return { accepted, rejected, contentlessIds };
6013
6298
  }
6014
-
6015
- // src/commands/catalog-audit.ts
6016
- init_validation_rules2();
6017
- init_logger();
6018
- function auditForbiddenTags(items) {
6019
- const failures = [];
6020
- const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
6021
- for (const item of items) {
6022
- for (const tag of Array.isArray(item.tags) ? item.tags : []) {
6023
- if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
6299
+ async function pruneEmptyDirs(root, dir = root) {
6300
+ const entries = await fs28.readdir(dir, { withFileTypes: true });
6301
+ for (const entry of entries) {
6302
+ if (entry.isDirectory()) {
6303
+ await pruneEmptyDirs(root, path31.join(dir, entry.name));
6024
6304
  }
6025
- for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
6026
- if (forbidden.has(token)) failures.push(`${item.id} has unsupported tag ${token}`);
6305
+ }
6306
+ if (dir !== root && (await fs28.readdir(dir)).length === 0) {
6307
+ await fs28.remove(dir);
6308
+ }
6309
+ }
6310
+ async function stripSymlinks(dir) {
6311
+ const removed = [];
6312
+ const entries = await fs28.readdir(dir, { withFileTypes: true });
6313
+ for (const entry of entries) {
6314
+ const abs = path31.join(dir, entry.name);
6315
+ if (entry.isSymbolicLink()) {
6316
+ await fs28.remove(abs);
6317
+ removed.push(abs);
6318
+ continue;
6319
+ }
6320
+ if (entry.isDirectory()) {
6321
+ removed.push(...await stripSymlinks(abs));
6027
6322
  }
6028
6323
  }
6029
- return failures;
6324
+ return removed;
6030
6325
  }
6031
- async function runCatalogAudit() {
6032
- const items = await loadCatalog(process.cwd());
6033
- const failures = auditForbiddenTags(items);
6034
- if (failures.length) {
6035
- failures.forEach((f) => error(f));
6326
+ async function runBundleImport(file, options = {}) {
6327
+ const dryRun = Boolean(options.dryRun);
6328
+ const archive = path31.resolve(file);
6329
+ if (!await fs28.pathExists(archive)) {
6330
+ error(`No such bundle: ${file}`);
6036
6331
  process.exitCode = 1;
6037
6332
  return;
6038
6333
  }
6039
- log("Catalog audit passed.");
6334
+ const staging = await fs28.mkdtemp(path31.join(os2.tmpdir(), "haus-catalog-bundle-import-"));
6335
+ try {
6336
+ await tar.extract({
6337
+ file: archive,
6338
+ cwd: staging,
6339
+ // Refuse anything that would land outside the staging dir. node-tar's own
6340
+ // extraction already refuses `..`, absolute paths, and symlink escapes by
6341
+ // default (preservePaths is left unset) — this filter is a second, independent
6342
+ // check using the same path-safety module the network sync uses. Both this
6343
+ // filter's refusals and node-tar's own built-in ones are logged — a tampered
6344
+ // bundle's dropped entries must be visible, not silently absorbed.
6345
+ filter: (entryPath) => {
6346
+ const safe = isSafeCatalogPath(entryPath);
6347
+ if (!safe) warn(`Refusing unsafe tar entry path: ${entryPath}`);
6348
+ return safe;
6349
+ },
6350
+ onwarn: (code, message2) => warn(`tar: ${code}: ${message2}`)
6351
+ });
6352
+ const strippedSymlinks = await stripSymlinks(staging);
6353
+ for (const abs of strippedSymlinks) {
6354
+ warn(`Refusing symlink in bundle: ${path31.relative(staging, abs)}`);
6355
+ }
6356
+ const manifest = await readJson(path31.join(staging, MANIFEST_FILE));
6357
+ if (!manifest?.items?.length) {
6358
+ error("Bundle contains no catalog manifest \u2014 refusing to import.");
6359
+ process.exitCode = 1;
6360
+ return;
6361
+ }
6362
+ assertSupportedManifestFormat(manifest.manifestFormat, "The imported catalog bundle");
6363
+ const { accepted, rejected, contentlessIds } = await validateStagedItems(
6364
+ staging,
6365
+ manifest.items
6366
+ );
6367
+ const meta = await readJson(path31.join(staging, BUNDLE_META_FILE));
6368
+ const label = meta?.catalogRef ?? (meta?.version ? `v${meta.version}` : "unknown ref");
6369
+ const installedCount = accepted.length;
6370
+ if (dryRun) {
6371
+ log(
6372
+ dryRunPrefix(
6373
+ true,
6374
+ `would import ${installedCount} catalog item(s) from ${label} into ${getCacheDir()}`
6375
+ )
6376
+ );
6377
+ accepted.forEach(
6378
+ (id) => log(
6379
+ dryRunPrefix(
6380
+ true,
6381
+ contentlessIds.has(id) ? ` ${id} (declared only, no content in bundle)` : ` ${id}`
6382
+ )
6383
+ )
6384
+ );
6385
+ if (rejected > 0) {
6386
+ log(dryRunPrefix(true, `${rejected} item(s) would be rejected by validation (see above)`));
6387
+ }
6388
+ return;
6389
+ }
6390
+ await fs28.ensureDir(getCacheDir());
6391
+ for (const entry of await fs28.readdir(staging)) {
6392
+ await fs28.copy(path31.join(staging, entry), path31.join(getCacheDir(), entry), {
6393
+ overwrite: true,
6394
+ errorOnExist: false
6395
+ });
6396
+ }
6397
+ log(
6398
+ `Imported ${installedCount} catalog item(s) from ${label}.` + (rejected > 0 ? ` ${rejected} rejected by validation.` : "")
6399
+ );
6400
+ log("Run `haus apply --write` to install them.");
6401
+ } catch (err) {
6402
+ if (err instanceof UnsupportedManifestFormatError) {
6403
+ error(err.message);
6404
+ process.exitCode = 1;
6405
+ return;
6406
+ }
6407
+ throw err;
6408
+ } finally {
6409
+ await fs28.remove(staging);
6410
+ }
6040
6411
  }
6041
6412
 
6042
6413
  // src/commands/doctor.ts
6043
6414
  import path45 from "node:path";
6044
- import fs35 from "fs-extra";
6415
+ import fs37 from "fs-extra";
6045
6416
  init_manifest_format();
6046
6417
 
6047
6418
  // src/claude/former-install-orphans.ts
6048
- import fs27 from "fs-extra";
6419
+ import fs29 from "fs-extra";
6049
6420
  init_path_safety();
6050
6421
  init_paths();
6051
6422
  var toPosix = (value) => value.replace(/\\/g, "/");
@@ -6069,7 +6440,7 @@ async function findOrphanedFormerInstalls(root, items, lockRows) {
6069
6440
  const projectRel = `.claude/${relPath}`;
6070
6441
  if (trackedPaths.has(projectRel) || reported.has(projectRel)) continue;
6071
6442
  const abs = safeJoin(claudePath(root), relPath);
6072
- if (abs === null || !await fs27.pathExists(abs)) continue;
6443
+ if (abs === null || !await fs29.pathExists(abs)) continue;
6073
6444
  reported.add(projectRel);
6074
6445
  orphans.push({ formerId: entry.formerId, currentId: entry.currentId, relPath: projectRel });
6075
6446
  }
@@ -6080,7 +6451,7 @@ async function findOrphanedFormerInstalls(root, items, lockRows) {
6080
6451
  init_paths();
6081
6452
  import path32 from "node:path";
6082
6453
  import fg4 from "fast-glob";
6083
- import fs28 from "fs-extra";
6454
+ import fs30 from "fs-extra";
6084
6455
  var REFERENCE_PATTERN = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+)+):([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b(?!:\/\/)/g;
6085
6456
  function stripFencedCodeBlocks(text) {
6086
6457
  return text.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, " "));
@@ -6088,15 +6459,15 @@ function stripFencedCodeBlocks(text) {
6088
6459
  async function findUnresolvedSkillReferences(root, options = {}) {
6089
6460
  const resolvableNamespaces = new Set(options.pluginNamespaces ?? []);
6090
6461
  const skillsRoot = claudePath(root, "skills");
6091
- if (!await fs28.pathExists(skillsRoot)) return [];
6092
- const skillDirs = (await fs28.readdir(skillsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== "shared").map((entry) => entry.name);
6462
+ if (!await fs30.pathExists(skillsRoot)) return [];
6463
+ const skillDirs = (await fs30.readdir(skillsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== "shared").map((entry) => entry.name);
6093
6464
  const installedSkillNames = new Set(skillDirs);
6094
6465
  const files = await fg4("*/**/*.md", { cwd: skillsRoot, onlyFiles: true, dot: true });
6095
6466
  const issues = [];
6096
6467
  for (const rel of files) {
6097
6468
  if (rel.startsWith("shared/")) continue;
6098
6469
  const abs = path32.join(skillsRoot, rel);
6099
- const raw = await fs28.readFile(abs, "utf8");
6470
+ const raw = await fs30.readFile(abs, "utf8");
6100
6471
  const lines = stripFencedCodeBlocks(raw).split("\n");
6101
6472
  lines.forEach((line2, idx) => {
6102
6473
  for (const match of line2.matchAll(REFERENCE_PATTERN)) {
@@ -6119,13 +6490,13 @@ async function findUnresolvedSkillReferences(root, options = {}) {
6119
6490
  // src/decisions/doctor.ts
6120
6491
  init_fs();
6121
6492
  import path37 from "node:path";
6122
- import fs32 from "fs-extra";
6493
+ import fs34 from "fs-extra";
6123
6494
 
6124
6495
  // src/decisions/check.ts
6125
6496
  init_fs();
6126
6497
  init_paths();
6127
6498
  import path36 from "node:path";
6128
- import fs31 from "fs-extra";
6499
+ import fs33 from "fs-extra";
6129
6500
 
6130
6501
  // src/decisions/dep-bump.ts
6131
6502
  var DEP_SECTIONS = [
@@ -6318,12 +6689,12 @@ function firstMatchingRegex(filePath, patterns) {
6318
6689
 
6319
6690
  // src/decisions/paths.ts
6320
6691
  import path34 from "node:path";
6321
- import fs29 from "fs-extra";
6692
+ import fs31 from "fs-extra";
6322
6693
  async function resolveDecisionsDir(root) {
6323
6694
  const primary = path34.join(root, DECISIONS_TRIGGERS.decisionsDir);
6324
- if (await fs29.pathExists(primary)) return primary;
6695
+ if (await fs31.pathExists(primary)) return primary;
6325
6696
  const legacy = path34.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
6326
- if (await fs29.pathExists(legacy)) return legacy;
6697
+ if (await fs31.pathExists(legacy)) return legacy;
6327
6698
  return primary;
6328
6699
  }
6329
6700
  function relativeDecisionPath(decisionsDir, root) {
@@ -6333,7 +6704,7 @@ function relativeDecisionPath(decisionsDir, root) {
6333
6704
  // src/decisions/validate.ts
6334
6705
  init_fs();
6335
6706
  import path35 from "node:path";
6336
- import fs30 from "fs-extra";
6707
+ import fs32 from "fs-extra";
6337
6708
  function validateDecisionContent(content2) {
6338
6709
  const errors = [];
6339
6710
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -6363,7 +6734,7 @@ function decisionNumberFromFile(name) {
6363
6734
  }
6364
6735
  async function validateIndexSync(decisionsDir, decisionFiles) {
6365
6736
  const readmePath = path35.join(decisionsDir, "README.md");
6366
- if (!await fs30.pathExists(readmePath)) {
6737
+ if (!await fs32.pathExists(readmePath)) {
6367
6738
  return ["docs/decisions/README.md is missing"];
6368
6739
  }
6369
6740
  const readme = await readText(readmePath) ?? "";
@@ -6535,8 +6906,8 @@ ${opts.commitMessages ?? ""}`;
6535
6906
  changedFiles: stats.files
6536
6907
  });
6537
6908
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
6538
- const legacyExists = await fs31.pathExists(path36.join(root, legacyRel));
6539
- if (legacyExists && !await fs31.pathExists(decisionsDir)) {
6909
+ const legacyExists = await fs33.pathExists(path36.join(root, legacyRel));
6910
+ if (legacyExists && !await fs33.pathExists(decisionsDir)) {
6540
6911
  satisfactionErrors = await checkSatisfaction({
6541
6912
  root,
6542
6913
  decisionsRel: legacyRel,
@@ -6558,8 +6929,8 @@ ${opts.commitMessages ?? ""}`;
6558
6929
  return { triggered: true, satisfied: false, reasons, securityTriggered };
6559
6930
  }
6560
6931
  async function listDecisionNumbers(decisionsDir) {
6561
- if (!await fs31.pathExists(decisionsDir)) return [];
6562
- const entries = await fs31.readdir(decisionsDir);
6932
+ if (!await fs33.pathExists(decisionsDir)) return [];
6933
+ const entries = await fs33.readdir(decisionsDir);
6563
6934
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
6564
6935
  }
6565
6936
 
@@ -6568,8 +6939,8 @@ async function auditDecisionsLayout(root) {
6568
6939
  const findings = [];
6569
6940
  const primary = path37.join(root, DECISIONS_TRIGGERS.decisionsDir);
6570
6941
  const legacy = path37.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
6571
- const hasPrimary = await fs32.pathExists(primary);
6572
- const hasLegacy = await fs32.pathExists(legacy);
6942
+ const hasPrimary = await fs34.pathExists(primary);
6943
+ const hasLegacy = await fs34.pathExists(legacy);
6573
6944
  if (!hasPrimary && hasLegacy) {
6574
6945
  findings.push({
6575
6946
  level: "advisory",
@@ -6583,7 +6954,7 @@ async function auditDecisionsLayout(root) {
6583
6954
  const decisionsDir = await resolveDecisionsDir(root);
6584
6955
  const rel = relativeDecisionPath(decisionsDir, root);
6585
6956
  const readmePath = path37.join(decisionsDir, "README.md");
6586
- if (!await fs32.pathExists(readmePath)) {
6957
+ if (!await fs34.pathExists(readmePath)) {
6587
6958
  findings.push({
6588
6959
  level: "advisory",
6589
6960
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -6593,7 +6964,7 @@ async function auditDecisionsLayout(root) {
6593
6964
  return findings;
6594
6965
  }
6595
6966
  const readme = await readText(readmePath);
6596
- const entries = (await fs32.readdir(decisionsDir)).filter(
6967
+ const entries = (await fs34.readdir(decisionsDir)).filter(
6597
6968
  (f) => f.endsWith(".md") && f !== "README.md"
6598
6969
  );
6599
6970
  for (const file of entries) {
@@ -6642,7 +7013,7 @@ init_fs();
6642
7013
  init_logger();
6643
7014
  init_paths();
6644
7015
  import path38 from "node:path";
6645
- import fs33 from "fs-extra";
7016
+ import fs35 from "fs-extra";
6646
7017
  function manifestPathIn(configDir) {
6647
7018
  return path38.join(configDir, "haus", "install-manifest.json");
6648
7019
  }
@@ -6672,9 +7043,9 @@ async function pruneVacatedDirs(root, vacatedFiles) {
6672
7043
  async function destinationWritable(dir) {
6673
7044
  let candidate = path38.resolve(dir);
6674
7045
  for (; ; ) {
6675
- if (await fs33.pathExists(candidate)) {
7046
+ if (await fs35.pathExists(candidate)) {
6676
7047
  try {
6677
- await fs33.access(candidate, fs33.constants.W_OK);
7048
+ await fs35.access(candidate, fs35.constants.W_OK);
6678
7049
  return true;
6679
7050
  } catch (err) {
6680
7051
  return err instanceof Error ? err.message : String(err);
@@ -6706,7 +7077,7 @@ async function migrateGlobalConfigDir(options = {}) {
6706
7077
  if (!legacy || !Array.isArray(legacy.files)) {
6707
7078
  return { ...base, reason: "no install manifest under ~/.claude" };
6708
7079
  }
6709
- if (await fs33.pathExists(manifestPathIn(to))) {
7080
+ if (await fs35.pathExists(manifestPathIn(to))) {
6710
7081
  return { ...base, reason: "an install manifest already exists in the resolved config dir" };
6711
7082
  }
6712
7083
  const writable = await destinationWritable(to);
@@ -6729,14 +7100,14 @@ async function migrateGlobalConfigDir(options = {}) {
6729
7100
  continue;
6730
7101
  }
6731
7102
  const newDest = path38.join(to, rel);
6732
- if (!await fs33.pathExists(entry.destPath)) {
7103
+ if (!await fs35.pathExists(entry.destPath)) {
6733
7104
  rewritten.push({ ...entry, destPath: newDest });
6734
7105
  continue;
6735
7106
  }
6736
- if (await fs33.pathExists(newDest)) {
7107
+ if (await fs35.pathExists(newDest)) {
6737
7108
  if (await sameContent(entry.destPath, newDest)) {
6738
7109
  if (!dryRun) {
6739
- await fs33.remove(entry.destPath);
7110
+ await fs35.remove(entry.destPath);
6740
7111
  vacated.push(entry.destPath);
6741
7112
  }
6742
7113
  moved.push(newDest);
@@ -6753,7 +7124,7 @@ async function migrateGlobalConfigDir(options = {}) {
6753
7124
  continue;
6754
7125
  }
6755
7126
  try {
6756
- await fs33.move(entry.destPath, newDest);
7127
+ await fs35.move(entry.destPath, newDest);
6757
7128
  vacated.push(entry.destPath);
6758
7129
  moved.push(newDest);
6759
7130
  rewritten.push({ ...entry, destPath: newDest });
@@ -6778,7 +7149,7 @@ async function migrateGlobalConfigDir(options = {}) {
6778
7149
  }
6779
7150
  try {
6780
7151
  await writeJson(manifestPathIn(to), { ...legacy, files: rewritten });
6781
- await fs33.remove(legacyManifestPath);
7152
+ await fs35.remove(legacyManifestPath);
6782
7153
  vacated.push(legacyManifestPath);
6783
7154
  } catch (err) {
6784
7155
  return {
@@ -6824,7 +7195,7 @@ function printConfigDirMigration(result) {
6824
7195
 
6825
7196
  // src/scanner/read-context.ts
6826
7197
  import path44 from "node:path";
6827
- import fs34 from "fs-extra";
7198
+ import fs36 from "fs-extra";
6828
7199
 
6829
7200
  // src/commands/workspace/config.ts
6830
7201
  init_fs();
@@ -7408,10 +7779,10 @@ async function isCacheFresh(root, cachePath) {
7408
7779
  path44.join(root, WORKSPACE_FILE),
7409
7780
  path44.join(root, REPOS_MANIFEST_FILE)
7410
7781
  ];
7411
- const cacheStat = await fs34.stat(cachePath);
7782
+ const cacheStat = await fs36.stat(cachePath);
7412
7783
  for (const markerPath of markerPaths) {
7413
- if (!await fs34.pathExists(markerPath)) continue;
7414
- const markerStat = await fs34.stat(markerPath);
7784
+ if (!await fs36.pathExists(markerPath)) continue;
7785
+ const markerStat = await fs36.stat(markerPath);
7415
7786
  if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
7416
7787
  }
7417
7788
  return true;
@@ -7425,11 +7796,15 @@ async function readContextOrScan(root) {
7425
7796
  }
7426
7797
 
7427
7798
  // src/update/npm-version.ts
7799
+ init_offline();
7428
7800
  var NPM_PACKAGE_NAME = "@haus-tech/haus-workflow";
7429
7801
  function isTestMode2() {
7430
7802
  return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
7431
7803
  }
7432
7804
  async function fetchNpmVersionStatus(currentVersion) {
7805
+ if (isOffline()) {
7806
+ return { current: currentVersion, latest: null, updateAvailable: false };
7807
+ }
7433
7808
  if (isTestMode2() && process.env["HAUS_SKIP_NPM_CHECK"]) {
7434
7809
  return { current: currentVersion, latest: null, updateAvailable: false };
7435
7810
  }
@@ -7568,7 +7943,7 @@ async function runDoctor(options) {
7568
7943
  const resolved = path45.resolve(root, target);
7569
7944
  const relFromRoot = path45.relative(root, resolved);
7570
7945
  const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path45.sep}`) || path45.isAbsolute(relFromRoot);
7571
- if (escapesRoot || !await fs35.pathExists(resolved)) {
7946
+ if (escapesRoot || !await fs37.pathExists(resolved)) {
7572
7947
  flag(
7573
7948
  `- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
7574
7949
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -7586,7 +7961,7 @@ async function runDoctor(options) {
7586
7961
  );
7587
7962
  }
7588
7963
  const workflowPath = hausPath(root, "WORKFLOW.md");
7589
- const workflowExists = await fs35.pathExists(workflowPath);
7964
+ const workflowExists = await fs37.pathExists(workflowPath);
7590
7965
  if (!workflowExists) {
7591
7966
  flag(
7592
7967
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -7609,7 +7984,7 @@ async function runDoctor(options) {
7609
7984
  "templates",
7610
7985
  "agentic-workflow-standard.md"
7611
7986
  );
7612
- const templatePath = await fs35.pathExists(cachePath) ? cachePath : bundledPath;
7987
+ const templatePath = await fs37.pathExists(cachePath) ? cachePath : bundledPath;
7613
7988
  const templateContent = await readText(templatePath);
7614
7989
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
7615
7990
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -7637,7 +8012,7 @@ async function runDoctor(options) {
7637
8012
  }
7638
8013
  }
7639
8014
  const workflowConfigPath = hausPath(root, "workflow-config.md");
7640
- const workflowConfigExists = await fs35.pathExists(workflowConfigPath);
8015
+ const workflowConfigExists = await fs37.pathExists(workflowConfigPath);
7641
8016
  if (!workflowConfigExists) {
7642
8017
  flag(
7643
8018
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -7645,7 +8020,7 @@ async function runDoctor(options) {
7645
8020
  "haus apply --write"
7646
8021
  );
7647
8022
  } else {
7648
- const cfg = await fs35.readFile(workflowConfigPath, "utf8");
8023
+ const cfg = await fs37.readFile(workflowConfigPath, "utf8");
7649
8024
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
7650
8025
  if (unfilled > 0) {
7651
8026
  suggest(
@@ -7699,8 +8074,10 @@ async function runDoctor(options) {
7699
8074
  else suggest(finding.line, finding.sentence, finding.fix);
7700
8075
  }
7701
8076
  let catalogItems = null;
8077
+ let catalogCtx = null;
7702
8078
  try {
7703
- catalogItems = await loadCatalog(root);
8079
+ catalogCtx = await loadCatalogContext(root);
8080
+ catalogItems = catalogCtx.items;
7704
8081
  } catch (err) {
7705
8082
  if (!(err instanceof UnsupportedManifestFormatError)) throw err;
7706
8083
  flag(
@@ -7793,6 +8170,31 @@ async function runDoctor(options) {
7793
8170
  );
7794
8171
  }
7795
8172
  }
8173
+ let catalogTier;
8174
+ let catalogVersion;
8175
+ let installableItems;
8176
+ if (catalogCtx !== null) {
8177
+ const { installable } = await countInstallableItems(catalogCtx);
8178
+ catalogTier = catalogCtx.source;
8179
+ catalogVersion = catalogCtx.version;
8180
+ installableItems = installable;
8181
+ const tierLabel = `${catalogTier}${catalogVersion ? ` (v${catalogVersion})` : ""}`;
8182
+ if (catalogTier === "bundled") {
8183
+ suggest(
8184
+ `- CATALOG TIER: ${tierLabel} \u2014 frozen at publish time, metadata only (${installableItems} installable item(s))`,
8185
+ `Catalog is being served from the snapshot bundled with this CLI (${tierLabel}), frozen at publish time and carrying metadata only \u2014 ${installableItems} of its items have installable content, so \`haus apply\` will write core files only`,
8186
+ "haus update (with network access), or `haus catalog bundle import` on a disconnected machine"
8187
+ );
8188
+ } else if (installableItems === 0) {
8189
+ suggest(
8190
+ `- CATALOG TIER: ${tierLabel} \u2014 0 installable items (\`haus apply\` will write core files only)`,
8191
+ `The ${catalogTier} catalog tier has 0 installable items`,
8192
+ "haus update (or `haus catalog bundle import` on a disconnected machine)"
8193
+ );
8194
+ } else {
8195
+ ok(`- CATALOG TIER: ${tierLabel} \u2014 ${installableItems} installable item(s)`);
8196
+ }
8197
+ }
7796
8198
  const cacheAgeMs = await getCacheManifestAge();
7797
8199
  if (cacheAgeMs === null) {
7798
8200
  suggest(
@@ -7886,7 +8288,7 @@ async function runDoctor(options) {
7886
8288
  let onDisk = false;
7887
8289
  for (const rel of row.paths ?? []) {
7888
8290
  const abs = resolveContainedPath(root, rel);
7889
- if (abs !== null && await fs35.pathExists(abs)) {
8291
+ if (abs !== null && await fs37.pathExists(abs)) {
7890
8292
  onDisk = true;
7891
8293
  break;
7892
8294
  }
@@ -7918,7 +8320,21 @@ async function runDoctor(options) {
7918
8320
  }
7919
8321
  if (attention.length > 0) process.exitCode = 1;
7920
8322
  if (options?.json) {
7921
- log(JSON.stringify({ ok: attention.length === 0, attention, suggestions, detail }, null, 2));
8323
+ log(
8324
+ JSON.stringify(
8325
+ {
8326
+ ok: attention.length === 0,
8327
+ attention,
8328
+ suggestions,
8329
+ detail,
8330
+ catalogTier,
8331
+ catalogVersion,
8332
+ installableItems
8333
+ },
8334
+ null,
8335
+ 2
8336
+ )
8337
+ );
7922
8338
  return;
7923
8339
  }
7924
8340
  if (attention.length === 0) {
@@ -7943,11 +8359,11 @@ import path48 from "node:path";
7943
8359
  init_github_rate_limit();
7944
8360
 
7945
8361
  // src/claude/refresh-project.ts
7946
- import fs36 from "fs-extra";
8362
+ import fs38 from "fs-extra";
7947
8363
  init_fs();
7948
8364
  init_paths();
7949
8365
  async function isHausProject(root) {
7950
- if (await fs36.pathExists(hausPath(root, "recommendation.json"))) return true;
8366
+ if (await fs38.pathExists(hausPath(root, "recommendation.json"))) return true;
7951
8367
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
7952
8368
  if (result.status === "ok") return result.value._haus != null;
7953
8369
  return false;
@@ -7964,7 +8380,7 @@ async function refreshProjectApply(root) {
7964
8380
  // src/install/apply.ts
7965
8381
  import crypto2 from "node:crypto";
7966
8382
  import path46 from "node:path";
7967
- import fs37 from "fs-extra";
8383
+ import fs39 from "fs-extra";
7968
8384
  init_fs();
7969
8385
  init_logger();
7970
8386
  init_paths();
@@ -8053,7 +8469,7 @@ function hashContent(content2) {
8053
8469
  }
8054
8470
  function destFileMissing(destPath) {
8055
8471
  try {
8056
- return !fs37.statSync(destPath).isFile();
8472
+ return !fs39.statSync(destPath).isFile();
8057
8473
  } catch {
8058
8474
  return true;
8059
8475
  }
@@ -8061,7 +8477,7 @@ function destFileMissing(destPath) {
8061
8477
  function sourceVersion() {
8062
8478
  try {
8063
8479
  const pkgPath = path46.join(packageRoot(), "package.json");
8064
- const pkg = JSON.parse(fs37.readFileSync(pkgPath, "utf8"));
8480
+ const pkg = JSON.parse(fs39.readFileSync(pkgPath, "utf8"));
8065
8481
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
8066
8482
  } catch {
8067
8483
  return "haus@0.0.0";
@@ -8073,10 +8489,10 @@ function globalSrcDir() {
8073
8489
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
8074
8490
  const skillDir = path46.join(skillsDir, skillName);
8075
8491
  const entries = [];
8076
- for (const rel of fs37.readdirSync(skillDir, { recursive: true })) {
8492
+ for (const rel of fs39.readdirSync(skillDir, { recursive: true })) {
8077
8493
  if (rel === "SKILL.md") continue;
8078
8494
  const abs = path46.join(skillDir, rel);
8079
- if (!fs37.statSync(abs).isFile()) continue;
8495
+ if (!fs39.statSync(abs).isFile()) continue;
8080
8496
  const posixRel = rel.split(path46.sep).join("/");
8081
8497
  entries.push({
8082
8498
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
@@ -8089,10 +8505,10 @@ function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
8089
8505
  function collectSourceFiles(srcDir, claudeDir) {
8090
8506
  const entries = [];
8091
8507
  const skillsDir = path46.join(srcDir, "skills");
8092
- if (fs37.pathExistsSync(skillsDir)) {
8093
- for (const skillName of fs37.readdirSync(skillsDir)) {
8508
+ if (fs39.pathExistsSync(skillsDir)) {
8509
+ for (const skillName of fs39.readdirSync(skillsDir)) {
8094
8510
  const skillFile = path46.join(skillsDir, skillName, "SKILL.md");
8095
- if (fs37.pathExistsSync(skillFile)) {
8511
+ if (fs39.pathExistsSync(skillFile)) {
8096
8512
  entries.push({
8097
8513
  stableId: `skill.${skillName}`,
8098
8514
  srcRelPath: path46.join("library", "global", "skills", skillName, "SKILL.md"),
@@ -8103,8 +8519,8 @@ function collectSourceFiles(srcDir, claudeDir) {
8103
8519
  }
8104
8520
  }
8105
8521
  const commandsDir = path46.join(srcDir, "commands");
8106
- if (fs37.pathExistsSync(commandsDir)) {
8107
- for (const fileName of fs37.readdirSync(commandsDir)) {
8522
+ if (fs39.pathExistsSync(commandsDir)) {
8523
+ for (const fileName of fs39.readdirSync(commandsDir)) {
8108
8524
  if (!fileName.endsWith(".md")) continue;
8109
8525
  const commandName = fileName.slice(0, -".md".length);
8110
8526
  entries.push({
@@ -8163,7 +8579,7 @@ async function applyInstall(options = {}) {
8163
8579
  }
8164
8580
  continue;
8165
8581
  }
8166
- const destExists = fs37.pathExistsSync(entry.destPath);
8582
+ const destExists = fs39.pathExistsSync(entry.destPath);
8167
8583
  if (destExists) {
8168
8584
  const currentContent = await readText(entry.destPath);
8169
8585
  if (currentContent !== void 0) {
@@ -8222,13 +8638,13 @@ async function applyInstall(options = {}) {
8222
8638
  manifestFiles.push(entry);
8223
8639
  continue;
8224
8640
  }
8225
- if (!fs37.pathExistsSync(entry.destPath)) continue;
8641
+ if (!fs39.pathExistsSync(entry.destPath)) continue;
8226
8642
  const content2 = await readText(entry.destPath);
8227
8643
  if (!content2) continue;
8228
8644
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
8229
8645
  const currentHash = hashContent(content2);
8230
8646
  if (hasHeader && currentHash === entry.hash) {
8231
- if (!dryRun) await fs37.remove(entry.destPath);
8647
+ if (!dryRun) await fs39.remove(entry.destPath);
8232
8648
  result.deleted.push(entry.destPath);
8233
8649
  } else {
8234
8650
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -8342,7 +8758,7 @@ async function backupLockfile(root) {
8342
8758
  await mkdir(backupDir, { recursive: true });
8343
8759
  let stamp = Date.now();
8344
8760
  for (; ; ) {
8345
- const dest = path47.join(backupDir, `haus.lock.${stamp}.json`);
8761
+ const dest = path47.join(backupDir, lockBackupFileName(stamp));
8346
8762
  try {
8347
8763
  await copyFile(lockPath, dest, fsConstants.COPYFILE_EXCL);
8348
8764
  return;
@@ -8823,7 +9239,7 @@ async function runClone(url, opts = {}) {
8823
9239
  // src/install/scaffold.ts
8824
9240
  init_logger();
8825
9241
  import path50 from "node:path";
8826
- import fs38 from "fs-extra";
9242
+ import fs40 from "fs-extra";
8827
9243
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8828
9244
  const scaffolded = [];
8829
9245
  const skipped = [];
@@ -8836,7 +9252,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8836
9252
  skipped.push(name);
8837
9253
  if (outcome === "skipped-exists") skippedExisting.push(name);
8838
9254
  };
8839
- const realRoot = await fs38.realpath(catalogRoot).catch(() => null);
9255
+ const realRoot = await fs40.realpath(catalogRoot).catch(() => null);
8840
9256
  for (const item of items) {
8841
9257
  if (item.type !== "config") continue;
8842
9258
  const sourcePath = path50.resolve(catalogRoot, item.path);
@@ -8846,7 +9262,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8846
9262
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
8847
9263
  continue;
8848
9264
  }
8849
- const realSource = await fs38.realpath(sourcePath).catch(() => null);
9265
+ const realSource = await fs40.realpath(sourcePath).catch(() => null);
8850
9266
  if (!realRoot || !realSource) {
8851
9267
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
8852
9268
  continue;
@@ -8856,7 +9272,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8856
9272
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
8857
9273
  continue;
8858
9274
  }
8859
- const stat = await fs38.lstat(sourcePath).catch(() => null);
9275
+ const stat = await fs40.lstat(sourcePath).catch(() => null);
8860
9276
  if (!stat) {
8861
9277
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
8862
9278
  continue;
@@ -8870,7 +9286,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8870
9286
  const dest = path50.join(projectRoot, filename);
8871
9287
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
8872
9288
  } else if (stat.isDirectory()) {
8873
- const entries = await fs38.readdir(sourcePath);
9289
+ const entries = await fs40.readdir(sourcePath);
8874
9290
  for (const entry of entries) {
8875
9291
  const src = path50.join(sourcePath, entry);
8876
9292
  const dest = path50.join(projectRoot, entry);
@@ -8882,7 +9298,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8882
9298
  }
8883
9299
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8884
9300
  const destDisplay = path50.relative(projectRoot, dest) || path50.basename(dest);
8885
- const srcStat = await fs38.lstat(src).catch(() => null);
9301
+ const srcStat = await fs40.lstat(src).catch(() => null);
8886
9302
  if (!srcStat) {
8887
9303
  warn(`Skipping ${path50.basename(src)}: source not found`);
8888
9304
  return "skipped-refused";
@@ -8891,7 +9307,7 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8891
9307
  warn(`Skipping ${path50.basename(src)}: source is a symlink`);
8892
9308
  return "skipped-refused";
8893
9309
  }
8894
- const exists = await fs38.pathExists(dest);
9310
+ const exists = await fs40.pathExists(dest);
8895
9311
  if (exists && !opts.force) {
8896
9312
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
8897
9313
  return "skipped-exists";
@@ -8901,16 +9317,16 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8901
9317
  return "scaffolded";
8902
9318
  }
8903
9319
  if (exists && opts.force) {
8904
- await fs38.remove(dest);
9320
+ await fs40.remove(dest);
8905
9321
  }
8906
- await fs38.ensureDir(path50.dirname(dest));
8907
- await fs38.copy(src, dest, {
9322
+ await fs40.ensureDir(path50.dirname(dest));
9323
+ await fs40.copy(src, dest, {
8908
9324
  overwrite: true,
8909
9325
  dereference: false,
8910
9326
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
8911
9327
  filter: (s) => {
8912
9328
  try {
8913
- return !fs38.lstatSync(s).isSymbolicLink();
9329
+ return !fs40.lstatSync(s).isSymbolicLink();
8914
9330
  } catch {
8915
9331
  return false;
8916
9332
  }
@@ -9432,6 +9848,61 @@ async function runGuard(kind, _options) {
9432
9848
  // src/claude/setup-core.ts
9433
9849
  init_github_rate_limit();
9434
9850
 
9851
+ // src/claude/project-exclusions.ts
9852
+ import fs41 from "fs-extra";
9853
+ init_project_pin();
9854
+ init_fs();
9855
+ init_logger();
9856
+ init_paths();
9857
+ function normalizeExcludedIds(value) {
9858
+ if (!Array.isArray(value)) return [];
9859
+ return [
9860
+ ...new Set(
9861
+ value.filter((entry) => typeof entry === "string" && entry.length > 0)
9862
+ )
9863
+ ];
9864
+ }
9865
+ async function readProjectExcludedIds(root) {
9866
+ try {
9867
+ const data = await readJson(hausPath(root, PROJECT_CONFIG_FILE));
9868
+ if (!data) return [];
9869
+ return normalizeExcludedIds(data.excludedIds);
9870
+ } catch (err) {
9871
+ if (err.code === "ENOENT") return [];
9872
+ warn(
9873
+ `${PROJECT_CONFIG_REL} could not be read for excludedIds (${err instanceof Error ? err.message : String(err)}) \u2014 treating as none`
9874
+ );
9875
+ return [];
9876
+ }
9877
+ }
9878
+ function isItemExcluded(item, excluded) {
9879
+ if (excluded.has(item.id)) return true;
9880
+ return normalizeFormerIds(item.formerIds).some((formerId) => excluded.has(formerId));
9881
+ }
9882
+ async function addProjectExcludedIds(root, ids) {
9883
+ const toAdd = [...new Set(ids.filter((id) => id.length > 0))];
9884
+ if (toAdd.length === 0) return;
9885
+ const file = hausPath(root, PROJECT_CONFIG_FILE);
9886
+ let existing = {};
9887
+ if (await fs41.pathExists(file)) {
9888
+ try {
9889
+ const parsed = JSON.parse(await fs41.readFile(file, "utf8"));
9890
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
9891
+ existing = { ...parsed };
9892
+ } else {
9893
+ warn(`${PROJECT_CONFIG_REL} is not a JSON object \u2014 rewriting with excludedIds only`);
9894
+ }
9895
+ } catch (err) {
9896
+ warn(
9897
+ `${PROJECT_CONFIG_REL} could not be parsed (${err instanceof Error ? err.message : String(err)}) \u2014 rewriting with excludedIds only`
9898
+ );
9899
+ }
9900
+ }
9901
+ const merged = normalizeExcludedIds(existing.excludedIds);
9902
+ const next = [.../* @__PURE__ */ new Set([...merged, ...toAdd])].sort();
9903
+ await writeJson(file, { ...existing, excludedIds: next });
9904
+ }
9905
+
9435
9906
  // src/recommender/recommend.ts
9436
9907
  init_fs();
9437
9908
  init_paths();
@@ -9478,6 +9949,7 @@ async function readChangedFiles(root) {
9478
9949
  async function recommend(root, context, opts = {}) {
9479
9950
  const activePluginIds = opts.activePluginIds ?? [];
9480
9951
  const items = await loadCatalog(root);
9952
+ const excludedIds = await readProjectExcludedIds(root);
9481
9953
  const sources = buildSourcesReport(items);
9482
9954
  const deep = await readJson(hausPath(root, "deep-context.json")) ?? {};
9483
9955
  const scannerStacks = buildStackSet(context);
@@ -9687,6 +10159,7 @@ async function recommend(root, context, opts = {}) {
9687
10159
  }
9688
10160
  }
9689
10161
  applyCoInstallSuppression(recommended, skipped);
10162
+ applyManualExcludes(excludedIds, items, recommended, skipped);
9690
10163
  const includeWarnings = applyManualIncludes(opts.include ?? [], items, recommended, skipped);
9691
10164
  const optInEligible = buildOptInEligible(items, skipped);
9692
10165
  recommended.sort((a, b) => a.id.localeCompare(b.id));
@@ -9715,6 +10188,29 @@ function buildStackSet(context) {
9715
10188
  )
9716
10189
  );
9717
10190
  }
10191
+ function applyManualExcludes(excludedIds, items, recommended, skipped) {
10192
+ if (excludedIds.length === 0) return;
10193
+ const excluded = new Set(excludedIds);
10194
+ const byId = new Map(items.map((item) => [item.id, item]));
10195
+ for (let i = recommended.length - 1; i >= 0; i--) {
10196
+ const entry = recommended[i];
10197
+ const item = byId.get(entry.id);
10198
+ if (!item || !isItemExcluded(item, excluded)) continue;
10199
+ recommended.splice(i, 1);
10200
+ skipped.push({
10201
+ id: entry.id,
10202
+ reason: "manually excluded",
10203
+ skipReasons: [
10204
+ {
10205
+ code: "manual-exclude",
10206
+ message: "manually excluded via haus remove",
10207
+ signal: "selection:manual-exclude"
10208
+ }
10209
+ ],
10210
+ ...entry.gates ? { gates: entry.gates } : {}
10211
+ });
10212
+ }
10213
+ }
9718
10214
  function applyCoInstallSuppression(recommended, skipped) {
9719
10215
  const recommendedIds = new Set(recommended.map((r) => r.id));
9720
10216
  const rules = [
@@ -10087,7 +10583,12 @@ async function runSetupCore(root, opts) {
10087
10583
  recommendedCount: 0,
10088
10584
  warnings,
10089
10585
  hooksOk: false,
10090
- written: []
10586
+ written: [],
10587
+ // Pre-existing zero-signal guard, not the #325 catalog-install honesty check —
10588
+ // `catalogInstallOk` stays true; `setupOk` is false so workspace setup cannot
10589
+ // record this repo as ok (issue #330).
10590
+ catalogInstallOk: true,
10591
+ setupOk: false
10091
10592
  };
10092
10593
  }
10093
10594
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
@@ -10116,7 +10617,9 @@ async function runSetupCore(root, opts) {
10116
10617
  recommendedCount: recommendation.recommended.length,
10117
10618
  warnings: warningLines,
10118
10619
  hooksOk,
10119
- written: []
10620
+ written: [],
10621
+ catalogInstallOk: true,
10622
+ setupOk: true
10120
10623
  };
10121
10624
  const envelope = (hooksResult) => ({
10122
10625
  scan: scanResult,
@@ -10145,7 +10648,7 @@ async function runSetupCore(root, opts) {
10145
10648
  error(sync.formatRefusal);
10146
10649
  process.exitCode = 1;
10147
10650
  emitJsonEnvelope(json, quiet, envelope(hooks));
10148
- return baseResult;
10651
+ return { ...baseResult, setupOk: false };
10149
10652
  }
10150
10653
  if (sync.newItems.length > 0) {
10151
10654
  say(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
@@ -10156,12 +10659,66 @@ async function runSetupCore(root, opts) {
10156
10659
  warn(formatGithubRateLimitMessage(sync.rateLimit));
10157
10660
  }
10158
10661
  }
10662
+ const isPreview = dryRun ?? false;
10663
+ const wantedIds = installableRecommendedItems(recommendation.recommended).map((item) => item.id);
10664
+ let catalogCtx = null;
10665
+ let resolvedWantedIds = wantedIds;
10666
+ if (wantedIds.length > 0) {
10667
+ catalogCtx = await loadCatalogContext(root);
10668
+ const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
10669
+ const migrations = findFormerIdMigrations(
10670
+ prevLock.filter((entry) => Boolean(entry.id)),
10671
+ catalogCtx.items
10672
+ );
10673
+ const migrationByOldId = new Map(migrations.map((m) => [m.oldId, m.newId]));
10674
+ resolvedWantedIds = wantedIds.map((id) => migrationByOldId.get(id) ?? id);
10675
+ const shortfall = await countInstallableItems(catalogCtx, resolvedWantedIds);
10676
+ const tier = `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}`;
10677
+ if (shortfall.installable === 0) {
10678
+ const message2 = "No catalog content found. Run `haus update` first.";
10679
+ if (!opts.allowEmptyCache) {
10680
+ if (isPreview) {
10681
+ warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message2}`);
10682
+ } else {
10683
+ error(message2);
10684
+ process.exitCode = 1;
10685
+ emitJsonEnvelope(json, quiet, envelope(hooks));
10686
+ return { ...baseResult, catalogInstallOk: false, setupOk: false };
10687
+ }
10688
+ } else {
10689
+ warn(
10690
+ `Serving catalog metadata from the ${tier} tier, which has no installable content. Core files will be written; all ${wantedIds.length} catalog item(s) will be skipped.`
10691
+ );
10692
+ }
10693
+ } else if (shortfall.missing.length > 0) {
10694
+ warn(
10695
+ `The ${tier} catalog tier is missing content for ${shortfall.missing.length} item(s): ${shortfall.missing.join(", ")}. They will be skipped.`
10696
+ );
10697
+ }
10698
+ }
10159
10699
  const files = await writeClaudeFiles(root, dryRun ?? false, void 0, {
10160
10700
  quiet: opts.quiet,
10161
10701
  activePluginIds
10162
10702
  });
10163
10703
  say("Applied files:");
10164
10704
  files.forEach((f) => say(`- ${displayPath(root, f)}`));
10705
+ let catalogInstallOk = true;
10706
+ if (!isPreview && resolvedWantedIds.length > 0) {
10707
+ const { installedCount: installedWantedCount, missingWantedIds } = await recountInstalledAgainstLock(root, resolvedWantedIds);
10708
+ if (missingWantedIds.length > 0) {
10709
+ const tier = catalogCtx ? `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}` : "unknown";
10710
+ say(
10711
+ `${installedWantedCount} of ${resolvedWantedIds.length} catalog items installed (${tier} tier).`
10712
+ );
10713
+ if (installedWantedCount === 0) {
10714
+ error(
10715
+ "No catalog items were installed \u2014 only core files were written. Run `haus update` with network access, or import a catalog bundle."
10716
+ );
10717
+ process.exitCode = 1;
10718
+ catalogInstallOk = false;
10719
+ }
10720
+ }
10721
+ }
10165
10722
  const hooksAfter = await verifyProjectSettingsHooksContract(root);
10166
10723
  const hooksOkAfter = hooksAfter.ok;
10167
10724
  if (hooksAfter.skipped) {
@@ -10173,7 +10730,13 @@ async function runSetupCore(root, opts) {
10173
10730
  say(`- HOOKS OK: ${hooksAfter.message}`);
10174
10731
  }
10175
10732
  emitJsonEnvelope(json, quiet, envelope(hooksAfter));
10176
- return { ...baseResult, hooksOk: hooksOkAfter, written: files };
10733
+ return {
10734
+ ...baseResult,
10735
+ hooksOk: hooksOkAfter,
10736
+ written: files,
10737
+ catalogInstallOk,
10738
+ setupOk: catalogInstallOk
10739
+ };
10177
10740
  }
10178
10741
 
10179
10742
  // src/commands/init.ts
@@ -10193,6 +10756,7 @@ async function runInit(options) {
10193
10756
  apply: !dryRun,
10194
10757
  dryRun: false,
10195
10758
  force: options.force,
10759
+ allowEmptyCache: options.allowEmptyCache,
10196
10760
  confirm: () => confirm("Approve and write Claude files now?")
10197
10761
  });
10198
10762
  }
@@ -10323,7 +10887,7 @@ async function runInstall(options, deps = {}) {
10323
10887
 
10324
10888
  // src/new-project/create-project.ts
10325
10889
  import path53 from "node:path";
10326
- import fs39 from "fs-extra";
10890
+ import fs42 from "fs-extra";
10327
10891
  init_exec();
10328
10892
  init_logger();
10329
10893
 
@@ -10477,7 +11041,7 @@ async function createProject(opts, deps = defaultDeps) {
10477
11041
  `Invalid project name "${opts.name}" (lowercase letters, digits, . _ - only).`
10478
11042
  );
10479
11043
  }
10480
- if (await fs39.pathExists(projectPath)) {
11044
+ if (await fs42.pathExists(projectPath)) {
10481
11045
  return fail("preflight", `Target ${projectPath} already exists \u2014 refusing to write into it.`);
10482
11046
  }
10483
11047
  const scaffoldArgs = stack.scaffold.args.map(
@@ -10555,15 +11119,15 @@ async function createProject(opts, deps = defaultDeps) {
10555
11119
  steps.push({ step: "scaffold", status: "done" });
10556
11120
  phase = "git";
10557
11121
  if (!opts.json) log("\u25B6 git: init/branch/commit");
10558
- if (!await fs39.pathExists(path53.join(projectPath, ".git"))) {
11122
+ if (!await fs42.pathExists(path53.join(projectPath, ".git"))) {
10559
11123
  const init = await deps.git(["init"], { cwd: projectPath });
10560
11124
  if (init.exitCode !== 0) return fail("git", `git init failed: ${init.stderr}`);
10561
11125
  }
10562
11126
  const branch = await deps.git(["branch", "-M", "main"], { cwd: projectPath });
10563
11127
  if (branch.exitCode !== 0) return fail("git", `git branch -M main failed: ${branch.stderr}`);
10564
11128
  const lockfile = opts.pm === "yarn" ? "yarn.lock" : opts.pm === "pnpm" ? "pnpm-lock.yaml" : "package-lock.json";
10565
- if (!await fs39.pathExists(path53.join(projectPath, lockfile))) {
10566
- await fs39.writeFile(path53.join(projectPath, lockfile), "");
11129
+ if (!await fs42.pathExists(path53.join(projectPath, lockfile))) {
11130
+ await fs42.writeFile(path53.join(projectPath, lockfile), "");
10567
11131
  }
10568
11132
  const add = await deps.git(["add", "-A"], { cwd: projectPath });
10569
11133
  if (add.exitCode !== 0) return fail("git", `git add failed: ${add.stderr}`);
@@ -10598,13 +11162,15 @@ async function createProject(opts, deps = defaultDeps) {
10598
11162
  phase = "init";
10599
11163
  if (!opts.json) log("\u25B6 init: haus setup pipeline");
10600
11164
  const exitCodeBeforeSetup = process.exitCode;
11165
+ let setupResult;
10601
11166
  try {
10602
- await deps.setup(projectPath, {
11167
+ setupResult = await deps.setup(projectPath, {
10603
11168
  json: false,
10604
11169
  apply: true,
10605
11170
  dryRun: false,
10606
11171
  quiet: opts.json,
10607
- confirm: opts.yes ? async () => true : () => confirm("Approve and write Claude files now?")
11172
+ confirm: opts.yes ? async () => true : () => confirm("Approve and write Claude files now?"),
11173
+ allowEmptyCache: opts.allowEmptyCache
10608
11174
  });
10609
11175
  } catch (err) {
10610
11176
  steps.push({
@@ -10614,15 +11180,26 @@ async function createProject(opts, deps = defaultDeps) {
10614
11180
  });
10615
11181
  return { ok: false, projectPath, repoUrl, installHint, steps };
10616
11182
  }
11183
+ const catalogInstallRefused = setupResult?.catalogInstallOk === false;
11184
+ const retryCmd = catalogInstallRefused ? `cd ${opts.name} && haus init --allow-empty-cache` : `cd ${opts.name} && haus init`;
10617
11185
  if (process.exitCode === 1 && exitCodeBeforeSetup !== 1) {
10618
- steps.push({
10619
- step: "init",
10620
- status: "failed",
10621
- detail: `haus init reported failure. Project and repo are intact \u2014 cd ${opts.name} && haus init to retry.`
10622
- });
10623
- return { ok: false, projectPath, repoUrl, installHint, steps };
11186
+ if (opts.allowEmptyCache && catalogInstallRefused) {
11187
+ steps.push({
11188
+ step: "init",
11189
+ status: "done",
11190
+ detail: "core files written; catalog items skipped (--allow-empty-cache)"
11191
+ });
11192
+ } else {
11193
+ steps.push({
11194
+ step: "init",
11195
+ status: "failed",
11196
+ detail: `haus init reported failure. Project and repo are intact \u2014 ${retryCmd} to retry.`
11197
+ });
11198
+ return { ok: false, projectPath, repoUrl, installHint, steps };
11199
+ }
11200
+ } else {
11201
+ steps.push({ step: "init", status: "done" });
10624
11202
  }
10625
- steps.push({ step: "init", status: "done" });
10626
11203
  phase = "overlay";
10627
11204
  if (!opts.json) log("\u25B6 overlay: haus config items");
10628
11205
  let overlay;
@@ -10709,7 +11286,7 @@ async function createProject(opts, deps = defaultDeps) {
10709
11286
  }
10710
11287
  if (!opts.json) log(`\u2713 Created ${projectPath}${repoUrl ? ` \u2192 ${repoUrl}` : ""}
10711
11288
  ${installHint}`);
10712
- return { ok: true, projectPath, repoUrl, installHint, steps };
11289
+ return { ok: process.exitCode !== 1, projectPath, repoUrl, installHint, steps };
10713
11290
  }
10714
11291
 
10715
11292
  // src/commands/new.ts
@@ -10742,7 +11319,8 @@ function resolveNewOptions(stackArg, nameArg, flags, parentDir) {
10742
11319
  pm,
10743
11320
  yes: flags.yes === true,
10744
11321
  dryRun,
10745
- json: flags.json === true
11322
+ json: flags.json === true,
11323
+ allowEmptyCache: flags.allowEmptyCache === true
10746
11324
  }
10747
11325
  };
10748
11326
  }
@@ -10931,6 +11509,10 @@ async function runRemove(ids, options = {}) {
10931
11509
  root,
10932
11510
  cleared.map((c) => c.id)
10933
11511
  );
11512
+ await addProjectExcludedIds(
11513
+ root,
11514
+ cleared.map((c) => c.id)
11515
+ );
10934
11516
  try {
10935
11517
  const remaining = await readJson(hausPath(root, "haus.lock.json")) ?? [];
10936
11518
  const remainingIds = new Set(remaining.map((e) => e.id));
@@ -10946,7 +11528,6 @@ async function runRemove(ids, options = {}) {
10946
11528
  }
10947
11529
  if (!options.json) {
10948
11530
  log(`Removed ${report.removed.length} item(s). Restore with \`haus backups list\`.`);
10949
- log("Note: a later `haus apply --write` reinstalls any of these still recommended.");
10950
11531
  }
10951
11532
  emitReport(report, options.json);
10952
11533
  }
@@ -11009,7 +11590,7 @@ async function runScan(options) {
11009
11590
 
11010
11591
  // src/commands/undo.ts
11011
11592
  import path55 from "node:path";
11012
- import fs40 from "fs-extra";
11593
+ import fs43 from "fs-extra";
11013
11594
 
11014
11595
  // src/claude/managed-paths.ts
11015
11596
  init_paths();
@@ -11028,7 +11609,7 @@ init_paths();
11028
11609
  async function collectCoreManagedPaths(root) {
11029
11610
  const existing = [];
11030
11611
  for (const abs of coreManagedAbsolutePaths(root)) {
11031
- if (await fs40.pathExists(abs)) existing.push(abs);
11612
+ if (await fs43.pathExists(abs)) existing.push(abs);
11032
11613
  }
11033
11614
  return existing;
11034
11615
  }
@@ -11037,41 +11618,35 @@ async function collectLockTrackedPaths(root) {
11037
11618
  const removable = [];
11038
11619
  const preserved = [];
11039
11620
  for (const row of lock ?? []) {
11040
- const relPaths = row.paths ?? [];
11041
- const existingRel = [];
11042
- for (const rel of relPaths) {
11043
- if (await fs40.pathExists(path55.resolve(root, rel))) existingRel.push(rel);
11044
- }
11045
- if (existingRel.length === 0) continue;
11046
- if (row.hash === void 0) {
11047
- removable.push(...existingRel.map((rel) => path55.resolve(root, rel)));
11621
+ const id = row.id ?? "(unknown item)";
11622
+ const existing = await resolveExistingTrackedPaths(root, id, row.paths ?? [], log);
11623
+ if (existing.length === 0) continue;
11624
+ const gate = await hashGateEntry(root, { hash: row.hash }, existing);
11625
+ if (!gate.ok) {
11626
+ preserved.push(...existing.map((e) => e.abs));
11627
+ const detail = gate.reason === "no-hash" ? "has no lock hash" : "was modified locally";
11628
+ log(`Preserving ${id} (${detail}): ${existing.map((e) => e.rel).join(", ")}`);
11048
11629
  continue;
11049
11630
  }
11050
- const currentHash = await hashInstalledPaths(root, existingRel);
11051
- if (currentHash === row.hash) {
11052
- removable.push(...existingRel.map((rel) => path55.resolve(root, rel)));
11053
- } else {
11054
- preserved.push(...existingRel.map((rel) => path55.resolve(root, rel)));
11055
- log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
11056
- }
11631
+ removable.push({ id, existing });
11057
11632
  }
11058
11633
  return { removable, preserved };
11059
11634
  }
11060
11635
  async function settingsHasHausContent(root) {
11061
11636
  const settingsPath = claudePath(root, "settings.json");
11062
- if (!await fs40.pathExists(settingsPath)) return false;
11637
+ if (!await fs43.pathExists(settingsPath)) return false;
11063
11638
  const settings = await readProjectSettings(root);
11064
11639
  return settings._haus != null;
11065
11640
  }
11066
11641
  async function claudeMdHasHausBlock(root) {
11067
11642
  const filePath = path55.join(root, "CLAUDE.md");
11068
- if (!await fs40.pathExists(filePath)) return false;
11069
- const text = await fs40.readFile(filePath, "utf8");
11643
+ if (!await fs43.pathExists(filePath)) return false;
11644
+ const text = await fs43.readFile(filePath, "utf8");
11070
11645
  return text.includes(BLOCK_BEGIN);
11071
11646
  }
11072
11647
  async function stripProjectSettings(root) {
11073
11648
  const settingsPath = claudePath(root, "settings.json");
11074
- if (!await fs40.pathExists(settingsPath)) return false;
11649
+ if (!await fs43.pathExists(settingsPath)) return false;
11075
11650
  let settings = await readProjectSettings(root);
11076
11651
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
11077
11652
  const hasContent = Object.keys(settings).length > 0;
@@ -11080,43 +11655,43 @@ async function stripProjectSettings(root) {
11080
11655
  log(`Stripped haus rules from ${path55.relative(root, settingsPath)} (user settings preserved).`);
11081
11656
  return true;
11082
11657
  }
11083
- await fs40.remove(settingsPath);
11658
+ await fs43.remove(settingsPath);
11084
11659
  log(`Removed ${path55.relative(root, settingsPath)} (no user-owned settings remained).`);
11085
11660
  return true;
11086
11661
  }
11087
11662
  async function stripRootClaudeMd(root) {
11088
11663
  const filePath = path55.join(root, "CLAUDE.md");
11089
- if (!await fs40.pathExists(filePath)) return false;
11090
- const prev = await fs40.readFile(filePath, "utf8");
11664
+ if (!await fs43.pathExists(filePath)) return false;
11665
+ const prev = await fs43.readFile(filePath, "utf8");
11091
11666
  if (!prev.includes(BLOCK_BEGIN)) return false;
11092
11667
  const next = stripHausBlock(prev);
11093
11668
  if (next.length === 0) {
11094
- await fs40.remove(filePath);
11669
+ await fs43.remove(filePath);
11095
11670
  log("Removed CLAUDE.md (only contained haus import block).");
11096
11671
  } else {
11097
- await fs40.writeFile(filePath, next, "utf8");
11672
+ await fs43.writeFile(filePath, next, "utf8");
11098
11673
  log("Removed haus import block from CLAUDE.md (user content preserved).");
11099
11674
  }
11100
11675
  return true;
11101
11676
  }
11102
11677
  async function pruneDirIfEmpty(dir) {
11103
- if (!await fs40.pathExists(dir)) return;
11678
+ if (!await fs43.pathExists(dir)) return;
11104
11679
  try {
11105
- const entries = await fs40.readdir(dir);
11106
- if (entries.length === 0) await fs40.remove(dir);
11680
+ const entries = await fs43.readdir(dir);
11681
+ if (entries.length === 0) await fs43.remove(dir);
11107
11682
  } catch {
11108
11683
  }
11109
11684
  }
11110
11685
  async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
11111
11686
  if (managedAbsPaths.length === 0) return;
11112
11687
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
11113
- const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
11688
+ const backupRoot = hausPath(root, "backups", dirBackupDirName("undo", stamp));
11114
11689
  for (const abs of managedAbsPaths) {
11115
- if (!await fs40.pathExists(abs)) continue;
11690
+ if (!await fs43.pathExists(abs)) continue;
11116
11691
  const rel = path55.relative(root, abs);
11117
11692
  const backupPath = path55.join(backupRoot, rel);
11118
- await fs40.ensureDir(path55.dirname(backupPath));
11119
- await fs40.copy(abs, backupPath);
11693
+ await fs43.ensureDir(path55.dirname(backupPath));
11694
+ await fs43.copy(abs, backupPath);
11120
11695
  }
11121
11696
  log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path55.relative(root, backupRoot)}.`);
11122
11697
  }
@@ -11124,7 +11699,8 @@ async function runUndo(options) {
11124
11699
  const root = options.root ?? process.cwd();
11125
11700
  const coreManaged = await collectCoreManagedPaths(root);
11126
11701
  const { removable: lockRemovable, preserved: lockPreserved } = await collectLockTrackedPaths(root);
11127
- const managed = [.../* @__PURE__ */ new Set([...coreManaged, ...lockRemovable])];
11702
+ const lockAbs = lockRemovable.flatMap(({ existing }) => existing.map((e) => e.abs));
11703
+ const managed = [.../* @__PURE__ */ new Set([...coreManaged, ...lockAbs])];
11128
11704
  const stripSettings = await settingsHasHausContent(root);
11129
11705
  const stripClaudeMd = await claudeMdHasHausBlock(root);
11130
11706
  if (managed.length === 0 && !stripSettings && !stripClaudeMd) {
@@ -11136,9 +11712,7 @@ async function runUndo(options) {
11136
11712
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
11137
11713
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
11138
11714
  if (lockPreserved.length > 0) {
11139
- summaryParts.push(
11140
- `(preserving ${lockPreserved.length} locally-modified catalog item file(s) \u2014 not shown above)`
11141
- );
11715
+ summaryParts.push(`(preserving ${lockPreserved.length} catalog item file(s) \u2014 not shown above)`);
11142
11716
  }
11143
11717
  if (options.dryRun) {
11144
11718
  log(`[dry-run] Would remove:
@@ -11158,11 +11732,14 @@ User-owned .claude/ files will be preserved.`
11158
11732
  }
11159
11733
  }
11160
11734
  await backupManagedFilesBeforeUndo(root, managed);
11161
- for (const abs of managed) {
11162
- if (!await fs40.pathExists(abs)) continue;
11163
- await fs40.remove(abs);
11735
+ for (const abs of coreManaged) {
11736
+ if (!await fs43.pathExists(abs)) continue;
11737
+ await fs43.remove(abs);
11164
11738
  log(`Removed ${path55.relative(root, abs)}`);
11165
11739
  }
11740
+ for (const { id, existing } of lockRemovable) {
11741
+ await deleteTrackedPaths(root, id, existing, { verb: "Removed" }, log);
11742
+ }
11166
11743
  if (stripSettings) await stripProjectSettings(root);
11167
11744
  if (stripClaudeMd) await stripRootClaudeMd(root);
11168
11745
  await pruneDirIfEmpty(claudePath(root));
@@ -11176,7 +11753,7 @@ init_logger();
11176
11753
  init_paths();
11177
11754
  import crypto3 from "node:crypto";
11178
11755
  import path56 from "node:path";
11179
- import fs41 from "fs-extra";
11756
+ import fs44 from "fs-extra";
11180
11757
  async function runUninstall(options = {}) {
11181
11758
  const { force = false, removePlugins = removeGlobalPlugins } = options;
11182
11759
  const manifest = await readManifest();
@@ -11213,7 +11790,7 @@ async function runUninstall(options = {}) {
11213
11790
  );
11214
11791
  }
11215
11792
  for (const entry of manifest.files) {
11216
- const exists = fs41.pathExistsSync(entry.destPath);
11793
+ const exists = fs44.pathExistsSync(entry.destPath);
11217
11794
  if (!exists) continue;
11218
11795
  const content2 = await readText(entry.destPath);
11219
11796
  if (content2 === void 0) continue;
@@ -11231,7 +11808,7 @@ async function runUninstall(options = {}) {
11231
11808
  result.skipped.push(entry.destPath);
11232
11809
  continue;
11233
11810
  }
11234
- await fs41.remove(entry.destPath);
11811
+ await fs44.remove(entry.destPath);
11235
11812
  await pruneEmptyDir(path56.dirname(entry.destPath));
11236
11813
  result.deleted.push(entry.destPath);
11237
11814
  }
@@ -11241,13 +11818,13 @@ async function runUninstall(options = {}) {
11241
11818
  result.hooksStripped = true;
11242
11819
  const hausDir = path56.join(globalClaudeDir(), "haus");
11243
11820
  const manifestPath2 = hausManifestPath();
11244
- if (fs41.pathExistsSync(manifestPath2)) {
11245
- await fs41.remove(manifestPath2);
11821
+ if (fs44.pathExistsSync(manifestPath2)) {
11822
+ await fs44.remove(manifestPath2);
11246
11823
  }
11247
- if (fs41.pathExistsSync(hausDir)) {
11824
+ if (fs44.pathExistsSync(hausDir)) {
11248
11825
  try {
11249
- const remaining = await fs41.readdir(hausDir);
11250
- if (remaining.length === 0) await fs41.remove(hausDir);
11826
+ const remaining = await fs44.readdir(hausDir);
11827
+ if (remaining.length === 0) await fs44.remove(hausDir);
11251
11828
  } catch {
11252
11829
  }
11253
11830
  }
@@ -11323,7 +11900,7 @@ init_forbidden_content();
11323
11900
  init_manifest_item_fields();
11324
11901
  init_path_safety();
11325
11902
  init_validation_rules2();
11326
- import fs42 from "node:fs";
11903
+ import fs45 from "node:fs";
11327
11904
  import path57 from "node:path";
11328
11905
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
11329
11906
  var ITEM_ID_PATTERN = /^haus\./;
@@ -11401,11 +11978,11 @@ function auditOptInMetadata(items) {
11401
11978
  }
11402
11979
  return failures;
11403
11980
  }
11404
- function auditManifestStructure(manifestVersion, items) {
11981
+ function auditManifestStructure(manifestVersion2, items) {
11405
11982
  const failures = [];
11406
11983
  const seenIds = /* @__PURE__ */ new Map();
11407
11984
  const seenPaths = /* @__PURE__ */ new Map();
11408
- if (typeof manifestVersion !== "string" || !ITEM_SEMVER_RE.test(manifestVersion)) {
11985
+ if (typeof manifestVersion2 !== "string" || !ITEM_SEMVER_RE.test(manifestVersion2)) {
11409
11986
  failures.push('manifest.json: top-level "version" is not valid semver (expected X.Y.Z)');
11410
11987
  }
11411
11988
  for (let i = 0; i < items.length; i++) {
@@ -11468,7 +12045,7 @@ function checkRequiredFrontmatter(text, label) {
11468
12045
  }
11469
12046
  function readForAudit(absPath, label, failures) {
11470
12047
  try {
11471
- return fs42.readFileSync(absPath, "utf8");
12048
+ return fs45.readFileSync(absPath, "utf8");
11472
12049
  } catch (err) {
11473
12050
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
11474
12051
  return null;
@@ -11500,7 +12077,7 @@ function auditShippedFiles(manifestDir, items) {
11500
12077
  const absPath = path57.join(manifestDir, item.path);
11501
12078
  if (item.type === "skill") {
11502
12079
  const skillMd = path57.join(absPath, "SKILL.md");
11503
- if (!fs42.existsSync(skillMd)) {
12080
+ if (!fs45.existsSync(skillMd)) {
11504
12081
  failures.push(`${item.id}: missing ${path57.relative(manifestDir, skillMd)}`);
11505
12082
  continue;
11506
12083
  }
@@ -11511,7 +12088,7 @@ function auditShippedFiles(manifestDir, items) {
11511
12088
  ...auditForbiddenTagsInText(text, `${item.id}: ${path57.relative(manifestDir, skillMd)}`)
11512
12089
  );
11513
12090
  } else if (item.type === "agent") {
11514
- if (!fs42.existsSync(absPath)) {
12091
+ if (!fs45.existsSync(absPath)) {
11515
12092
  failures.push(`${item.id}: missing agent file ${item.path}`);
11516
12093
  continue;
11517
12094
  }
@@ -11521,13 +12098,13 @@ function auditShippedFiles(manifestDir, items) {
11521
12098
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
11522
12099
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
11523
12100
  } else if (item.type === "template") {
11524
- if (!fs42.existsSync(absPath)) {
12101
+ if (!fs45.existsSync(absPath)) {
11525
12102
  failures.push(`${item.id}: missing template file ${item.path}`);
11526
12103
  continue;
11527
12104
  }
11528
12105
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
11529
12106
  } else if (item.type === "command") {
11530
- if (!fs42.existsSync(absPath)) {
12107
+ if (!fs45.existsSync(absPath)) {
11531
12108
  failures.push(`${item.id}: missing command file ${item.path}`);
11532
12109
  continue;
11533
12110
  }
@@ -11537,15 +12114,15 @@ function auditShippedFiles(manifestDir, items) {
11537
12114
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
11538
12115
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
11539
12116
  } else if (item.type === "config") {
11540
- if (!fs42.existsSync(absPath)) {
12117
+ if (!fs45.existsSync(absPath)) {
11541
12118
  failures.push(`${item.id}: missing config path ${item.path}`);
11542
12119
  }
11543
12120
  } else if (item.type === "rule") {
11544
- if (!fs42.existsSync(absPath)) {
12121
+ if (!fs45.existsSync(absPath)) {
11545
12122
  failures.push(`${item.id}: missing rule file ${item.path}`);
11546
12123
  }
11547
12124
  } else if (item.type === "asset") {
11548
- if (!fs42.existsSync(absPath)) {
12125
+ if (!fs45.existsSync(absPath)) {
11549
12126
  failures.push(`${item.id}: missing asset file ${item.path}`);
11550
12127
  }
11551
12128
  }
@@ -11580,7 +12157,7 @@ function resolveItemType(relPath, pathTypeMap) {
11580
12157
  function walkAllFiles(dir, fn, failures) {
11581
12158
  let entries;
11582
12159
  try {
11583
- entries = fs42.readdirSync(dir, { withFileTypes: true });
12160
+ entries = fs45.readdirSync(dir, { withFileTypes: true });
11584
12161
  } catch (err) {
11585
12162
  failures.push(
11586
12163
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -11614,7 +12191,7 @@ function auditDiskOrphans(manifestDir, items) {
11614
12191
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
11615
12192
  for (const dir of CONTENT_ROOTS) {
11616
12193
  const abs = path57.join(manifestDir, dir);
11617
- if (!fs42.existsSync(abs)) continue;
12194
+ if (!fs45.existsSync(abs)) continue;
11618
12195
  walkAllFiles(
11619
12196
  abs,
11620
12197
  (file) => {
@@ -11631,7 +12208,7 @@ function auditDiskOrphans(manifestDir, items) {
11631
12208
  function walkMd(dir, fn, failures) {
11632
12209
  let entries;
11633
12210
  try {
11634
- entries = fs42.readdirSync(dir, { withFileTypes: true });
12211
+ entries = fs45.readdirSync(dir, { withFileTypes: true });
11635
12212
  } catch (err) {
11636
12213
  failures.push(
11637
12214
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -11650,7 +12227,7 @@ function auditMarkdownContent(manifestDir, items) {
11650
12227
  const pathTypeMap = buildItemPathTypeMap(items);
11651
12228
  for (const dir of ["skills", "agents", "templates", "commands", "sources"]) {
11652
12229
  const abs = path57.join(manifestDir, dir);
11653
- if (!fs42.existsSync(abs)) continue;
12230
+ if (!fs45.existsSync(abs)) continue;
11654
12231
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
11655
12232
  walkMd(
11656
12233
  abs,
@@ -11678,9 +12255,9 @@ function auditMarkdownContent(manifestDir, items) {
11678
12255
  }
11679
12256
  return failures;
11680
12257
  }
11681
- function validateCatalogData(manifestDir, manifestVersion, items) {
12258
+ function validateCatalogData(manifestDir, manifestVersion2, items) {
11682
12259
  const failures = [
11683
- ...auditManifestStructure(manifestVersion, items),
12260
+ ...auditManifestStructure(manifestVersion2, items),
11684
12261
  ...validateFormerIds(items),
11685
12262
  ...auditOptInMetadata(items),
11686
12263
  ...auditForbiddenStacks(items),
@@ -11726,7 +12303,7 @@ init_logger();
11726
12303
  // src/workspace/worktree/add.ts
11727
12304
  init_fs();
11728
12305
  import path65 from "node:path";
11729
- import fs46 from "fs-extra";
12306
+ import fs49 from "fs-extra";
11730
12307
 
11731
12308
  // src/workspace/worktree/git-worktree.ts
11732
12309
  init_exec();
@@ -11847,12 +12424,12 @@ async function hasUnpushedWork(cwd) {
11847
12424
 
11848
12425
  // src/workspace/worktree/hydrate.ts
11849
12426
  import path61 from "node:path";
11850
- import fs44 from "fs-extra";
12427
+ import fs47 from "fs-extra";
11851
12428
 
11852
12429
  // src/workspace/worktree/cow-copy.ts
11853
12430
  init_exec();
11854
12431
  import path59 from "node:path";
11855
- import fs43 from "fs-extra";
12432
+ import fs46 from "fs-extra";
11856
12433
  async function getMacFilesystemType(dir) {
11857
12434
  const df = await runCommand("df", ["-P", dir]);
11858
12435
  if (df.exitCode !== 0) return void 0;
@@ -11883,14 +12460,14 @@ async function detectCowStrategy(dir) {
11883
12460
  return "unknown-platform";
11884
12461
  }
11885
12462
  async function cowCopyDir(src, dest) {
11886
- const srcExists = await fs43.pathExists(src);
12463
+ const srcExists = await fs46.pathExists(src);
11887
12464
  if (!srcExists)
11888
12465
  return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
11889
12466
  const strategy = await detectCowStrategy(path59.dirname(dest));
11890
12467
  if (strategy === "unsupported" || strategy === "unknown-platform") {
11891
12468
  return { strategy, attempted: false, ok: false };
11892
12469
  }
11893
- await fs43.ensureDir(path59.dirname(dest));
12470
+ await fs46.ensureDir(path59.dirname(dest));
11894
12471
  const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
11895
12472
  const result = await runCommand("cp", args);
11896
12473
  return {
@@ -12077,11 +12654,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
12077
12654
  for (const target of HYDRATION_TARGETS) {
12078
12655
  const src = path61.join(mainMemberPath, target);
12079
12656
  const dest = path61.join(worktreeMemberPath, target);
12080
- if (!await fs44.pathExists(src)) continue;
12081
- const destExists = await fs44.pathExists(dest);
12657
+ if (!await fs47.pathExists(src)) continue;
12658
+ const destExists = await fs47.pathExists(dest);
12082
12659
  if (!opts.force && destExists) continue;
12083
12660
  if (opts.force && destExists) {
12084
- await fs44.remove(dest);
12661
+ await fs47.remove(dest);
12085
12662
  }
12086
12663
  const copy = await cowCopyDir(src, dest);
12087
12664
  targets.push({ target, copy });
@@ -12095,7 +12672,7 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
12095
12672
  // src/workspace/worktree/local-files.ts
12096
12673
  init_exec();
12097
12674
  import path62 from "node:path";
12098
- import fs45 from "fs-extra";
12675
+ import fs48 from "fs-extra";
12099
12676
  var MACHINE_LOCAL_FILES = [
12100
12677
  "docker-compose.local.yml",
12101
12678
  path62.join(".claude", "settings.local.json")
@@ -12109,11 +12686,11 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
12109
12686
  const copied = [];
12110
12687
  for (const rel of MACHINE_LOCAL_FILES) {
12111
12688
  const src = path62.join(mainMemberPath, rel);
12112
- if (!await fs45.pathExists(src)) continue;
12689
+ if (!await fs48.pathExists(src)) continue;
12113
12690
  if (await isTracked(mainMemberPath, rel)) continue;
12114
12691
  const dest = path62.join(worktreeMemberPath, rel);
12115
- await fs45.ensureDir(path62.dirname(dest));
12116
- await fs45.copy(src, dest, { overwrite: true });
12692
+ await fs48.ensureDir(path62.dirname(dest));
12693
+ await fs48.copy(src, dest, { overwrite: true });
12117
12694
  copied.push(rel);
12118
12695
  }
12119
12696
  return copied;
@@ -12194,7 +12771,7 @@ async function runAdd(opts) {
12194
12771
  }
12195
12772
  const branch = opts.branch ?? opts.slug;
12196
12773
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
12197
- if (await fs46.pathExists(wsWorktreePath)) {
12774
+ if (await fs49.pathExists(wsWorktreePath)) {
12198
12775
  return {
12199
12776
  ok: false,
12200
12777
  error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate --materialize\` from inside it to check out any missing members (e.g. a worktree created by plain \`git worktree add\`), or \`remove\` first to start over.`
@@ -12563,14 +13140,14 @@ async function runHydrateWorktree(opts) {
12563
13140
 
12564
13141
  // src/workspace/worktree/list.ts
12565
13142
  import path69 from "node:path";
12566
- import fs47 from "fs-extra";
13143
+ import fs50 from "fs-extra";
12567
13144
  async function runList2() {
12568
13145
  const resolved = await resolveWorkspaceForWorktree();
12569
13146
  if (!resolved.ok) return { ok: false, error: resolved.reason };
12570
13147
  const { rootInfo, workspaceRoot } = resolved;
12571
13148
  const dir = worktreesDir(workspaceRoot);
12572
- if (!await fs47.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
12573
- const entries = await fs47.readdir(dir, { withFileTypes: true });
13149
+ if (!await fs50.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
13150
+ const entries = await fs50.readdir(dir, { withFileTypes: true });
12574
13151
  const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
12575
13152
  const configuredMembers = await readMembers(rootInfo);
12576
13153
  const worktrees = [];
@@ -12581,7 +13158,7 @@ async function runList2() {
12581
13158
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
12582
13159
  for (const m of source) {
12583
13160
  const memberPath = path69.join(wtPath, m.folder);
12584
- const materialized = await fs47.pathExists(memberPath);
13161
+ const materialized = await fs50.pathExists(memberPath);
12585
13162
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
12586
13163
  members.push({
12587
13164
  id: m.id,
@@ -12643,13 +13220,13 @@ async function runPrune2(opts) {
12643
13220
 
12644
13221
  // src/workspace/worktree/remove.ts
12645
13222
  import path70 from "node:path";
12646
- import fs48 from "fs-extra";
13223
+ import fs51 from "fs-extra";
12647
13224
  async function runRemove2(opts) {
12648
13225
  const resolved = await resolveWorkspaceForWorktree();
12649
13226
  if (!resolved.ok) return { ok: false, error: resolved.reason };
12650
13227
  const { rootInfo, workspaceRoot } = resolved;
12651
13228
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
12652
- if (!await fs48.pathExists(wsWorktreePath)) {
13229
+ if (!await fs51.pathExists(wsWorktreePath)) {
12653
13230
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
12654
13231
  }
12655
13232
  const state = await readWorktreeState(wsWorktreePath);
@@ -12674,7 +13251,7 @@ async function runRemove2(opts) {
12674
13251
  }
12675
13252
  const existingTargets = [];
12676
13253
  for (const target of targets) {
12677
- if (await fs48.pathExists(target.path)) existingTargets.push(target);
13254
+ if (await fs51.pathExists(target.path)) existingTargets.push(target);
12678
13255
  }
12679
13256
  if (!opts.force) {
12680
13257
  const blockers = [];
@@ -12730,8 +13307,8 @@ async function runRemove2(opts) {
12730
13307
  }
12731
13308
  await pruneWorktrees(workspaceRoot);
12732
13309
  }
12733
- if (wsRemoveOk && await fs48.pathExists(wsWorktreePath)) {
12734
- await fs48.remove(wsWorktreePath);
13310
+ if (wsRemoveOk && await fs51.pathExists(wsWorktreePath)) {
13311
+ await fs51.remove(wsWorktreePath);
12735
13312
  }
12736
13313
  return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
12737
13314
  }
@@ -13075,9 +13652,9 @@ init_fs();
13075
13652
  init_paths();
13076
13653
  import { readFileSync as readFileSync4 } from "node:fs";
13077
13654
  import path71 from "node:path";
13078
- var MANIFEST_FILE = "workspace.manifest.json";
13655
+ var MANIFEST_FILE2 = "workspace.manifest.json";
13079
13656
  function manifestPath(workspaceRoot) {
13080
- return hausPath(workspaceRoot, MANIFEST_FILE);
13657
+ return hausPath(workspaceRoot, MANIFEST_FILE2);
13081
13658
  }
13082
13659
  function hausVersion() {
13083
13660
  try {
@@ -13293,17 +13870,17 @@ init_logger();
13293
13870
 
13294
13871
  // src/workspace/link-context/link.ts
13295
13872
  import path75 from "node:path";
13296
- import fs51 from "fs-extra";
13873
+ import fs54 from "fs-extra";
13297
13874
  init_fs();
13298
13875
  init_logger();
13299
13876
 
13300
13877
  // src/workspace/link-context/plan.ts
13301
13878
  import path74 from "node:path";
13302
- import fs50 from "fs-extra";
13879
+ import fs53 from "fs-extra";
13303
13880
 
13304
13881
  // src/workspace/link-context/scan-source.ts
13305
13882
  import path73 from "node:path";
13306
- import fs49 from "fs-extra";
13883
+ import fs52 from "fs-extra";
13307
13884
  var ASSET_SUBDIR = {
13308
13885
  skill: "skills",
13309
13886
  agent: "agents",
@@ -13311,20 +13888,20 @@ var ASSET_SUBDIR = {
13311
13888
  };
13312
13889
  async function hasLinkableContext(member) {
13313
13890
  for (const subdir of Object.values(ASSET_SUBDIR)) {
13314
- if (await fs49.pathExists(path73.join(member.absPath, ".claude", subdir))) return true;
13891
+ if (await fs52.pathExists(path73.join(member.absPath, ".claude", subdir))) return true;
13315
13892
  }
13316
13893
  return false;
13317
13894
  }
13318
13895
  async function collectSkills(member) {
13319
13896
  const dir = path73.join(member.absPath, ".claude", "skills");
13320
- if (!await fs49.pathExists(dir)) return [];
13321
- const names = await fs49.readdir(dir);
13897
+ if (!await fs52.pathExists(dir)) return [];
13898
+ const names = await fs52.readdir(dir);
13322
13899
  const out = [];
13323
13900
  for (const name of [...names].sort()) {
13324
13901
  const abs = path73.join(dir, name);
13325
- const stat = await fs49.lstat(abs);
13902
+ const stat = await fs52.lstat(abs);
13326
13903
  if (!stat.isDirectory()) continue;
13327
- if (!await fs49.pathExists(path73.join(abs, "SKILL.md"))) continue;
13904
+ if (!await fs52.pathExists(path73.join(abs, "SKILL.md"))) continue;
13328
13905
  out.push({ type: "skill", name, sourceRelPath: path73.posix.join(".claude", "skills", name) });
13329
13906
  }
13330
13907
  return out;
@@ -13332,13 +13909,13 @@ async function collectSkills(member) {
13332
13909
  async function collectFlatMarkdown(member, type) {
13333
13910
  const subdir = ASSET_SUBDIR[type];
13334
13911
  const dir = path73.join(member.absPath, ".claude", subdir);
13335
- if (!await fs49.pathExists(dir)) return [];
13336
- const names = await fs49.readdir(dir);
13912
+ if (!await fs52.pathExists(dir)) return [];
13913
+ const names = await fs52.readdir(dir);
13337
13914
  const out = [];
13338
13915
  for (const fileName of [...names].sort()) {
13339
13916
  if (!fileName.endsWith(".md")) continue;
13340
13917
  const abs = path73.join(dir, fileName);
13341
- if (!(await fs49.lstat(abs)).isFile()) continue;
13918
+ if (!(await fs52.lstat(abs)).isFile()) continue;
13342
13919
  const name = fileName.slice(0, -".md".length);
13343
13920
  out.push({ type, name, sourceRelPath: path73.posix.join(".claude", subdir, fileName) });
13344
13921
  }
@@ -13372,7 +13949,7 @@ async function buildLinkPlan(members) {
13372
13949
  const byKey = /* @__PURE__ */ new Map();
13373
13950
  const membersById = new Map(members.map((m) => [m.id, m]));
13374
13951
  for (const member of members) {
13375
- if (!await fs50.pathExists(member.absPath)) {
13952
+ if (!await fs53.pathExists(member.absPath)) {
13376
13953
  skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
13377
13954
  continue;
13378
13955
  }
@@ -13453,15 +14030,15 @@ async function runLinkContext(workspaceRoot, opts = {}) {
13453
14030
  const destRel = destRelPathFor(entry.type, entry.destKey);
13454
14031
  const destAbs = path75.join(workspaceRoot, destRel);
13455
14032
  if (write) {
13456
- await fs51.ensureDir(path75.dirname(destAbs));
13457
- if (await fs51.pathExists(destAbs)) await fs51.remove(destAbs);
13458
- await fs51.copy(entry.sourceAbsPath, destAbs, {
14033
+ await fs54.ensureDir(path75.dirname(destAbs));
14034
+ if (await fs54.pathExists(destAbs)) await fs54.remove(destAbs);
14035
+ await fs54.copy(entry.sourceAbsPath, destAbs, {
13459
14036
  overwrite: true,
13460
14037
  errorOnExist: false,
13461
14038
  dereference: false,
13462
14039
  filter: (s) => {
13463
14040
  try {
13464
- return !fs51.lstatSync(s).isSymbolicLink();
14041
+ return !fs54.lstatSync(s).isSymbolicLink();
13465
14042
  } catch {
13466
14043
  return false;
13467
14044
  }
@@ -13490,7 +14067,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
13490
14067
  warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
13491
14068
  continue;
13492
14069
  }
13493
- if (await fs51.pathExists(abs)) await fs51.remove(abs);
14070
+ if (await fs54.pathExists(abs)) await fs54.remove(abs);
13494
14071
  }
13495
14072
  }
13496
14073
  if (write) {
@@ -13546,7 +14123,7 @@ import path77 from "node:path";
13546
14123
 
13547
14124
  // src/claude/write-workspace-claude-md.ts
13548
14125
  import path76 from "node:path";
13549
- import fs52 from "fs-extra";
14126
+ import fs55 from "fs-extra";
13550
14127
  init_fs();
13551
14128
  init_logger();
13552
14129
  init_paths();
@@ -13571,8 +14148,8 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
13571
14148
  const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path76.join(workspaceRoot, "CLAUDE.md");
13572
14149
  const say = opts.quiet ? () => {
13573
14150
  } : log;
13574
- const existed = await fs52.pathExists(filePath);
13575
- const prev = existed ? await fs52.readFile(filePath, "utf8") : "";
14151
+ const existed = await fs55.pathExists(filePath);
14152
+ const prev = existed ? await fs55.readFile(filePath, "utf8") : "";
13576
14153
  const next = opts.collision ? `${block}
13577
14154
  ` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
13578
14155
  const printable = displayPath(workspaceRoot, filePath);
@@ -13644,34 +14221,49 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13644
14221
  const repoRoot = path77.resolve(workspaceRoot, repo.path);
13645
14222
  say(`
13646
14223
  \u2192 ${repo.name} (${repo.path})`);
14224
+ let res;
13647
14225
  try {
13648
14226
  if (!existsSync9(repoRoot) || !statSync(repoRoot).isDirectory()) {
13649
14227
  throw new Error(`Repo path is not a directory: ${repo.path}`);
13650
14228
  }
13651
- const res = await runSetupCore(repoRoot, {
14229
+ res = await runSetupCore(repoRoot, {
13652
14230
  apply,
13653
14231
  dryRun: options.dryRun,
13654
14232
  // Under --json, mute the inner pipeline entirely; we serialize the
13655
14233
  // aggregated result below. Otherwise let it print its normal human output.
13656
- quiet: options.json
14234
+ quiet: options.json,
14235
+ allowEmptyCache: options.allowEmptyCache
13657
14236
  });
14237
+ } catch (err) {
14238
+ const message2 = err instanceof Error ? err.message : String(err);
13658
14239
  statuses.push({
13659
14240
  name: repo.name,
13660
14241
  path: repo.path,
13661
14242
  root: repoRoot,
13662
- status: "ok",
13663
- roles: res.roles,
13664
- recommendedCount: res.recommendedCount
14243
+ status: "failed",
14244
+ error: message2
13665
14245
  });
13666
- const context = await readContextOrScan(repoRoot);
13667
- aggregateInputs.push({ name: repo.name, path: repo.path, context });
13668
- } catch (err) {
13669
- const message2 = err instanceof Error ? err.message : String(err);
14246
+ if (!options.continueOnError) {
14247
+ if (options.json) {
14248
+ const result2 = { workspaceRoot, statuses, written: [] };
14249
+ log(JSON.stringify(result2, null, 2));
14250
+ process.exitCode = 1;
14251
+ return result2;
14252
+ }
14253
+ throw new Error(`Setup failed for ${repo.name}: ${message2}`, { cause: err });
14254
+ }
14255
+ error(`Setup failed for ${repo.name}: ${message2}`);
14256
+ continue;
14257
+ }
14258
+ if (!res.setupOk) {
14259
+ const message2 = !res.catalogInstallOk ? "No catalog items were installed for this repo (zero installable content) \u2014 see the per-repo output above, or re-run with --allow-empty-cache." : "Setup wrote nothing for this repo \u2014 see the per-repo output above.";
13670
14260
  statuses.push({
13671
14261
  name: repo.name,
13672
14262
  path: repo.path,
13673
14263
  root: repoRoot,
13674
14264
  status: "failed",
14265
+ roles: res.roles,
14266
+ recommendedCount: res.recommendedCount,
13675
14267
  error: message2
13676
14268
  });
13677
14269
  if (!options.continueOnError) {
@@ -13681,10 +14273,21 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13681
14273
  process.exitCode = 1;
13682
14274
  return result2;
13683
14275
  }
13684
- throw new Error(`Setup failed for ${repo.name}: ${message2}`, { cause: err });
14276
+ throw new Error(`Setup failed for ${repo.name}: ${message2}`);
13685
14277
  }
13686
14278
  error(`Setup failed for ${repo.name}: ${message2}`);
14279
+ continue;
13687
14280
  }
14281
+ statuses.push({
14282
+ name: repo.name,
14283
+ path: repo.path,
14284
+ root: repoRoot,
14285
+ status: "ok",
14286
+ roles: res.roles,
14287
+ recommendedCount: res.recommendedCount
14288
+ });
14289
+ const context = await readContextOrScan(repoRoot);
14290
+ aggregateInputs.push({ name: repo.name, path: repo.path, context });
13688
14291
  }
13689
14292
  const written = [];
13690
14293
  if (apply && aggregateInputs.length > 0) {
@@ -13804,6 +14407,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13804
14407
  }
13805
14408
  const ok = statuses.filter((s) => s.status === "ok").length;
13806
14409
  const failed = statuses.length - ok;
14410
+ if (failed > 0) process.exitCode = 1;
13807
14411
  const result = { workspaceRoot, statuses, written };
13808
14412
  if (options.json) {
13809
14413
  log(JSON.stringify(result, null, 2));
@@ -13820,7 +14424,7 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
13820
14424
  // src/commands/workspace/undo.ts
13821
14425
  import { existsSync as existsSync10, statSync as statSync2 } from "node:fs";
13822
14426
  import path78 from "node:path";
13823
- import fs53 from "fs-extra";
14427
+ import fs56 from "fs-extra";
13824
14428
  init_fs();
13825
14429
  init_logger();
13826
14430
  init_paths();
@@ -13871,7 +14475,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13871
14475
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
13872
14476
  continue;
13873
14477
  }
13874
- if (await fs53.pathExists(abs)) {
14478
+ if (await fs56.pathExists(abs)) {
13875
14479
  log(`[dry-run] Would remove ${entry.path} (linked ${entry.type} from ${entry.repo})`);
13876
14480
  }
13877
14481
  }
@@ -13881,11 +14485,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13881
14485
  hausPath(workspaceRoot, "WORKSPACE.md")
13882
14486
  ];
13883
14487
  for (const abs of workspaceArtifacts) {
13884
- if (await fs53.pathExists(abs)) {
14488
+ if (await fs56.pathExists(abs)) {
13885
14489
  log(`[dry-run] Would remove ${path78.relative(workspaceRoot, abs)}`);
13886
14490
  }
13887
14491
  }
13888
- if (!await fs53.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
14492
+ if (!await fs56.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
13889
14493
  const claudeMd = await readText(path78.join(workspaceRoot, "CLAUDE.md"));
13890
14494
  if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
13891
14495
  log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
@@ -13925,26 +14529,26 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13925
14529
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
13926
14530
  continue;
13927
14531
  }
13928
- if (await fs53.pathExists(abs)) {
13929
- await fs53.remove(abs);
14532
+ if (await fs56.pathExists(abs)) {
14533
+ await fs56.remove(abs);
13930
14534
  log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
13931
14535
  }
13932
14536
  }
13933
14537
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
13934
14538
  const abs = hausPath(workspaceRoot, rel);
13935
- if (await fs53.pathExists(abs)) {
13936
- await fs53.remove(abs);
14539
+ if (await fs56.pathExists(abs)) {
14540
+ await fs56.remove(abs);
13937
14541
  log(`Removed ${path78.relative(workspaceRoot, abs)}`);
13938
14542
  }
13939
14543
  }
13940
14544
  const manifestFile = manifestPath(workspaceRoot);
13941
- if (await fs53.pathExists(manifestFile)) {
13942
- await fs53.remove(manifestFile);
14545
+ if (await fs56.pathExists(manifestFile)) {
14546
+ await fs56.remove(manifestFile);
13943
14547
  log(`Removed ${path78.relative(workspaceRoot, manifestFile)}`);
13944
14548
  }
13945
14549
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
13946
- if (await fs53.pathExists(workspaceMdPath)) {
13947
- await fs53.remove(workspaceMdPath);
14550
+ if (await fs56.pathExists(workspaceMdPath)) {
14551
+ await fs56.remove(workspaceMdPath);
13948
14552
  log(`Removed ${path78.relative(workspaceRoot, workspaceMdPath)}`);
13949
14553
  } else {
13950
14554
  const claudeMdPath = path78.join(workspaceRoot, "CLAUDE.md");
@@ -13956,19 +14560,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13956
14560
  end: WORKSPACE_BLOCK_END
13957
14561
  });
13958
14562
  if (next.length === 0) {
13959
- await fs53.remove(claudeMdPath);
14563
+ await fs56.remove(claudeMdPath);
13960
14564
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
13961
14565
  } else {
13962
- await fs53.writeFile(claudeMdPath, next, "utf8");
14566
+ await fs56.writeFile(claudeMdPath, next, "utf8");
13963
14567
  log("Removed haus import block from CLAUDE.md (user content preserved).");
13964
14568
  }
13965
14569
  } else if (prev && hasLegacyWorkspaceBlock(prev)) {
13966
14570
  const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
13967
14571
  if (next.length === 0) {
13968
- await fs53.remove(claudeMdPath);
14572
+ await fs56.remove(claudeMdPath);
13969
14573
  log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
13970
14574
  } else {
13971
- await fs53.writeFile(claudeMdPath, next, "utf8");
14575
+ await fs56.writeFile(claudeMdPath, next, "utf8");
13972
14576
  log(
13973
14577
  "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
13974
14578
  );
@@ -14086,7 +14690,8 @@ async function runWorkspace(action, options = {}) {
14086
14690
  dryRun: options.dryRun,
14087
14691
  json: options.json,
14088
14692
  continueOnError: options.continueOnError,
14089
- only: normalizeOnly(options.only)
14693
+ only: normalizeOnly(options.only),
14694
+ allowEmptyCache: options.allowEmptyCache
14090
14695
  });
14091
14696
  return;
14092
14697
  case "doctor":
@@ -14130,6 +14735,9 @@ function validateRuntimeNodeVersion() {
14130
14735
  }
14131
14736
  validateRuntimeNodeVersion();
14132
14737
  program.name("haus").description("Haus AI workflow CLI").version(cliVersion());
14738
+ program.option("--offline", "Make no network calls; use cached or bundled catalog content only").hook("preAction", (thisCommand) => {
14739
+ setOfflineFlag(Boolean(thisCommand.opts()["offline"]));
14740
+ });
14133
14741
  program.command("scan").description(
14134
14742
  "Scan the repo for roles, dependencies, and package manager; writes context-map.json"
14135
14743
  ).option("--json").action(runScan);
@@ -14146,7 +14754,7 @@ program.command("apply").description("Write recommended catalog items and core C
14146
14754
  "Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
14147
14755
  ).option(
14148
14756
  "--allow-empty-cache",
14149
- "Apply core files only when catalog cache is empty (skip catalog items without error)"
14757
+ "Proceed when no catalog item has installable content: write core files and skip the catalog items. Still exits 1 if none installed (ADR-0060)"
14150
14758
  ).option(
14151
14759
  "--refill-config",
14152
14760
  "Fill still-blank fields in an existing workflow-config.md without touching edited ones"
@@ -14180,14 +14788,26 @@ program.command("explain-recommendation").description("Print a per-item breakdow
14180
14788
  program.command("init").description("Full setup: scan, recommend, and optionally apply Claude files (safe to re-run)").option("--dry-run", "Preview the `.claude/` write; scan artifacts are still written").option("--json", "Emit one JSON envelope on stdout (requires --dry-run)").option(
14181
14789
  "--force",
14182
14790
  "Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
14791
+ ).option(
14792
+ "--allow-empty-cache",
14793
+ "Proceed when no catalog item has installable content: write core files and skip the catalog items. Still exits 1 if none installed (ADR-0060)"
14183
14794
  ).action(runInit);
14184
- program.command("new").description("Create a new project from an allowed Haus stack, push it to GitHub, and run init").argument("[stack]", "Stack id (omit to pick interactively; see --list)").argument("[name]", "Project directory and repo name").option("--list", "Print the allowed stacks and exit").option("--org <org>", "GitHub org to create the repo in (default: personal account)").option("--public", "Create the GitHub repo public").option("--private", "Create the GitHub repo private (default)").option("--no-github", "Skip GitHub repo creation and push").option("--pm <pm>", "Package manager: yarn | npm | pnpm", "yarn").option("-y, --yes", "Skip confirmations (non-interactive)").option("--dry-run", "Print the full plan without executing anything").option("--json", "Emit a machine-readable result document").action(runNew);
14795
+ program.command("new").description("Create a new project from an allowed Haus stack, push it to GitHub, and run init").argument("[stack]", "Stack id (omit to pick interactively; see --list)").argument("[name]", "Project directory and repo name").option("--list", "Print the allowed stacks and exit").option("--org <org>", "GitHub org to create the repo in (default: personal account)").option("--public", "Create the GitHub repo public").option("--private", "Create the GitHub repo private (default)").option("--no-github", "Skip GitHub repo creation and push").option("--pm <pm>", "Package manager: yarn | npm | pnpm", "yarn").option("-y, --yes", "Skip confirmations (non-interactive)").option("--dry-run", "Print the full plan without executing anything").option("--json", "Emit a machine-readable result document").option(
14796
+ "--allow-empty-cache",
14797
+ "Proceed when no catalog item has installable content: write core files and skip the catalog items. Still exits 1 if none installed (ADR-0060)"
14798
+ ).action(runNew);
14185
14799
  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(
14186
14800
  (url, dir, opts) => runClone(url, { dir, dryRun: opts.dryRun })
14187
14801
  );
14188
14802
  program.command("rescan").description("Re-scan the project, refresh recommendations, and update scan artifacts").option("--dry-run", "Report what would change without writing scan artifacts").action((opts) => runRescan({ dryRun: opts.dryRun }));
14189
14803
  program.command("catalog-audit").description("Check the bundled catalog manifest for unsupported or forbidden stacks").action(runCatalogAudit);
14190
14804
  program.command("validate-catalog").description("Validate a catalog manifest at an explicit path").argument("[manifest]").action(runValidateCatalog);
14805
+ var catalog = program.command("catalog").description("Catalog cache maintenance");
14806
+ var catalogBundle = catalog.command("bundle").description("Move catalog content between machines (for air-gapped installs)");
14807
+ catalogBundle.command("export").description("Write the populated catalog cache to a portable archive").requiredOption("--out <file>", "Destination .tgz path").action((opts) => runBundleExport({ out: opts.out }));
14808
+ catalogBundle.command("import <file>").description("Unpack a catalog bundle into the local cache (validated like a network sync)").option("--dry-run", "Print what would be imported without writing anything").action(
14809
+ (file, opts) => runBundleImport(file, { dryRun: opts.dryRun })
14810
+ );
14191
14811
  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);
14192
14812
  program.command("ci-gate").description(
14193
14813
  "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."
@@ -14216,7 +14836,10 @@ var workspace = program.command("workspace").description("Multi-repo workspace c
14216
14836
  workspace.command("init").option("-y, --yes", "Skip confirmation when haus.workspace.yaml already exists").action((opts) => runWorkspace("init", opts));
14217
14837
  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));
14218
14838
  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));
14219
- 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));
14839
+ 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").option(
14840
+ "--allow-empty-cache",
14841
+ "Proceed when a member repo has no installable catalog content: write core files and skip catalog items for that repo. That repo is still recorded failed if nothing installed (ADR-0060)"
14842
+ ).action((opts) => runWorkspace("setup", opts));
14220
14843
  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));
14221
14844
  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));
14222
14845
  workspace.command("link-context").description(