@haus-tech/haus-workflow 4.0.0 → 4.1.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,20 +1932,204 @@ 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
- import path79 from "node:path";
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
 
1943
+ // src/catalog/former-ids.ts
1944
+ import path4 from "node:path";
1945
+
1946
+ // src/catalog/install-mapping.ts
1947
+ init_paths();
1948
+ init_path_safety();
1949
+ import path3 from "node:path";
1950
+ var FLATTEN_DIR_BY_TYPE = {
1951
+ skill: "skills",
1952
+ agent: "agents",
1953
+ template: "templates",
1954
+ command: "commands"
1955
+ };
1956
+ var MIRRORED_TYPES = /* @__PURE__ */ new Set(["rule", "asset"]);
1957
+ function upstreamRelative(itemPath) {
1958
+ const match = itemPath.replace(/\\/g, "/").match(/^sources\/[^/]+\/(.+)$/);
1959
+ return match ? match[1] : null;
1960
+ }
1961
+ function installTargetFor(item) {
1962
+ const type = item.type;
1963
+ if (type === "config") {
1964
+ return {
1965
+ ok: false,
1966
+ code: "scaffold-only",
1967
+ reason: 'type "config" is installed by `haus configs`, not into .claude/'
1968
+ };
1969
+ }
1970
+ const flattenDir = FLATTEN_DIR_BY_TYPE[type];
1971
+ const mirrored = MIRRORED_TYPES.has(type);
1972
+ if (!flattenDir && !mirrored) {
1973
+ return {
1974
+ ok: false,
1975
+ code: "unknown-type",
1976
+ reason: `type "${type}" is unknown to this haus version \u2014 upgrade the CLI to use it`
1977
+ };
1978
+ }
1979
+ const itemPath = typeof item.path === "string" ? item.path : "";
1980
+ if (!isSafeCatalogPath(itemPath)) {
1981
+ return {
1982
+ ok: false,
1983
+ code: "unsafe-path",
1984
+ reason: `path "${itemPath}" is not a safe relative catalog path`
1985
+ };
1986
+ }
1987
+ if (flattenDir) {
1988
+ return {
1989
+ ok: true,
1990
+ layout: "flatten",
1991
+ relPath: `${flattenDir}/${path3.posix.basename(itemPath)}`
1992
+ };
1993
+ }
1994
+ const rel = upstreamRelative(itemPath);
1995
+ if (rel === null) {
1996
+ return {
1997
+ ok: false,
1998
+ code: "not-mirrorable",
1999
+ reason: `type "${type}" installs by mirroring its upstream path, but "${itemPath}" is not under sources/<source>/`
2000
+ };
2001
+ }
2002
+ if (!isSafeRelativeFilePath(rel)) {
2003
+ return {
2004
+ ok: false,
2005
+ code: "unsafe-path",
2006
+ reason: `path "${itemPath}" escapes its own sources/<source>/ prefix`
2007
+ };
2008
+ }
2009
+ return { ok: true, layout: "mirror", relPath: rel };
2010
+ }
2011
+ function installDestinationFor(root, item) {
2012
+ const target = installTargetFor(item);
2013
+ if (!target.ok) return target;
2014
+ const destination = safeJoin(claudePath(root), target.relPath);
2015
+ if (destination === null) {
2016
+ return {
2017
+ ok: false,
2018
+ code: "unsafe-path",
2019
+ reason: `install path "${target.relPath}" escapes .claude/`
2020
+ };
2021
+ }
2022
+ return { ok: true, layout: target.layout, relPath: target.relPath, destination };
2023
+ }
2024
+
2025
+ // src/catalog/former-ids.ts
2026
+ function normalizeFormerIds(value) {
2027
+ if (value === void 0 || value === null) return [];
2028
+ if (!Array.isArray(value) || value.length === 0) return [];
2029
+ if (!value.every((entry) => typeof entry === "string")) return [];
2030
+ return value.filter((entry) => entry.length > 0);
2031
+ }
2032
+ function assertFormerIdsShape(itemId, formerIds) {
2033
+ if (formerIds === void 0) return;
2034
+ if (Array.isArray(formerIds) && formerIds.length === 0) return;
2035
+ if (!Array.isArray(formerIds) || !formerIds.every((entry) => typeof entry === "string")) {
2036
+ throw new Error(`${itemId}: formerIds must be a string array`);
2037
+ }
2038
+ }
2039
+ function buildFormerIdMap(items) {
2040
+ const map = /* @__PURE__ */ new Map();
2041
+ const currentIds = new Set(items.map((item) => item.id));
2042
+ for (const item of items) {
2043
+ assertFormerIdsShape(item.id, item.formerIds);
2044
+ const formerIds = item.formerIds ?? [];
2045
+ if (!Array.isArray(formerIds) || formerIds.length === 0) continue;
2046
+ for (const formerId of formerIds) {
2047
+ if (currentIds.has(formerId) && formerId !== item.id) {
2048
+ throw new Error(
2049
+ `formerId ${formerId} on ${item.id} conflicts with another item's current id`
2050
+ );
2051
+ }
2052
+ const existingOwner = map.get(formerId);
2053
+ if (existingOwner !== void 0) {
2054
+ throw new Error(
2055
+ `duplicate formerId ${formerId} claimed by both ${existingOwner} and ${item.id}`
2056
+ );
2057
+ }
2058
+ map.set(formerId, item.id);
2059
+ }
2060
+ }
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
+
1923
2127
  // src/catalog/load-catalog.ts
1924
2128
  init_fs();
1925
2129
  init_logger();
1926
2130
  init_paths();
1927
2131
  init_manifest_format();
1928
- import path9 from "node:path";
2132
+ import path11 from "node:path";
1929
2133
 
1930
2134
  // src/catalog/remote-catalog.ts
1931
2135
  init_github_tree();
@@ -1935,18 +2139,23 @@ init_project_pin();
1935
2139
  init_manifest();
1936
2140
 
1937
2141
  // src/catalog/remote-catalog/workflow-template.ts
2142
+ init_offline();
1938
2143
  init_http();
1939
2144
  init_ref();
1940
- import path7 from "node:path";
1941
- import fs6 from "fs-extra";
2145
+ import path9 from "node:path";
2146
+ import fs7 from "fs-extra";
1942
2147
  var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
1943
2148
  async function readWorkflowTemplate(opts = {}) {
1944
- const dest = path7.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
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
+ }
1945
2154
  const base = await remoteBase(opts.projectRoot);
1946
2155
  const { url, headers } = await catalogContentRequest(base, WORKFLOW_TEMPLATE_REL);
1947
2156
  const text = await fetchText(url, headers);
1948
2157
  if (text === null) {
1949
- if (await fs6.pathExists(dest)) return fs6.readFile(dest, "utf8");
2158
+ if (await fs7.pathExists(dest)) return fs7.readFile(dest, "utf8");
1950
2159
  return null;
1951
2160
  }
1952
2161
  if (!opts.dryRun) {
@@ -1960,6 +2169,9 @@ init_github_tree();
1960
2169
  init_sync();
1961
2170
 
1962
2171
  // src/catalog/load-catalog.ts
2172
+ function manifestVersion(raw) {
2173
+ return typeof raw === "string" && raw !== "" ? raw : null;
2174
+ }
1963
2175
  function cacheRefMatches(cached, expected) {
1964
2176
  if (expected === null) return true;
1965
2177
  if (typeof cached !== "string" || cached === "") return true;
@@ -1971,14 +2183,15 @@ async function loadCatalogContext(root) {
1971
2183
  const data2 = await readJson(envPath);
1972
2184
  return {
1973
2185
  items: data2?.items ?? [],
1974
- contentRoot: path9.dirname(envPath),
2186
+ contentRoot: path11.dirname(envPath),
1975
2187
  source: "fixture",
1976
2188
  manifestFormat: assertSupportedManifestFormat(data2?.manifestFormat, "The fixture catalog"),
2189
+ version: manifestVersion(data2?.version),
1977
2190
  globalPlugins: data2?.globalPlugins
1978
2191
  };
1979
2192
  }
1980
2193
  const cacheDir = getCacheDir();
1981
- const cacheManifestPath = path9.join(cacheDir, "manifest.json");
2194
+ const cacheManifestPath = path11.join(cacheDir, "manifest.json");
1982
2195
  const cacheData = await readJson(cacheManifestPath);
1983
2196
  if (cacheData?.items?.length) {
1984
2197
  const wantedRef = expectedCatalogRef(root);
@@ -1995,31 +2208,34 @@ async function loadCatalogContext(root) {
1995
2208
  cacheData.manifestFormat,
1996
2209
  "The cached catalog"
1997
2210
  ),
2211
+ version: manifestVersion(cacheData.version),
1998
2212
  globalPlugins: cacheData.globalPlugins
1999
2213
  };
2000
2214
  }
2001
2215
  }
2002
- const localManifest = path9.join(root, "library/catalog/manifest.json");
2216
+ const localManifest = path11.join(root, "library/catalog/manifest.json");
2003
2217
  const localData = await readJson(localManifest);
2004
2218
  if (localData?.items?.length) {
2005
2219
  return {
2006
2220
  items: localData.items,
2007
- contentRoot: path9.dirname(localManifest),
2221
+ contentRoot: path11.dirname(localManifest),
2008
2222
  source: "local",
2009
2223
  manifestFormat: assertSupportedManifestFormat(
2010
2224
  localData.manifestFormat,
2011
2225
  "The project-local catalog"
2012
2226
  ),
2227
+ version: manifestVersion(localData.version),
2013
2228
  globalPlugins: localData.globalPlugins
2014
2229
  };
2015
2230
  }
2016
- const packageManifest = path9.join(packageRoot(), "library/catalog/manifest.json");
2231
+ const packageManifest = path11.join(packageRoot(), "library/catalog/manifest.json");
2017
2232
  const data = await readJson(packageManifest);
2018
2233
  return {
2019
2234
  items: data?.items ?? [],
2020
- contentRoot: path9.dirname(packageManifest),
2235
+ contentRoot: path11.dirname(packageManifest),
2021
2236
  source: "bundled",
2022
2237
  manifestFormat: assertSupportedManifestFormat(data?.manifestFormat, "The bundled catalog"),
2238
+ version: manifestVersion(data?.version),
2023
2239
  globalPlugins: data?.globalPlugins
2024
2240
  };
2025
2241
  }
@@ -2037,9 +2253,12 @@ async function loadCatalog(root) {
2037
2253
  return ctx.items;
2038
2254
  }
2039
2255
  function catalogItemContentPath(contentRoot, item) {
2040
- return path9.join(contentRoot, item.path);
2256
+ return path11.join(contentRoot, item.path);
2041
2257
  }
2042
2258
 
2259
+ // src/commands/apply.ts
2260
+ init_offline();
2261
+
2043
2262
  // src/claude/gitignore-owned-check.ts
2044
2263
  init_exec();
2045
2264
  var HAUS_OWNED_TRACKED_PATHS = [
@@ -2061,171 +2280,19 @@ async function findGitignoredHausPaths(root) {
2061
2280
  result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean)
2062
2281
  )
2063
2282
  ];
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
- // src/catalog/former-ids.ts
2079
- import path11 from "node:path";
2080
-
2081
- // src/catalog/install-mapping.ts
2082
- init_paths();
2083
- init_path_safety();
2084
- import path10 from "node:path";
2085
- var FLATTEN_DIR_BY_TYPE = {
2086
- skill: "skills",
2087
- agent: "agents",
2088
- template: "templates",
2089
- command: "commands"
2090
- };
2091
- var MIRRORED_TYPES = /* @__PURE__ */ new Set(["rule", "asset"]);
2092
- function upstreamRelative(itemPath) {
2093
- const match = itemPath.replace(/\\/g, "/").match(/^sources\/[^/]+\/(.+)$/);
2094
- return match ? match[1] : null;
2095
- }
2096
- function installTargetFor(item) {
2097
- const type = item.type;
2098
- if (type === "config") {
2099
- return {
2100
- ok: false,
2101
- code: "scaffold-only",
2102
- reason: 'type "config" is installed by `haus configs`, not into .claude/'
2103
- };
2104
- }
2105
- const flattenDir = FLATTEN_DIR_BY_TYPE[type];
2106
- const mirrored = MIRRORED_TYPES.has(type);
2107
- if (!flattenDir && !mirrored) {
2108
- return {
2109
- ok: false,
2110
- code: "unknown-type",
2111
- reason: `type "${type}" is unknown to this haus version \u2014 upgrade the CLI to use it`
2112
- };
2113
- }
2114
- const itemPath = typeof item.path === "string" ? item.path : "";
2115
- if (!isSafeCatalogPath(itemPath)) {
2116
- return {
2117
- ok: false,
2118
- code: "unsafe-path",
2119
- reason: `path "${itemPath}" is not a safe relative catalog path`
2120
- };
2121
- }
2122
- if (flattenDir) {
2123
- return {
2124
- ok: true,
2125
- layout: "flatten",
2126
- relPath: `${flattenDir}/${path10.posix.basename(itemPath)}`
2127
- };
2128
- }
2129
- const rel = upstreamRelative(itemPath);
2130
- if (rel === null) {
2131
- return {
2132
- ok: false,
2133
- code: "not-mirrorable",
2134
- reason: `type "${type}" installs by mirroring its upstream path, but "${itemPath}" is not under sources/<source>/`
2135
- };
2136
- }
2137
- if (!isSafeRelativeFilePath(rel)) {
2138
- return {
2139
- ok: false,
2140
- code: "unsafe-path",
2141
- reason: `path "${itemPath}" escapes its own sources/<source>/ prefix`
2142
- };
2143
- }
2144
- return { ok: true, layout: "mirror", relPath: rel };
2145
- }
2146
- function installDestinationFor(root, item) {
2147
- const target = installTargetFor(item);
2148
- if (!target.ok) return target;
2149
- const destination = safeJoin(claudePath(root), target.relPath);
2150
- if (destination === null) {
2151
- return {
2152
- ok: false,
2153
- code: "unsafe-path",
2154
- reason: `install path "${target.relPath}" escapes .claude/`
2155
- };
2156
- }
2157
- return { ok: true, layout: target.layout, relPath: target.relPath, destination };
2158
- }
2159
-
2160
- // src/catalog/former-ids.ts
2161
- function normalizeFormerIds(value) {
2162
- if (value === void 0 || value === null) return [];
2163
- if (!Array.isArray(value) || value.length === 0) return [];
2164
- if (!value.every((entry) => typeof entry === "string")) return [];
2165
- return value.filter((entry) => entry.length > 0);
2166
- }
2167
- function assertFormerIdsShape(itemId, formerIds) {
2168
- if (formerIds === void 0) return;
2169
- if (Array.isArray(formerIds) && formerIds.length === 0) return;
2170
- if (!Array.isArray(formerIds) || !formerIds.every((entry) => typeof entry === "string")) {
2171
- throw new Error(`${itemId}: formerIds must be a string array`);
2172
- }
2173
- }
2174
- function buildFormerIdMap(items) {
2175
- const map = /* @__PURE__ */ new Map();
2176
- const currentIds = new Set(items.map((item) => item.id));
2177
- for (const item of items) {
2178
- assertFormerIdsShape(item.id, item.formerIds);
2179
- const formerIds = item.formerIds ?? [];
2180
- if (!Array.isArray(formerIds) || formerIds.length === 0) continue;
2181
- for (const formerId of formerIds) {
2182
- if (currentIds.has(formerId) && formerId !== item.id) {
2183
- throw new Error(
2184
- `formerId ${formerId} on ${item.id} conflicts with another item's current id`
2185
- );
2186
- }
2187
- const existingOwner = map.get(formerId);
2188
- if (existingOwner !== void 0) {
2189
- throw new Error(
2190
- `duplicate formerId ${formerId} claimed by both ${existingOwner} and ${item.id}`
2191
- );
2192
- }
2193
- map.set(formerId, item.id);
2194
- }
2283
+ } catch {
2284
+ return [];
2195
2285
  }
2196
- return map;
2197
- }
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
- });
2204
2286
  }
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
- }
2224
- }
2225
- return names;
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
  }
@@ -3050,8 +3117,8 @@ function buildDenyRules() {
3050
3117
  for (const command of DENY_COMMANDS) {
3051
3118
  rules.push(`Bash(${command}:*)`);
3052
3119
  }
3053
- for (const path80 of DENY_PATHS) {
3054
- const pattern = DENY_DIRS.has(path80) ? `${path80}/**` : path80;
3120
+ for (const path81 of DENY_PATHS) {
3121
+ const pattern = DENY_DIRS.has(path81) ? `${path81}/**` : path81;
3055
3122
  for (const tool of FILE_TOOLS) {
3056
3123
  rules.push(`${tool}(${pattern})`);
3057
3124
  }
@@ -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
  }
@@ -4441,7 +4533,7 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
4441
4533
  const relPaths = entry.paths ?? [];
4442
4534
  const existing = await resolveExistingTrackedPaths(root, migration.oldId, relPaths, warn);
4443
4535
  if (existing.length === 0) continue;
4444
- const gate = await hashGateEntry(root, { id: migration.oldId, hash: entry.hash }, existing);
4536
+ const gate = await hashGateEntry(root, { hash: entry.hash }, existing);
4445
4537
  if (!gate.ok) {
4446
4538
  if (gate.reason === "no-hash") {
4447
4539
  warn(
@@ -4468,10 +4560,10 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
4468
4560
  return;
4469
4561
  }
4470
4562
  const allAbsPaths = toRemove.flatMap(({ existing }) => existing.map(({ abs }) => abs));
4471
- await backupTrackedPaths(root, allAbsPaths, say, "migrate");
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
  }
@@ -4487,11 +4579,13 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet, activ
4487
4579
  );
4488
4580
  let keepShared = false;
4489
4581
  for (const entry of supersededEntries) {
4582
+ const id = entry.id;
4583
+ if (!id) continue;
4490
4584
  const relPaths = entry.paths ?? [];
4491
4585
  if (relPaths.length === 0) continue;
4492
- const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
4586
+ const existing = await resolveExistingTrackedPaths(root, id, relPaths, warn);
4493
4587
  if (existing.length === 0) continue;
4494
- const gate = await hashGateEntry(root, { id: entry.id, hash: entry.hash }, existing);
4588
+ const gate = await hashGateEntry(root, { hash: entry.hash }, existing);
4495
4589
  if (!gate.ok) keepShared = true;
4496
4590
  }
4497
4591
  for (const entry of prevLock) {
@@ -4503,12 +4597,15 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet, activ
4503
4597
  if (!removedFromManifest && !deprecated && !superseded) continue;
4504
4598
  const relPaths = entry.paths ?? [];
4505
4599
  if (relPaths.length === 0) continue;
4506
- if (await hasProtectedUnsafePath(root, relPaths)) refusedIds.add(entry.id);
4507
4600
  const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
4601
+ if (await hasProtectedUnsafePath(root, relPaths)) {
4602
+ refusedIds.add(entry.id);
4603
+ continue;
4604
+ }
4508
4605
  if (existing.length === 0) continue;
4509
4606
  const pruneReason = superseded ? "superseded" : deprecated ? "deprecated" : "stale";
4510
4607
  const label = superseded ? "Superseded" : deprecated ? "Deprecated" : "Stale";
4511
- const gate = await hashGateEntry(root, { id: entry.id, hash: entry.hash }, existing);
4608
+ const gate = await hashGateEntry(root, { hash: entry.hash }, existing);
4512
4609
  if (!gate.ok) {
4513
4610
  if (gate.reason === "no-hash") {
4514
4611
  warn(
@@ -4533,7 +4630,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet, activ
4533
4630
  return true;
4534
4631
  }) : existing;
4535
4632
  if (!dryRun && removable.length > 0) {
4536
- await backupTrackedPaths(
4633
+ void await backupTrackedPaths(
4537
4634
  root,
4538
4635
  removable.map((e) => e.abs),
4539
4636
  say,
@@ -4545,7 +4642,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet, activ
4545
4642
  say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
4546
4643
  continue;
4547
4644
  }
4548
- await fs22.remove(abs);
4645
+ await fs23.remove(abs);
4549
4646
  await pruneEmptyDir(path25.dirname(abs));
4550
4647
  say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
4551
4648
  }
@@ -4563,10 +4660,13 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
4563
4660
  if (!entry.id) continue;
4564
4661
  const relPaths = entry.paths ?? [];
4565
4662
  if (relPaths.length === 0) continue;
4566
- if (await hasProtectedUnsafePath(root, relPaths)) refusedIds.add(entry.id);
4567
4663
  const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
4664
+ if (await hasProtectedUnsafePath(root, relPaths)) {
4665
+ refusedIds.add(entry.id);
4666
+ continue;
4667
+ }
4568
4668
  if (existing.length === 0) continue;
4569
- const gate = await hashGateEntry(root, { id: entry.id, hash: entry.hash }, existing);
4669
+ const gate = await hashGateEntry(root, { hash: entry.hash }, existing);
4570
4670
  if (!gate.ok) {
4571
4671
  const detail = gate.reason === "no-hash" ? "has no lock hash" : "was modified locally";
4572
4672
  warn(
@@ -4587,7 +4687,7 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
4587
4687
  return refusedIds;
4588
4688
  }
4589
4689
  const allAbsPaths = toRemove.flatMap(({ existing }) => existing.map(({ abs }) => abs));
4590
- await backupTrackedPaths(root, allAbsPaths, say, "prune");
4690
+ void await backupTrackedPaths(root, allAbsPaths, say, "prune");
4591
4691
  for (const { id, existing } of toRemove) {
4592
4692
  await deleteTrackedPaths(root, id, existing, { verb: "Pruned", reason: "orphaned" }, say);
4593
4693
  }
@@ -4597,14 +4697,15 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
4597
4697
  // src/claude/write-gitignore.ts
4598
4698
  init_exec();
4599
4699
  import path26 from "node:path";
4600
- import fs23 from "fs-extra";
4700
+ import fs24 from "fs-extra";
4601
4701
  var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
4602
4702
  var GITIGNORE_END = "# HAUS:END haus-managed";
4603
4703
  var GITIGNORED_ARTIFACT_PATHS = [
4604
4704
  ".haus-workflow/context-map.json",
4605
4705
  ".haus-workflow/recommendation.json",
4606
4706
  ".haus-workflow/sources-report.json",
4607
- ".haus-workflow/deep-context.json"
4707
+ ".haus-workflow/deep-context.json",
4708
+ ".haus-workflow/backups/"
4608
4709
  ];
4609
4710
  function findLineMarker3(content2, marker, from = 0) {
4610
4711
  let idx = content2.indexOf(marker, from);
@@ -4649,7 +4750,7 @@ function buildBlockFor(existing, beginMarker, endMarker, comment, patterns) {
4649
4750
  if (needed.length === 0) return null;
4650
4751
  return [beginMarker, comment, ...needed, endMarker].join("\n");
4651
4752
  }
4652
- var ARTIFACT_BLOCK_COMMENT = "# haus scan artifacts \u2014 machine-local, must never be committed (see ADR-0025)";
4753
+ var ARTIFACT_BLOCK_COMMENT = "# haus machine-local artifacts \u2014 must never be committed (see ADR-0025, ADR-0058)";
4653
4754
  function injectBlockFor(existing, beginMarker, endMarker, comment, patterns) {
4654
4755
  const block = buildBlockFor(existing, beginMarker, endMarker, comment, patterns);
4655
4756
  const range = findBlockRangeFor(existing, beginMarker, endMarker);
@@ -4712,14 +4813,14 @@ function injectLinkContextGitignoreBlock(existing) {
4712
4813
  }
4713
4814
  async function writeGitignore(root, dryRun, quiet = false) {
4714
4815
  const filePath = path26.join(root, ".gitignore");
4715
- const prev = await fs23.pathExists(filePath) ? await fs23.readFile(filePath, "utf8") : "";
4816
+ const prev = await fs24.pathExists(filePath) ? await fs24.readFile(filePath, "utf8") : "";
4716
4817
  const next = injectGitignoreBlock(prev);
4717
4818
  await writeManagedText(root, filePath, next, dryRun, quiet);
4718
4819
  return filePath;
4719
4820
  }
4720
4821
  async function writeLinkContextGitignore(root, dryRun, quiet = false) {
4721
4822
  const filePath = path26.join(root, ".gitignore");
4722
- const prev = await fs23.pathExists(filePath) ? await fs23.readFile(filePath, "utf8") : "";
4823
+ const prev = await fs24.pathExists(filePath) ? await fs24.readFile(filePath, "utf8") : "";
4723
4824
  const next = injectLinkContextGitignoreBlock(prev);
4724
4825
  await writeManagedText(root, filePath, next, dryRun, quiet);
4725
4826
  return filePath;
@@ -4742,7 +4843,7 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
4742
4843
  for (const rel of tracked) {
4743
4844
  if (dryRun) {
4744
4845
  notify(
4745
- `Would untrack ${rel} from git \u2014 it contains machine-local scan output and should never have been committed.`
4846
+ `Would untrack ${rel} from git \u2014 it is machine-local haus output and should never have been committed.`
4746
4847
  );
4747
4848
  continue;
4748
4849
  }
@@ -4750,7 +4851,7 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
4750
4851
  if (result.exitCode === 0) {
4751
4852
  untracked.push(rel);
4752
4853
  notify(
4753
- `Untracked ${rel} from git \u2014 it contains machine-local scan output and should never have been committed. Run \`git status\` to review before committing this change.`
4854
+ `Untracked ${rel} from git \u2014 it is machine-local haus output and should never have been committed. Run \`git status\` to review before committing this change.`
4754
4855
  );
4755
4856
  } else {
4756
4857
  notify(`Could not untrack ${rel} from git: ${result.stderr.trim() || result.stdout.trim()}`);
@@ -4761,6 +4862,7 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
4761
4862
 
4762
4863
  // src/install/ensure-global-plugins.ts
4763
4864
  import path27 from "node:path";
4865
+ init_offline();
4764
4866
  init_exec();
4765
4867
  init_fs();
4766
4868
  init_logger();
@@ -4941,6 +5043,9 @@ var EXEC_ENV = { GIT_TERMINAL_PROMPT: "0" };
4941
5043
  function globalPluginsDisabled() {
4942
5044
  return process.env.HAUS_NO_GLOBAL_PLUGINS === "1";
4943
5045
  }
5046
+ function globalPluginMutationsDisabled() {
5047
+ return globalPluginsDisabled() || isOffline();
5048
+ }
4944
5049
  var realExec = (args) => runCommand("claude", args, {
4945
5050
  timeout: EXEC_TIMEOUT_MS,
4946
5051
  env: EXEC_ENV,
@@ -5084,7 +5189,7 @@ async function ensureGlobalPlugins(pins, deps = {}) {
5084
5189
  const log2 = deps.log ?? log;
5085
5190
  const warn2 = deps.warn ?? warn;
5086
5191
  const dryRun = deps.dryRun ?? false;
5087
- if (globalPluginsDisabled() && deps.exec === void 0) {
5192
+ if (globalPluginMutationsDisabled() && deps.exec === void 0) {
5088
5193
  return { actions: [], ensured: [], failed: [], unavailable: true, restartRequired: false };
5089
5194
  }
5090
5195
  const state = await probePluginEnv(deps);
@@ -5153,7 +5258,7 @@ async function removeGlobalPlugins(plugins, deps = {}) {
5153
5258
  const log2 = deps.log ?? log;
5154
5259
  const result = { removed: [], failed: [], unavailable: false };
5155
5260
  if (plugins.length === 0) return result;
5156
- if (globalPluginsDisabled() && deps.exec === void 0) {
5261
+ if (globalPluginMutationsDisabled() && deps.exec === void 0) {
5157
5262
  return { ...result, unavailable: true };
5158
5263
  }
5159
5264
  const state = await probePluginEnv(deps);
@@ -5202,25 +5307,25 @@ async function removeGlobalPlugins(plugins, deps = {}) {
5202
5307
 
5203
5308
  // src/refs/fetch-refs.ts
5204
5309
  import path29 from "node:path";
5205
- import fs25 from "fs-extra";
5310
+ import fs26 from "fs-extra";
5206
5311
 
5207
5312
  // src/refs/cache-meta.ts
5208
5313
  import path28 from "node:path";
5209
- import fs24 from "fs-extra";
5314
+ import fs25 from "fs-extra";
5210
5315
  var META_FILENAME = "cache-meta.json";
5211
5316
  async function readCacheMeta(cacheDir) {
5212
5317
  const metaPath = path28.join(cacheDir, META_FILENAME);
5213
5318
  try {
5214
- const raw = await fs24.readFile(metaPath, "utf8");
5319
+ const raw = await fs25.readFile(metaPath, "utf8");
5215
5320
  return JSON.parse(raw);
5216
5321
  } catch {
5217
5322
  return {};
5218
5323
  }
5219
5324
  }
5220
5325
  async function writeCacheMeta(cacheDir, meta) {
5221
- await fs24.ensureDir(cacheDir);
5326
+ await fs25.ensureDir(cacheDir);
5222
5327
  const metaPath = path28.join(cacheDir, META_FILENAME);
5223
- await fs24.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
5328
+ await fs25.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
5224
5329
  }
5225
5330
 
5226
5331
  // src/refs/fetch-refs.ts
@@ -5241,7 +5346,7 @@ async function fetchSingleRef(url, cacheDir, meta) {
5241
5346
  const existing = meta[url];
5242
5347
  const headers = {};
5243
5348
  const safeFile = existing?.file && existing.file === path29.basename(existing.file);
5244
- const cachedFileExists = safeFile && await fs25.pathExists(path29.join(cacheDir, existing.file));
5349
+ const cachedFileExists = safeFile && await fs26.pathExists(path29.join(cacheDir, existing.file));
5245
5350
  if (cachedFileExists) {
5246
5351
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
5247
5352
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -5252,8 +5357,8 @@ async function fetchSingleRef(url, cacheDir, meta) {
5252
5357
  if (!res.ok) return { result: "failed" };
5253
5358
  const text = await res.text();
5254
5359
  const file = `${urlToSlug(url)}.md`;
5255
- await fs25.ensureDir(cacheDir);
5256
- await fs25.writeFile(path29.join(cacheDir, file), text, "utf8");
5360
+ await fs26.ensureDir(cacheDir);
5361
+ await fs26.writeFile(path29.join(cacheDir, file), text, "utf8");
5257
5362
  meta[url] = {
5258
5363
  url,
5259
5364
  etag: res.headers.get("etag") ?? void 0,
@@ -5277,7 +5382,7 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
5277
5382
  delete meta[url];
5278
5383
  removed++;
5279
5384
  const safeFile = typeof entry.file === "string" && entry.file === path29.basename(entry.file);
5280
- if (safeFile) await fs25.remove(path29.join(cacheDir, entry.file)).catch(() => {
5385
+ if (safeFile) await fs26.remove(path29.join(cacheDir, entry.file)).catch(() => {
5281
5386
  });
5282
5387
  }
5283
5388
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -5351,13 +5456,6 @@ function dryRunPrefix(dryRun, text) {
5351
5456
 
5352
5457
  // src/commands/apply.ts
5353
5458
  var STALE_CACHE_THRESHOLD_DAYS = 7;
5354
- async function cacheHasItems() {
5355
- const data = await readJson(path30.join(getCacheDir(), "manifest.json"));
5356
- return Array.isArray(data?.items) && data.items.length > 0;
5357
- }
5358
- function installableRecommendedItems(recommended) {
5359
- return recommended.filter((item) => item.install !== false);
5360
- }
5361
5459
  async function runApply(options) {
5362
5460
  const mode = resolveWriteMode(options, "explicit-choice");
5363
5461
  if (mode.kind === "unspecified") {
@@ -5374,11 +5472,11 @@ async function runApply(options) {
5374
5472
  process.exitCode = 1;
5375
5473
  return;
5376
5474
  }
5377
- const rec = await readJson(hausPath(root, "recommendation.json"));
5378
- const recommended = rec?.recommended ?? [];
5475
+ const rec2 = await readJson(hausPath(root, "recommendation.json"));
5476
+ const recommended = rec2?.recommended ?? [];
5379
5477
  const installableIds = new Set(installableRecommendedItems(recommended).map((i) => i.id));
5380
5478
  const configIds = new Set(recommended.filter((i) => i.install === false).map((i) => i.id));
5381
- const skippedIds = new Set((rec?.skipped ?? []).map((s) => s.id));
5479
+ const skippedIds = new Set((rec2?.skipped ?? []).map((s) => s.id));
5382
5480
  for (const id of explicitIds.filter((id2) => !installableIds.has(id2))) {
5383
5481
  if (configIds.has(id)) {
5384
5482
  warn(`--ids: "${id}" is a config item \u2014 install it with \`haus configs ${id}\`. Ignoring.`);
@@ -5401,12 +5499,12 @@ async function runApply(options) {
5401
5499
  process.exitCode = 1;
5402
5500
  return;
5403
5501
  }
5404
- const rec = await readJson(hausPath(root, "recommendation.json"));
5405
- if (!rec) {
5502
+ const rec2 = await readJson(hausPath(root, "recommendation.json"));
5503
+ if (!rec2) {
5406
5504
  log("No recommendation.json found \u2014 run `haus recommend` first. Writing core files only.");
5407
5505
  selectedIds = [];
5408
5506
  } else {
5409
- const items = installableRecommendedItems(rec.recommended);
5507
+ const items = installableRecommendedItems(rec2.recommended);
5410
5508
  if (items.length === 0) {
5411
5509
  log("Recommendation contains no catalog items. Writing core files only.");
5412
5510
  selectedIds = [];
@@ -5426,14 +5524,31 @@ async function runApply(options) {
5426
5524
  }
5427
5525
  }
5428
5526
  }
5429
- if (!process.env["HAUS_FIXTURE_CATALOG"]) {
5430
- const rec = await readJson(hausPath(root, "recommendation.json"));
5431
- const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
5432
- const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : installableRecommended.length;
5433
- if (catalogItemCount > 0) {
5434
- const hasItems = await cacheHasItems();
5435
- if (!hasItems && !options.allowEmptyCache) {
5436
- 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) {
5437
5552
  if (isDryRun) {
5438
5553
  warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message2}`);
5439
5554
  } else {
@@ -5441,25 +5556,32 @@ async function runApply(options) {
5441
5556
  process.exitCode = 1;
5442
5557
  return;
5443
5558
  }
5444
- } else if (hasItems) {
5445
- const cacheAgeMs = await getCacheManifestAge();
5446
- if (cacheAgeMs !== null) {
5447
- const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
5448
- if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
5449
- warn(
5450
- `Catalog cache is ${cacheAgeDays} days old \u2014 run \`haus update\` for the latest content.`
5451
- );
5452
- }
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
+ );
5453
5577
  }
5454
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
+ );
5455
5583
  }
5456
5584
  }
5457
- const formatRefusal = await catalogFormatRefusal(root);
5458
- if (formatRefusal) {
5459
- error(formatRefusal);
5460
- process.exitCode = 1;
5461
- return;
5462
- }
5463
5585
  const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
5464
5586
  refillConfig: options.refillConfig,
5465
5587
  force: options.force,
@@ -5476,13 +5598,19 @@ async function runApply(options) {
5476
5598
  const installedIds = new Set(lock.map((entry) => entry.id));
5477
5599
  const allItems = await loadCatalog(root);
5478
5600
  const installedItems = allItems.filter((item) => installedIds.has(item.id));
5479
- const summary = await fetchRefsForItems(installedItems, cacheDir);
5480
- if (summary.fetched > 0)
5481
- log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
5482
- if (summary.failed > 0)
5483
- warn(
5484
- `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
5485
- );
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
+ }
5486
5614
  const pruned = await pruneOrphanedRefs(cacheDir, new Set(collectLlmsTxtUrls(installedItems)));
5487
5615
  if (pruned > 0)
5488
5616
  log(
@@ -5505,17 +5633,32 @@ async function runApply(options) {
5505
5633
  }
5506
5634
  if (isDryRun) {
5507
5635
  log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
5508
- } else {
5509
- log("Applied files:");
5510
- 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
+ }
5511
5654
  }
5512
5655
  }
5513
5656
 
5514
5657
  // src/commands/backups.ts
5658
+ import path30 from "node:path";
5659
+ import fs27 from "fs-extra";
5515
5660
  init_logger();
5516
5661
  init_paths();
5517
- import path31 from "node:path";
5518
- import fs26 from "fs-extra";
5519
5662
 
5520
5663
  // src/utils/prompts.ts
5521
5664
  import { stdin, stdout } from "node:process";
@@ -5578,25 +5721,16 @@ Enter a number (1-${choices.length})`, io);
5578
5721
  }
5579
5722
 
5580
5723
  // src/commands/backups.ts
5581
- var LOCK_BACKUP_RE = /^haus\.lock\.\d+\.json$/;
5582
- function classify(name) {
5583
- if (LOCK_BACKUP_RE.test(name)) return "lock";
5584
- if (name.startsWith("undo-")) return "undo";
5585
- if (name.startsWith("prune-")) return "prune";
5586
- if (name.startsWith("remove-")) return "remove";
5587
- if (name.startsWith("migrate-")) return "migrate";
5588
- return void 0;
5589
- }
5590
5724
  async function listBackups(root) {
5591
5725
  const dir = hausPath(root, "backups");
5592
- if (!await fs26.pathExists(dir)) return [];
5593
- const names = await fs26.readdir(dir);
5726
+ if (!await fs27.pathExists(dir)) return [];
5727
+ const names = await fs27.readdir(dir);
5594
5728
  const entries = [];
5595
5729
  for (const name of names) {
5596
- const kind = classify(name);
5730
+ const kind = classifyBackupName(name);
5597
5731
  if (!kind) continue;
5598
- const absPath = path31.join(dir, name);
5599
- const stat = await fs26.lstat(absPath);
5732
+ const absPath = path30.join(dir, name);
5733
+ const stat = await fs27.lstat(absPath);
5600
5734
  if (stat.isSymbolicLink()) continue;
5601
5735
  const expectDirectory = kind !== "lock";
5602
5736
  if (stat.isDirectory() !== expectDirectory) continue;
@@ -5635,13 +5769,13 @@ async function runList(root, json) {
5635
5769
  }
5636
5770
  }
5637
5771
  async function isUnsafeWriteTarget(root, target) {
5638
- const segments = path31.relative(root, target).split(path31.sep).filter(Boolean);
5772
+ const segments = path30.relative(root, target).split(path30.sep).filter(Boolean);
5639
5773
  let current = root;
5640
5774
  for (let i = 0; i < segments.length; i++) {
5641
- current = path31.join(current, segments[i]);
5775
+ current = path30.join(current, segments[i]);
5642
5776
  const isLastSegment = i === segments.length - 1;
5643
5777
  try {
5644
- const stat = await fs26.lstat(current);
5778
+ const stat = await fs27.lstat(current);
5645
5779
  if (isLastSegment) {
5646
5780
  if (!stat.isFile()) return true;
5647
5781
  } else if (!stat.isDirectory()) {
@@ -5656,7 +5790,7 @@ async function isUnsafeWriteTarget(root, target) {
5656
5790
  }
5657
5791
  async function lstatIfRegularFile(abs) {
5658
5792
  try {
5659
- const stat = await fs26.lstat(abs);
5793
+ const stat = await fs27.lstat(abs);
5660
5794
  return stat.isFile() ? stat : null;
5661
5795
  } catch (err) {
5662
5796
  if (err.code === "ENOENT") return null;
@@ -5671,8 +5805,8 @@ async function restoreLockBackup(root, entry, yes, json, dryRun) {
5671
5805
  );
5672
5806
  return { ok: false, restored: 0 };
5673
5807
  }
5674
- if (await fs26.pathExists(target)) {
5675
- const targetStat = await fs26.stat(target);
5808
+ if (await fs27.pathExists(target)) {
5809
+ const targetStat = await fs27.stat(target);
5676
5810
  if (targetStat.mtimeMs > entry.mtimeMs) {
5677
5811
  warn(
5678
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.`
@@ -5706,7 +5840,7 @@ async function restoreLockBackup(root, entry, yes, json, dryRun) {
5706
5840
  error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
5707
5841
  return { ok: false, restored: 0 };
5708
5842
  }
5709
- await fs26.copy(entry.absPath, target, { overwrite: true });
5843
+ await fs27.copy(entry.absPath, target, { overwrite: true });
5710
5844
  if (!json) log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
5711
5845
  return { ok: true, restored: 1 };
5712
5846
  }
@@ -5714,10 +5848,10 @@ async function collectFilesRecursive(dir) {
5714
5848
  const files = [];
5715
5849
  const skippedLinks = [];
5716
5850
  const skippedOther = [];
5717
- const names = await fs26.readdir(dir);
5851
+ const names = await fs27.readdir(dir);
5718
5852
  for (const name of names) {
5719
- const abs = path31.join(dir, name);
5720
- const stat = await fs26.lstat(abs);
5853
+ const abs = path30.join(dir, name);
5854
+ const stat = await fs27.lstat(abs);
5721
5855
  if (stat.isSymbolicLink()) {
5722
5856
  skippedLinks.push(abs);
5723
5857
  continue;
@@ -5739,12 +5873,12 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5739
5873
  const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
5740
5874
  if (skippedLinks.length > 0) {
5741
5875
  warn(
5742
- `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(", ")}`
5743
5877
  );
5744
5878
  }
5745
5879
  if (skippedOther.length > 0) {
5746
5880
  warn(
5747
- `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(", ")}`
5748
5882
  );
5749
5883
  }
5750
5884
  if (files.length === 0) {
@@ -5754,8 +5888,8 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5754
5888
  const safeFiles = [];
5755
5889
  const unsafeTargets = [];
5756
5890
  for (const abs of files) {
5757
- const rel = path31.relative(entry.absPath, abs);
5758
- const target = path31.join(root, rel);
5891
+ const rel = path30.relative(entry.absPath, abs);
5892
+ const target = path30.join(root, rel);
5759
5893
  if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
5760
5894
  else safeFiles.push(abs);
5761
5895
  }
@@ -5772,16 +5906,16 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5772
5906
  const vanished = [];
5773
5907
  const toRestore = [];
5774
5908
  for (const abs of safeFiles) {
5775
- const rel = path31.relative(entry.absPath, abs);
5909
+ const rel = path30.relative(entry.absPath, abs);
5776
5910
  const backupFileStat = await lstatIfRegularFile(abs);
5777
5911
  if (!backupFileStat) {
5778
5912
  vanished.push(rel);
5779
5913
  continue;
5780
5914
  }
5781
5915
  toRestore.push(abs);
5782
- const target = path31.join(root, rel);
5783
- if (await fs26.pathExists(target)) {
5784
- 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);
5785
5919
  if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
5786
5920
  stale.push({
5787
5921
  rel,
@@ -5809,7 +5943,7 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5809
5943
  );
5810
5944
  }
5811
5945
  if (dryRun) {
5812
- const rels = toRestore.map((abs) => path31.relative(entry.absPath, abs));
5946
+ const rels = toRestore.map((abs) => path30.relative(entry.absPath, abs));
5813
5947
  if (!json) {
5814
5948
  log(`[dry-run] Would restore ${rels.length} file(s) from ${entry.id}:`);
5815
5949
  for (const rel of rels) log(` ${rel}`);
@@ -5835,14 +5969,14 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5835
5969
  let restoredCount = 0;
5836
5970
  const skippedAtWrite = [];
5837
5971
  for (const abs of toRestore) {
5838
- const rel = path31.relative(entry.absPath, abs);
5839
- const target = path31.join(root, rel);
5972
+ const rel = path30.relative(entry.absPath, abs);
5973
+ const target = path30.join(root, rel);
5840
5974
  if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
5841
5975
  skippedAtWrite.push(rel);
5842
5976
  continue;
5843
5977
  }
5844
- await fs26.ensureDir(path31.dirname(target));
5845
- await fs26.copy(abs, target, { overwrite: true });
5978
+ await fs27.ensureDir(path30.dirname(target));
5979
+ await fs27.copy(abs, target, { overwrite: true });
5846
5980
  restoredCount++;
5847
5981
  }
5848
5982
  if (skippedAtWrite.length > 0) {
@@ -5923,7 +6057,7 @@ Keeping ${keepCount}.`
5923
6057
  const failed = [];
5924
6058
  for (const entry of removeList) {
5925
6059
  try {
5926
- await fs26.remove(entry.absPath);
6060
+ await fs27.remove(entry.absPath);
5927
6061
  removed.push(entry.id);
5928
6062
  } catch (err) {
5929
6063
  failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
@@ -6017,26 +6151,272 @@ function auditForbiddenTags(items) {
6017
6151
  if (forbidden.has(token)) failures.push(`${item.id} has unsupported tag ${token}`);
6018
6152
  }
6019
6153
  }
6020
- return failures;
6021
- }
6022
- async function runCatalogAudit() {
6023
- const items = await loadCatalog(process.cwd());
6024
- const failures = auditForbiddenTags(items);
6025
- if (failures.length) {
6026
- failures.forEach((f) => error(f));
6027
- process.exitCode = 1;
6028
- return;
6029
- }
6030
- log("Catalog audit passed.");
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);
6286
+ }
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 };
6298
+ }
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));
6304
+ }
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));
6322
+ }
6323
+ }
6324
+ return removed;
6325
+ }
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}`);
6331
+ process.exitCode = 1;
6332
+ return;
6333
+ }
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
+ }
6031
6411
  }
6032
6412
 
6033
6413
  // src/commands/doctor.ts
6034
6414
  import path45 from "node:path";
6035
- import fs35 from "fs-extra";
6415
+ import fs37 from "fs-extra";
6036
6416
  init_manifest_format();
6037
6417
 
6038
6418
  // src/claude/former-install-orphans.ts
6039
- import fs27 from "fs-extra";
6419
+ import fs29 from "fs-extra";
6040
6420
  init_path_safety();
6041
6421
  init_paths();
6042
6422
  var toPosix = (value) => value.replace(/\\/g, "/");
@@ -6060,7 +6440,7 @@ async function findOrphanedFormerInstalls(root, items, lockRows) {
6060
6440
  const projectRel = `.claude/${relPath}`;
6061
6441
  if (trackedPaths.has(projectRel) || reported.has(projectRel)) continue;
6062
6442
  const abs = safeJoin(claudePath(root), relPath);
6063
- if (abs === null || !await fs27.pathExists(abs)) continue;
6443
+ if (abs === null || !await fs29.pathExists(abs)) continue;
6064
6444
  reported.add(projectRel);
6065
6445
  orphans.push({ formerId: entry.formerId, currentId: entry.currentId, relPath: projectRel });
6066
6446
  }
@@ -6071,7 +6451,7 @@ async function findOrphanedFormerInstalls(root, items, lockRows) {
6071
6451
  init_paths();
6072
6452
  import path32 from "node:path";
6073
6453
  import fg4 from "fast-glob";
6074
- import fs28 from "fs-extra";
6454
+ import fs30 from "fs-extra";
6075
6455
  var REFERENCE_PATTERN = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+)+):([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b(?!:\/\/)/g;
6076
6456
  function stripFencedCodeBlocks(text) {
6077
6457
  return text.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, " "));
@@ -6079,15 +6459,15 @@ function stripFencedCodeBlocks(text) {
6079
6459
  async function findUnresolvedSkillReferences(root, options = {}) {
6080
6460
  const resolvableNamespaces = new Set(options.pluginNamespaces ?? []);
6081
6461
  const skillsRoot = claudePath(root, "skills");
6082
- if (!await fs28.pathExists(skillsRoot)) return [];
6083
- 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);
6084
6464
  const installedSkillNames = new Set(skillDirs);
6085
6465
  const files = await fg4("*/**/*.md", { cwd: skillsRoot, onlyFiles: true, dot: true });
6086
6466
  const issues = [];
6087
6467
  for (const rel of files) {
6088
6468
  if (rel.startsWith("shared/")) continue;
6089
6469
  const abs = path32.join(skillsRoot, rel);
6090
- const raw = await fs28.readFile(abs, "utf8");
6470
+ const raw = await fs30.readFile(abs, "utf8");
6091
6471
  const lines = stripFencedCodeBlocks(raw).split("\n");
6092
6472
  lines.forEach((line2, idx) => {
6093
6473
  for (const match of line2.matchAll(REFERENCE_PATTERN)) {
@@ -6110,13 +6490,13 @@ async function findUnresolvedSkillReferences(root, options = {}) {
6110
6490
  // src/decisions/doctor.ts
6111
6491
  init_fs();
6112
6492
  import path37 from "node:path";
6113
- import fs32 from "fs-extra";
6493
+ import fs34 from "fs-extra";
6114
6494
 
6115
6495
  // src/decisions/check.ts
6116
6496
  init_fs();
6117
6497
  init_paths();
6118
6498
  import path36 from "node:path";
6119
- import fs31 from "fs-extra";
6499
+ import fs33 from "fs-extra";
6120
6500
 
6121
6501
  // src/decisions/dep-bump.ts
6122
6502
  var DEP_SECTIONS = [
@@ -6309,12 +6689,12 @@ function firstMatchingRegex(filePath, patterns) {
6309
6689
 
6310
6690
  // src/decisions/paths.ts
6311
6691
  import path34 from "node:path";
6312
- import fs29 from "fs-extra";
6692
+ import fs31 from "fs-extra";
6313
6693
  async function resolveDecisionsDir(root) {
6314
6694
  const primary = path34.join(root, DECISIONS_TRIGGERS.decisionsDir);
6315
- if (await fs29.pathExists(primary)) return primary;
6695
+ if (await fs31.pathExists(primary)) return primary;
6316
6696
  const legacy = path34.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
6317
- if (await fs29.pathExists(legacy)) return legacy;
6697
+ if (await fs31.pathExists(legacy)) return legacy;
6318
6698
  return primary;
6319
6699
  }
6320
6700
  function relativeDecisionPath(decisionsDir, root) {
@@ -6324,7 +6704,7 @@ function relativeDecisionPath(decisionsDir, root) {
6324
6704
  // src/decisions/validate.ts
6325
6705
  init_fs();
6326
6706
  import path35 from "node:path";
6327
- import fs30 from "fs-extra";
6707
+ import fs32 from "fs-extra";
6328
6708
  function validateDecisionContent(content2) {
6329
6709
  const errors = [];
6330
6710
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -6354,7 +6734,7 @@ function decisionNumberFromFile(name) {
6354
6734
  }
6355
6735
  async function validateIndexSync(decisionsDir, decisionFiles) {
6356
6736
  const readmePath = path35.join(decisionsDir, "README.md");
6357
- if (!await fs30.pathExists(readmePath)) {
6737
+ if (!await fs32.pathExists(readmePath)) {
6358
6738
  return ["docs/decisions/README.md is missing"];
6359
6739
  }
6360
6740
  const readme = await readText(readmePath) ?? "";
@@ -6526,8 +6906,8 @@ ${opts.commitMessages ?? ""}`;
6526
6906
  changedFiles: stats.files
6527
6907
  });
6528
6908
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
6529
- const legacyExists = await fs31.pathExists(path36.join(root, legacyRel));
6530
- if (legacyExists && !await fs31.pathExists(decisionsDir)) {
6909
+ const legacyExists = await fs33.pathExists(path36.join(root, legacyRel));
6910
+ if (legacyExists && !await fs33.pathExists(decisionsDir)) {
6531
6911
  satisfactionErrors = await checkSatisfaction({
6532
6912
  root,
6533
6913
  decisionsRel: legacyRel,
@@ -6549,8 +6929,8 @@ ${opts.commitMessages ?? ""}`;
6549
6929
  return { triggered: true, satisfied: false, reasons, securityTriggered };
6550
6930
  }
6551
6931
  async function listDecisionNumbers(decisionsDir) {
6552
- if (!await fs31.pathExists(decisionsDir)) return [];
6553
- const entries = await fs31.readdir(decisionsDir);
6932
+ if (!await fs33.pathExists(decisionsDir)) return [];
6933
+ const entries = await fs33.readdir(decisionsDir);
6554
6934
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
6555
6935
  }
6556
6936
 
@@ -6559,8 +6939,8 @@ async function auditDecisionsLayout(root) {
6559
6939
  const findings = [];
6560
6940
  const primary = path37.join(root, DECISIONS_TRIGGERS.decisionsDir);
6561
6941
  const legacy = path37.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
6562
- const hasPrimary = await fs32.pathExists(primary);
6563
- const hasLegacy = await fs32.pathExists(legacy);
6942
+ const hasPrimary = await fs34.pathExists(primary);
6943
+ const hasLegacy = await fs34.pathExists(legacy);
6564
6944
  if (!hasPrimary && hasLegacy) {
6565
6945
  findings.push({
6566
6946
  level: "advisory",
@@ -6574,7 +6954,7 @@ async function auditDecisionsLayout(root) {
6574
6954
  const decisionsDir = await resolveDecisionsDir(root);
6575
6955
  const rel = relativeDecisionPath(decisionsDir, root);
6576
6956
  const readmePath = path37.join(decisionsDir, "README.md");
6577
- if (!await fs32.pathExists(readmePath)) {
6957
+ if (!await fs34.pathExists(readmePath)) {
6578
6958
  findings.push({
6579
6959
  level: "advisory",
6580
6960
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -6584,7 +6964,7 @@ async function auditDecisionsLayout(root) {
6584
6964
  return findings;
6585
6965
  }
6586
6966
  const readme = await readText(readmePath);
6587
- const entries = (await fs32.readdir(decisionsDir)).filter(
6967
+ const entries = (await fs34.readdir(decisionsDir)).filter(
6588
6968
  (f) => f.endsWith(".md") && f !== "README.md"
6589
6969
  );
6590
6970
  for (const file of entries) {
@@ -6633,7 +7013,7 @@ init_fs();
6633
7013
  init_logger();
6634
7014
  init_paths();
6635
7015
  import path38 from "node:path";
6636
- import fs33 from "fs-extra";
7016
+ import fs35 from "fs-extra";
6637
7017
  function manifestPathIn(configDir) {
6638
7018
  return path38.join(configDir, "haus", "install-manifest.json");
6639
7019
  }
@@ -6663,9 +7043,9 @@ async function pruneVacatedDirs(root, vacatedFiles) {
6663
7043
  async function destinationWritable(dir) {
6664
7044
  let candidate = path38.resolve(dir);
6665
7045
  for (; ; ) {
6666
- if (await fs33.pathExists(candidate)) {
7046
+ if (await fs35.pathExists(candidate)) {
6667
7047
  try {
6668
- await fs33.access(candidate, fs33.constants.W_OK);
7048
+ await fs35.access(candidate, fs35.constants.W_OK);
6669
7049
  return true;
6670
7050
  } catch (err) {
6671
7051
  return err instanceof Error ? err.message : String(err);
@@ -6697,7 +7077,7 @@ async function migrateGlobalConfigDir(options = {}) {
6697
7077
  if (!legacy || !Array.isArray(legacy.files)) {
6698
7078
  return { ...base, reason: "no install manifest under ~/.claude" };
6699
7079
  }
6700
- if (await fs33.pathExists(manifestPathIn(to))) {
7080
+ if (await fs35.pathExists(manifestPathIn(to))) {
6701
7081
  return { ...base, reason: "an install manifest already exists in the resolved config dir" };
6702
7082
  }
6703
7083
  const writable = await destinationWritable(to);
@@ -6720,14 +7100,14 @@ async function migrateGlobalConfigDir(options = {}) {
6720
7100
  continue;
6721
7101
  }
6722
7102
  const newDest = path38.join(to, rel);
6723
- if (!await fs33.pathExists(entry.destPath)) {
7103
+ if (!await fs35.pathExists(entry.destPath)) {
6724
7104
  rewritten.push({ ...entry, destPath: newDest });
6725
7105
  continue;
6726
7106
  }
6727
- if (await fs33.pathExists(newDest)) {
7107
+ if (await fs35.pathExists(newDest)) {
6728
7108
  if (await sameContent(entry.destPath, newDest)) {
6729
7109
  if (!dryRun) {
6730
- await fs33.remove(entry.destPath);
7110
+ await fs35.remove(entry.destPath);
6731
7111
  vacated.push(entry.destPath);
6732
7112
  }
6733
7113
  moved.push(newDest);
@@ -6744,7 +7124,7 @@ async function migrateGlobalConfigDir(options = {}) {
6744
7124
  continue;
6745
7125
  }
6746
7126
  try {
6747
- await fs33.move(entry.destPath, newDest);
7127
+ await fs35.move(entry.destPath, newDest);
6748
7128
  vacated.push(entry.destPath);
6749
7129
  moved.push(newDest);
6750
7130
  rewritten.push({ ...entry, destPath: newDest });
@@ -6769,7 +7149,7 @@ async function migrateGlobalConfigDir(options = {}) {
6769
7149
  }
6770
7150
  try {
6771
7151
  await writeJson(manifestPathIn(to), { ...legacy, files: rewritten });
6772
- await fs33.remove(legacyManifestPath);
7152
+ await fs35.remove(legacyManifestPath);
6773
7153
  vacated.push(legacyManifestPath);
6774
7154
  } catch (err) {
6775
7155
  return {
@@ -6815,7 +7195,7 @@ function printConfigDirMigration(result) {
6815
7195
 
6816
7196
  // src/scanner/read-context.ts
6817
7197
  import path44 from "node:path";
6818
- import fs34 from "fs-extra";
7198
+ import fs36 from "fs-extra";
6819
7199
 
6820
7200
  // src/commands/workspace/config.ts
6821
7201
  init_fs();
@@ -7399,10 +7779,10 @@ async function isCacheFresh(root, cachePath) {
7399
7779
  path44.join(root, WORKSPACE_FILE),
7400
7780
  path44.join(root, REPOS_MANIFEST_FILE)
7401
7781
  ];
7402
- const cacheStat = await fs34.stat(cachePath);
7782
+ const cacheStat = await fs36.stat(cachePath);
7403
7783
  for (const markerPath of markerPaths) {
7404
- if (!await fs34.pathExists(markerPath)) continue;
7405
- const markerStat = await fs34.stat(markerPath);
7784
+ if (!await fs36.pathExists(markerPath)) continue;
7785
+ const markerStat = await fs36.stat(markerPath);
7406
7786
  if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
7407
7787
  }
7408
7788
  return true;
@@ -7416,11 +7796,15 @@ async function readContextOrScan(root) {
7416
7796
  }
7417
7797
 
7418
7798
  // src/update/npm-version.ts
7799
+ init_offline();
7419
7800
  var NPM_PACKAGE_NAME = "@haus-tech/haus-workflow";
7420
7801
  function isTestMode2() {
7421
7802
  return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
7422
7803
  }
7423
7804
  async function fetchNpmVersionStatus(currentVersion) {
7805
+ if (isOffline()) {
7806
+ return { current: currentVersion, latest: null, updateAvailable: false };
7807
+ }
7424
7808
  if (isTestMode2() && process.env["HAUS_SKIP_NPM_CHECK"]) {
7425
7809
  return { current: currentVersion, latest: null, updateAvailable: false };
7426
7810
  }
@@ -7559,7 +7943,7 @@ async function runDoctor(options) {
7559
7943
  const resolved = path45.resolve(root, target);
7560
7944
  const relFromRoot = path45.relative(root, resolved);
7561
7945
  const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path45.sep}`) || path45.isAbsolute(relFromRoot);
7562
- if (escapesRoot || !await fs35.pathExists(resolved)) {
7946
+ if (escapesRoot || !await fs37.pathExists(resolved)) {
7563
7947
  flag(
7564
7948
  `- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
7565
7949
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -7577,7 +7961,7 @@ async function runDoctor(options) {
7577
7961
  );
7578
7962
  }
7579
7963
  const workflowPath = hausPath(root, "WORKFLOW.md");
7580
- const workflowExists = await fs35.pathExists(workflowPath);
7964
+ const workflowExists = await fs37.pathExists(workflowPath);
7581
7965
  if (!workflowExists) {
7582
7966
  flag(
7583
7967
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -7600,7 +7984,7 @@ async function runDoctor(options) {
7600
7984
  "templates",
7601
7985
  "agentic-workflow-standard.md"
7602
7986
  );
7603
- const templatePath = await fs35.pathExists(cachePath) ? cachePath : bundledPath;
7987
+ const templatePath = await fs37.pathExists(cachePath) ? cachePath : bundledPath;
7604
7988
  const templateContent = await readText(templatePath);
7605
7989
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
7606
7990
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -7628,7 +8012,7 @@ async function runDoctor(options) {
7628
8012
  }
7629
8013
  }
7630
8014
  const workflowConfigPath = hausPath(root, "workflow-config.md");
7631
- const workflowConfigExists = await fs35.pathExists(workflowConfigPath);
8015
+ const workflowConfigExists = await fs37.pathExists(workflowConfigPath);
7632
8016
  if (!workflowConfigExists) {
7633
8017
  flag(
7634
8018
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -7636,7 +8020,7 @@ async function runDoctor(options) {
7636
8020
  "haus apply --write"
7637
8021
  );
7638
8022
  } else {
7639
- const cfg = await fs35.readFile(workflowConfigPath, "utf8");
8023
+ const cfg = await fs37.readFile(workflowConfigPath, "utf8");
7640
8024
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
7641
8025
  if (unfilled > 0) {
7642
8026
  suggest(
@@ -7665,12 +8049,12 @@ async function runDoctor(options) {
7665
8049
  const trackedArtifacts = await listTrackedArtifactPaths(root);
7666
8050
  if (trackedArtifacts.length > 0) {
7667
8051
  flag(
7668
- `- GITIGNORE: ${trackedArtifacts.length} machine-local scan artifact(s) still tracked in git (${trackedArtifacts.join(", ")})`,
7669
- `${trackedArtifacts.length} machine-local scan artifact(s) are still tracked in git`,
8052
+ `- GITIGNORE: ${trackedArtifacts.length} machine-local artifact(s) still tracked in git (${trackedArtifacts.join(", ")})`,
8053
+ `${trackedArtifacts.length} machine-local artifact(s) are still tracked in git`,
7670
8054
  `git rm --cached ${trackedArtifacts.join(" ")} (or run \`haus apply --write\` to do this and update .gitignore for you)`
7671
8055
  );
7672
8056
  } else {
7673
- ok("- GITIGNORE: OK (no machine-local scan artifacts tracked in git)");
8057
+ ok("- GITIGNORE: OK (no machine-local artifacts tracked in git)");
7674
8058
  }
7675
8059
  const gitignoredHausPaths = await findGitignoredHausPaths(root);
7676
8060
  if (gitignoredHausPaths.length > 0) {
@@ -7690,8 +8074,10 @@ async function runDoctor(options) {
7690
8074
  else suggest(finding.line, finding.sentence, finding.fix);
7691
8075
  }
7692
8076
  let catalogItems = null;
8077
+ let catalogCtx = null;
7693
8078
  try {
7694
- catalogItems = await loadCatalog(root);
8079
+ catalogCtx = await loadCatalogContext(root);
8080
+ catalogItems = catalogCtx.items;
7695
8081
  } catch (err) {
7696
8082
  if (!(err instanceof UnsupportedManifestFormatError)) throw err;
7697
8083
  flag(
@@ -7784,6 +8170,31 @@ async function runDoctor(options) {
7784
8170
  );
7785
8171
  }
7786
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
+ }
7787
8198
  const cacheAgeMs = await getCacheManifestAge();
7788
8199
  if (cacheAgeMs === null) {
7789
8200
  suggest(
@@ -7877,7 +8288,7 @@ async function runDoctor(options) {
7877
8288
  let onDisk = false;
7878
8289
  for (const rel of row.paths ?? []) {
7879
8290
  const abs = resolveContainedPath(root, rel);
7880
- if (abs !== null && await fs35.pathExists(abs)) {
8291
+ if (abs !== null && await fs37.pathExists(abs)) {
7881
8292
  onDisk = true;
7882
8293
  break;
7883
8294
  }
@@ -7909,7 +8320,21 @@ async function runDoctor(options) {
7909
8320
  }
7910
8321
  if (attention.length > 0) process.exitCode = 1;
7911
8322
  if (options?.json) {
7912
- 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
+ );
7913
8338
  return;
7914
8339
  }
7915
8340
  if (attention.length === 0) {
@@ -7934,11 +8359,11 @@ import path48 from "node:path";
7934
8359
  init_github_rate_limit();
7935
8360
 
7936
8361
  // src/claude/refresh-project.ts
7937
- import fs36 from "fs-extra";
8362
+ import fs38 from "fs-extra";
7938
8363
  init_fs();
7939
8364
  init_paths();
7940
8365
  async function isHausProject(root) {
7941
- if (await fs36.pathExists(hausPath(root, "recommendation.json"))) return true;
8366
+ if (await fs38.pathExists(hausPath(root, "recommendation.json"))) return true;
7942
8367
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
7943
8368
  if (result.status === "ok") return result.value._haus != null;
7944
8369
  return false;
@@ -7955,7 +8380,7 @@ async function refreshProjectApply(root) {
7955
8380
  // src/install/apply.ts
7956
8381
  import crypto2 from "node:crypto";
7957
8382
  import path46 from "node:path";
7958
- import fs37 from "fs-extra";
8383
+ import fs39 from "fs-extra";
7959
8384
  init_fs();
7960
8385
  init_logger();
7961
8386
  init_paths();
@@ -8044,7 +8469,7 @@ function hashContent(content2) {
8044
8469
  }
8045
8470
  function destFileMissing(destPath) {
8046
8471
  try {
8047
- return !fs37.statSync(destPath).isFile();
8472
+ return !fs39.statSync(destPath).isFile();
8048
8473
  } catch {
8049
8474
  return true;
8050
8475
  }
@@ -8052,7 +8477,7 @@ function destFileMissing(destPath) {
8052
8477
  function sourceVersion() {
8053
8478
  try {
8054
8479
  const pkgPath = path46.join(packageRoot(), "package.json");
8055
- const pkg = JSON.parse(fs37.readFileSync(pkgPath, "utf8"));
8480
+ const pkg = JSON.parse(fs39.readFileSync(pkgPath, "utf8"));
8056
8481
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
8057
8482
  } catch {
8058
8483
  return "haus@0.0.0";
@@ -8064,10 +8489,10 @@ function globalSrcDir() {
8064
8489
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
8065
8490
  const skillDir = path46.join(skillsDir, skillName);
8066
8491
  const entries = [];
8067
- for (const rel of fs37.readdirSync(skillDir, { recursive: true })) {
8492
+ for (const rel of fs39.readdirSync(skillDir, { recursive: true })) {
8068
8493
  if (rel === "SKILL.md") continue;
8069
8494
  const abs = path46.join(skillDir, rel);
8070
- if (!fs37.statSync(abs).isFile()) continue;
8495
+ if (!fs39.statSync(abs).isFile()) continue;
8071
8496
  const posixRel = rel.split(path46.sep).join("/");
8072
8497
  entries.push({
8073
8498
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
@@ -8080,10 +8505,10 @@ function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
8080
8505
  function collectSourceFiles(srcDir, claudeDir) {
8081
8506
  const entries = [];
8082
8507
  const skillsDir = path46.join(srcDir, "skills");
8083
- if (fs37.pathExistsSync(skillsDir)) {
8084
- for (const skillName of fs37.readdirSync(skillsDir)) {
8508
+ if (fs39.pathExistsSync(skillsDir)) {
8509
+ for (const skillName of fs39.readdirSync(skillsDir)) {
8085
8510
  const skillFile = path46.join(skillsDir, skillName, "SKILL.md");
8086
- if (fs37.pathExistsSync(skillFile)) {
8511
+ if (fs39.pathExistsSync(skillFile)) {
8087
8512
  entries.push({
8088
8513
  stableId: `skill.${skillName}`,
8089
8514
  srcRelPath: path46.join("library", "global", "skills", skillName, "SKILL.md"),
@@ -8094,8 +8519,8 @@ function collectSourceFiles(srcDir, claudeDir) {
8094
8519
  }
8095
8520
  }
8096
8521
  const commandsDir = path46.join(srcDir, "commands");
8097
- if (fs37.pathExistsSync(commandsDir)) {
8098
- for (const fileName of fs37.readdirSync(commandsDir)) {
8522
+ if (fs39.pathExistsSync(commandsDir)) {
8523
+ for (const fileName of fs39.readdirSync(commandsDir)) {
8099
8524
  if (!fileName.endsWith(".md")) continue;
8100
8525
  const commandName = fileName.slice(0, -".md".length);
8101
8526
  entries.push({
@@ -8154,7 +8579,7 @@ async function applyInstall(options = {}) {
8154
8579
  }
8155
8580
  continue;
8156
8581
  }
8157
- const destExists = fs37.pathExistsSync(entry.destPath);
8582
+ const destExists = fs39.pathExistsSync(entry.destPath);
8158
8583
  if (destExists) {
8159
8584
  const currentContent = await readText(entry.destPath);
8160
8585
  if (currentContent !== void 0) {
@@ -8213,13 +8638,13 @@ async function applyInstall(options = {}) {
8213
8638
  manifestFiles.push(entry);
8214
8639
  continue;
8215
8640
  }
8216
- if (!fs37.pathExistsSync(entry.destPath)) continue;
8641
+ if (!fs39.pathExistsSync(entry.destPath)) continue;
8217
8642
  const content2 = await readText(entry.destPath);
8218
8643
  if (!content2) continue;
8219
8644
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
8220
8645
  const currentHash = hashContent(content2);
8221
8646
  if (hasHeader && currentHash === entry.hash) {
8222
- if (!dryRun) await fs37.remove(entry.destPath);
8647
+ if (!dryRun) await fs39.remove(entry.destPath);
8223
8648
  result.deleted.push(entry.destPath);
8224
8649
  } else {
8225
8650
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -8291,7 +8716,8 @@ function summarizeLockDiff(before, after) {
8291
8716
  }
8292
8717
 
8293
8718
  // src/update/lockfile.ts
8294
- import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
8719
+ import { constants as fsConstants } from "node:fs";
8720
+ import { copyFile, mkdir, readFile as readFile2 } from "node:fs/promises";
8295
8721
  import path47 from "node:path";
8296
8722
  init_fs();
8297
8723
  init_paths();
@@ -8325,16 +8751,30 @@ async function readLockRaw(root) {
8325
8751
  return "[]";
8326
8752
  }
8327
8753
  }
8328
- async function applyLock(root) {
8754
+ async function backupLockfile(root) {
8329
8755
  const lockPath = hausPath(root, "haus.lock.json");
8330
- const before = await readLockRaw(root);
8331
- const lock = await readJson(lockPath) ?? [];
8332
8756
  try {
8333
8757
  const backupDir = hausPath(root, "backups");
8334
8758
  await mkdir(backupDir, { recursive: true });
8335
- await copyFile(lockPath, path47.join(backupDir, `haus.lock.${Date.now()}.json`));
8759
+ let stamp = Date.now();
8760
+ for (; ; ) {
8761
+ const dest = path47.join(backupDir, lockBackupFileName(stamp));
8762
+ try {
8763
+ await copyFile(lockPath, dest, fsConstants.COPYFILE_EXCL);
8764
+ return;
8765
+ } catch (err) {
8766
+ if (err.code !== "EEXIST") throw err;
8767
+ stamp += 1;
8768
+ }
8769
+ }
8336
8770
  } catch {
8337
8771
  }
8772
+ }
8773
+ async function applyLock(root) {
8774
+ const lockPath = hausPath(root, "haus.lock.json");
8775
+ const before = await readLockRaw(root);
8776
+ const lock = await readJson(lockPath) ?? [];
8777
+ await backupLockfile(root);
8338
8778
  const enriched = await Promise.all(
8339
8779
  lock.map(async (x) => {
8340
8780
  const paths = Array.isArray(x.paths) ? x.paths.map(String) : [];
@@ -8357,12 +8797,7 @@ async function removeLockEntries(root, ids) {
8357
8797
  const removed = [...wanted].filter((id) => present.has(id));
8358
8798
  const missing = [...wanted].filter((id) => !present.has(id));
8359
8799
  if (removed.length === 0) return { removed, missing, before, after: before };
8360
- try {
8361
- const backupDir = hausPath(root, "backups");
8362
- await mkdir(backupDir, { recursive: true });
8363
- await copyFile(lockPath, path47.join(backupDir, `haus.lock.${Date.now()}.json`));
8364
- } catch {
8365
- }
8800
+ await backupLockfile(root);
8366
8801
  const survivors = lock.filter((item) => !wanted.has(item.id));
8367
8802
  await writeJson(lockPath, survivors);
8368
8803
  return { removed, missing, before, after: `${JSON.stringify(survivors, null, 2)}
@@ -8804,7 +9239,7 @@ async function runClone(url, opts = {}) {
8804
9239
  // src/install/scaffold.ts
8805
9240
  init_logger();
8806
9241
  import path50 from "node:path";
8807
- import fs38 from "fs-extra";
9242
+ import fs40 from "fs-extra";
8808
9243
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8809
9244
  const scaffolded = [];
8810
9245
  const skipped = [];
@@ -8817,7 +9252,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8817
9252
  skipped.push(name);
8818
9253
  if (outcome === "skipped-exists") skippedExisting.push(name);
8819
9254
  };
8820
- const realRoot = await fs38.realpath(catalogRoot).catch(() => null);
9255
+ const realRoot = await fs40.realpath(catalogRoot).catch(() => null);
8821
9256
  for (const item of items) {
8822
9257
  if (item.type !== "config") continue;
8823
9258
  const sourcePath = path50.resolve(catalogRoot, item.path);
@@ -8827,7 +9262,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8827
9262
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
8828
9263
  continue;
8829
9264
  }
8830
- const realSource = await fs38.realpath(sourcePath).catch(() => null);
9265
+ const realSource = await fs40.realpath(sourcePath).catch(() => null);
8831
9266
  if (!realRoot || !realSource) {
8832
9267
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
8833
9268
  continue;
@@ -8837,7 +9272,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8837
9272
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
8838
9273
  continue;
8839
9274
  }
8840
- const stat = await fs38.lstat(sourcePath).catch(() => null);
9275
+ const stat = await fs40.lstat(sourcePath).catch(() => null);
8841
9276
  if (!stat) {
8842
9277
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
8843
9278
  continue;
@@ -8851,7 +9286,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8851
9286
  const dest = path50.join(projectRoot, filename);
8852
9287
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
8853
9288
  } else if (stat.isDirectory()) {
8854
- const entries = await fs38.readdir(sourcePath);
9289
+ const entries = await fs40.readdir(sourcePath);
8855
9290
  for (const entry of entries) {
8856
9291
  const src = path50.join(sourcePath, entry);
8857
9292
  const dest = path50.join(projectRoot, entry);
@@ -8863,7 +9298,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8863
9298
  }
8864
9299
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8865
9300
  const destDisplay = path50.relative(projectRoot, dest) || path50.basename(dest);
8866
- const srcStat = await fs38.lstat(src).catch(() => null);
9301
+ const srcStat = await fs40.lstat(src).catch(() => null);
8867
9302
  if (!srcStat) {
8868
9303
  warn(`Skipping ${path50.basename(src)}: source not found`);
8869
9304
  return "skipped-refused";
@@ -8872,7 +9307,7 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8872
9307
  warn(`Skipping ${path50.basename(src)}: source is a symlink`);
8873
9308
  return "skipped-refused";
8874
9309
  }
8875
- const exists = await fs38.pathExists(dest);
9310
+ const exists = await fs40.pathExists(dest);
8876
9311
  if (exists && !opts.force) {
8877
9312
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
8878
9313
  return "skipped-exists";
@@ -8882,16 +9317,16 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8882
9317
  return "scaffolded";
8883
9318
  }
8884
9319
  if (exists && opts.force) {
8885
- await fs38.remove(dest);
9320
+ await fs40.remove(dest);
8886
9321
  }
8887
- await fs38.ensureDir(path50.dirname(dest));
8888
- await fs38.copy(src, dest, {
9322
+ await fs40.ensureDir(path50.dirname(dest));
9323
+ await fs40.copy(src, dest, {
8889
9324
  overwrite: true,
8890
9325
  dereference: false,
8891
9326
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
8892
9327
  filter: (s) => {
8893
9328
  try {
8894
- return !fs38.lstatSync(s).isSymbolicLink();
9329
+ return !fs40.lstatSync(s).isSymbolicLink();
8895
9330
  } catch {
8896
9331
  return false;
8897
9332
  }
@@ -9413,6 +9848,61 @@ async function runGuard(kind, _options) {
9413
9848
  // src/claude/setup-core.ts
9414
9849
  init_github_rate_limit();
9415
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
+
9416
9906
  // src/recommender/recommend.ts
9417
9907
  init_fs();
9418
9908
  init_paths();
@@ -9459,6 +9949,7 @@ async function readChangedFiles(root) {
9459
9949
  async function recommend(root, context, opts = {}) {
9460
9950
  const activePluginIds = opts.activePluginIds ?? [];
9461
9951
  const items = await loadCatalog(root);
9952
+ const excludedIds = await readProjectExcludedIds(root);
9462
9953
  const sources = buildSourcesReport(items);
9463
9954
  const deep = await readJson(hausPath(root, "deep-context.json")) ?? {};
9464
9955
  const scannerStacks = buildStackSet(context);
@@ -9668,6 +10159,7 @@ async function recommend(root, context, opts = {}) {
9668
10159
  }
9669
10160
  }
9670
10161
  applyCoInstallSuppression(recommended, skipped);
10162
+ applyManualExcludes(excludedIds, items, recommended, skipped);
9671
10163
  const includeWarnings = applyManualIncludes(opts.include ?? [], items, recommended, skipped);
9672
10164
  const optInEligible = buildOptInEligible(items, skipped);
9673
10165
  recommended.sort((a, b) => a.id.localeCompare(b.id));
@@ -9696,6 +10188,29 @@ function buildStackSet(context) {
9696
10188
  )
9697
10189
  );
9698
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
+ }
9699
10214
  function applyCoInstallSuppression(recommended, skipped) {
9700
10215
  const recommendedIds = new Set(recommended.map((r) => r.id));
9701
10216
  const rules = [
@@ -10068,7 +10583,10 @@ async function runSetupCore(root, opts) {
10068
10583
  recommendedCount: 0,
10069
10584
  warnings,
10070
10585
  hooksOk: false,
10071
- written: []
10586
+ written: [],
10587
+ // Pre-existing zero-signal guard, not the #325 catalog-install honesty check —
10588
+ // deliberately left out of scope for `catalogInstallOk` (see its doc comment).
10589
+ catalogInstallOk: true
10072
10590
  };
10073
10591
  }
10074
10592
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
@@ -10097,7 +10615,8 @@ async function runSetupCore(root, opts) {
10097
10615
  recommendedCount: recommendation.recommended.length,
10098
10616
  warnings: warningLines,
10099
10617
  hooksOk,
10100
- written: []
10618
+ written: [],
10619
+ catalogInstallOk: true
10101
10620
  };
10102
10621
  const envelope = (hooksResult) => ({
10103
10622
  scan: scanResult,
@@ -10137,12 +10656,66 @@ async function runSetupCore(root, opts) {
10137
10656
  warn(formatGithubRateLimitMessage(sync.rateLimit));
10138
10657
  }
10139
10658
  }
10659
+ const isPreview = dryRun ?? false;
10660
+ const wantedIds = installableRecommendedItems(recommendation.recommended).map((item) => item.id);
10661
+ let catalogCtx = null;
10662
+ let resolvedWantedIds = wantedIds;
10663
+ if (wantedIds.length > 0) {
10664
+ catalogCtx = await loadCatalogContext(root);
10665
+ const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
10666
+ const migrations = findFormerIdMigrations(
10667
+ prevLock.filter((entry) => Boolean(entry.id)),
10668
+ catalogCtx.items
10669
+ );
10670
+ const migrationByOldId = new Map(migrations.map((m) => [m.oldId, m.newId]));
10671
+ resolvedWantedIds = wantedIds.map((id) => migrationByOldId.get(id) ?? id);
10672
+ const shortfall = await countInstallableItems(catalogCtx, resolvedWantedIds);
10673
+ const tier = `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}`;
10674
+ if (shortfall.installable === 0) {
10675
+ const message2 = "No catalog content found. Run `haus update` first.";
10676
+ if (!opts.allowEmptyCache) {
10677
+ if (isPreview) {
10678
+ warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message2}`);
10679
+ } else {
10680
+ error(message2);
10681
+ process.exitCode = 1;
10682
+ emitJsonEnvelope(json, quiet, envelope(hooks));
10683
+ return { ...baseResult, catalogInstallOk: false };
10684
+ }
10685
+ } else {
10686
+ warn(
10687
+ `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.`
10688
+ );
10689
+ }
10690
+ } else if (shortfall.missing.length > 0) {
10691
+ warn(
10692
+ `The ${tier} catalog tier is missing content for ${shortfall.missing.length} item(s): ${shortfall.missing.join(", ")}. They will be skipped.`
10693
+ );
10694
+ }
10695
+ }
10140
10696
  const files = await writeClaudeFiles(root, dryRun ?? false, void 0, {
10141
10697
  quiet: opts.quiet,
10142
10698
  activePluginIds
10143
10699
  });
10144
10700
  say("Applied files:");
10145
10701
  files.forEach((f) => say(`- ${displayPath(root, f)}`));
10702
+ let catalogInstallOk = true;
10703
+ if (!isPreview && resolvedWantedIds.length > 0) {
10704
+ const { installedCount: installedWantedCount, missingWantedIds } = await recountInstalledAgainstLock(root, resolvedWantedIds);
10705
+ if (missingWantedIds.length > 0) {
10706
+ const tier = catalogCtx ? `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}` : "unknown";
10707
+ say(
10708
+ `${installedWantedCount} of ${resolvedWantedIds.length} catalog items installed (${tier} tier).`
10709
+ );
10710
+ if (installedWantedCount === 0) {
10711
+ error(
10712
+ "No catalog items were installed \u2014 only core files were written. Run `haus update` with network access, or import a catalog bundle."
10713
+ );
10714
+ process.exitCode = 1;
10715
+ catalogInstallOk = false;
10716
+ }
10717
+ }
10718
+ }
10146
10719
  const hooksAfter = await verifyProjectSettingsHooksContract(root);
10147
10720
  const hooksOkAfter = hooksAfter.ok;
10148
10721
  if (hooksAfter.skipped) {
@@ -10154,7 +10727,7 @@ async function runSetupCore(root, opts) {
10154
10727
  say(`- HOOKS OK: ${hooksAfter.message}`);
10155
10728
  }
10156
10729
  emitJsonEnvelope(json, quiet, envelope(hooksAfter));
10157
- return { ...baseResult, hooksOk: hooksOkAfter, written: files };
10730
+ return { ...baseResult, hooksOk: hooksOkAfter, written: files, catalogInstallOk };
10158
10731
  }
10159
10732
 
10160
10733
  // src/commands/init.ts
@@ -10174,6 +10747,7 @@ async function runInit(options) {
10174
10747
  apply: !dryRun,
10175
10748
  dryRun: false,
10176
10749
  force: options.force,
10750
+ allowEmptyCache: options.allowEmptyCache,
10177
10751
  confirm: () => confirm("Approve and write Claude files now?")
10178
10752
  });
10179
10753
  }
@@ -10304,7 +10878,7 @@ async function runInstall(options, deps = {}) {
10304
10878
 
10305
10879
  // src/new-project/create-project.ts
10306
10880
  import path53 from "node:path";
10307
- import fs39 from "fs-extra";
10881
+ import fs42 from "fs-extra";
10308
10882
  init_exec();
10309
10883
  init_logger();
10310
10884
 
@@ -10458,7 +11032,7 @@ async function createProject(opts, deps = defaultDeps) {
10458
11032
  `Invalid project name "${opts.name}" (lowercase letters, digits, . _ - only).`
10459
11033
  );
10460
11034
  }
10461
- if (await fs39.pathExists(projectPath)) {
11035
+ if (await fs42.pathExists(projectPath)) {
10462
11036
  return fail("preflight", `Target ${projectPath} already exists \u2014 refusing to write into it.`);
10463
11037
  }
10464
11038
  const scaffoldArgs = stack.scaffold.args.map(
@@ -10536,15 +11110,15 @@ async function createProject(opts, deps = defaultDeps) {
10536
11110
  steps.push({ step: "scaffold", status: "done" });
10537
11111
  phase = "git";
10538
11112
  if (!opts.json) log("\u25B6 git: init/branch/commit");
10539
- if (!await fs39.pathExists(path53.join(projectPath, ".git"))) {
11113
+ if (!await fs42.pathExists(path53.join(projectPath, ".git"))) {
10540
11114
  const init = await deps.git(["init"], { cwd: projectPath });
10541
11115
  if (init.exitCode !== 0) return fail("git", `git init failed: ${init.stderr}`);
10542
11116
  }
10543
11117
  const branch = await deps.git(["branch", "-M", "main"], { cwd: projectPath });
10544
11118
  if (branch.exitCode !== 0) return fail("git", `git branch -M main failed: ${branch.stderr}`);
10545
11119
  const lockfile = opts.pm === "yarn" ? "yarn.lock" : opts.pm === "pnpm" ? "pnpm-lock.yaml" : "package-lock.json";
10546
- if (!await fs39.pathExists(path53.join(projectPath, lockfile))) {
10547
- await fs39.writeFile(path53.join(projectPath, lockfile), "");
11120
+ if (!await fs42.pathExists(path53.join(projectPath, lockfile))) {
11121
+ await fs42.writeFile(path53.join(projectPath, lockfile), "");
10548
11122
  }
10549
11123
  const add = await deps.git(["add", "-A"], { cwd: projectPath });
10550
11124
  if (add.exitCode !== 0) return fail("git", `git add failed: ${add.stderr}`);
@@ -10799,6 +11373,7 @@ async function runRecommend(options) {
10799
11373
  }
10800
11374
 
10801
11375
  // src/commands/remove.ts
11376
+ import path54 from "node:path";
10802
11377
  init_fs();
10803
11378
  init_logger();
10804
11379
  init_paths();
@@ -10810,7 +11385,7 @@ async function runRemove(ids, options = {}) {
10810
11385
  const dryRun = Boolean(options.dryRun);
10811
11386
  const say = options.json ? () => {
10812
11387
  } : log;
10813
- const wanted = parseIdList(ids);
11388
+ const wanted = [...new Set(parseIdList(ids))];
10814
11389
  if (wanted.length === 0) {
10815
11390
  error("Specify at least one catalog item id \u2014 see `haus doctor` for what is installed.");
10816
11391
  process.exitCode = 1;
@@ -10856,7 +11431,7 @@ async function runRemove(ids, options = {}) {
10856
11431
  cleared.push({ id, existing: [] });
10857
11432
  continue;
10858
11433
  }
10859
- const gate = await hashGateEntry(root, { id, hash: entry.hash }, existing);
11434
+ const gate = await hashGateEntry(root, { hash: entry.hash }, existing);
10860
11435
  if (!gate.ok) {
10861
11436
  const detail = gate.reason === "no-hash" ? "has no lock hash" : "was modified locally";
10862
11437
  warn(
@@ -10902,7 +11477,8 @@ async function runRemove(ids, options = {}) {
10902
11477
  }
10903
11478
  }
10904
11479
  const allAbs = cleared.flatMap(({ existing }) => existing.map(({ abs }) => abs));
10905
- report.backupDir = await backupTrackedPaths(root, allAbs, say, "remove");
11480
+ const backupRoot = await backupTrackedPaths(root, allAbs, say, "remove");
11481
+ report.backupDir = backupRoot === null ? null : path54.basename(backupRoot);
10906
11482
  for (const { id, existing } of cleared) {
10907
11483
  await deleteTrackedPaths(root, id, existing, { verb: "Removed", reason: "" }, say);
10908
11484
  }
@@ -10910,6 +11486,10 @@ async function runRemove(ids, options = {}) {
10910
11486
  root,
10911
11487
  cleared.map((c) => c.id)
10912
11488
  );
11489
+ await addProjectExcludedIds(
11490
+ root,
11491
+ cleared.map((c) => c.id)
11492
+ );
10913
11493
  try {
10914
11494
  const remaining = await readJson(hausPath(root, "haus.lock.json")) ?? [];
10915
11495
  const remainingIds = new Set(remaining.map((e) => e.id));
@@ -10925,7 +11505,6 @@ async function runRemove(ids, options = {}) {
10925
11505
  }
10926
11506
  if (!options.json) {
10927
11507
  log(`Removed ${report.removed.length} item(s). Restore with \`haus backups list\`.`);
10928
- log("Note: a later `haus apply --write` reinstalls any of these still recommended.");
10929
11508
  }
10930
11509
  emitReport(report, options.json);
10931
11510
  }
@@ -10987,8 +11566,8 @@ async function runScan(options) {
10987
11566
  }
10988
11567
 
10989
11568
  // src/commands/undo.ts
10990
- import path54 from "node:path";
10991
- import fs40 from "fs-extra";
11569
+ import path55 from "node:path";
11570
+ import fs43 from "fs-extra";
10992
11571
 
10993
11572
  // src/claude/managed-paths.ts
10994
11573
  init_paths();
@@ -11007,7 +11586,7 @@ init_paths();
11007
11586
  async function collectCoreManagedPaths(root) {
11008
11587
  const existing = [];
11009
11588
  for (const abs of coreManagedAbsolutePaths(root)) {
11010
- if (await fs40.pathExists(abs)) existing.push(abs);
11589
+ if (await fs43.pathExists(abs)) existing.push(abs);
11011
11590
  }
11012
11591
  return existing;
11013
11592
  }
@@ -11016,108 +11595,101 @@ async function collectLockTrackedPaths(root) {
11016
11595
  const removable = [];
11017
11596
  const preserved = [];
11018
11597
  for (const row of lock ?? []) {
11019
- const relPaths = row.paths ?? [];
11020
- const existingRel = [];
11021
- for (const rel of relPaths) {
11022
- if (await fs40.pathExists(path54.resolve(root, rel))) existingRel.push(rel);
11023
- }
11024
- if (existingRel.length === 0) continue;
11025
- if (row.hash === void 0) {
11026
- removable.push(...existingRel.map((rel) => path54.resolve(root, rel)));
11598
+ const id = row.id ?? "(unknown item)";
11599
+ const existing = await resolveExistingTrackedPaths(root, id, row.paths ?? [], log);
11600
+ if (existing.length === 0) continue;
11601
+ const gate = await hashGateEntry(root, { hash: row.hash }, existing);
11602
+ if (!gate.ok) {
11603
+ preserved.push(...existing.map((e) => e.abs));
11604
+ const detail = gate.reason === "no-hash" ? "has no lock hash" : "was modified locally";
11605
+ log(`Preserving ${id} (${detail}): ${existing.map((e) => e.rel).join(", ")}`);
11027
11606
  continue;
11028
11607
  }
11029
- const currentHash = await hashInstalledPaths(root, existingRel);
11030
- if (currentHash === row.hash) {
11031
- removable.push(...existingRel.map((rel) => path54.resolve(root, rel)));
11032
- } else {
11033
- preserved.push(...existingRel.map((rel) => path54.resolve(root, rel)));
11034
- log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
11035
- }
11608
+ removable.push({ id, existing });
11036
11609
  }
11037
11610
  return { removable, preserved };
11038
11611
  }
11039
11612
  async function settingsHasHausContent(root) {
11040
11613
  const settingsPath = claudePath(root, "settings.json");
11041
- if (!await fs40.pathExists(settingsPath)) return false;
11614
+ if (!await fs43.pathExists(settingsPath)) return false;
11042
11615
  const settings = await readProjectSettings(root);
11043
11616
  return settings._haus != null;
11044
11617
  }
11045
11618
  async function claudeMdHasHausBlock(root) {
11046
- const filePath = path54.join(root, "CLAUDE.md");
11047
- if (!await fs40.pathExists(filePath)) return false;
11048
- const text = await fs40.readFile(filePath, "utf8");
11619
+ const filePath = path55.join(root, "CLAUDE.md");
11620
+ if (!await fs43.pathExists(filePath)) return false;
11621
+ const text = await fs43.readFile(filePath, "utf8");
11049
11622
  return text.includes(BLOCK_BEGIN);
11050
11623
  }
11051
11624
  async function stripProjectSettings(root) {
11052
11625
  const settingsPath = claudePath(root, "settings.json");
11053
- if (!await fs40.pathExists(settingsPath)) return false;
11626
+ if (!await fs43.pathExists(settingsPath)) return false;
11054
11627
  let settings = await readProjectSettings(root);
11055
11628
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
11056
11629
  const hasContent = Object.keys(settings).length > 0;
11057
11630
  if (hasContent) {
11058
11631
  await writeProjectSettings(root, settings);
11059
- log(`Stripped haus rules from ${path54.relative(root, settingsPath)} (user settings preserved).`);
11632
+ log(`Stripped haus rules from ${path55.relative(root, settingsPath)} (user settings preserved).`);
11060
11633
  return true;
11061
11634
  }
11062
- await fs40.remove(settingsPath);
11063
- log(`Removed ${path54.relative(root, settingsPath)} (no user-owned settings remained).`);
11635
+ await fs43.remove(settingsPath);
11636
+ log(`Removed ${path55.relative(root, settingsPath)} (no user-owned settings remained).`);
11064
11637
  return true;
11065
11638
  }
11066
11639
  async function stripRootClaudeMd(root) {
11067
- const filePath = path54.join(root, "CLAUDE.md");
11068
- if (!await fs40.pathExists(filePath)) return false;
11069
- const prev = await fs40.readFile(filePath, "utf8");
11640
+ const filePath = path55.join(root, "CLAUDE.md");
11641
+ if (!await fs43.pathExists(filePath)) return false;
11642
+ const prev = await fs43.readFile(filePath, "utf8");
11070
11643
  if (!prev.includes(BLOCK_BEGIN)) return false;
11071
11644
  const next = stripHausBlock(prev);
11072
11645
  if (next.length === 0) {
11073
- await fs40.remove(filePath);
11646
+ await fs43.remove(filePath);
11074
11647
  log("Removed CLAUDE.md (only contained haus import block).");
11075
11648
  } else {
11076
- await fs40.writeFile(filePath, next, "utf8");
11649
+ await fs43.writeFile(filePath, next, "utf8");
11077
11650
  log("Removed haus import block from CLAUDE.md (user content preserved).");
11078
11651
  }
11079
11652
  return true;
11080
11653
  }
11081
11654
  async function pruneDirIfEmpty(dir) {
11082
- if (!await fs40.pathExists(dir)) return;
11655
+ if (!await fs43.pathExists(dir)) return;
11083
11656
  try {
11084
- const entries = await fs40.readdir(dir);
11085
- if (entries.length === 0) await fs40.remove(dir);
11657
+ const entries = await fs43.readdir(dir);
11658
+ if (entries.length === 0) await fs43.remove(dir);
11086
11659
  } catch {
11087
11660
  }
11088
11661
  }
11089
11662
  async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
11090
11663
  if (managedAbsPaths.length === 0) return;
11091
11664
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
11092
- const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
11665
+ const backupRoot = hausPath(root, "backups", dirBackupDirName("undo", stamp));
11093
11666
  for (const abs of managedAbsPaths) {
11094
- if (!await fs40.pathExists(abs)) continue;
11095
- const rel = path54.relative(root, abs);
11096
- const backupPath = path54.join(backupRoot, rel);
11097
- await fs40.ensureDir(path54.dirname(backupPath));
11098
- await fs40.copy(abs, backupPath);
11667
+ if (!await fs43.pathExists(abs)) continue;
11668
+ const rel = path55.relative(root, abs);
11669
+ const backupPath = path55.join(backupRoot, rel);
11670
+ await fs43.ensureDir(path55.dirname(backupPath));
11671
+ await fs43.copy(abs, backupPath);
11099
11672
  }
11100
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path54.relative(root, backupRoot)}.`);
11673
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path55.relative(root, backupRoot)}.`);
11101
11674
  }
11102
11675
  async function runUndo(options) {
11103
11676
  const root = options.root ?? process.cwd();
11104
11677
  const coreManaged = await collectCoreManagedPaths(root);
11105
11678
  const { removable: lockRemovable, preserved: lockPreserved } = await collectLockTrackedPaths(root);
11106
- const managed = [.../* @__PURE__ */ new Set([...coreManaged, ...lockRemovable])];
11679
+ const lockAbs = lockRemovable.flatMap(({ existing }) => existing.map((e) => e.abs));
11680
+ const managed = [.../* @__PURE__ */ new Set([...coreManaged, ...lockAbs])];
11107
11681
  const stripSettings = await settingsHasHausContent(root);
11108
11682
  const stripClaudeMd = await claudeMdHasHausBlock(root);
11109
11683
  if (managed.length === 0 && !stripSettings && !stripClaudeMd) {
11110
11684
  log("Nothing to remove: no haus-managed files found in this directory.");
11111
11685
  return;
11112
11686
  }
11113
- const relTargets = managed.map((p) => path54.relative(root, p));
11687
+ const relTargets = managed.map((p) => path55.relative(root, p));
11114
11688
  const summaryParts = [...relTargets];
11115
11689
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
11116
11690
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
11117
11691
  if (lockPreserved.length > 0) {
11118
- summaryParts.push(
11119
- `(preserving ${lockPreserved.length} locally-modified catalog item file(s) \u2014 not shown above)`
11120
- );
11692
+ summaryParts.push(`(preserving ${lockPreserved.length} catalog item file(s) \u2014 not shown above)`);
11121
11693
  }
11122
11694
  if (options.dryRun) {
11123
11695
  log(`[dry-run] Would remove:
@@ -11137,10 +11709,13 @@ User-owned .claude/ files will be preserved.`
11137
11709
  }
11138
11710
  }
11139
11711
  await backupManagedFilesBeforeUndo(root, managed);
11140
- for (const abs of managed) {
11141
- if (!await fs40.pathExists(abs)) continue;
11142
- await fs40.remove(abs);
11143
- log(`Removed ${path54.relative(root, abs)}`);
11712
+ for (const abs of coreManaged) {
11713
+ if (!await fs43.pathExists(abs)) continue;
11714
+ await fs43.remove(abs);
11715
+ log(`Removed ${path55.relative(root, abs)}`);
11716
+ }
11717
+ for (const { id, existing } of lockRemovable) {
11718
+ await deleteTrackedPaths(root, id, existing, { verb: "Removed" }, log);
11144
11719
  }
11145
11720
  if (stripSettings) await stripProjectSettings(root);
11146
11721
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -11154,8 +11729,8 @@ init_fs();
11154
11729
  init_logger();
11155
11730
  init_paths();
11156
11731
  import crypto3 from "node:crypto";
11157
- import path55 from "node:path";
11158
- import fs41 from "fs-extra";
11732
+ import path56 from "node:path";
11733
+ import fs44 from "fs-extra";
11159
11734
  async function runUninstall(options = {}) {
11160
11735
  const { force = false, removePlugins = removeGlobalPlugins } = options;
11161
11736
  const manifest = await readManifest();
@@ -11192,7 +11767,7 @@ async function runUninstall(options = {}) {
11192
11767
  );
11193
11768
  }
11194
11769
  for (const entry of manifest.files) {
11195
- const exists = fs41.pathExistsSync(entry.destPath);
11770
+ const exists = fs44.pathExistsSync(entry.destPath);
11196
11771
  if (!exists) continue;
11197
11772
  const content2 = await readText(entry.destPath);
11198
11773
  if (content2 === void 0) continue;
@@ -11210,23 +11785,23 @@ async function runUninstall(options = {}) {
11210
11785
  result.skipped.push(entry.destPath);
11211
11786
  continue;
11212
11787
  }
11213
- await fs41.remove(entry.destPath);
11214
- await pruneEmptyDir(path55.dirname(entry.destPath));
11788
+ await fs44.remove(entry.destPath);
11789
+ await pruneEmptyDir(path56.dirname(entry.destPath));
11215
11790
  result.deleted.push(entry.destPath);
11216
11791
  }
11217
11792
  const settings = await readSettings();
11218
11793
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
11219
11794
  await writeSettings(stripped);
11220
11795
  result.hooksStripped = true;
11221
- const hausDir = path55.join(globalClaudeDir(), "haus");
11796
+ const hausDir = path56.join(globalClaudeDir(), "haus");
11222
11797
  const manifestPath2 = hausManifestPath();
11223
- if (fs41.pathExistsSync(manifestPath2)) {
11224
- await fs41.remove(manifestPath2);
11798
+ if (fs44.pathExistsSync(manifestPath2)) {
11799
+ await fs44.remove(manifestPath2);
11225
11800
  }
11226
- if (fs41.pathExistsSync(hausDir)) {
11801
+ if (fs44.pathExistsSync(hausDir)) {
11227
11802
  try {
11228
- const remaining = await fs41.readdir(hausDir);
11229
- if (remaining.length === 0) await fs41.remove(hausDir);
11803
+ const remaining = await fs44.readdir(hausDir);
11804
+ if (remaining.length === 0) await fs44.remove(hausDir);
11230
11805
  } catch {
11231
11806
  }
11232
11807
  }
@@ -11295,15 +11870,15 @@ async function runUninstallCommand(options) {
11295
11870
  }
11296
11871
 
11297
11872
  // src/commands/validate-catalog.ts
11298
- import path57 from "node:path";
11873
+ import path58 from "node:path";
11299
11874
 
11300
11875
  // src/catalog/validate-core.ts
11301
11876
  init_forbidden_content();
11302
11877
  init_manifest_item_fields();
11303
11878
  init_path_safety();
11304
11879
  init_validation_rules2();
11305
- import fs42 from "node:fs";
11306
- import path56 from "node:path";
11880
+ import fs45 from "node:fs";
11881
+ import path57 from "node:path";
11307
11882
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
11308
11883
  var ITEM_ID_PATTERN = /^haus\./;
11309
11884
  function auditForbiddenStacks(items) {
@@ -11380,11 +11955,11 @@ function auditOptInMetadata(items) {
11380
11955
  }
11381
11956
  return failures;
11382
11957
  }
11383
- function auditManifestStructure(manifestVersion, items) {
11958
+ function auditManifestStructure(manifestVersion2, items) {
11384
11959
  const failures = [];
11385
11960
  const seenIds = /* @__PURE__ */ new Map();
11386
11961
  const seenPaths = /* @__PURE__ */ new Map();
11387
- if (typeof manifestVersion !== "string" || !ITEM_SEMVER_RE.test(manifestVersion)) {
11962
+ if (typeof manifestVersion2 !== "string" || !ITEM_SEMVER_RE.test(manifestVersion2)) {
11388
11963
  failures.push('manifest.json: top-level "version" is not valid semver (expected X.Y.Z)');
11389
11964
  }
11390
11965
  for (let i = 0; i < items.length; i++) {
@@ -11447,14 +12022,14 @@ function checkRequiredFrontmatter(text, label) {
11447
12022
  }
11448
12023
  function readForAudit(absPath, label, failures) {
11449
12024
  try {
11450
- return fs42.readFileSync(absPath, "utf8");
12025
+ return fs45.readFileSync(absPath, "utf8");
11451
12026
  } catch (err) {
11452
12027
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
11453
12028
  return null;
11454
12029
  }
11455
12030
  }
11456
12031
  function auditTemplateContent(manifestDir, absPath, itemId) {
11457
- const rel = path56.relative(manifestDir, absPath);
12032
+ const rel = path57.relative(manifestDir, absPath);
11458
12033
  const failures = [];
11459
12034
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
11460
12035
  if (text === null) return failures;
@@ -11476,55 +12051,55 @@ function auditShippedFiles(manifestDir, items) {
11476
12051
  const failures = [];
11477
12052
  for (const item of items) {
11478
12053
  if (!item.path || !isSafeCatalogPath(item.path)) continue;
11479
- const absPath = path56.join(manifestDir, item.path);
12054
+ const absPath = path57.join(manifestDir, item.path);
11480
12055
  if (item.type === "skill") {
11481
- const skillMd = path56.join(absPath, "SKILL.md");
11482
- if (!fs42.existsSync(skillMd)) {
11483
- failures.push(`${item.id}: missing ${path56.relative(manifestDir, skillMd)}`);
12056
+ const skillMd = path57.join(absPath, "SKILL.md");
12057
+ if (!fs45.existsSync(skillMd)) {
12058
+ failures.push(`${item.id}: missing ${path57.relative(manifestDir, skillMd)}`);
11484
12059
  continue;
11485
12060
  }
11486
12061
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
11487
12062
  if (text === null) continue;
11488
12063
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
11489
12064
  failures.push(
11490
- ...auditForbiddenTagsInText(text, `${item.id}: ${path56.relative(manifestDir, skillMd)}`)
12065
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path57.relative(manifestDir, skillMd)}`)
11491
12066
  );
11492
12067
  } else if (item.type === "agent") {
11493
- if (!fs42.existsSync(absPath)) {
12068
+ if (!fs45.existsSync(absPath)) {
11494
12069
  failures.push(`${item.id}: missing agent file ${item.path}`);
11495
12070
  continue;
11496
12071
  }
11497
- const rel = path56.relative(manifestDir, absPath);
12072
+ const rel = path57.relative(manifestDir, absPath);
11498
12073
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
11499
12074
  if (text === null) continue;
11500
12075
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
11501
12076
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
11502
12077
  } else if (item.type === "template") {
11503
- if (!fs42.existsSync(absPath)) {
12078
+ if (!fs45.existsSync(absPath)) {
11504
12079
  failures.push(`${item.id}: missing template file ${item.path}`);
11505
12080
  continue;
11506
12081
  }
11507
12082
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
11508
12083
  } else if (item.type === "command") {
11509
- if (!fs42.existsSync(absPath)) {
12084
+ if (!fs45.existsSync(absPath)) {
11510
12085
  failures.push(`${item.id}: missing command file ${item.path}`);
11511
12086
  continue;
11512
12087
  }
11513
- const rel = path56.relative(manifestDir, absPath);
12088
+ const rel = path57.relative(manifestDir, absPath);
11514
12089
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
11515
12090
  if (text === null) continue;
11516
12091
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
11517
12092
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
11518
12093
  } else if (item.type === "config") {
11519
- if (!fs42.existsSync(absPath)) {
12094
+ if (!fs45.existsSync(absPath)) {
11520
12095
  failures.push(`${item.id}: missing config path ${item.path}`);
11521
12096
  }
11522
12097
  } else if (item.type === "rule") {
11523
- if (!fs42.existsSync(absPath)) {
12098
+ if (!fs45.existsSync(absPath)) {
11524
12099
  failures.push(`${item.id}: missing rule file ${item.path}`);
11525
12100
  }
11526
12101
  } else if (item.type === "asset") {
11527
- if (!fs42.existsSync(absPath)) {
12102
+ if (!fs45.existsSync(absPath)) {
11528
12103
  failures.push(`${item.id}: missing asset file ${item.path}`);
11529
12104
  }
11530
12105
  }
@@ -11559,7 +12134,7 @@ function resolveItemType(relPath, pathTypeMap) {
11559
12134
  function walkAllFiles(dir, fn, failures) {
11560
12135
  let entries;
11561
12136
  try {
11562
- entries = fs42.readdirSync(dir, { withFileTypes: true });
12137
+ entries = fs45.readdirSync(dir, { withFileTypes: true });
11563
12138
  } catch (err) {
11564
12139
  failures.push(
11565
12140
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -11568,7 +12143,7 @@ function walkAllFiles(dir, fn, failures) {
11568
12143
  }
11569
12144
  for (const entry of entries) {
11570
12145
  if (entry.name === ".DS_Store") continue;
11571
- const full = path56.join(dir, entry.name);
12146
+ const full = path57.join(dir, entry.name);
11572
12147
  if (entry.isDirectory()) walkAllFiles(full, fn, failures);
11573
12148
  else fn(full);
11574
12149
  }
@@ -11592,12 +12167,12 @@ function auditDiskOrphans(manifestDir, items) {
11592
12167
  const failures = [];
11593
12168
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
11594
12169
  for (const dir of CONTENT_ROOTS) {
11595
- const abs = path56.join(manifestDir, dir);
11596
- if (!fs42.existsSync(abs)) continue;
12170
+ const abs = path57.join(manifestDir, dir);
12171
+ if (!fs45.existsSync(abs)) continue;
11597
12172
  walkAllFiles(
11598
12173
  abs,
11599
12174
  (file) => {
11600
- const relPath = path56.relative(manifestDir, file).replace(/\\/g, "/");
12175
+ const relPath = path57.relative(manifestDir, file).replace(/\\/g, "/");
11601
12176
  if (!isPathClaimed(relPath, claimedPaths)) {
11602
12177
  failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
11603
12178
  }
@@ -11610,7 +12185,7 @@ function auditDiskOrphans(manifestDir, items) {
11610
12185
  function walkMd(dir, fn, failures) {
11611
12186
  let entries;
11612
12187
  try {
11613
- entries = fs42.readdirSync(dir, { withFileTypes: true });
12188
+ entries = fs45.readdirSync(dir, { withFileTypes: true });
11614
12189
  } catch (err) {
11615
12190
  failures.push(
11616
12191
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -11618,7 +12193,7 @@ function walkMd(dir, fn, failures) {
11618
12193
  return;
11619
12194
  }
11620
12195
  for (const entry of entries) {
11621
- const full = path56.join(dir, entry.name);
12196
+ const full = path57.join(dir, entry.name);
11622
12197
  if (entry.isDirectory()) walkMd(full, fn, failures);
11623
12198
  else if (entry.name.endsWith(".md")) fn(full);
11624
12199
  }
@@ -11628,13 +12203,13 @@ function auditMarkdownContent(manifestDir, items) {
11628
12203
  const pathSourceMap = buildItemPathSourceMap(items);
11629
12204
  const pathTypeMap = buildItemPathTypeMap(items);
11630
12205
  for (const dir of ["skills", "agents", "templates", "commands", "sources"]) {
11631
- const abs = path56.join(manifestDir, dir);
11632
- if (!fs42.existsSync(abs)) continue;
12206
+ const abs = path57.join(manifestDir, dir);
12207
+ if (!fs45.existsSync(abs)) continue;
11633
12208
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
11634
12209
  walkMd(
11635
12210
  abs,
11636
12211
  (file) => {
11637
- const rel = path56.relative(manifestDir, file);
12212
+ const rel = path57.relative(manifestDir, file);
11638
12213
  const text = readForAudit(file, rel, failures);
11639
12214
  if (text === null) return;
11640
12215
  const norm = rel.replace(/\\/g, "/");
@@ -11657,9 +12232,9 @@ function auditMarkdownContent(manifestDir, items) {
11657
12232
  }
11658
12233
  return failures;
11659
12234
  }
11660
- function validateCatalogData(manifestDir, manifestVersion, items) {
12235
+ function validateCatalogData(manifestDir, manifestVersion2, items) {
11661
12236
  const failures = [
11662
- ...auditManifestStructure(manifestVersion, items),
12237
+ ...auditManifestStructure(manifestVersion2, items),
11663
12238
  ...validateFormerIds(items),
11664
12239
  ...auditOptInMetadata(items),
11665
12240
  ...auditForbiddenStacks(items),
@@ -11682,8 +12257,8 @@ async function runValidateCatalog(manifestPath2) {
11682
12257
  process.exitCode = 1;
11683
12258
  return;
11684
12259
  }
11685
- const abs = path57.resolve(process.cwd(), manifestPath2);
11686
- const manifestDir = path57.dirname(abs);
12260
+ const abs = path58.resolve(process.cwd(), manifestPath2);
12261
+ const manifestDir = path58.dirname(abs);
11687
12262
  const data = await readJson(abs);
11688
12263
  if (!data?.items) {
11689
12264
  error(`Could not read catalog manifest at ${abs}`);
@@ -11704,8 +12279,8 @@ init_logger();
11704
12279
 
11705
12280
  // src/workspace/worktree/add.ts
11706
12281
  init_fs();
11707
- import path64 from "node:path";
11708
- import fs46 from "fs-extra";
12282
+ import path65 from "node:path";
12283
+ import fs49 from "fs-extra";
11709
12284
 
11710
12285
  // src/workspace/worktree/git-worktree.ts
11711
12286
  init_exec();
@@ -11825,13 +12400,13 @@ async function hasUnpushedWork(cwd) {
11825
12400
  }
11826
12401
 
11827
12402
  // src/workspace/worktree/hydrate.ts
11828
- import path60 from "node:path";
11829
- import fs44 from "fs-extra";
12403
+ import path61 from "node:path";
12404
+ import fs47 from "fs-extra";
11830
12405
 
11831
12406
  // src/workspace/worktree/cow-copy.ts
11832
12407
  init_exec();
11833
- import path58 from "node:path";
11834
- import fs43 from "fs-extra";
12408
+ import path59 from "node:path";
12409
+ import fs46 from "fs-extra";
11835
12410
  async function getMacFilesystemType(dir) {
11836
12411
  const df = await runCommand("df", ["-P", dir]);
11837
12412
  if (df.exitCode !== 0) return void 0;
@@ -11862,14 +12437,14 @@ async function detectCowStrategy(dir) {
11862
12437
  return "unknown-platform";
11863
12438
  }
11864
12439
  async function cowCopyDir(src, dest) {
11865
- const srcExists = await fs43.pathExists(src);
12440
+ const srcExists = await fs46.pathExists(src);
11866
12441
  if (!srcExists)
11867
12442
  return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
11868
- const strategy = await detectCowStrategy(path58.dirname(dest));
12443
+ const strategy = await detectCowStrategy(path59.dirname(dest));
11869
12444
  if (strategy === "unsupported" || strategy === "unknown-platform") {
11870
12445
  return { strategy, attempted: false, ok: false };
11871
12446
  }
11872
- await fs43.ensureDir(path58.dirname(dest));
12447
+ await fs46.ensureDir(path59.dirname(dest));
11873
12448
  const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
11874
12449
  const result = await runCommand("cp", args);
11875
12450
  return {
@@ -11883,7 +12458,7 @@ async function cowCopyDir(src, dest) {
11883
12458
  // src/workspace/worktree/install.ts
11884
12459
  init_exec();
11885
12460
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
11886
- import path59 from "node:path";
12461
+ import path60 from "node:path";
11887
12462
  import fg6 from "fast-glob";
11888
12463
  function detectInstallPlan(signals) {
11889
12464
  if (signals.hasYarnLock) {
@@ -11904,10 +12479,10 @@ function detectInstallPlan(signals) {
11904
12479
  return { manager: "none" };
11905
12480
  }
11906
12481
  async function computeLockfileSignals(dir) {
11907
- const hasYarnLock = existsSync3(path59.join(dir, "yarn.lock"));
11908
- const hasPnpmLock = existsSync3(path59.join(dir, "pnpm-lock.yaml"));
11909
- const hasPackageLock = existsSync3(path59.join(dir, "package-lock.json"));
11910
- const hasComposerLock = existsSync3(path59.join(dir, "composer.lock"));
12482
+ const hasYarnLock = existsSync3(path60.join(dir, "yarn.lock"));
12483
+ const hasPnpmLock = existsSync3(path60.join(dir, "pnpm-lock.yaml"));
12484
+ const hasPackageLock = existsSync3(path60.join(dir, "package-lock.json"));
12485
+ const hasComposerLock = existsSync3(path60.join(dir, "composer.lock"));
11911
12486
  const dotnetFiles = await fg6(["**/*.csproj", "**/*.sln"], {
11912
12487
  cwd: dir,
11913
12488
  onlyFiles: true,
@@ -11921,7 +12496,7 @@ async function computeLockfileSignals(dir) {
11921
12496
  });
11922
12497
  const hasDotnetProject = dotnetFiles.length > 0;
11923
12498
  let hasPackageManagerField = false;
11924
- const pkgPath = path59.join(dir, "package.json");
12499
+ const pkgPath = path60.join(dir, "package.json");
11925
12500
  if (existsSync3(pkgPath)) {
11926
12501
  try {
11927
12502
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
@@ -11964,7 +12539,7 @@ function missingYarnrcEnvVars(content2, env) {
11964
12539
  return findRequiredYarnrcEnvVars(content2).filter((name) => env[name] === void 0);
11965
12540
  }
11966
12541
  function missingYarnrcEnvVarsInDir(dir, env) {
11967
- const yarnrcPath = path59.join(dir, ".yarnrc.yml");
12542
+ const yarnrcPath = path60.join(dir, ".yarnrc.yml");
11968
12543
  if (!existsSync3(yarnrcPath)) return [];
11969
12544
  try {
11970
12545
  return missingYarnrcEnvVars(readFileSync3(yarnrcPath, "utf8"), env);
@@ -12054,13 +12629,13 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
12054
12629
  const start = Date.now();
12055
12630
  const targets = [];
12056
12631
  for (const target of HYDRATION_TARGETS) {
12057
- const src = path60.join(mainMemberPath, target);
12058
- const dest = path60.join(worktreeMemberPath, target);
12059
- if (!await fs44.pathExists(src)) continue;
12060
- const destExists = await fs44.pathExists(dest);
12632
+ const src = path61.join(mainMemberPath, target);
12633
+ const dest = path61.join(worktreeMemberPath, target);
12634
+ if (!await fs47.pathExists(src)) continue;
12635
+ const destExists = await fs47.pathExists(dest);
12061
12636
  if (!opts.force && destExists) continue;
12062
12637
  if (opts.force && destExists) {
12063
- await fs44.remove(dest);
12638
+ await fs47.remove(dest);
12064
12639
  }
12065
12640
  const copy = await cowCopyDir(src, dest);
12066
12641
  targets.push({ target, copy });
@@ -12073,11 +12648,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
12073
12648
 
12074
12649
  // src/workspace/worktree/local-files.ts
12075
12650
  init_exec();
12076
- import path61 from "node:path";
12077
- import fs45 from "fs-extra";
12651
+ import path62 from "node:path";
12652
+ import fs48 from "fs-extra";
12078
12653
  var MACHINE_LOCAL_FILES = [
12079
12654
  "docker-compose.local.yml",
12080
- path61.join(".claude", "settings.local.json")
12655
+ path62.join(".claude", "settings.local.json")
12081
12656
  ];
12082
12657
  async function isTracked(repoRoot, relFile) {
12083
12658
  const gitPath = relFile.replace(/\\/g, "/");
@@ -12087,12 +12662,12 @@ async function isTracked(repoRoot, relFile) {
12087
12662
  async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
12088
12663
  const copied = [];
12089
12664
  for (const rel of MACHINE_LOCAL_FILES) {
12090
- const src = path61.join(mainMemberPath, rel);
12091
- if (!await fs45.pathExists(src)) continue;
12665
+ const src = path62.join(mainMemberPath, rel);
12666
+ if (!await fs48.pathExists(src)) continue;
12092
12667
  if (await isTracked(mainMemberPath, rel)) continue;
12093
- const dest = path61.join(worktreeMemberPath, rel);
12094
- await fs45.ensureDir(path61.dirname(dest));
12095
- await fs45.copy(src, dest, { overwrite: true });
12668
+ const dest = path62.join(worktreeMemberPath, rel);
12669
+ await fs48.ensureDir(path62.dirname(dest));
12670
+ await fs48.copy(src, dest, { overwrite: true });
12096
12671
  copied.push(rel);
12097
12672
  }
12098
12673
  return copied;
@@ -12100,15 +12675,15 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
12100
12675
 
12101
12676
  // src/workspace/worktree/root.ts
12102
12677
  import { existsSync as existsSync4 } from "node:fs";
12103
- import path62 from "node:path";
12678
+ import path63 from "node:path";
12104
12679
  async function resolveWorkspaceForWorktree(start) {
12105
12680
  const rootInfo = await resolveRoots(start);
12106
12681
  if (!rootInfo.isGitRepo) {
12107
12682
  return { ok: false, reason: "Not inside a git repository." };
12108
12683
  }
12109
12684
  const workspaceRoot = rootInfo.mainRoot;
12110
- const hasYaml = existsSync4(path62.join(workspaceRoot, WORKSPACE_FILE));
12111
- const hasManifest = existsSync4(path62.join(workspaceRoot, REPOS_MANIFEST_FILE));
12685
+ const hasYaml = existsSync4(path63.join(workspaceRoot, WORKSPACE_FILE));
12686
+ const hasManifest = existsSync4(path63.join(workspaceRoot, REPOS_MANIFEST_FILE));
12112
12687
  if (!hasYaml && !hasManifest) {
12113
12688
  return {
12114
12689
  ok: false,
@@ -12134,10 +12709,10 @@ function selectMembers(members, only) {
12134
12709
 
12135
12710
  // src/workspace/worktree/state.ts
12136
12711
  init_fs();
12137
- import path63 from "node:path";
12712
+ import path64 from "node:path";
12138
12713
  var WORKTREE_STATE_FILE = ".haus-worktree.json";
12139
12714
  function worktreesDir(mainRoot) {
12140
- return path63.join(mainRoot, ".claude", "worktrees");
12715
+ return path64.join(mainRoot, ".claude", "worktrees");
12141
12716
  }
12142
12717
  function isValidWorktreeSlug(slug) {
12143
12718
  if (slug === "" || slug === "." || slug === "..") return false;
@@ -12150,15 +12725,15 @@ function worktreePath(mainRoot, slug) {
12150
12725
  `Invalid worktree slug ${JSON.stringify(slug)} \u2014 slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
12151
12726
  );
12152
12727
  }
12153
- return path63.join(worktreesDir(mainRoot), slug);
12728
+ return path64.join(worktreesDir(mainRoot), slug);
12154
12729
  }
12155
12730
  async function readWorktreeState(worktreeRoot) {
12156
- const state = await readJson(path63.join(worktreeRoot, WORKTREE_STATE_FILE));
12731
+ const state = await readJson(path64.join(worktreeRoot, WORKTREE_STATE_FILE));
12157
12732
  if (state == null || typeof state !== "object" || !Array.isArray(state.members)) return void 0;
12158
12733
  return state;
12159
12734
  }
12160
12735
  async function writeWorktreeState(worktreeRoot, state) {
12161
- await writeJson(path63.join(worktreeRoot, WORKTREE_STATE_FILE), state);
12736
+ await writeJson(path64.join(worktreeRoot, WORKTREE_STATE_FILE), state);
12162
12737
  }
12163
12738
 
12164
12739
  // src/workspace/worktree/add.ts
@@ -12173,7 +12748,7 @@ async function runAdd(opts) {
12173
12748
  }
12174
12749
  const branch = opts.branch ?? opts.slug;
12175
12750
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
12176
- if (await fs46.pathExists(wsWorktreePath)) {
12751
+ if (await fs49.pathExists(wsWorktreePath)) {
12177
12752
  return {
12178
12753
  ok: false,
12179
12754
  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.`
@@ -12192,7 +12767,7 @@ async function runAdd(opts) {
12192
12767
  const memberResults = [];
12193
12768
  for (const member of selected) {
12194
12769
  const start = Date.now();
12195
- const memberWorktreePath = path64.join(wsWorktreePath, member.folder);
12770
+ const memberWorktreePath = path65.join(wsWorktreePath, member.folder);
12196
12771
  const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
12197
12772
  preferDefaultBranchFrom: true
12198
12773
  });
@@ -12222,7 +12797,7 @@ async function runAdd(opts) {
12222
12797
  await mapWithConcurrency(okResults, async (result) => {
12223
12798
  const member = selected.find((m) => m.id === result.member);
12224
12799
  if (!member) return;
12225
- const memberWorktreePath = path64.join(wsWorktreePath, member.folder);
12800
+ const memberWorktreePath = path65.join(wsWorktreePath, member.folder);
12226
12801
  const hydrationStart = Date.now();
12227
12802
  if (opts.hydrate !== false) {
12228
12803
  result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
@@ -12289,25 +12864,25 @@ async function runDryRun(args) {
12289
12864
 
12290
12865
  // src/workspace/worktree/doctor.ts
12291
12866
  import { existsSync as existsSync6 } from "node:fs";
12292
- import path66 from "node:path";
12867
+ import path67 from "node:path";
12293
12868
 
12294
12869
  // src/workspace/worktree/orphans.ts
12295
12870
  import { existsSync as existsSync5 } from "node:fs";
12296
- import path65 from "node:path";
12871
+ import path66 from "node:path";
12297
12872
  var WORKSPACE_REPO_ID = "(workspace)";
12298
12873
  async function findOrphanedRegistrations(repos, wsWorktreesDir) {
12299
12874
  const orphans = [];
12300
12875
  for (const repo of repos) {
12301
12876
  const entries = await listWorktrees(repo.absPath);
12302
12877
  for (const entry of entries) {
12303
- const entryPath = path65.normalize(entry.path);
12304
- const marker = `${path65.sep}.claude${path65.sep}worktrees${path65.sep}`;
12878
+ const entryPath = path66.normalize(entry.path);
12879
+ const marker = `${path66.sep}.claude${path66.sep}worktrees${path66.sep}`;
12305
12880
  const idx = entryPath.indexOf(marker);
12306
12881
  if (idx === -1) continue;
12307
12882
  const rest = entryPath.slice(idx + marker.length);
12308
- const slug = rest.split(path65.sep)[0];
12883
+ const slug = rest.split(path66.sep)[0];
12309
12884
  if (!slug) continue;
12310
- if (existsSync5(path65.join(wsWorktreesDir, slug))) continue;
12885
+ if (existsSync5(path66.join(wsWorktreesDir, slug))) continue;
12311
12886
  orphans.push({
12312
12887
  repoId: repo.id,
12313
12888
  ownerRepo: repo.absPath,
@@ -12322,7 +12897,7 @@ async function findOrphanedRegistrations(repos, wsWorktreesDir) {
12322
12897
  // src/workspace/worktree/doctor.ts
12323
12898
  var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
12324
12899
  function looksHydrated(memberPath) {
12325
- return HYDRATION_MARKERS.some((marker) => existsSync6(path66.join(memberPath, marker)));
12900
+ return HYDRATION_MARKERS.some((marker) => existsSync6(path67.join(memberPath, marker)));
12326
12901
  }
12327
12902
  async function runDoctor2() {
12328
12903
  const rootInfo = await resolveRoots();
@@ -12338,7 +12913,7 @@ async function runDoctor2() {
12338
12913
  };
12339
12914
  }
12340
12915
  const workspaceRoot = rootInfo.mainRoot;
12341
- const isWorkspace = existsSync6(path66.join(workspaceRoot, WORKSPACE_FILE)) || existsSync6(path66.join(workspaceRoot, REPOS_MANIFEST_FILE));
12916
+ const isWorkspace = existsSync6(path67.join(workspaceRoot, WORKSPACE_FILE)) || existsSync6(path67.join(workspaceRoot, REPOS_MANIFEST_FILE));
12342
12917
  if (!isWorkspace) {
12343
12918
  return {
12344
12919
  isWorkspace: false,
@@ -12353,7 +12928,7 @@ async function runDoctor2() {
12353
12928
  };
12354
12929
  }
12355
12930
  const wsWorktreesDir = worktreesDir(workspaceRoot);
12356
- const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path66.sep}`);
12931
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path67.sep}`);
12357
12932
  const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
12358
12933
  const workspaceConfig = await readWorkspaceConfig(workspaceRoot);
12359
12934
  const autoHydrate = workspaceConfig?.worktree?.autoHydrate === true;
@@ -12364,10 +12939,10 @@ async function runDoctor2() {
12364
12939
  let slug;
12365
12940
  if (inWorkspaceWorktree) {
12366
12941
  const wtPath = rootInfo.repoRoot;
12367
- slug = rootInfo.worktreeName ?? path66.basename(wtPath);
12942
+ slug = rootInfo.worktreeName ?? path67.basename(wtPath);
12368
12943
  const state = await readWorktreeState(wtPath);
12369
12944
  for (const member of members) {
12370
- const memberPath = path66.join(wtPath, member.folder);
12945
+ const memberPath = path67.join(wtPath, member.folder);
12371
12946
  const materialized = existsSync6(memberPath);
12372
12947
  const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
12373
12948
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
@@ -12407,7 +12982,7 @@ async function runDoctor2() {
12407
12982
  wsWorktreesDir
12408
12983
  );
12409
12984
  const orphans = orphanScan.map(
12410
- (o) => `${o.repoId}: worktree registered at ${o.registeredPath} but ${path66.join(wsWorktreesDir, o.slug)} no longer exists.`
12985
+ (o) => `${o.repoId}: worktree registered at ${o.registeredPath} but ${path67.join(wsWorktreesDir, o.slug)} no longer exists.`
12411
12986
  );
12412
12987
  if (orphans.length > 0) {
12413
12988
  problems.push(...orphans);
@@ -12428,7 +13003,7 @@ async function runDoctor2() {
12428
13003
  // src/workspace/worktree/hydrate-command.ts
12429
13004
  init_fs();
12430
13005
  import { existsSync as existsSync7 } from "node:fs";
12431
- import path67 from "node:path";
13006
+ import path68 from "node:path";
12432
13007
  async function runHydrateWorktree(opts) {
12433
13008
  const rootInfo = await resolveRoots();
12434
13009
  if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
@@ -12439,7 +13014,7 @@ async function runHydrateWorktree(opts) {
12439
13014
  }
12440
13015
  const workspaceRoot = rootInfo.mainRoot;
12441
13016
  const wtPath = rootInfo.repoRoot;
12442
- if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path67.sep}`)) {
13017
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path68.sep}`)) {
12443
13018
  return {
12444
13019
  ok: false,
12445
13020
  error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
@@ -12451,13 +13026,13 @@ async function runHydrateWorktree(opts) {
12451
13026
  return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
12452
13027
  }
12453
13028
  const state = await readWorktreeState(wtPath);
12454
- const slug = state?.slug ?? path67.basename(wtPath);
13029
+ const slug = state?.slug ?? path68.basename(wtPath);
12455
13030
  const detectedBranch = await currentBranch(wtPath);
12456
13031
  const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
12457
13032
  if (opts.dryRun) {
12458
13033
  const results2 = await Promise.all(
12459
13034
  selected.map(async (member) => {
12460
- const memberPath = path67.join(wtPath, member.folder);
13035
+ const memberPath = path68.join(wtPath, member.folder);
12461
13036
  if (existsSync7(memberPath)) {
12462
13037
  return { member: member.id, folder: member.folder, status: "planned" };
12463
13038
  }
@@ -12478,7 +13053,7 @@ async function runHydrateWorktree(opts) {
12478
13053
  const results = await mapWithConcurrency(
12479
13054
  selected,
12480
13055
  async (member) => {
12481
- const memberPath = path67.join(wtPath, member.folder);
13056
+ const memberPath = path68.join(wtPath, member.folder);
12482
13057
  let branchAction;
12483
13058
  if (!existsSync7(memberPath)) {
12484
13059
  if (!opts.materialize) {
@@ -12541,26 +13116,26 @@ async function runHydrateWorktree(opts) {
12541
13116
  }
12542
13117
 
12543
13118
  // src/workspace/worktree/list.ts
12544
- import path68 from "node:path";
12545
- import fs47 from "fs-extra";
13119
+ import path69 from "node:path";
13120
+ import fs50 from "fs-extra";
12546
13121
  async function runList2() {
12547
13122
  const resolved = await resolveWorkspaceForWorktree();
12548
13123
  if (!resolved.ok) return { ok: false, error: resolved.reason };
12549
13124
  const { rootInfo, workspaceRoot } = resolved;
12550
13125
  const dir = worktreesDir(workspaceRoot);
12551
- if (!await fs47.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
12552
- const entries = await fs47.readdir(dir, { withFileTypes: true });
13126
+ if (!await fs50.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
13127
+ const entries = await fs50.readdir(dir, { withFileTypes: true });
12553
13128
  const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
12554
13129
  const configuredMembers = await readMembers(rootInfo);
12555
13130
  const worktrees = [];
12556
13131
  for (const slug of slugs) {
12557
- const wtPath = path68.join(dir, slug);
13132
+ const wtPath = path69.join(dir, slug);
12558
13133
  const state = await readWorktreeState(wtPath);
12559
13134
  const members = [];
12560
13135
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
12561
13136
  for (const m of source) {
12562
- const memberPath = path68.join(wtPath, m.folder);
12563
- const materialized = await fs47.pathExists(memberPath);
13137
+ const memberPath = path69.join(wtPath, m.folder);
13138
+ const materialized = await fs50.pathExists(memberPath);
12564
13139
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
12565
13140
  members.push({
12566
13141
  id: m.id,
@@ -12621,14 +13196,14 @@ async function runPrune2(opts) {
12621
13196
  }
12622
13197
 
12623
13198
  // src/workspace/worktree/remove.ts
12624
- import path69 from "node:path";
12625
- import fs48 from "fs-extra";
13199
+ import path70 from "node:path";
13200
+ import fs51 from "fs-extra";
12626
13201
  async function runRemove2(opts) {
12627
13202
  const resolved = await resolveWorkspaceForWorktree();
12628
13203
  if (!resolved.ok) return { ok: false, error: resolved.reason };
12629
13204
  const { rootInfo, workspaceRoot } = resolved;
12630
13205
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
12631
- if (!await fs48.pathExists(wsWorktreePath)) {
13206
+ if (!await fs51.pathExists(wsWorktreePath)) {
12632
13207
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
12633
13208
  }
12634
13209
  const state = await readWorktreeState(wsWorktreePath);
@@ -12648,12 +13223,12 @@ async function runRemove2(opts) {
12648
13223
  targets.push({
12649
13224
  repo: configMember?.id ?? stateMember.id,
12650
13225
  ownerRepo,
12651
- path: path69.join(wsWorktreePath, stateMember.folder)
13226
+ path: path70.join(wsWorktreePath, stateMember.folder)
12652
13227
  });
12653
13228
  }
12654
13229
  const existingTargets = [];
12655
13230
  for (const target of targets) {
12656
- if (await fs48.pathExists(target.path)) existingTargets.push(target);
13231
+ if (await fs51.pathExists(target.path)) existingTargets.push(target);
12657
13232
  }
12658
13233
  if (!opts.force) {
12659
13234
  const blockers = [];
@@ -12709,8 +13284,8 @@ async function runRemove2(opts) {
12709
13284
  }
12710
13285
  await pruneWorktrees(workspaceRoot);
12711
13286
  }
12712
- if (wsRemoveOk && await fs48.pathExists(wsWorktreePath)) {
12713
- await fs48.remove(wsWorktreePath);
13287
+ if (wsRemoveOk && await fs51.pathExists(wsWorktreePath)) {
13288
+ await fs51.remove(wsWorktreePath);
12714
13289
  }
12715
13290
  return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
12716
13291
  }
@@ -12986,7 +13561,7 @@ async function runWorktree(action, opts = {}) {
12986
13561
 
12987
13562
  // src/commands/workspace.ts
12988
13563
  import { existsSync as existsSync11, statSync as statSync3 } from "node:fs";
12989
- import path78 from "node:path";
13564
+ import path79 from "node:path";
12990
13565
  init_fs();
12991
13566
  init_logger();
12992
13567
 
@@ -13045,7 +13620,7 @@ ${summaries.map(
13045
13620
 
13046
13621
  // src/commands/workspace/doctor.ts
13047
13622
  import { existsSync as existsSync8 } from "node:fs";
13048
- import path71 from "node:path";
13623
+ import path72 from "node:path";
13049
13624
  init_logger();
13050
13625
  init_paths();
13051
13626
 
@@ -13053,14 +13628,14 @@ init_paths();
13053
13628
  init_fs();
13054
13629
  init_paths();
13055
13630
  import { readFileSync as readFileSync4 } from "node:fs";
13056
- import path70 from "node:path";
13057
- var MANIFEST_FILE = "workspace.manifest.json";
13631
+ import path71 from "node:path";
13632
+ var MANIFEST_FILE2 = "workspace.manifest.json";
13058
13633
  function manifestPath(workspaceRoot) {
13059
- return hausPath(workspaceRoot, MANIFEST_FILE);
13634
+ return hausPath(workspaceRoot, MANIFEST_FILE2);
13060
13635
  }
13061
13636
  function hausVersion() {
13062
13637
  try {
13063
- const pkg = JSON.parse(readFileSync4(path70.join(packageRoot(), "package.json"), "utf8"));
13638
+ const pkg = JSON.parse(readFileSync4(path71.join(packageRoot(), "package.json"), "utf8"));
13064
13639
  return pkg.version ?? "0.0.0";
13065
13640
  } catch {
13066
13641
  return "0.0.0";
@@ -13152,7 +13727,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
13152
13727
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
13153
13728
  const catalogRefByRepo = [];
13154
13729
  for (const repo of config.repos) {
13155
- const repoRoot = path71.resolve(workspaceRoot, repo.path);
13730
+ const repoRoot = path72.resolve(workspaceRoot, repo.path);
13156
13731
  const entry = manifestByName.get(repo.name);
13157
13732
  if (!entry) {
13158
13733
  flag({
@@ -13225,7 +13800,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
13225
13800
  });
13226
13801
  continue;
13227
13802
  }
13228
- if (!existsSync8(path71.join(workspaceRoot, entry.path))) {
13803
+ if (!existsSync8(path72.join(workspaceRoot, entry.path))) {
13229
13804
  flag({
13230
13805
  repo: entry.repo,
13231
13806
  kind: "missing-linked-context-copy",
@@ -13271,18 +13846,18 @@ function emit(args) {
13271
13846
  init_logger();
13272
13847
 
13273
13848
  // src/workspace/link-context/link.ts
13274
- import path74 from "node:path";
13275
- import fs51 from "fs-extra";
13849
+ import path75 from "node:path";
13850
+ import fs54 from "fs-extra";
13276
13851
  init_fs();
13277
13852
  init_logger();
13278
13853
 
13279
13854
  // src/workspace/link-context/plan.ts
13280
- import path73 from "node:path";
13281
- import fs50 from "fs-extra";
13855
+ import path74 from "node:path";
13856
+ import fs53 from "fs-extra";
13282
13857
 
13283
13858
  // src/workspace/link-context/scan-source.ts
13284
- import path72 from "node:path";
13285
- import fs49 from "fs-extra";
13859
+ import path73 from "node:path";
13860
+ import fs52 from "fs-extra";
13286
13861
  var ASSET_SUBDIR = {
13287
13862
  skill: "skills",
13288
13863
  agent: "agents",
@@ -13290,36 +13865,36 @@ var ASSET_SUBDIR = {
13290
13865
  };
13291
13866
  async function hasLinkableContext(member) {
13292
13867
  for (const subdir of Object.values(ASSET_SUBDIR)) {
13293
- if (await fs49.pathExists(path72.join(member.absPath, ".claude", subdir))) return true;
13868
+ if (await fs52.pathExists(path73.join(member.absPath, ".claude", subdir))) return true;
13294
13869
  }
13295
13870
  return false;
13296
13871
  }
13297
13872
  async function collectSkills(member) {
13298
- const dir = path72.join(member.absPath, ".claude", "skills");
13299
- if (!await fs49.pathExists(dir)) return [];
13300
- const names = await fs49.readdir(dir);
13873
+ const dir = path73.join(member.absPath, ".claude", "skills");
13874
+ if (!await fs52.pathExists(dir)) return [];
13875
+ const names = await fs52.readdir(dir);
13301
13876
  const out = [];
13302
13877
  for (const name of [...names].sort()) {
13303
- const abs = path72.join(dir, name);
13304
- const stat = await fs49.lstat(abs);
13878
+ const abs = path73.join(dir, name);
13879
+ const stat = await fs52.lstat(abs);
13305
13880
  if (!stat.isDirectory()) continue;
13306
- if (!await fs49.pathExists(path72.join(abs, "SKILL.md"))) continue;
13307
- out.push({ type: "skill", name, sourceRelPath: path72.posix.join(".claude", "skills", name) });
13881
+ if (!await fs52.pathExists(path73.join(abs, "SKILL.md"))) continue;
13882
+ out.push({ type: "skill", name, sourceRelPath: path73.posix.join(".claude", "skills", name) });
13308
13883
  }
13309
13884
  return out;
13310
13885
  }
13311
13886
  async function collectFlatMarkdown(member, type) {
13312
13887
  const subdir = ASSET_SUBDIR[type];
13313
- const dir = path72.join(member.absPath, ".claude", subdir);
13314
- if (!await fs49.pathExists(dir)) return [];
13315
- const names = await fs49.readdir(dir);
13888
+ const dir = path73.join(member.absPath, ".claude", subdir);
13889
+ if (!await fs52.pathExists(dir)) return [];
13890
+ const names = await fs52.readdir(dir);
13316
13891
  const out = [];
13317
13892
  for (const fileName of [...names].sort()) {
13318
13893
  if (!fileName.endsWith(".md")) continue;
13319
- const abs = path72.join(dir, fileName);
13320
- if (!(await fs49.lstat(abs)).isFile()) continue;
13894
+ const abs = path73.join(dir, fileName);
13895
+ if (!(await fs52.lstat(abs)).isFile()) continue;
13321
13896
  const name = fileName.slice(0, -".md".length);
13322
- out.push({ type, name, sourceRelPath: path72.posix.join(".claude", subdir, fileName) });
13897
+ out.push({ type, name, sourceRelPath: path73.posix.join(".claude", subdir, fileName) });
13323
13898
  }
13324
13899
  return out;
13325
13900
  }
@@ -13334,9 +13909,9 @@ async function collectSourceAssets(member) {
13334
13909
 
13335
13910
  // src/workspace/link-context/plan.ts
13336
13911
  function repoFolderPrefix(member) {
13337
- const base = path73.posix.basename(member.folder);
13912
+ const base = path74.posix.basename(member.folder);
13338
13913
  if (base && base !== "." && base !== "/") return base;
13339
- const fromAbs = path73.basename(member.absPath);
13914
+ const fromAbs = path74.basename(member.absPath);
13340
13915
  return fromAbs || member.id;
13341
13916
  }
13342
13917
  function assetKey(type, destKey) {
@@ -13351,7 +13926,7 @@ async function buildLinkPlan(members) {
13351
13926
  const byKey = /* @__PURE__ */ new Map();
13352
13927
  const membersById = new Map(members.map((m) => [m.id, m]));
13353
13928
  for (const member of members) {
13354
- if (!await fs50.pathExists(member.absPath)) {
13929
+ if (!await fs53.pathExists(member.absPath)) {
13355
13930
  skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
13356
13931
  continue;
13357
13932
  }
@@ -13393,7 +13968,7 @@ async function buildLinkPlan(members) {
13393
13968
  name: asset.name,
13394
13969
  destKey,
13395
13970
  sourceRelPath: asset.sourceRelPath,
13396
- sourceAbsPath: path73.join(member.absPath, asset.sourceRelPath),
13971
+ sourceAbsPath: path74.join(member.absPath, asset.sourceRelPath),
13397
13972
  sourceHash
13398
13973
  });
13399
13974
  }
@@ -13404,7 +13979,7 @@ async function buildLinkPlan(members) {
13404
13979
  function destRelPathFor(type, destKey) {
13405
13980
  const subdir = `${type}s`;
13406
13981
  const leaf = type === "skill" ? destKey : `${destKey}.md`;
13407
- return path74.posix.join(".claude", subdir, leaf);
13982
+ return path75.posix.join(".claude", subdir, leaf);
13408
13983
  }
13409
13984
  async function runLinkContext(workspaceRoot, opts = {}) {
13410
13985
  const write = resolveWriteMode(opts, "preview-by-default").kind === "write";
@@ -13430,17 +14005,17 @@ async function runLinkContext(workspaceRoot, opts = {}) {
13430
14005
  const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
13431
14006
  for (const entry of plan.entries) {
13432
14007
  const destRel = destRelPathFor(entry.type, entry.destKey);
13433
- const destAbs = path74.join(workspaceRoot, destRel);
14008
+ const destAbs = path75.join(workspaceRoot, destRel);
13434
14009
  if (write) {
13435
- await fs51.ensureDir(path74.dirname(destAbs));
13436
- if (await fs51.pathExists(destAbs)) await fs51.remove(destAbs);
13437
- await fs51.copy(entry.sourceAbsPath, destAbs, {
14010
+ await fs54.ensureDir(path75.dirname(destAbs));
14011
+ if (await fs54.pathExists(destAbs)) await fs54.remove(destAbs);
14012
+ await fs54.copy(entry.sourceAbsPath, destAbs, {
13438
14013
  overwrite: true,
13439
14014
  errorOnExist: false,
13440
14015
  dereference: false,
13441
14016
  filter: (s) => {
13442
14017
  try {
13443
- return !fs51.lstatSync(s).isSymbolicLink();
14018
+ return !fs54.lstatSync(s).isSymbolicLink();
13444
14019
  } catch {
13445
14020
  return false;
13446
14021
  }
@@ -13469,7 +14044,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
13469
14044
  warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
13470
14045
  continue;
13471
14046
  }
13472
- if (await fs51.pathExists(abs)) await fs51.remove(abs);
14047
+ if (await fs54.pathExists(abs)) await fs54.remove(abs);
13473
14048
  }
13474
14049
  }
13475
14050
  if (write) {
@@ -13521,11 +14096,11 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
13521
14096
 
13522
14097
  // src/commands/workspace/setup.ts
13523
14098
  import { existsSync as existsSync9, statSync } from "node:fs";
13524
- import path76 from "node:path";
14099
+ import path77 from "node:path";
13525
14100
 
13526
14101
  // src/claude/write-workspace-claude-md.ts
13527
- import path75 from "node:path";
13528
- import fs52 from "fs-extra";
14102
+ import path76 from "node:path";
14103
+ import fs55 from "fs-extra";
13529
14104
  init_fs();
13530
14105
  init_logger();
13531
14106
  init_paths();
@@ -13547,11 +14122,11 @@ ${WORKSPACE_BLOCK_END}`;
13547
14122
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
13548
14123
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
13549
14124
  const dryRun = opts.dryRun ?? false;
13550
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path75.join(workspaceRoot, "CLAUDE.md");
14125
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path76.join(workspaceRoot, "CLAUDE.md");
13551
14126
  const say = opts.quiet ? () => {
13552
14127
  } : log;
13553
- const existed = await fs52.pathExists(filePath);
13554
- const prev = existed ? await fs52.readFile(filePath, "utf8") : "";
14128
+ const existed = await fs55.pathExists(filePath);
14129
+ const prev = existed ? await fs55.readFile(filePath, "utf8") : "";
13555
14130
  const next = opts.collision ? `${block}
13556
14131
  ` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
13557
14132
  const printable = displayPath(workspaceRoot, filePath);
@@ -13580,20 +14155,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
13580
14155
  init_fs();
13581
14156
  init_logger();
13582
14157
  function resolveWorkspaceRoot(start = process.cwd()) {
13583
- let dir = path76.resolve(start);
14158
+ let dir = path77.resolve(start);
13584
14159
  for (; ; ) {
13585
- if (existsSync9(path76.join(dir, WORKSPACE_FILE))) return dir;
13586
- const parent = path76.dirname(dir);
13587
- if (parent === dir) return path76.resolve(start);
14160
+ if (existsSync9(path77.join(dir, WORKSPACE_FILE))) return dir;
14161
+ const parent = path77.dirname(dir);
14162
+ if (parent === dir) return path77.resolve(start);
13588
14163
  dir = parent;
13589
14164
  }
13590
14165
  }
13591
14166
  function isRootRepo(workspaceRoot, repoPath) {
13592
- return path76.resolve(workspaceRoot, repoPath) === path76.resolve(workspaceRoot);
14167
+ return path77.resolve(workspaceRoot, repoPath) === path77.resolve(workspaceRoot);
13593
14168
  }
13594
14169
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
13595
14170
  const apply = options.write ?? false;
13596
- const configText = await readText(path76.join(workspaceRoot, WORKSPACE_FILE));
14171
+ const configText = await readText(path77.join(workspaceRoot, WORKSPACE_FILE));
13597
14172
  if (!configText) {
13598
14173
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
13599
14174
  process.exitCode = 1;
@@ -13620,37 +14195,52 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13620
14195
  const statuses = [];
13621
14196
  const aggregateInputs = [];
13622
14197
  for (const repo of repos) {
13623
- const repoRoot = path76.resolve(workspaceRoot, repo.path);
14198
+ const repoRoot = path77.resolve(workspaceRoot, repo.path);
13624
14199
  say(`
13625
14200
  \u2192 ${repo.name} (${repo.path})`);
14201
+ let res;
13626
14202
  try {
13627
14203
  if (!existsSync9(repoRoot) || !statSync(repoRoot).isDirectory()) {
13628
14204
  throw new Error(`Repo path is not a directory: ${repo.path}`);
13629
14205
  }
13630
- const res = await runSetupCore(repoRoot, {
14206
+ res = await runSetupCore(repoRoot, {
13631
14207
  apply,
13632
14208
  dryRun: options.dryRun,
13633
14209
  // Under --json, mute the inner pipeline entirely; we serialize the
13634
14210
  // aggregated result below. Otherwise let it print its normal human output.
13635
- quiet: options.json
14211
+ quiet: options.json,
14212
+ allowEmptyCache: options.allowEmptyCache
13636
14213
  });
14214
+ } catch (err) {
14215
+ const message2 = err instanceof Error ? err.message : String(err);
13637
14216
  statuses.push({
13638
14217
  name: repo.name,
13639
14218
  path: repo.path,
13640
14219
  root: repoRoot,
13641
- status: "ok",
13642
- roles: res.roles,
13643
- recommendedCount: res.recommendedCount
14220
+ status: "failed",
14221
+ error: message2
13644
14222
  });
13645
- const context = await readContextOrScan(repoRoot);
13646
- aggregateInputs.push({ name: repo.name, path: repo.path, context });
13647
- } catch (err) {
13648
- const message2 = err instanceof Error ? err.message : String(err);
14223
+ if (!options.continueOnError) {
14224
+ if (options.json) {
14225
+ const result2 = { workspaceRoot, statuses, written: [] };
14226
+ log(JSON.stringify(result2, null, 2));
14227
+ process.exitCode = 1;
14228
+ return result2;
14229
+ }
14230
+ throw new Error(`Setup failed for ${repo.name}: ${message2}`, { cause: err });
14231
+ }
14232
+ error(`Setup failed for ${repo.name}: ${message2}`);
14233
+ continue;
14234
+ }
14235
+ if (!res.catalogInstallOk) {
14236
+ const message2 = "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.";
13649
14237
  statuses.push({
13650
14238
  name: repo.name,
13651
14239
  path: repo.path,
13652
14240
  root: repoRoot,
13653
14241
  status: "failed",
14242
+ roles: res.roles,
14243
+ recommendedCount: res.recommendedCount,
13654
14244
  error: message2
13655
14245
  });
13656
14246
  if (!options.continueOnError) {
@@ -13660,10 +14250,21 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13660
14250
  process.exitCode = 1;
13661
14251
  return result2;
13662
14252
  }
13663
- throw new Error(`Setup failed for ${repo.name}: ${message2}`, { cause: err });
14253
+ throw new Error(`Setup failed for ${repo.name}: ${message2}`);
13664
14254
  }
13665
14255
  error(`Setup failed for ${repo.name}: ${message2}`);
14256
+ continue;
13666
14257
  }
14258
+ statuses.push({
14259
+ name: repo.name,
14260
+ path: repo.path,
14261
+ root: repoRoot,
14262
+ status: "ok",
14263
+ roles: res.roles,
14264
+ recommendedCount: res.recommendedCount
14265
+ });
14266
+ const context = await readContextOrScan(repoRoot);
14267
+ aggregateInputs.push({ name: repo.name, path: repo.path, context });
13667
14268
  }
13668
14269
  const written = [];
13669
14270
  if (apply && aggregateInputs.length > 0) {
@@ -13710,7 +14311,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13710
14311
  const status = statusByName.get(repo.name);
13711
14312
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
13712
14313
  if (status?.status === "ok") {
13713
- const lock = await checkLock(path76.resolve(workspaceRoot, repo.path));
14314
+ const lock = await checkLock(path77.resolve(workspaceRoot, repo.path));
13714
14315
  manifestRepos.push({
13715
14316
  name: repo.name,
13716
14317
  path: repo.path,
@@ -13766,7 +14367,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13766
14367
  try {
13767
14368
  const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
13768
14369
  if (linkResult.ok) {
13769
- written.push(...linkResult.added.map((rel) => path76.join(workspaceRoot, rel)));
14370
+ written.push(...linkResult.added.map((rel) => path77.join(workspaceRoot, rel)));
13770
14371
  if (!options.json) {
13771
14372
  say(
13772
14373
  `Linked ${linkResult.linked.length} cross-repo skill/agent/command copy(ies) into .claude/ (${linkResult.added.length} new, ${linkResult.removed.length} removed)${linkResult.skipped.length > 0 ? ` \u2014 ${linkResult.skipped.length} member(s) skipped, see above` : ""}.`
@@ -13783,6 +14384,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13783
14384
  }
13784
14385
  const ok = statuses.filter((s) => s.status === "ok").length;
13785
14386
  const failed = statuses.length - ok;
14387
+ if (failed > 0) process.exitCode = 1;
13786
14388
  const result = { workspaceRoot, statuses, written };
13787
14389
  if (options.json) {
13788
14390
  log(JSON.stringify(result, null, 2));
@@ -13798,8 +14400,8 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
13798
14400
 
13799
14401
  // src/commands/workspace/undo.ts
13800
14402
  import { existsSync as existsSync10, statSync as statSync2 } from "node:fs";
13801
- import path77 from "node:path";
13802
- import fs53 from "fs-extra";
14403
+ import path78 from "node:path";
14404
+ import fs56 from "fs-extra";
13803
14405
  init_fs();
13804
14406
  init_logger();
13805
14407
  init_paths();
@@ -13829,7 +14431,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13829
14431
  }
13830
14432
  if (options.dryRun) {
13831
14433
  for (const repo of config.repos) {
13832
- const repoRoot = path77.resolve(workspaceRoot, repo.path);
14434
+ const repoRoot = path78.resolve(workspaceRoot, repo.path);
13833
14435
  log(`
13834
14436
  \u2192 ${repo.name} (${repo.path})`);
13835
14437
  try {
@@ -13850,7 +14452,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13850
14452
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
13851
14453
  continue;
13852
14454
  }
13853
- if (await fs53.pathExists(abs)) {
14455
+ if (await fs56.pathExists(abs)) {
13854
14456
  log(`[dry-run] Would remove ${entry.path} (linked ${entry.type} from ${entry.repo})`);
13855
14457
  }
13856
14458
  }
@@ -13860,12 +14462,12 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13860
14462
  hausPath(workspaceRoot, "WORKSPACE.md")
13861
14463
  ];
13862
14464
  for (const abs of workspaceArtifacts) {
13863
- if (await fs53.pathExists(abs)) {
13864
- log(`[dry-run] Would remove ${path77.relative(workspaceRoot, abs)}`);
14465
+ if (await fs56.pathExists(abs)) {
14466
+ log(`[dry-run] Would remove ${path78.relative(workspaceRoot, abs)}`);
13865
14467
  }
13866
14468
  }
13867
- if (!await fs53.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
13868
- const claudeMd = await readText(path77.join(workspaceRoot, "CLAUDE.md"));
14469
+ if (!await fs56.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
14470
+ const claudeMd = await readText(path78.join(workspaceRoot, "CLAUDE.md"));
13869
14471
  if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
13870
14472
  log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
13871
14473
  }
@@ -13884,7 +14486,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13884
14486
  }
13885
14487
  let anyRepoFailed = false;
13886
14488
  for (const repo of config.repos) {
13887
- const repoRoot = path77.resolve(workspaceRoot, repo.path);
14489
+ const repoRoot = path78.resolve(workspaceRoot, repo.path);
13888
14490
  log(`
13889
14491
  \u2192 ${repo.name} (${repo.path})`);
13890
14492
  try {
@@ -13904,29 +14506,29 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13904
14506
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
13905
14507
  continue;
13906
14508
  }
13907
- if (await fs53.pathExists(abs)) {
13908
- await fs53.remove(abs);
14509
+ if (await fs56.pathExists(abs)) {
14510
+ await fs56.remove(abs);
13909
14511
  log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
13910
14512
  }
13911
14513
  }
13912
14514
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
13913
14515
  const abs = hausPath(workspaceRoot, rel);
13914
- if (await fs53.pathExists(abs)) {
13915
- await fs53.remove(abs);
13916
- log(`Removed ${path77.relative(workspaceRoot, abs)}`);
14516
+ if (await fs56.pathExists(abs)) {
14517
+ await fs56.remove(abs);
14518
+ log(`Removed ${path78.relative(workspaceRoot, abs)}`);
13917
14519
  }
13918
14520
  }
13919
14521
  const manifestFile = manifestPath(workspaceRoot);
13920
- if (await fs53.pathExists(manifestFile)) {
13921
- await fs53.remove(manifestFile);
13922
- log(`Removed ${path77.relative(workspaceRoot, manifestFile)}`);
14522
+ if (await fs56.pathExists(manifestFile)) {
14523
+ await fs56.remove(manifestFile);
14524
+ log(`Removed ${path78.relative(workspaceRoot, manifestFile)}`);
13923
14525
  }
13924
14526
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
13925
- if (await fs53.pathExists(workspaceMdPath)) {
13926
- await fs53.remove(workspaceMdPath);
13927
- log(`Removed ${path77.relative(workspaceRoot, workspaceMdPath)}`);
14527
+ if (await fs56.pathExists(workspaceMdPath)) {
14528
+ await fs56.remove(workspaceMdPath);
14529
+ log(`Removed ${path78.relative(workspaceRoot, workspaceMdPath)}`);
13928
14530
  } else {
13929
- const claudeMdPath = path77.join(workspaceRoot, "CLAUDE.md");
14531
+ const claudeMdPath = path78.join(workspaceRoot, "CLAUDE.md");
13930
14532
  if (existsSync10(claudeMdPath)) {
13931
14533
  const prev = await readText(claudeMdPath);
13932
14534
  if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
@@ -13935,19 +14537,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13935
14537
  end: WORKSPACE_BLOCK_END
13936
14538
  });
13937
14539
  if (next.length === 0) {
13938
- await fs53.remove(claudeMdPath);
14540
+ await fs56.remove(claudeMdPath);
13939
14541
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
13940
14542
  } else {
13941
- await fs53.writeFile(claudeMdPath, next, "utf8");
14543
+ await fs56.writeFile(claudeMdPath, next, "utf8");
13942
14544
  log("Removed haus import block from CLAUDE.md (user content preserved).");
13943
14545
  }
13944
14546
  } else if (prev && hasLegacyWorkspaceBlock(prev)) {
13945
14547
  const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
13946
14548
  if (next.length === 0) {
13947
- await fs53.remove(claudeMdPath);
14549
+ await fs56.remove(claudeMdPath);
13948
14550
  log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
13949
14551
  } else {
13950
- await fs53.writeFile(claudeMdPath, next, "utf8");
14552
+ await fs56.writeFile(claudeMdPath, next, "utf8");
13951
14553
  log(
13952
14554
  "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
13953
14555
  );
@@ -13995,7 +14597,7 @@ relationships: []
13995
14597
  log("Workspace initialized.");
13996
14598
  }
13997
14599
  async function scanWorkspace(workspaceRoot, opts) {
13998
- const configText = await readText(path78.join(workspaceRoot, WORKSPACE_FILE));
14600
+ const configText = await readText(path79.join(workspaceRoot, WORKSPACE_FILE));
13999
14601
  if (!configText) {
14000
14602
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
14001
14603
  process.exitCode = 1;
@@ -14017,7 +14619,7 @@ async function scanWorkspace(workspaceRoot, opts) {
14017
14619
  const inputs = [];
14018
14620
  const failures = [];
14019
14621
  for (const repo of config.repos) {
14020
- const repoRoot = path78.resolve(workspaceRoot, repo.path);
14622
+ const repoRoot = path79.resolve(workspaceRoot, repo.path);
14021
14623
  try {
14022
14624
  if (!existsSync11(repoRoot) || !statSync3(repoRoot).isDirectory()) {
14023
14625
  throw new Error("path is not a directory");
@@ -14065,7 +14667,8 @@ async function runWorkspace(action, options = {}) {
14065
14667
  dryRun: options.dryRun,
14066
14668
  json: options.json,
14067
14669
  continueOnError: options.continueOnError,
14068
- only: normalizeOnly(options.only)
14670
+ only: normalizeOnly(options.only),
14671
+ allowEmptyCache: options.allowEmptyCache
14069
14672
  });
14070
14673
  return;
14071
14674
  case "doctor":
@@ -14085,7 +14688,7 @@ init_logger();
14085
14688
  init_paths();
14086
14689
  function cliVersion() {
14087
14690
  try {
14088
- const pkgPath = path79.join(packageRoot(), "package.json");
14691
+ const pkgPath = path80.join(packageRoot(), "package.json");
14089
14692
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
14090
14693
  return pkg.version ?? "0.0.0";
14091
14694
  } catch {
@@ -14095,7 +14698,7 @@ function cliVersion() {
14095
14698
  var program = new Command();
14096
14699
  function validateRuntimeNodeVersion() {
14097
14700
  try {
14098
- const pkgPath = path79.join(packageRoot(), "package.json");
14701
+ const pkgPath = path80.join(packageRoot(), "package.json");
14099
14702
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
14100
14703
  const requiredRange = pkg.engines?.node;
14101
14704
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -14109,6 +14712,9 @@ function validateRuntimeNodeVersion() {
14109
14712
  }
14110
14713
  validateRuntimeNodeVersion();
14111
14714
  program.name("haus").description("Haus AI workflow CLI").version(cliVersion());
14715
+ program.option("--offline", "Make no network calls; use cached or bundled catalog content only").hook("preAction", (thisCommand) => {
14716
+ setOfflineFlag(Boolean(thisCommand.opts()["offline"]));
14717
+ });
14112
14718
  program.command("scan").description(
14113
14719
  "Scan the repo for roles, dependencies, and package manager; writes context-map.json"
14114
14720
  ).option("--json").action(runScan);
@@ -14125,7 +14731,7 @@ program.command("apply").description("Write recommended catalog items and core C
14125
14731
  "Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
14126
14732
  ).option(
14127
14733
  "--allow-empty-cache",
14128
- "Apply core files only when catalog cache is empty (skip catalog items without error)"
14734
+ "Proceed when no catalog item has installable content: write core files and skip the catalog items. Still exits 1 if none installed (ADR-0060)"
14129
14735
  ).option(
14130
14736
  "--refill-config",
14131
14737
  "Fill still-blank fields in an existing workflow-config.md without touching edited ones"
@@ -14159,6 +14765,9 @@ program.command("explain-recommendation").description("Print a per-item breakdow
14159
14765
  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(
14160
14766
  "--force",
14161
14767
  "Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
14768
+ ).option(
14769
+ "--allow-empty-cache",
14770
+ "Proceed when no catalog item has installable content: write core files and skip the catalog items. Still exits 1 if none installed (ADR-0060)"
14162
14771
  ).action(runInit);
14163
14772
  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);
14164
14773
  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(
@@ -14167,6 +14776,12 @@ program.command("clone").description("Clone a single git repository by URL").arg
14167
14776
  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 }));
14168
14777
  program.command("catalog-audit").description("Check the bundled catalog manifest for unsupported or forbidden stacks").action(runCatalogAudit);
14169
14778
  program.command("validate-catalog").description("Validate a catalog manifest at an explicit path").argument("[manifest]").action(runValidateCatalog);
14779
+ var catalog = program.command("catalog").description("Catalog cache maintenance");
14780
+ var catalogBundle = catalog.command("bundle").description("Move catalog content between machines (for air-gapped installs)");
14781
+ 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 }));
14782
+ 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(
14783
+ (file, opts) => runBundleImport(file, { dryRun: opts.dryRun })
14784
+ );
14170
14785
  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);
14171
14786
  program.command("ci-gate").description(
14172
14787
  "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."
@@ -14195,7 +14810,10 @@ var workspace = program.command("workspace").description("Multi-repo workspace c
14195
14810
  workspace.command("init").option("-y, --yes", "Skip confirmation when haus.workspace.yaml already exists").action((opts) => runWorkspace("init", opts));
14196
14811
  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));
14197
14812
  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));
14198
- 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));
14813
+ 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(
14814
+ "--allow-empty-cache",
14815
+ "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)"
14816
+ ).action((opts) => runWorkspace("setup", opts));
14199
14817
  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));
14200
14818
  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));
14201
14819
  workspace.command("link-context").description(