@haus-tech/haus-workflow 1.5.4 → 1.5.5

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
@@ -60,11 +60,11 @@ var init_exec = __esm({
60
60
 
61
61
  // src/cli.ts
62
62
  import { readFileSync as readFileSync5 } from "node:fs";
63
- import path71 from "node:path";
63
+ import path72 from "node:path";
64
64
  import { Command } from "commander";
65
65
 
66
66
  // src/commands/apply.ts
67
- import path23 from "node:path";
67
+ import path24 from "node:path";
68
68
  import checkbox from "@inquirer/checkbox";
69
69
 
70
70
  // src/catalog/load-catalog.ts
@@ -185,6 +185,14 @@ async function listFiles(root, patterns) {
185
185
  function hashText(value) {
186
186
  return `sha256-${crypto.createHash("sha256").update(value).digest("hex")}`;
187
187
  }
188
+ function resolveContainedPath(root, rel) {
189
+ const resolvedRoot = path.resolve(root);
190
+ const resolved = path.resolve(root, rel);
191
+ if (resolved === resolvedRoot) return null;
192
+ const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
193
+ if (!resolved.startsWith(prefix)) return null;
194
+ return resolved;
195
+ }
188
196
  async function mapWithConcurrency(items, fn, concurrency = 24) {
189
197
  const size = Number.isFinite(concurrency) ? Math.max(1, Math.floor(concurrency)) : 24;
190
198
  const results = new Array(items.length);
@@ -929,6 +937,7 @@ var validation_rules_default = {
929
937
  "haus",
930
938
  "security",
931
939
  "quality",
940
+ "a11y",
932
941
  "frontend",
933
942
  "backend",
934
943
  "testing",
@@ -1552,7 +1561,7 @@ function summarizeGitignoredHausDirs(paths) {
1552
1561
  }
1553
1562
 
1554
1563
  // src/claude/write-claude-files.ts
1555
- import path19 from "node:path";
1564
+ import path20 from "node:path";
1556
1565
  import fs18 from "fs-extra";
1557
1566
 
1558
1567
  // src/catalog/former-ids.ts
@@ -1608,8 +1617,199 @@ function findOrphanedLockEntries(lock, recommendedIds) {
1608
1617
  );
1609
1618
  }
1610
1619
 
1611
- // src/update/hash-installed.ts
1620
+ // src/scanner/detection.ts
1612
1621
  import path10 from "node:path";
1622
+
1623
+ // src/security/sensitive-paths.ts
1624
+ var DENY_PATHS = [
1625
+ "*.pem",
1626
+ "*.key",
1627
+ "*.p12",
1628
+ "*.pfx",
1629
+ "id_rsa",
1630
+ "id_ed25519",
1631
+ "customer-data",
1632
+ "secrets",
1633
+ "certs"
1634
+ ];
1635
+ var DENY_DIRS = /* @__PURE__ */ new Set(["customer-data", "secrets", "certs"]);
1636
+ var DENY_PATH_REGEXES = [
1637
+ /(^|\/)[^/]+\.pem$/i,
1638
+ /(^|\/)[^/]+\.key$/i,
1639
+ /(^|\/)[^/]+\.p12$/i,
1640
+ /(^|\/)[^/]+\.pfx$/i,
1641
+ /(^|\/)id_rsa$/i,
1642
+ /(^|\/)id_ed25519$/i,
1643
+ /(^|\/)customer-data(\/|$)/i,
1644
+ /(^|\/)secrets(\/|$)/i,
1645
+ /(^|\/)certs(\/|$)/i
1646
+ ];
1647
+ function normalizePathForMatch(candidate) {
1648
+ return candidate.replace(/\\/g, "/").replace(/\/+/g, "/").trim();
1649
+ }
1650
+ var ASK_PATHS = [
1651
+ { pattern: ".env", tools: ["Edit", "Write"] },
1652
+ { pattern: ".env.*", tools: ["Edit", "Write"] },
1653
+ { pattern: "storage/logs/**", tools: ["Edit", "Write"] },
1654
+ { pattern: "exports/**", tools: ["Edit", "Write"] },
1655
+ { pattern: "*.dump", tools: ["Read", "Edit", "Write"] },
1656
+ { pattern: "*.backup", tools: ["Read", "Edit", "Write"] },
1657
+ { pattern: "*.bak", tools: ["Read", "Edit", "Write"] },
1658
+ { pattern: "wp-content/uploads/**", tools: ["Read", "Edit", "Write"] },
1659
+ { pattern: "uploads/**", tools: ["Read", "Edit", "Write"] }
1660
+ ];
1661
+ var SENSITIVE_PATH_REGEXES = [
1662
+ /^\.env(\.|$)/,
1663
+ /(^|\/)\.env(\.|$)/,
1664
+ /\.pem$/,
1665
+ /\.key$/,
1666
+ /\.p12$/,
1667
+ /\.pfx$/,
1668
+ /\.sql$/,
1669
+ /\.dump$/,
1670
+ /customer-data/,
1671
+ /exports/,
1672
+ /certs/,
1673
+ /secrets/,
1674
+ /(^|\/)storage\/logs(\/|$)/,
1675
+ /(^|\/)wp-content\/uploads(\/|$)/,
1676
+ /(^|\/)uploads(\/|$)/
1677
+ ];
1678
+ var SENSITIVE_ITEM_KEYWORDS = [
1679
+ ".env",
1680
+ "secrets",
1681
+ "certs",
1682
+ "customer-data",
1683
+ "exports",
1684
+ ".pem",
1685
+ ".key"
1686
+ ];
1687
+
1688
+ // src/utils/audit-checks.ts
1689
+ function isRecord(v) {
1690
+ return typeof v === "object" && v !== null;
1691
+ }
1692
+
1693
+ // src/scanner/detection.ts
1694
+ var UNSUPPORTED_MARKERS = {
1695
+ "requirements.txt": "python",
1696
+ "pyproject.toml": "python",
1697
+ "go.mod": "go",
1698
+ "Cargo.toml": "rust",
1699
+ "pom.xml": "java",
1700
+ "build.gradle": "java",
1701
+ "build.gradle.kts": "java",
1702
+ Gemfile: "ruby"
1703
+ };
1704
+ var WEAK_STACK_SIGNALS = /* @__PURE__ */ new Set(["missing-prettier", "missing-eslint"]);
1705
+ function computeDetectionStatus(roles, stacks, unsupportedSignals) {
1706
+ const hasRealStack = Object.entries(stacks).some(
1707
+ ([bucket, names]) => bucket !== "packageManagers" && names.some((n) => !WEAK_STACK_SIGNALS.has(n))
1708
+ );
1709
+ const hasRealSignal = roles.length > 0 || hasRealStack;
1710
+ if (!hasRealSignal) return "unknown";
1711
+ return unsupportedSignals.length > 0 ? "partial" : "supported";
1712
+ }
1713
+ function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
1714
+ const markers = unsupportedSignals.join(", ");
1715
+ const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
1716
+ const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree \u2014 sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
1717
+ return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
1718
+ }
1719
+ function blocked(rel) {
1720
+ return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
1721
+ }
1722
+ function dependencyVersionMap(pkg, composer) {
1723
+ const versions = /* @__PURE__ */ new Map();
1724
+ const pushObj = (obj) => {
1725
+ if (!isRecord(obj)) return;
1726
+ for (const key of Object.keys(obj)) {
1727
+ if (versions.has(key)) continue;
1728
+ const value = obj[key];
1729
+ versions.set(key, typeof value === "string" ? value : "");
1730
+ }
1731
+ };
1732
+ pushObj(pkg?.dependencies);
1733
+ pushObj(pkg?.devDependencies);
1734
+ pushObj(composer?.require);
1735
+ pushObj(composer?.["require-dev"]);
1736
+ return versions;
1737
+ }
1738
+ function finalizeRoles(registryRoles, deps, files) {
1739
+ const roles = new Set(registryRoles);
1740
+ const hasWpConfig = files.some((f) => f.endsWith("wp-config.php"));
1741
+ const hasBedrockLayout = files.some((f) => f.includes("web/app")) || deps.includes("roots/wordpress");
1742
+ if (hasWpConfig && hasBedrockLayout) {
1743
+ roles.add("wordpress-bedrock-site");
1744
+ roles.add("wordpress-site");
1745
+ } else if (hasWpConfig) {
1746
+ roles.add("wordpress-vanilla-site");
1747
+ roles.add("wordpress-site");
1748
+ } else if (deps.includes("roots/wordpress")) {
1749
+ roles.add("wordpress-bedrock-site");
1750
+ roles.add("wordpress-site");
1751
+ }
1752
+ return [...roles].sort();
1753
+ }
1754
+ function collectUnsupportedSignals(files) {
1755
+ return [
1756
+ ...new Set(
1757
+ files.map((f) => UNSUPPORTED_MARKERS[path10.basename(f)]).filter((s) => Boolean(s))
1758
+ )
1759
+ ].sort();
1760
+ }
1761
+
1762
+ // src/recommender/policies.ts
1763
+ var UNSUPPORTED = FORBIDDEN_TAGS;
1764
+ function matchRequiresAny(clauses, ctx) {
1765
+ for (const clause of clauses) {
1766
+ if ("stack" in clause) {
1767
+ if (ctx.stackSet.has(clause.stack.toLowerCase())) {
1768
+ return { matched: true, signal: `stack:${clause.stack}` };
1769
+ }
1770
+ } else if ("dependency" in clause) {
1771
+ if (ctx.depSet.has(clause.dependency.toLowerCase())) {
1772
+ return { matched: true, signal: `dependency:${clause.dependency}` };
1773
+ }
1774
+ } else if ("packageNamePattern" in clause) {
1775
+ const pattern = clause.packageNamePattern.toLowerCase();
1776
+ const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
1777
+ for (const dep2 of ctx.depSet) {
1778
+ if (pattern.endsWith("*") ? dep2.startsWith(prefix) : dep2 === pattern) {
1779
+ return {
1780
+ matched: true,
1781
+ signal: `packageNamePattern:${clause.packageNamePattern}`
1782
+ };
1783
+ }
1784
+ }
1785
+ } else if ("role" in clause) {
1786
+ if (ctx.roleSet.has(clause.role.toLowerCase())) {
1787
+ return { matched: true, signal: `role:${clause.role}` };
1788
+ }
1789
+ }
1790
+ }
1791
+ return { matched: false };
1792
+ }
1793
+ function describeRequiresAny(clauses) {
1794
+ return clauses.map((c) => {
1795
+ if ("stack" in c) return `stack=${c.stack}`;
1796
+ if ("dependency" in c) return `dependency=${c.dependency}`;
1797
+ if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
1798
+ if ("role" in c) return `role=${c.role}`;
1799
+ return "unknown";
1800
+ }).join(" | ");
1801
+ }
1802
+ function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
1803
+ const markers = context.unsupportedSignals?.join(", ");
1804
+ const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
1805
+ `Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
1806
+ ] : [];
1807
+ const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
1808
+ return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
1809
+ }
1810
+
1811
+ // src/update/hash-installed.ts
1812
+ import path11 from "node:path";
1613
1813
  import fg2 from "fast-glob";
1614
1814
  import fs7 from "fs-extra";
1615
1815
 
@@ -1666,7 +1866,7 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
1666
1866
  const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
1667
1867
  const fileDigests = [];
1668
1868
  for (const rel of normalized) {
1669
- const abs = path10.join(root, rel);
1869
+ const abs = path11.join(root, rel);
1670
1870
  if (!await fs7.pathExists(abs)) continue;
1671
1871
  const stat = followSymlinks ? await fs7.stat(abs) : await fs7.lstat(abs);
1672
1872
  if (!followSymlinks && stat.isSymbolicLink()) continue;
@@ -1683,9 +1883,9 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
1683
1883
  followSymbolicLinks: followSymlinks
1684
1884
  });
1685
1885
  for (const sub of inner.sort()) {
1686
- const absFile = path10.join(abs, sub);
1886
+ const absFile = path11.join(abs, sub);
1687
1887
  if (!followSymlinks && (await fs7.lstat(absFile)).isSymbolicLink()) continue;
1688
- const relFile = path10.join(rel, sub).replace(/\\/g, "/");
1888
+ const relFile = path11.join(rel, sub).replace(/\\/g, "/");
1689
1889
  const body = await fs7.readFile(absFile);
1690
1890
  fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
1691
1891
  }
@@ -1757,18 +1957,18 @@ function buildAllowRules() {
1757
1957
  }
1758
1958
 
1759
1959
  // src/install/settings-merge.ts
1760
- import path12 from "node:path";
1960
+ import path13 from "node:path";
1761
1961
  import fs9 from "fs-extra";
1762
1962
 
1763
1963
  // src/install/manifest.ts
1764
1964
  import os3 from "node:os";
1765
- import path11 from "node:path";
1965
+ import path12 from "node:path";
1766
1966
  var MANIFEST_SCHEMA = "haus-install-manifest/1";
1767
1967
  function globalClaudeDir() {
1768
- return path11.join(os3.homedir(), ".claude");
1968
+ return path12.join(os3.homedir(), ".claude");
1769
1969
  }
1770
1970
  function hausManifestPath() {
1771
- return path11.join(globalClaudeDir(), "haus", "install-manifest.json");
1971
+ return path12.join(globalClaudeDir(), "haus", "install-manifest.json");
1772
1972
  }
1773
1973
  async function readManifest() {
1774
1974
  return readJson(hausManifestPath());
@@ -1788,7 +1988,7 @@ function buildManifest(source, files, hooks) {
1788
1988
 
1789
1989
  // src/install/settings-merge.ts
1790
1990
  function settingsJsonPath() {
1791
- return path12.join(globalClaudeDir(), "settings.json");
1991
+ return path13.join(globalClaudeDir(), "settings.json");
1792
1992
  }
1793
1993
  async function readSettings() {
1794
1994
  const filePath = settingsJsonPath();
@@ -2017,71 +2217,6 @@ var ASK_COMMANDS = [
2017
2217
  "php artisan migrate --force"
2018
2218
  ];
2019
2219
 
2020
- // src/security/sensitive-paths.ts
2021
- var DENY_PATHS = [
2022
- "*.pem",
2023
- "*.key",
2024
- "*.p12",
2025
- "*.pfx",
2026
- "id_rsa",
2027
- "id_ed25519",
2028
- "customer-data",
2029
- "secrets",
2030
- "certs"
2031
- ];
2032
- var DENY_DIRS = /* @__PURE__ */ new Set(["customer-data", "secrets", "certs"]);
2033
- var DENY_PATH_REGEXES = [
2034
- /(^|\/)[^/]+\.pem$/i,
2035
- /(^|\/)[^/]+\.key$/i,
2036
- /(^|\/)[^/]+\.p12$/i,
2037
- /(^|\/)[^/]+\.pfx$/i,
2038
- /(^|\/)id_rsa$/i,
2039
- /(^|\/)id_ed25519$/i,
2040
- /(^|\/)customer-data(\/|$)/i,
2041
- /(^|\/)secrets(\/|$)/i,
2042
- /(^|\/)certs(\/|$)/i
2043
- ];
2044
- function normalizePathForMatch(candidate) {
2045
- return candidate.replace(/\\/g, "/").replace(/\/+/g, "/").trim();
2046
- }
2047
- var ASK_PATHS = [
2048
- { pattern: ".env", tools: ["Edit", "Write"] },
2049
- { pattern: ".env.*", tools: ["Edit", "Write"] },
2050
- { pattern: "storage/logs/**", tools: ["Edit", "Write"] },
2051
- { pattern: "exports/**", tools: ["Edit", "Write"] },
2052
- { pattern: "*.dump", tools: ["Read", "Edit", "Write"] },
2053
- { pattern: "*.backup", tools: ["Read", "Edit", "Write"] },
2054
- { pattern: "*.bak", tools: ["Read", "Edit", "Write"] },
2055
- { pattern: "wp-content/uploads/**", tools: ["Read", "Edit", "Write"] },
2056
- { pattern: "uploads/**", tools: ["Read", "Edit", "Write"] }
2057
- ];
2058
- var SENSITIVE_PATH_REGEXES = [
2059
- /^\.env(\.|$)/,
2060
- /(^|\/)\.env(\.|$)/,
2061
- /\.pem$/,
2062
- /\.key$/,
2063
- /\.p12$/,
2064
- /\.pfx$/,
2065
- /\.sql$/,
2066
- /\.dump$/,
2067
- /customer-data/,
2068
- /exports/,
2069
- /certs/,
2070
- /secrets/,
2071
- /(^|\/)storage\/logs(\/|$)/,
2072
- /(^|\/)wp-content\/uploads(\/|$)/,
2073
- /(^|\/)uploads(\/|$)/
2074
- ];
2075
- var SENSITIVE_ITEM_KEYWORDS = [
2076
- ".env",
2077
- "secrets",
2078
- "certs",
2079
- "customer-data",
2080
- "exports",
2081
- ".pem",
2082
- ".key"
2083
- ];
2084
-
2085
2220
  // src/security/ask-rules.ts
2086
2221
  function buildAskRules() {
2087
2222
  const rules = [];
@@ -2103,8 +2238,8 @@ function buildDenyRules() {
2103
2238
  for (const command of DENY_COMMANDS) {
2104
2239
  rules.push(`Bash(${command}:*)`);
2105
2240
  }
2106
- for (const path72 of DENY_PATHS) {
2107
- const pattern = DENY_DIRS.has(path72) ? `${path72}/**` : path72;
2241
+ for (const path73 of DENY_PATHS) {
2242
+ const pattern = DENY_DIRS.has(path73) ? `${path73}/**` : path73;
2108
2243
  for (const tool of FILE_TOOLS) {
2109
2244
  rules.push(`${tool}(${pattern})`);
2110
2245
  }
@@ -2183,24 +2318,28 @@ async function applyProjectSettingsMerge(root) {
2183
2318
  }
2184
2319
 
2185
2320
  // src/claude/superpowers-install.ts
2186
- import path13 from "node:path";
2321
+ import path14 from "node:path";
2187
2322
  import fg3 from "fast-glob";
2188
2323
  import fs10 from "fs-extra";
2189
2324
  var SUPERPOWERS_ORIGIN_SOURCE_ID = "superpowers-pcvelz";
2190
2325
  var PATH_REWRITES = [
2191
2326
  ["skills/shared/", ".claude/skills/shared/"]
2192
2327
  ];
2328
+ var PREFIX_REWRITES = ["superpowers-extended-cc:"];
2193
2329
  function rewriteSuperpowersMarkdown(text) {
2194
2330
  let out = text;
2195
2331
  for (const [from, to] of PATH_REWRITES) {
2196
2332
  out = out.split(from).join(to);
2197
2333
  }
2334
+ for (const prefix of PREFIX_REWRITES) {
2335
+ out = out.split(prefix).join("");
2336
+ }
2198
2337
  return out;
2199
2338
  }
2200
2339
  async function rewriteMarkdownTree(dir) {
2201
2340
  const files = await fg3("**/*.md", { cwd: dir, onlyFiles: true, dot: true });
2202
2341
  for (const rel of files) {
2203
- const abs = path13.join(dir, rel);
2342
+ const abs = path14.join(dir, rel);
2204
2343
  const text = await fs10.readFile(abs, "utf8");
2205
2344
  const rewritten = rewriteSuperpowersMarkdown(text);
2206
2345
  if (rewritten !== text) {
@@ -2208,10 +2347,24 @@ async function rewriteMarkdownTree(dir) {
2208
2347
  }
2209
2348
  }
2210
2349
  }
2350
+ async function extraDestFiles(sourcePath, destination) {
2351
+ const [sourceFiles, destFiles] = await Promise.all([
2352
+ fg3("**/*", { cwd: sourcePath, onlyFiles: true, dot: true }),
2353
+ fg3("**/*", { cwd: destination, onlyFiles: true, dot: true })
2354
+ ]);
2355
+ const sourceSet = new Set(sourceFiles);
2356
+ return destFiles.filter((f) => !sourceSet.has(f));
2357
+ }
2211
2358
  async function installCatalogSkill(sourcePath, destination, opts) {
2212
2359
  if (opts.dryRun) return;
2213
- await fs10.ensureDir(path13.dirname(destination));
2360
+ await fs10.ensureDir(path14.dirname(destination));
2214
2361
  if (await fs10.pathExists(destination)) {
2362
+ const extra = await extraDestFiles(sourcePath, destination);
2363
+ if (extra.length > 0) {
2364
+ warn(
2365
+ `${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
2366
+ );
2367
+ }
2215
2368
  await fs10.remove(destination);
2216
2369
  }
2217
2370
  await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
@@ -2220,17 +2373,23 @@ async function installCatalogSkill(sourcePath, destination, opts) {
2220
2373
  }
2221
2374
  }
2222
2375
  async function installSuperpowersShared(contentRoot, projectRoot, dryRun) {
2223
- const source = path13.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
2376
+ const source = path14.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
2224
2377
  if (!await fs10.pathExists(source)) return null;
2225
2378
  const destination = claudePath(projectRoot, "skills", "shared");
2226
- if (dryRun) return path13.relative(projectRoot, destination);
2227
- await fs10.ensureDir(path13.dirname(destination));
2379
+ if (dryRun) return path14.relative(projectRoot, destination);
2380
+ await fs10.ensureDir(path14.dirname(destination));
2228
2381
  if (await fs10.pathExists(destination)) {
2382
+ const extra = await extraDestFiles(source, destination);
2383
+ if (extra.length > 0) {
2384
+ warn(
2385
+ `${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
2386
+ );
2387
+ }
2229
2388
  await fs10.remove(destination);
2230
2389
  }
2231
2390
  await fs10.copy(source, destination, { overwrite: true, errorOnExist: false });
2232
2391
  await rewriteMarkdownTree(destination);
2233
- return path13.relative(projectRoot, destination);
2392
+ return path14.relative(projectRoot, destination);
2234
2393
  }
2235
2394
 
2236
2395
  // src/claude/verify-hooks-contract.ts
@@ -2345,7 +2504,7 @@ async function verifyProjectSettingsHooksContract(root) {
2345
2504
  }
2346
2505
 
2347
2506
  // src/claude/write-decisions-seed.ts
2348
- import path14 from "node:path";
2507
+ import path15 from "node:path";
2349
2508
  import fs12 from "fs-extra";
2350
2509
 
2351
2510
  // library/catalog/decisions-triggers.json
@@ -2389,8 +2548,8 @@ Why we chose X \u2014 short, dated, write-once decision log for humans and agent
2389
2548
  | --- | ----- | -------------- | ------ |
2390
2549
  `;
2391
2550
  async function writeDecisionsSeed(root, dryRun) {
2392
- const decisionsDir = path14.join(root, DECISIONS_TRIGGERS.decisionsDir);
2393
- const readmePath = path14.join(decisionsDir, "README.md");
2551
+ const decisionsDir = path15.join(root, DECISIONS_TRIGGERS.decisionsDir);
2552
+ const readmePath = path15.join(decisionsDir, "README.md");
2394
2553
  if (await fs12.pathExists(readmePath)) return null;
2395
2554
  if (!dryRun) await fs12.ensureDir(decisionsDir);
2396
2555
  await writeManagedText(root, readmePath, SEED_README, dryRun);
@@ -2398,7 +2557,7 @@ async function writeDecisionsSeed(root, dryRun) {
2398
2557
  }
2399
2558
 
2400
2559
  // src/claude/write-prettierignore.ts
2401
- import path15 from "node:path";
2560
+ import path16 from "node:path";
2402
2561
  import fs13 from "fs-extra";
2403
2562
  var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
2404
2563
  var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
@@ -2467,7 +2626,7 @@ ${block}
2467
2626
  `;
2468
2627
  }
2469
2628
  async function writePrettierIgnore(root, dryRun) {
2470
- const filePath = path15.join(root, ".prettierignore");
2629
+ const filePath = path16.join(root, ".prettierignore");
2471
2630
  const prev = await fs13.pathExists(filePath) ? await fs13.readFile(filePath, "utf8") : "";
2472
2631
  const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
2473
2632
  await writeManagedText(root, filePath, next, dryRun);
@@ -2475,7 +2634,7 @@ async function writePrettierIgnore(root, dryRun) {
2475
2634
  }
2476
2635
 
2477
2636
  // src/claude/write-root-claude-md.ts
2478
- import path16 from "node:path";
2637
+ import path17 from "node:path";
2479
2638
  import fs14 from "fs-extra";
2480
2639
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
2481
2640
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
@@ -2553,7 +2712,7 @@ ${block}
2553
2712
  `;
2554
2713
  }
2555
2714
  async function writeRootClaudeMd(root, dryRun) {
2556
- const filePath = path16.join(root, "CLAUDE.md");
2715
+ const filePath = path17.join(root, "CLAUDE.md");
2557
2716
  const block = buildImportBlock();
2558
2717
  const prev = await fs14.pathExists(filePath) ? await fs14.readFile(filePath, "utf8") : "";
2559
2718
  const next = injectHausBlock(prev, block);
@@ -2562,11 +2721,11 @@ async function writeRootClaudeMd(root, dryRun) {
2562
2721
  }
2563
2722
 
2564
2723
  // src/claude/write-workflow-config.ts
2565
- import path18 from "node:path";
2724
+ import path19 from "node:path";
2566
2725
  import fs16 from "fs-extra";
2567
2726
 
2568
2727
  // src/claude/derive-workflow-config.ts
2569
- import path17 from "node:path";
2728
+ import path18 from "node:path";
2570
2729
  import fs15 from "fs-extra";
2571
2730
  function binCmd(pm, bin, args) {
2572
2731
  const tail = args ? ` ${args}` : "";
@@ -2576,7 +2735,7 @@ function binCmd(pm, bin, args) {
2576
2735
  }
2577
2736
  async function deriveWorkflowConfig(root, ctx) {
2578
2737
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
2579
- const pkg = await readJson(path17.join(root, "package.json"));
2738
+ const pkg = await readJson(path18.join(root, "package.json"));
2580
2739
  const scripts = pkg?.scripts ?? {};
2581
2740
  const deps = new Set(ctx.dependencies);
2582
2741
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -2586,7 +2745,7 @@ async function deriveWorkflowConfig(root, ctx) {
2586
2745
  return null;
2587
2746
  };
2588
2747
  const hasDep = (name) => deps.has(name);
2589
- const exists = (rel) => fs15.pathExistsSync(path17.join(root, rel));
2748
+ const exists = (rel) => fs15.pathExistsSync(path18.join(root, rel));
2590
2749
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
2591
2750
  const hasCypress = hasDep("cypress");
2592
2751
  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;
@@ -2661,7 +2820,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2661
2820
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
2662
2821
  ...FALLBACK_CONTEXT,
2663
2822
  root,
2664
- repoName: path18.basename(root)
2823
+ repoName: path19.basename(root)
2665
2824
  };
2666
2825
  const values = await deriveWorkflowConfig(root, ctx);
2667
2826
  if (exists) {
@@ -2706,7 +2865,8 @@ function checkManagedTamper(body, storedHash, templateContent) {
2706
2865
  // src/claude/write-workflow.ts
2707
2866
  var STABLE_ID = "template.workflow";
2708
2867
  function makeWorkflowHeader(pkgVersion, contentHash) {
2709
- return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
2868
+ const hashAttr = contentHash === void 0 ? "" : ` hash=${contentHash}`;
2869
+ return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion}${hashAttr} -->`;
2710
2870
  }
2711
2871
  async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
2712
2872
  const templateContent = await readWorkflowTemplate({ dryRun });
@@ -2747,12 +2907,12 @@ ${templateContent}`;
2747
2907
  return null;
2748
2908
  }
2749
2909
  if (verdict.status === "legacy-diverged" && !force) {
2750
- const migratedHeader = makeWorkflowHeader(pkgVersion, hashText(normaliseLF(existingContent)));
2910
+ const migratedHeader = makeWorkflowHeader(pkgVersion);
2751
2911
  const migrated = `${migratedHeader}
2752
2912
  ${existingContent}`;
2753
2913
  if (hasTextChanged(existing, migrated)) {
2754
2914
  if (dryRun) {
2755
- log(`${printable}: migrating legacy hash header (body preserved)`);
2915
+ log(`${printable}: migrating legacy header (staying hash-less; body preserved)`);
2756
2916
  } else {
2757
2917
  await writeText(destPath, migrated);
2758
2918
  }
@@ -2799,7 +2959,7 @@ async function listFilesRecursive2(dir) {
2799
2959
  const walk = async (current, relPrefix) => {
2800
2960
  const names = await fs18.readdir(current);
2801
2961
  for (const name of names) {
2802
- const abs = path19.join(current, name);
2962
+ const abs = path20.join(current, name);
2803
2963
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
2804
2964
  const stat = await fs18.lstat(abs);
2805
2965
  if (stat.isSymbolicLink()) {
@@ -2843,8 +3003,8 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
2843
3003
  for (const rel of sourceFiles) {
2844
3004
  await logFileDryRunDiff(
2845
3005
  root,
2846
- path19.join(sourcePath, rel),
2847
- path19.join(destination, rel),
3006
+ path20.join(sourcePath, rel),
3007
+ path20.join(destination, rel),
2848
3008
  itemId,
2849
3009
  say
2850
3010
  );
@@ -2852,7 +3012,7 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
2852
3012
  const sourceFileSet = new Set(sourceFiles);
2853
3013
  for (const rel of destFiles) {
2854
3014
  if (!sourceFileSet.has(rel)) {
2855
- say(`${displayPath(root, path19.join(destination, rel))}: would remove (${itemId})`);
3015
+ say(`${displayPath(root, path20.join(destination, rel))}: would remove (${itemId})`);
2856
3016
  }
2857
3017
  }
2858
3018
  }
@@ -2872,7 +3032,9 @@ async function removeLegacyManagedStub(root, relPathSegments, stub, dryRun, say)
2872
3032
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2873
3033
  const say = opts.quiet ? () => {
2874
3034
  } : log;
2875
- const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
3035
+ const recResult = await readJsonDetailed(hausPath(root, "recommendation.json"));
3036
+ const recommendationUnusable = recResult.status !== "ok";
3037
+ const rec = recResult.status === "ok" ? recResult.value : {
2876
3038
  recommended: [],
2877
3039
  skipped: [],
2878
3040
  warnings: [],
@@ -2882,7 +3044,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2882
3044
  estimatedTokenReductionPct: 0
2883
3045
  };
2884
3046
  const pkgRoot = packageRoot();
2885
- const hausVersion2 = (await readJson(path19.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
3047
+ const hausVersion2 = (await readJson(path20.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2886
3048
  const coreFiles = [claudePath(root, "settings.json"), claudePath(root, "rules", "haus.md")];
2887
3049
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
2888
3050
  const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
@@ -2972,6 +3134,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2972
3134
  const { items: manifestItems, contentRoot } = await loadCatalogContext(root);
2973
3135
  const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
2974
3136
  const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
3137
+ const prevLockById = new Map(
3138
+ prevLock.filter((entry) => Boolean(entry.id)).map((entry) => [entry.id, entry])
3139
+ );
2975
3140
  const allMigrations = findFormerIdMigrations(
2976
3141
  prevLock.filter((entry) => Boolean(entry.id)),
2977
3142
  manifestItems
@@ -3032,6 +3197,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3032
3197
  warn(`Skipping ${item.id}: reviewStatus is deprecated`);
3033
3198
  continue;
3034
3199
  }
3200
+ const manifestItemTags = (manifestItem.tags ?? []).map((t) => t.toLowerCase());
3201
+ if (UNSUPPORTED.some((x) => manifestItemTags.includes(x))) {
3202
+ warn(`Skipping ${item.id}: unsupported stack policy`);
3203
+ continue;
3204
+ }
3205
+ const sensitiveInId = SENSITIVE_ITEM_KEYWORDS.some((x) => item.id.toLowerCase().includes(x));
3206
+ const sensitiveInTags = SENSITIVE_ITEM_KEYWORDS.some((x) => manifestItemTags.includes(x));
3207
+ if (sensitiveInId || sensitiveInTags) {
3208
+ warn(`Skipping ${item.id}: sensitive content policy block`);
3209
+ continue;
3210
+ }
3035
3211
  if (manifestItem.source === "curated") {
3036
3212
  if (manifestItem.reviewStatus !== "approved") {
3037
3213
  curatedReviewStatusSkips++;
@@ -3056,7 +3232,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3056
3232
  );
3057
3233
  continue;
3058
3234
  }
3059
- const destination = claudePath(root, target, path19.basename(sourcePath));
3235
+ const destination = claudePath(root, target, path20.basename(sourcePath));
3060
3236
  if (await fs18.pathExists(sourcePath)) {
3061
3237
  if (dryRun) {
3062
3238
  if (item.type === "skill") {
@@ -3064,13 +3240,23 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3064
3240
  } else {
3065
3241
  await logFileDryRunDiff(root, sourcePath, destination, item.id, say);
3066
3242
  }
3243
+ } else if (await skipLocallyEditedItem(
3244
+ root,
3245
+ item.id,
3246
+ prevLockById.get(item.id),
3247
+ opts.force,
3248
+ files,
3249
+ installedPathsByItem,
3250
+ installedIds
3251
+ )) {
3252
+ continue;
3067
3253
  } else if (item.type === "skill") {
3068
3254
  const skillFiles = (await fs18.readdir(sourcePath, { recursive: true })).filter(
3069
3255
  (f) => typeof f === "string" && f.endsWith(".md")
3070
3256
  );
3071
3257
  let skillValid = true;
3072
3258
  for (const relFile of skillFiles) {
3073
- const mdPath = path19.join(sourcePath, relFile);
3259
+ const mdPath = path20.join(sourcePath, relFile);
3074
3260
  const mdContent = await fs18.readFile(mdPath, "utf8");
3075
3261
  const mdValidation = validateCatalogItem(
3076
3262
  {
@@ -3109,17 +3295,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3109
3295
  warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
3110
3296
  continue;
3111
3297
  }
3112
- await fs18.ensureDir(path19.dirname(destination));
3298
+ await fs18.ensureDir(path20.dirname(destination));
3113
3299
  await fs18.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
3114
3300
  }
3115
3301
  files.push(destination);
3116
- const relPaths = [path19.relative(root, destination)];
3302
+ const relPaths = [path20.relative(root, destination)];
3117
3303
  if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
3118
3304
  const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
3119
3305
  if (sharedRel) {
3120
3306
  superpowersSharedInstalled = true;
3121
3307
  relPaths.push(sharedRel);
3122
- files.push(path19.join(root, sharedRel));
3308
+ files.push(path20.join(root, sharedRel));
3123
3309
  }
3124
3310
  }
3125
3311
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -3137,18 +3323,31 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3137
3323
  );
3138
3324
  }
3139
3325
  await cleanupStaleCatalogItems(root, cleanupManifestById, dryRun, opts.quiet);
3326
+ const refuseDestructiveLockChange = recommendationUnusable && prevLock.length > 0 && !opts.force;
3140
3327
  if (opts.prune) {
3141
- const notOrphaned = new Set(rec.recommended.map((r) => r.id));
3142
- for (const migration of allMigrations) notOrphaned.add(migration.oldId);
3143
- for (const entry of prevLock) {
3144
- if (!entry.id) continue;
3145
- const manifestItem = manifestById.get(entry.id);
3146
- if (!manifestItem || manifestItem.reviewStatus === "deprecated") notOrphaned.add(entry.id);
3328
+ if (refuseDestructiveLockChange) {
3329
+ warn(
3330
+ "recommendation.json is missing or unreadable \u2014 refusing --prune (every installed item would look orphaned). Run `haus recommend` first, or pass --force to override."
3331
+ );
3332
+ } else {
3333
+ const notOrphaned = new Set(rec.recommended.map((r) => r.id));
3334
+ for (const migration of allMigrations) notOrphaned.add(migration.oldId);
3335
+ for (const entry of prevLock) {
3336
+ if (!entry.id) continue;
3337
+ const manifestItem = manifestById.get(entry.id);
3338
+ if (!manifestItem || manifestItem.reviewStatus === "deprecated") notOrphaned.add(entry.id);
3339
+ }
3340
+ await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
3147
3341
  }
3148
- await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
3149
3342
  }
3150
3343
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
3151
3344
  if (dryRun) {
3345
+ if (refuseDestructiveLockChange) {
3346
+ say(
3347
+ "[dry-run] recommendation.json is missing or unreadable \u2014 haus.lock.json would be left unchanged. Run `haus recommend` first, or pass --force."
3348
+ );
3349
+ return [...new Set(files)];
3350
+ }
3152
3351
  const prevIds = new Set(
3153
3352
  prevLock.map((e) => e.id ? migrationByOldId.get(e.id) ?? e.id : void 0).filter((id) => Boolean(id))
3154
3353
  );
@@ -3159,6 +3358,12 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3159
3358
  for (const id of removed) say(`[dry-run] haus.lock.json would remove: ${id}`);
3160
3359
  return [...new Set(files)];
3161
3360
  }
3361
+ if (refuseDestructiveLockChange) {
3362
+ warn(
3363
+ "recommendation.json is missing or unreadable \u2014 leaving haus.lock.json unchanged rather than writing an empty lock. Run `haus recommend` first, or pass --force to overwrite anyway."
3364
+ );
3365
+ return [...new Set(files)];
3366
+ }
3162
3367
  const prevRefById = new Map(
3163
3368
  prevLock.filter((e) => e.id && e.catalogRef).map((e) => [migrationByOldId.get(e.id) ?? e.id, e.catalogRef])
3164
3369
  );
@@ -3192,6 +3397,26 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3192
3397
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
3193
3398
  return [...new Set(files)];
3194
3399
  }
3400
+ async function skipLocallyEditedItem(root, itemId, prevEntry, force, files, installedPathsByItem, installedIds) {
3401
+ if (!prevEntry || prevEntry.hash === void 0 || force) return false;
3402
+ const existing = await resolveExistingTrackedPaths(root, itemId, prevEntry.paths ?? [], warn);
3403
+ if (existing.length === 0) return false;
3404
+ const currentHash = await hashInstalledPaths(
3405
+ root,
3406
+ existing.map((e) => e.rel)
3407
+ );
3408
+ if (currentHash === prevEntry.hash) return false;
3409
+ warn(
3410
+ `User edited ${itemId} (skipping reinstall): ${existing.map((e) => e.rel).join(", ")} \u2014 use --force to overwrite`
3411
+ );
3412
+ files.push(...existing.map((e) => e.abs));
3413
+ installedPathsByItem.set(
3414
+ itemId,
3415
+ existing.map((e) => e.rel)
3416
+ );
3417
+ installedIds.add(itemId);
3418
+ return true;
3419
+ }
3195
3420
  async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, quiet) {
3196
3421
  const say = quiet ? () => {
3197
3422
  } : log;
@@ -3199,26 +3424,25 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
3199
3424
  const entry = prevLock.find((candidate) => candidate.id === migration.oldId);
3200
3425
  if (!entry) continue;
3201
3426
  const relPaths = entry.paths ?? [];
3202
- const existing = [];
3203
- for (const rel of relPaths) {
3204
- if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3205
- }
3427
+ const existing = await resolveExistingTrackedPaths(root, migration.oldId, relPaths, warn);
3206
3428
  if (existing.length === 0) continue;
3207
3429
  if (entry.hash === void 0) {
3208
3430
  warn(
3209
- `Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.join(", ")}`
3431
+ `Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
3210
3432
  );
3211
3433
  continue;
3212
3434
  }
3213
- const currentHash = await hashInstalledPaths(root, existing);
3435
+ const currentHash = await hashInstalledPaths(
3436
+ root,
3437
+ existing.map((e) => e.rel)
3438
+ );
3214
3439
  if (currentHash !== entry.hash) {
3215
3440
  warn(
3216
- `Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.join(", ")}`
3441
+ `Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
3217
3442
  );
3218
3443
  continue;
3219
3444
  }
3220
- for (const rel of existing) {
3221
- const abs = path19.join(root, rel);
3445
+ for (const { abs } of existing) {
3222
3446
  if (dryRun) {
3223
3447
  say(
3224
3448
  `[dry-run] would remove renamed ${displayPath(root, abs)} (${migration.oldId} \u2192 ${migration.newId})`
@@ -3226,10 +3450,22 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
3226
3450
  continue;
3227
3451
  }
3228
3452
  await fs18.remove(abs);
3229
- await pruneEmptyDir(path19.dirname(abs));
3453
+ await pruneEmptyDir(path20.dirname(abs));
3230
3454
  }
3231
3455
  }
3232
3456
  }
3457
+ async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn) {
3458
+ const existing = [];
3459
+ for (const rel of relPaths) {
3460
+ const abs = resolveContainedPath(root, rel);
3461
+ if (abs === null) {
3462
+ warnFn(`Refusing to touch ${itemId}'s recorded path outside the project root: ${rel}`);
3463
+ continue;
3464
+ }
3465
+ if (await fs18.pathExists(abs)) existing.push({ rel, abs });
3466
+ }
3467
+ return existing;
3468
+ }
3233
3469
  async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
3234
3470
  const say = quiet ? () => {
3235
3471
  } : log;
@@ -3243,33 +3479,32 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
3243
3479
  if (!removedFromManifest && !deprecated) continue;
3244
3480
  const relPaths = entry.paths ?? [];
3245
3481
  if (relPaths.length === 0) continue;
3246
- const existing = [];
3247
- for (const rel of relPaths) {
3248
- if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3249
- }
3482
+ const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
3250
3483
  if (existing.length === 0) continue;
3251
3484
  const pruneReason = deprecated ? "deprecated" : "stale";
3252
3485
  if (entry.hash === void 0) {
3253
3486
  warn(
3254
- `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
3487
+ `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3255
3488
  );
3256
3489
  continue;
3257
3490
  }
3258
- const currentHash = await hashInstalledPaths(root, relPaths);
3491
+ const currentHash = await hashInstalledPaths(
3492
+ root,
3493
+ existing.map((e) => e.rel)
3494
+ );
3259
3495
  if (currentHash !== entry.hash) {
3260
3496
  warn(
3261
- `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
3497
+ `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3262
3498
  );
3263
3499
  continue;
3264
3500
  }
3265
- for (const rel of existing) {
3266
- const abs = path19.join(root, rel);
3501
+ for (const { abs } of existing) {
3267
3502
  if (dryRun) {
3268
3503
  say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
3269
3504
  continue;
3270
3505
  }
3271
3506
  await fs18.remove(abs);
3272
- await pruneEmptyDir(path19.dirname(abs));
3507
+ await pruneEmptyDir(path20.dirname(abs));
3273
3508
  say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
3274
3509
  }
3275
3510
  }
@@ -3280,12 +3515,12 @@ async function backupBeforePrune(root, absPaths, say) {
3280
3515
  const backupRoot = hausPath(root, "backups", `prune-${stamp}`);
3281
3516
  for (const abs of absPaths) {
3282
3517
  if (!await fs18.pathExists(abs)) continue;
3283
- const rel = path19.relative(root, abs);
3284
- const backupPath = path19.join(backupRoot, rel);
3285
- await fs18.ensureDir(path19.dirname(backupPath));
3518
+ const rel = path20.relative(root, abs);
3519
+ const backupPath = path20.join(backupRoot, rel);
3520
+ await fs18.ensureDir(path20.dirname(backupPath));
3286
3521
  await fs18.copy(abs, backupPath);
3287
3522
  }
3288
- say(`Backed up ${absPaths.length} path(s) to ${path19.relative(root, backupRoot)} before pruning.`);
3523
+ say(`Backed up ${absPaths.length} path(s) to ${path20.relative(root, backupRoot)} before pruning.`);
3289
3524
  }
3290
3525
  async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun, quiet) {
3291
3526
  const say = quiet ? () => {
@@ -3297,21 +3532,21 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3297
3532
  if (!entry.id) continue;
3298
3533
  const relPaths = entry.paths ?? [];
3299
3534
  if (relPaths.length === 0) continue;
3300
- const existing = [];
3301
- for (const rel of relPaths) {
3302
- if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3303
- }
3535
+ const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
3304
3536
  if (existing.length === 0) continue;
3305
3537
  if (entry.hash === void 0) {
3306
3538
  warn(
3307
- `Orphaned catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
3539
+ `Orphaned catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3308
3540
  );
3309
3541
  continue;
3310
3542
  }
3311
- const currentHash = await hashInstalledPaths(root, relPaths);
3543
+ const currentHash = await hashInstalledPaths(
3544
+ root,
3545
+ existing.map((e) => e.rel)
3546
+ );
3312
3547
  if (currentHash !== entry.hash) {
3313
3548
  warn(
3314
- `Orphaned catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
3549
+ `Orphaned catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3315
3550
  );
3316
3551
  continue;
3317
3552
  }
@@ -3320,21 +3555,18 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3320
3555
  if (toRemove.length === 0) return;
3321
3556
  if (dryRun) {
3322
3557
  for (const { id, existing } of toRemove) {
3323
- for (const rel of existing) {
3324
- say(`[dry-run] would prune orphaned ${displayPath(root, path19.join(root, rel))} (${id})`);
3558
+ for (const { abs } of existing) {
3559
+ say(`[dry-run] would prune orphaned ${displayPath(root, abs)} (${id})`);
3325
3560
  }
3326
3561
  }
3327
3562
  return;
3328
3563
  }
3329
- const allAbsPaths = toRemove.flatMap(
3330
- ({ existing }) => existing.map((rel) => path19.join(root, rel))
3331
- );
3564
+ const allAbsPaths = toRemove.flatMap(({ existing }) => existing.map(({ abs }) => abs));
3332
3565
  await backupBeforePrune(root, allAbsPaths, say);
3333
3566
  for (const { id, existing } of toRemove) {
3334
- for (const rel of existing) {
3335
- const abs = path19.join(root, rel);
3567
+ for (const { abs } of existing) {
3336
3568
  await fs18.remove(abs);
3337
- await pruneEmptyDir(path19.dirname(abs));
3569
+ await pruneEmptyDir(path20.dirname(abs));
3338
3570
  say(`Pruned orphaned ${displayPath(root, abs)} (${id})`);
3339
3571
  }
3340
3572
  }
@@ -3342,7 +3574,7 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3342
3574
 
3343
3575
  // src/claude/write-gitignore.ts
3344
3576
  init_exec();
3345
- import path20 from "node:path";
3577
+ import path21 from "node:path";
3346
3578
  import fs19 from "fs-extra";
3347
3579
  var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
3348
3580
  var GITIGNORE_END = "# HAUS:END haus-managed";
@@ -3457,14 +3689,14 @@ function injectLinkContextGitignoreBlock(existing) {
3457
3689
  );
3458
3690
  }
3459
3691
  async function writeGitignore(root, dryRun) {
3460
- const filePath = path20.join(root, ".gitignore");
3692
+ const filePath = path21.join(root, ".gitignore");
3461
3693
  const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3462
3694
  const next = injectGitignoreBlock(prev);
3463
3695
  await writeManagedText(root, filePath, next, dryRun);
3464
3696
  return filePath;
3465
3697
  }
3466
3698
  async function writeLinkContextGitignore(root, dryRun) {
3467
- const filePath = path20.join(root, ".gitignore");
3699
+ const filePath = path21.join(root, ".gitignore");
3468
3700
  const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3469
3701
  const next = injectLinkContextGitignoreBlock(prev);
3470
3702
  await writeManagedText(root, filePath, next, dryRun);
@@ -3506,15 +3738,15 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
3506
3738
  }
3507
3739
 
3508
3740
  // src/refs/fetch-refs.ts
3509
- import path22 from "node:path";
3741
+ import path23 from "node:path";
3510
3742
  import fs21 from "fs-extra";
3511
3743
 
3512
3744
  // src/refs/cache-meta.ts
3513
- import path21 from "node:path";
3745
+ import path22 from "node:path";
3514
3746
  import fs20 from "fs-extra";
3515
3747
  var META_FILENAME = "cache-meta.json";
3516
3748
  async function readCacheMeta(cacheDir) {
3517
- const metaPath = path21.join(cacheDir, META_FILENAME);
3749
+ const metaPath = path22.join(cacheDir, META_FILENAME);
3518
3750
  try {
3519
3751
  const raw = await fs20.readFile(metaPath, "utf8");
3520
3752
  return JSON.parse(raw);
@@ -3524,7 +3756,7 @@ async function readCacheMeta(cacheDir) {
3524
3756
  }
3525
3757
  async function writeCacheMeta(cacheDir, meta) {
3526
3758
  await fs20.ensureDir(cacheDir);
3527
- const metaPath = path21.join(cacheDir, META_FILENAME);
3759
+ const metaPath = path22.join(cacheDir, META_FILENAME);
3528
3760
  await fs20.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
3529
3761
  }
3530
3762
 
@@ -3545,8 +3777,8 @@ function isLlmsTxtUrl(ref) {
3545
3777
  async function fetchSingleRef(url, cacheDir, meta) {
3546
3778
  const existing = meta[url];
3547
3779
  const headers = {};
3548
- const safeFile = existing?.file && existing.file === path22.basename(existing.file);
3549
- const cachedFileExists = safeFile && await fs21.pathExists(path22.join(cacheDir, existing.file));
3780
+ const safeFile = existing?.file && existing.file === path23.basename(existing.file);
3781
+ const cachedFileExists = safeFile && await fs21.pathExists(path23.join(cacheDir, existing.file));
3550
3782
  if (cachedFileExists) {
3551
3783
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
3552
3784
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -3558,7 +3790,7 @@ async function fetchSingleRef(url, cacheDir, meta) {
3558
3790
  const text = await res.text();
3559
3791
  const file = `${urlToSlug(url)}.md`;
3560
3792
  await fs21.ensureDir(cacheDir);
3561
- await fs21.writeFile(path22.join(cacheDir, file), text, "utf8");
3793
+ await fs21.writeFile(path23.join(cacheDir, file), text, "utf8");
3562
3794
  meta[url] = {
3563
3795
  url,
3564
3796
  etag: res.headers.get("etag") ?? void 0,
@@ -3581,8 +3813,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
3581
3813
  if (keepUrls.has(url)) continue;
3582
3814
  delete meta[url];
3583
3815
  removed++;
3584
- const safeFile = typeof entry.file === "string" && entry.file === path22.basename(entry.file);
3585
- if (safeFile) await fs21.remove(path22.join(cacheDir, entry.file)).catch(() => {
3816
+ const safeFile = typeof entry.file === "string" && entry.file === path23.basename(entry.file);
3817
+ if (safeFile) await fs21.remove(path23.join(cacheDir, entry.file)).catch(() => {
3586
3818
  });
3587
3819
  }
3588
3820
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -3604,10 +3836,10 @@ async function fetchRefsForItems(items, cacheDir) {
3604
3836
  const outcome = await fetchSingleRef(url, cacheDir, meta);
3605
3837
  if (outcome.result === "fetched") {
3606
3838
  summary.fetched++;
3607
- summary.cachedFiles[url] = path22.join(cacheDir, outcome.file);
3839
+ summary.cachedFiles[url] = path23.join(cacheDir, outcome.file);
3608
3840
  } else if (outcome.result === "unchanged") {
3609
3841
  summary.unchanged++;
3610
- if (meta[url]?.file) summary.cachedFiles[url] = path22.join(cacheDir, meta[url].file);
3842
+ if (meta[url]?.file) summary.cachedFiles[url] = path23.join(cacheDir, meta[url].file);
3611
3843
  } else {
3612
3844
  summary.failed++;
3613
3845
  summary.failedUrls.push(url);
@@ -3630,7 +3862,7 @@ function parseIdList(value) {
3630
3862
  // src/commands/apply.ts
3631
3863
  var STALE_CACHE_THRESHOLD_DAYS = 7;
3632
3864
  async function cacheHasItems() {
3633
- const data = await readJson(path23.join(getCacheDir(), "manifest.json"));
3865
+ const data = await readJson(path24.join(getCacheDir(), "manifest.json"));
3634
3866
  return Array.isArray(data?.items) && data.items.length > 0;
3635
3867
  }
3636
3868
  function installableRecommendedItems(recommended) {
@@ -3781,7 +4013,7 @@ async function runApply(options) {
3781
4013
  }
3782
4014
 
3783
4015
  // src/commands/backups.ts
3784
- import path24 from "node:path";
4016
+ import path25 from "node:path";
3785
4017
  import fs22 from "fs-extra";
3786
4018
 
3787
4019
  // src/utils/prompts.ts
@@ -3818,7 +4050,7 @@ async function listBackups(root) {
3818
4050
  for (const name of names) {
3819
4051
  const kind = classify(name);
3820
4052
  if (!kind) continue;
3821
- const absPath = path24.join(dir, name);
4053
+ const absPath = path25.join(dir, name);
3822
4054
  const stat = await fs22.lstat(absPath);
3823
4055
  if (stat.isSymbolicLink()) continue;
3824
4056
  const expectDirectory = kind !== "lock";
@@ -3848,10 +4080,10 @@ async function runList(root) {
3848
4080
  }
3849
4081
  }
3850
4082
  async function isUnsafeWriteTarget(root, target) {
3851
- const segments = path24.relative(root, target).split(path24.sep).filter(Boolean);
4083
+ const segments = path25.relative(root, target).split(path25.sep).filter(Boolean);
3852
4084
  let current = root;
3853
4085
  for (let i = 0; i < segments.length; i++) {
3854
- current = path24.join(current, segments[i]);
4086
+ current = path25.join(current, segments[i]);
3855
4087
  const isLastSegment = i === segments.length - 1;
3856
4088
  try {
3857
4089
  const stat = await fs22.lstat(current);
@@ -3915,7 +4147,7 @@ async function collectFilesRecursive(dir) {
3915
4147
  const skippedOther = [];
3916
4148
  const names = await fs22.readdir(dir);
3917
4149
  for (const name of names) {
3918
- const abs = path24.join(dir, name);
4150
+ const abs = path25.join(dir, name);
3919
4151
  const stat = await fs22.lstat(abs);
3920
4152
  if (stat.isSymbolicLink()) {
3921
4153
  skippedLinks.push(abs);
@@ -3938,12 +4170,12 @@ async function restoreDirBackup(root, entry, yes) {
3938
4170
  const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
3939
4171
  if (skippedLinks.length > 0) {
3940
4172
  warn(
3941
- `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
4173
+ `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path25.relative(entry.absPath, abs)).join(", ")}`
3942
4174
  );
3943
4175
  }
3944
4176
  if (skippedOther.length > 0) {
3945
4177
  warn(
3946
- `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
4178
+ `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path25.relative(entry.absPath, abs)).join(", ")}`
3947
4179
  );
3948
4180
  }
3949
4181
  if (files.length === 0) {
@@ -3953,8 +4185,8 @@ async function restoreDirBackup(root, entry, yes) {
3953
4185
  const safeFiles = [];
3954
4186
  const unsafeTargets = [];
3955
4187
  for (const abs of files) {
3956
- const rel = path24.relative(entry.absPath, abs);
3957
- const target = path24.join(root, rel);
4188
+ const rel = path25.relative(entry.absPath, abs);
4189
+ const target = path25.join(root, rel);
3958
4190
  if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
3959
4191
  else safeFiles.push(abs);
3960
4192
  }
@@ -3971,14 +4203,14 @@ async function restoreDirBackup(root, entry, yes) {
3971
4203
  const vanished = [];
3972
4204
  const toRestore = [];
3973
4205
  for (const abs of safeFiles) {
3974
- const rel = path24.relative(entry.absPath, abs);
4206
+ const rel = path25.relative(entry.absPath, abs);
3975
4207
  const backupFileStat = await lstatIfRegularFile(abs);
3976
4208
  if (!backupFileStat) {
3977
4209
  vanished.push(rel);
3978
4210
  continue;
3979
4211
  }
3980
4212
  toRestore.push(abs);
3981
- const target = path24.join(root, rel);
4213
+ const target = path25.join(root, rel);
3982
4214
  if (await fs22.pathExists(target)) {
3983
4215
  const targetStat = await fs22.stat(target);
3984
4216
  if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
@@ -4019,13 +4251,13 @@ async function restoreDirBackup(root, entry, yes) {
4019
4251
  let restoredCount = 0;
4020
4252
  const skippedAtWrite = [];
4021
4253
  for (const abs of toRestore) {
4022
- const rel = path24.relative(entry.absPath, abs);
4023
- const target = path24.join(root, rel);
4254
+ const rel = path25.relative(entry.absPath, abs);
4255
+ const target = path25.join(root, rel);
4024
4256
  if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
4025
4257
  skippedAtWrite.push(rel);
4026
4258
  continue;
4027
4259
  }
4028
- await fs22.ensureDir(path24.dirname(target));
4260
+ await fs22.ensureDir(path25.dirname(target));
4029
4261
  await fs22.copy(abs, target, { overwrite: true });
4030
4262
  restoredCount++;
4031
4263
  }
@@ -4164,16 +4396,53 @@ async function runCatalogAudit() {
4164
4396
  }
4165
4397
 
4166
4398
  // src/commands/doctor.ts
4167
- import path38 from "node:path";
4168
- import fs29 from "fs-extra";
4399
+ import path39 from "node:path";
4400
+ import fs30 from "fs-extra";
4401
+
4402
+ // src/claude/skill-reference-check.ts
4403
+ import path26 from "node:path";
4404
+ import fg4 from "fast-glob";
4405
+ import fs23 from "fs-extra";
4406
+ var REFERENCE_PATTERN = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+)+):([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b(?!:\/\/)/g;
4407
+ function stripFencedCodeBlocks(text) {
4408
+ return text.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, " "));
4409
+ }
4410
+ async function findUnresolvedSkillReferences(root) {
4411
+ const skillsRoot = claudePath(root, "skills");
4412
+ if (!await fs23.pathExists(skillsRoot)) return [];
4413
+ const skillDirs = (await fs23.readdir(skillsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== "shared").map((entry) => entry.name);
4414
+ const installedSkillNames = new Set(skillDirs);
4415
+ const files = await fg4("*/**/*.md", { cwd: skillsRoot, onlyFiles: true, dot: true });
4416
+ const issues = [];
4417
+ for (const rel of files) {
4418
+ if (rel.startsWith("shared/")) continue;
4419
+ const abs = path26.join(skillsRoot, rel);
4420
+ const raw = await fs23.readFile(abs, "utf8");
4421
+ const lines = stripFencedCodeBlocks(raw).split("\n");
4422
+ lines.forEach((line2, idx) => {
4423
+ for (const match of line2.matchAll(REFERENCE_PATTERN)) {
4424
+ const [reference, , bareName] = match;
4425
+ if (!reference || !bareName) continue;
4426
+ issues.push({
4427
+ file: path26.join(".claude/skills", rel),
4428
+ line: idx + 1,
4429
+ reference,
4430
+ bareName,
4431
+ bareInstalled: installedSkillNames.has(bareName)
4432
+ });
4433
+ }
4434
+ });
4435
+ }
4436
+ return issues;
4437
+ }
4169
4438
 
4170
4439
  // src/decisions/doctor.ts
4171
- import path29 from "node:path";
4172
- import fs26 from "fs-extra";
4440
+ import path31 from "node:path";
4441
+ import fs27 from "fs-extra";
4173
4442
 
4174
4443
  // src/decisions/check.ts
4175
- import path28 from "node:path";
4176
- import fs25 from "fs-extra";
4444
+ import path30 from "node:path";
4445
+ import fs26 from "fs-extra";
4177
4446
 
4178
4447
  // src/decisions/dep-bump.ts
4179
4448
  var DEP_SECTIONS = [
@@ -4182,15 +4451,15 @@ var DEP_SECTIONS = [
4182
4451
  "peerDependencies",
4183
4452
  "optionalDependencies"
4184
4453
  ];
4185
- function isRecord(value) {
4454
+ function isRecord2(value) {
4186
4455
  return value != null && typeof value === "object" && !Array.isArray(value);
4187
4456
  }
4188
4457
  function extractDepMap(pkg) {
4189
4458
  const map = /* @__PURE__ */ new Map();
4190
- if (!isRecord(pkg)) return map;
4459
+ if (!isRecord2(pkg)) return map;
4191
4460
  for (const section of DEP_SECTIONS) {
4192
4461
  const block = pkg[section];
4193
- if (!isRecord(block)) continue;
4462
+ if (!isRecord2(block)) continue;
4194
4463
  for (const [name, ver] of Object.entries(block)) {
4195
4464
  if (typeof ver === "string") map.set(`${section}:${name}`, ver);
4196
4465
  }
@@ -4216,7 +4485,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
4216
4485
  } catch {
4217
4486
  return false;
4218
4487
  }
4219
- if (!isRecord(before) || !isRecord(after)) return false;
4488
+ if (!isRecord2(before) || !isRecord2(after)) return false;
4220
4489
  const beforeDeps = extractDepMap(before);
4221
4490
  const afterDeps = extractDepMap(after);
4222
4491
  if (beforeDeps.size === 0 && afterDeps.size === 0) return false;
@@ -4232,7 +4501,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
4232
4501
 
4233
4502
  // src/decisions/diff.ts
4234
4503
  init_exec();
4235
- import path25 from "node:path";
4504
+ import path27 from "node:path";
4236
4505
  function isPackageJsonPath(file) {
4237
4506
  return file === "package.json" || file.endsWith("/package.json");
4238
4507
  }
@@ -4259,6 +4528,11 @@ function parseDiffRange(range) {
4259
4528
  }
4260
4529
  return null;
4261
4530
  }
4531
+ function toAncestryLogRange(range) {
4532
+ const parsed = parseDiffRange(range);
4533
+ if (!parsed) return `${range}..HEAD`;
4534
+ return `${parsed.left}..${parsed.right}`;
4535
+ }
4262
4536
  async function getCurrentBranch(root) {
4263
4537
  const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root });
4264
4538
  if (result.exitCode !== 0) return void 0;
@@ -4288,7 +4562,7 @@ async function resolvePackageJsonPair(root, file, opts) {
4288
4562
  return { before: before2, after: after2 };
4289
4563
  }
4290
4564
  const before = await readBlob(root, `${range}:${file}`) ?? empty;
4291
- const after = await readText(path25.join(root, file)) ?? empty;
4565
+ const after = await readText(path27.join(root, file)) ?? empty;
4292
4566
  return { before, after };
4293
4567
  }
4294
4568
  async function collectDiffStats(root, opts) {
@@ -4359,22 +4633,22 @@ function firstMatchingRegex(filePath, patterns) {
4359
4633
  }
4360
4634
 
4361
4635
  // src/decisions/paths.ts
4362
- import path26 from "node:path";
4363
- import fs23 from "fs-extra";
4636
+ import path28 from "node:path";
4637
+ import fs24 from "fs-extra";
4364
4638
  async function resolveDecisionsDir(root) {
4365
- const primary = path26.join(root, DECISIONS_TRIGGERS.decisionsDir);
4366
- if (await fs23.pathExists(primary)) return primary;
4367
- const legacy = path26.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4368
- if (await fs23.pathExists(legacy)) return legacy;
4639
+ const primary = path28.join(root, DECISIONS_TRIGGERS.decisionsDir);
4640
+ if (await fs24.pathExists(primary)) return primary;
4641
+ const legacy = path28.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4642
+ if (await fs24.pathExists(legacy)) return legacy;
4369
4643
  return primary;
4370
4644
  }
4371
4645
  function relativeDecisionPath(decisionsDir, root) {
4372
- return path26.relative(root, decisionsDir).replace(/\\/g, "/");
4646
+ return path28.relative(root, decisionsDir).replace(/\\/g, "/");
4373
4647
  }
4374
4648
 
4375
4649
  // src/decisions/validate.ts
4376
- import path27 from "node:path";
4377
- import fs24 from "fs-extra";
4650
+ import path29 from "node:path";
4651
+ import fs25 from "fs-extra";
4378
4652
  function validateDecisionContent(content2) {
4379
4653
  const errors = [];
4380
4654
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -4403,14 +4677,14 @@ function decisionNumberFromFile(name) {
4403
4677
  return match?.[1] ?? null;
4404
4678
  }
4405
4679
  async function validateIndexSync(decisionsDir, decisionFiles) {
4406
- const readmePath = path27.join(decisionsDir, "README.md");
4407
- if (!await fs24.pathExists(readmePath)) {
4680
+ const readmePath = path29.join(decisionsDir, "README.md");
4681
+ if (!await fs25.pathExists(readmePath)) {
4408
4682
  return ["docs/decisions/README.md is missing"];
4409
4683
  }
4410
4684
  const readme = await readText(readmePath) ?? "";
4411
4685
  const errors = [];
4412
4686
  for (const file of decisionFiles) {
4413
- const num = decisionNumberFromFile(path27.basename(file));
4687
+ const num = decisionNumberFromFile(path29.basename(file));
4414
4688
  if (num && !indexReferencesNumber(readme, num)) {
4415
4689
  errors.push(`README.md missing index row for ADR-${num}`);
4416
4690
  }
@@ -4505,22 +4779,22 @@ async function checkSatisfaction(input2) {
4505
4779
  const errors = [];
4506
4780
  const decisionsPrefix = `${input2.decisionsRel}/`;
4507
4781
  const newDecisionFiles = input2.changedFiles.filter(
4508
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path28.basename(f))
4782
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path30.basename(f))
4509
4783
  );
4510
4784
  if (newDecisionFiles.length === 0) {
4511
4785
  errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
4512
4786
  return errors;
4513
4787
  }
4514
- const decisionsDir = path28.join(input2.root, input2.decisionsRel);
4788
+ const decisionsDir = path30.join(input2.root, input2.decisionsRel);
4515
4789
  for (const rel of newDecisionFiles) {
4516
- const abs = path28.join(input2.root, rel);
4790
+ const abs = path30.join(input2.root, rel);
4517
4791
  const content2 = await readText(abs) ?? "";
4518
4792
  const valid = validateDecisionContent(content2);
4519
4793
  if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
4520
4794
  }
4521
4795
  const indexErrors = await validateIndexSync(
4522
4796
  decisionsDir,
4523
- newDecisionFiles.map((f) => path28.basename(f))
4797
+ newDecisionFiles.map((f) => path30.basename(f))
4524
4798
  );
4525
4799
  errors.push(...indexErrors);
4526
4800
  return errors;
@@ -4576,8 +4850,8 @@ ${opts.commitMessages ?? ""}`;
4576
4850
  changedFiles: stats.files
4577
4851
  });
4578
4852
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
4579
- const legacyExists = await fs25.pathExists(path28.join(root, legacyRel));
4580
- if (legacyExists && !await fs25.pathExists(decisionsDir)) {
4853
+ const legacyExists = await fs26.pathExists(path30.join(root, legacyRel));
4854
+ if (legacyExists && !await fs26.pathExists(decisionsDir)) {
4581
4855
  satisfactionErrors = await checkSatisfaction({
4582
4856
  root,
4583
4857
  decisionsRel: legacyRel,
@@ -4599,18 +4873,18 @@ ${opts.commitMessages ?? ""}`;
4599
4873
  return { triggered: true, satisfied: false, reasons, securityTriggered };
4600
4874
  }
4601
4875
  async function listDecisionNumbers(decisionsDir) {
4602
- if (!await fs25.pathExists(decisionsDir)) return [];
4603
- const entries = await fs25.readdir(decisionsDir);
4876
+ if (!await fs26.pathExists(decisionsDir)) return [];
4877
+ const entries = await fs26.readdir(decisionsDir);
4604
4878
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
4605
4879
  }
4606
4880
 
4607
4881
  // src/decisions/doctor.ts
4608
4882
  async function auditDecisionsLayout(root) {
4609
4883
  const findings = [];
4610
- const primary = path29.join(root, DECISIONS_TRIGGERS.decisionsDir);
4611
- const legacy = path29.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4612
- const hasPrimary = await fs26.pathExists(primary);
4613
- const hasLegacy = await fs26.pathExists(legacy);
4884
+ const primary = path31.join(root, DECISIONS_TRIGGERS.decisionsDir);
4885
+ const legacy = path31.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4886
+ const hasPrimary = await fs27.pathExists(primary);
4887
+ const hasLegacy = await fs27.pathExists(legacy);
4614
4888
  if (!hasPrimary && hasLegacy) {
4615
4889
  findings.push({
4616
4890
  level: "advisory",
@@ -4623,8 +4897,8 @@ async function auditDecisionsLayout(root) {
4623
4897
  if (!hasPrimary) return findings;
4624
4898
  const decisionsDir = await resolveDecisionsDir(root);
4625
4899
  const rel = relativeDecisionPath(decisionsDir, root);
4626
- const readmePath = path29.join(decisionsDir, "README.md");
4627
- if (!await fs26.pathExists(readmePath)) {
4900
+ const readmePath = path31.join(decisionsDir, "README.md");
4901
+ if (!await fs27.pathExists(readmePath)) {
4628
4902
  findings.push({
4629
4903
  level: "advisory",
4630
4904
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -4634,7 +4908,7 @@ async function auditDecisionsLayout(root) {
4634
4908
  return findings;
4635
4909
  }
4636
4910
  const readme = await readText(readmePath);
4637
- const entries = (await fs26.readdir(decisionsDir)).filter(
4911
+ const entries = (await fs27.readdir(decisionsDir)).filter(
4638
4912
  (f) => f.endsWith(".md") && f !== "README.md"
4639
4913
  );
4640
4914
  for (const file of entries) {
@@ -4648,7 +4922,7 @@ async function auditDecisionsLayout(root) {
4648
4922
  });
4649
4923
  }
4650
4924
  }
4651
- const claude = await readText(path29.join(root, "CLAUDE.md"));
4925
+ const claude = await readText(path31.join(root, "CLAUDE.md"));
4652
4926
  if (claude && !claude.includes(`@${rel}/README.md`)) {
4653
4927
  findings.push({
4654
4928
  level: "advisory",
@@ -4679,11 +4953,11 @@ async function auditDecisionsLayout(root) {
4679
4953
  }
4680
4954
 
4681
4955
  // src/scanner/read-context.ts
4682
- import path37 from "node:path";
4683
- import fs28 from "fs-extra";
4956
+ import path38 from "node:path";
4957
+ import fs29 from "fs-extra";
4684
4958
 
4685
4959
  // src/commands/workspace/config.ts
4686
- import path30 from "node:path";
4960
+ import path32 from "node:path";
4687
4961
  import YAML from "yaml";
4688
4962
  var WORKSPACE_FILE = "haus.workspace.yaml";
4689
4963
  function parseWorkspaceConfig(text) {
@@ -4706,11 +4980,11 @@ function parseWorkspaceConfig(text) {
4706
4980
  };
4707
4981
  }
4708
4982
  async function readWorkspaceConfig(workspaceRoot) {
4709
- return parseWorkspaceConfig(await readText(path30.join(workspaceRoot, WORKSPACE_FILE)));
4983
+ return parseWorkspaceConfig(await readText(path32.join(workspaceRoot, WORKSPACE_FILE)));
4710
4984
  }
4711
4985
 
4712
4986
  // src/workspace/members.ts
4713
- import path31 from "node:path";
4987
+ import path33 from "node:path";
4714
4988
  var REPOS_MANIFEST_FILE = "repos.manifest.json";
4715
4989
  var REPOS_LOCAL_FILE = "repos.local.json";
4716
4990
  var MemberConfigError = class extends Error {
@@ -4723,14 +4997,14 @@ var MemberConfigError = class extends Error {
4723
4997
  };
4724
4998
  function resolveAbsPath(workspaceRoot, folder, overrides) {
4725
4999
  const override = overrides[folder];
4726
- if (override) return path31.resolve(workspaceRoot, override);
4727
- return path31.resolve(workspaceRoot, folder);
5000
+ if (override) return path33.resolve(workspaceRoot, override);
5001
+ return path33.resolve(workspaceRoot, folder);
4728
5002
  }
4729
5003
  function isSelfMember(workspaceRoot, member) {
4730
- return path31.resolve(member.absPath) === path31.resolve(workspaceRoot);
5004
+ return path33.resolve(member.absPath) === path33.resolve(workspaceRoot);
4731
5005
  }
4732
5006
  async function readPathOverrides(workspaceRoot) {
4733
- const filePath = path31.join(workspaceRoot, REPOS_LOCAL_FILE);
5007
+ const filePath = path33.join(workspaceRoot, REPOS_LOCAL_FILE);
4734
5008
  const result = await readJsonDetailed(filePath);
4735
5009
  if (result.status === "missing") return {};
4736
5010
  if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
@@ -4746,7 +5020,7 @@ async function readPathOverrides(workspaceRoot) {
4746
5020
  return overrides;
4747
5021
  }
4748
5022
  async function readFromWorkspaceYaml(workspaceRoot, overrides) {
4749
- const filePath = path31.join(workspaceRoot, WORKSPACE_FILE);
5023
+ const filePath = path33.join(workspaceRoot, WORKSPACE_FILE);
4750
5024
  const text = await readText(filePath);
4751
5025
  if (text === void 0) return void 0;
4752
5026
  const config = parseWorkspaceConfig(text);
@@ -4759,7 +5033,7 @@ async function readFromWorkspaceYaml(workspaceRoot, overrides) {
4759
5033
  }));
4760
5034
  }
4761
5035
  async function readFromManifest(workspaceRoot, overrides) {
4762
- const filePath = path31.join(workspaceRoot, REPOS_MANIFEST_FILE);
5036
+ const filePath = path33.join(workspaceRoot, REPOS_MANIFEST_FILE);
4763
5037
  const result = await readJsonDetailed(filePath);
4764
5038
  if (result.status === "missing") return void 0;
4765
5039
  if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
@@ -4801,16 +5075,11 @@ async function readMembers(rootInfo) {
4801
5075
  }
4802
5076
 
4803
5077
  // src/scanner/scan-project.ts
4804
- import path36 from "node:path";
4805
-
4806
- // src/utils/audit-checks.ts
4807
- function isRecord2(v) {
4808
- return typeof v === "object" && v !== null;
4809
- }
5078
+ import path37 from "node:path";
4810
5079
 
4811
5080
  // src/utils/git-root.ts
4812
5081
  init_exec();
4813
- import path32 from "node:path";
5082
+ import path34 from "node:path";
4814
5083
  function fallback(start) {
4815
5084
  return {
4816
5085
  cwd: start,
@@ -4826,11 +5095,11 @@ function fallback(start) {
4826
5095
  async function resolveAbsoluteGitPath(flag, cwd, repoRoot) {
4827
5096
  const withFormat = await runGit(["rev-parse", "--path-format=absolute", flag], { cwd });
4828
5097
  const raw = withFormat.exitCode === 0 ? withFormat.stdout.trim() : "";
4829
- if (raw && path32.isAbsolute(raw)) return path32.resolve(raw);
5098
+ if (raw && path34.isAbsolute(raw)) return path34.resolve(raw);
4830
5099
  const plain = await runGit(["rev-parse", flag], { cwd });
4831
5100
  const plainRaw = plain.exitCode === 0 ? plain.stdout.trim() : "";
4832
5101
  if (!plainRaw) return "";
4833
- return path32.isAbsolute(plainRaw) ? path32.resolve(plainRaw) : path32.resolve(repoRoot, plainRaw);
5102
+ return path34.isAbsolute(plainRaw) ? path34.resolve(plainRaw) : path34.resolve(repoRoot, plainRaw);
4834
5103
  }
4835
5104
  async function resolveRoots(start) {
4836
5105
  const cwd = start ?? process.cwd();
@@ -4839,15 +5108,15 @@ async function resolveRoots(start) {
4839
5108
  if (toplevel.exitCode !== 0) return fallback(cwd);
4840
5109
  const toplevelRaw = toplevel.stdout.trim();
4841
5110
  if (!toplevelRaw) return fallback(cwd);
4842
- const repoRoot = path32.resolve(toplevelRaw);
5111
+ const repoRoot = path34.resolve(toplevelRaw);
4843
5112
  const [gitDir, gitCommonDir] = await Promise.all([
4844
5113
  resolveAbsoluteGitPath("--git-dir", cwd, repoRoot),
4845
5114
  resolveAbsoluteGitPath("--git-common-dir", cwd, repoRoot)
4846
5115
  ]);
4847
5116
  if (!gitDir || !gitCommonDir) return fallback(cwd);
4848
5117
  const isLinkedWorktree = gitDir !== gitCommonDir;
4849
- const mainRoot = isLinkedWorktree && path32.basename(gitCommonDir) === ".git" ? path32.dirname(gitCommonDir) : repoRoot;
4850
- const worktreeName = isLinkedWorktree ? path32.basename(gitDir) : null;
5118
+ const mainRoot = isLinkedWorktree && path34.basename(gitCommonDir) === ".git" ? path34.dirname(gitCommonDir) : repoRoot;
5119
+ const worktreeName = isLinkedWorktree ? path34.basename(gitDir) : null;
4851
5120
  return {
4852
5121
  cwd,
4853
5122
  repoRoot,
@@ -4892,8 +5161,8 @@ function resolveMajorVersion(range) {
4892
5161
  }
4893
5162
 
4894
5163
  // src/scanner/detect-package-manager.ts
4895
- import path33 from "node:path";
4896
- import fs27 from "fs-extra";
5164
+ import path35 from "node:path";
5165
+ import fs28 from "fs-extra";
4897
5166
  function detectPackageManager(root, packageManagerField) {
4898
5167
  const field = String(packageManagerField ?? "").trim();
4899
5168
  if (field.startsWith("yarn@")) {
@@ -4911,9 +5180,9 @@ function detectPackageManager(root, packageManagerField) {
4911
5180
  if (satisfiesVersion(version, ">=9")) return "npm";
4912
5181
  return "unknown";
4913
5182
  }
4914
- if (fs27.existsSync(path33.join(root, "yarn.lock"))) return "yarn";
4915
- if (fs27.existsSync(path33.join(root, "pnpm-lock.yaml"))) return "pnpm";
4916
- if (fs27.existsSync(path33.join(root, "package-lock.json"))) return "npm";
5183
+ if (fs28.existsSync(path35.join(root, "yarn.lock"))) return "yarn";
5184
+ if (fs28.existsSync(path35.join(root, "pnpm-lock.yaml"))) return "pnpm";
5185
+ if (fs28.existsSync(path35.join(root, "package-lock.json"))) return "npm";
4917
5186
  return "unknown";
4918
5187
  }
4919
5188
 
@@ -5030,6 +5299,7 @@ var STACK_RULES = [
5030
5299
  stack: ["frontend", "shadcn"],
5031
5300
  all: [fileEndsWith("components.json"), dep("class-variance-authority")]
5032
5301
  },
5302
+ { stack: ["frontend", "radix"], any: [depPrefix("@radix-ui/")] },
5033
5303
  { stack: ["tooling", "typescript5"], any: [depMajor("typescript", 5)] },
5034
5304
  { stack: ["backend", "sanity"], any: [dep("sanity"), dep("next-sanity"), dep("@sanity/client")] },
5035
5305
  { stack: ["backend", "strapi"], any: [dep("@strapi/strapi"), depPrefix("@strapi/")] },
@@ -5100,114 +5370,44 @@ var STACK_RULES = [
5100
5370
  { stack: ["testing", "playwright"], any: [dep("@playwright/test")] },
5101
5371
  { stack: ["testing", "storybook"], any: [fileIncludes(".storybook")] },
5102
5372
  { stack: ["testing", "testing-library"], any: [depPrefix("@testing-library/")] },
5103
- { stack: ["testing", "phpunit"], any: [fileEndsWith("phpunit.xml")] },
5104
- { stack: ["testing", "storybook"], any: [depPrefix("@storybook/")] },
5105
- { stack: ["testing", "vitest"], any: [dep("vitest")] },
5106
- { stack: ["testing", "jest"], any: [dep("jest"), dep("jest-environment-jsdom")] },
5107
- { stack: ["databases", "postgresql"], any: [dep("pg")] },
5108
- { stack: ["databases", "mariadb"], any: [dep("mariadb"), dep("mysql2")] },
5109
- { stack: ["databases", "mysql"], any: [dep("mysql"), dep("mysql2")] },
5110
- { stack: ["databases", "mssql"], any: [dep("mssql")] },
5111
- { stack: ["databases", "elasticsearch"], any: [dep("@elastic/elasticsearch")] },
5112
- { stack: ["databases", "redis"], any: [dep("predis/predis"), dep("ioredis"), dep("redis")] },
5113
- { stack: ["auth", "oidc"], any: [content("openid")] },
5114
- { stack: ["auth", "azure-ad"], any: [content("AZURE_AD")] },
5115
- { stack: ["auth", "bankid"], any: [content("BANKID")] },
5116
- {
5117
- stack: ["auth", "saml2"],
5118
- any: [dep("24slides/laravel-saml2"), dep("aacotroneo/laravel-saml2")]
5119
- },
5120
- { stack: ["auth", "next-auth"], any: [dep("next-auth"), dep("@auth/core")] }
5121
- ];
5122
- function runDetection(ctx, rules = STACK_RULES) {
5123
- const roles = [];
5124
- for (const rule of ROLE_RULES) {
5125
- if (rule.role && matchRule(rule, ctx) && !roles.includes(rule.role)) roles.push(rule.role);
5126
- }
5127
- const stacks = {};
5128
- for (const bucket of STACK_BUCKETS) stacks[bucket] = [];
5129
- for (const rule of rules) {
5130
- if (!rule.stack || !matchRule(rule, ctx)) continue;
5131
- const [bucket, name] = rule.stack;
5132
- stacks[bucket] ??= [];
5133
- if (!stacks[bucket].includes(name)) stacks[bucket].push(name);
5134
- }
5135
- return { roles, stacks };
5136
- }
5137
-
5138
- // src/scanner/detection.ts
5139
- import path34 from "node:path";
5140
- var UNSUPPORTED_MARKERS = {
5141
- "requirements.txt": "python",
5142
- "pyproject.toml": "python",
5143
- "go.mod": "go",
5144
- "Cargo.toml": "rust",
5145
- "pom.xml": "java",
5146
- "build.gradle": "java",
5147
- "build.gradle.kts": "java",
5148
- Gemfile: "ruby"
5149
- };
5150
- var WEAK_STACK_SIGNALS = /* @__PURE__ */ new Set(["missing-prettier", "missing-eslint"]);
5151
- function computeDetectionStatus(roles, stacks, unsupportedSignals) {
5152
- const hasRealStack = Object.entries(stacks).some(
5153
- ([bucket, names]) => bucket !== "packageManagers" && names.some((n) => !WEAK_STACK_SIGNALS.has(n))
5154
- );
5155
- const hasRealSignal = roles.length > 0 || hasRealStack;
5156
- if (!hasRealSignal) return "unknown";
5157
- return unsupportedSignals.length > 0 ? "partial" : "supported";
5158
- }
5159
- function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
5160
- const markers = unsupportedSignals.join(", ");
5161
- const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
5162
- const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree \u2014 sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
5163
- return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
5164
- }
5165
- function blocked(rel) {
5166
- return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
5167
- }
5168
- function dependencyVersionMap(pkg, composer) {
5169
- const versions = /* @__PURE__ */ new Map();
5170
- const pushObj = (obj) => {
5171
- if (!isRecord2(obj)) return;
5172
- for (const key of Object.keys(obj)) {
5173
- if (versions.has(key)) continue;
5174
- const value = obj[key];
5175
- versions.set(key, typeof value === "string" ? value : "");
5176
- }
5177
- };
5178
- pushObj(pkg?.dependencies);
5179
- pushObj(pkg?.devDependencies);
5180
- pushObj(composer?.require);
5181
- pushObj(composer?.["require-dev"]);
5182
- return versions;
5183
- }
5184
- function finalizeRoles(registryRoles, deps, files) {
5185
- const roles = new Set(registryRoles);
5186
- const hasWpConfig = files.some((f) => f.endsWith("wp-config.php"));
5187
- const hasBedrockLayout = files.some((f) => f.includes("web/app")) || deps.includes("roots/wordpress");
5188
- if (hasWpConfig && hasBedrockLayout) {
5189
- roles.add("wordpress-bedrock-site");
5190
- roles.add("wordpress-site");
5191
- } else if (hasWpConfig) {
5192
- roles.add("wordpress-vanilla-site");
5193
- roles.add("wordpress-site");
5194
- } else if (deps.includes("roots/wordpress")) {
5195
- roles.add("wordpress-bedrock-site");
5196
- roles.add("wordpress-site");
5373
+ { stack: ["testing", "phpunit"], any: [fileEndsWith("phpunit.xml")] },
5374
+ { stack: ["testing", "storybook"], any: [depPrefix("@storybook/")] },
5375
+ { stack: ["testing", "vitest"], any: [dep("vitest")] },
5376
+ { stack: ["testing", "jest"], any: [dep("jest"), dep("jest-environment-jsdom")] },
5377
+ { stack: ["databases", "postgresql"], any: [dep("pg")] },
5378
+ { stack: ["databases", "mariadb"], any: [dep("mariadb"), dep("mysql2")] },
5379
+ { stack: ["databases", "mysql"], any: [dep("mysql"), dep("mysql2")] },
5380
+ { stack: ["databases", "mssql"], any: [dep("mssql")] },
5381
+ { stack: ["databases", "elasticsearch"], any: [dep("@elastic/elasticsearch")] },
5382
+ { stack: ["databases", "redis"], any: [dep("predis/predis"), dep("ioredis"), dep("redis")] },
5383
+ { stack: ["auth", "oidc"], any: [content("openid")] },
5384
+ { stack: ["auth", "azure-ad"], any: [content("AZURE_AD")] },
5385
+ { stack: ["auth", "bankid"], any: [content("BANKID")] },
5386
+ {
5387
+ stack: ["auth", "saml2"],
5388
+ any: [dep("24slides/laravel-saml2"), dep("aacotroneo/laravel-saml2")]
5389
+ },
5390
+ { stack: ["auth", "next-auth"], any: [dep("next-auth"), dep("@auth/core")] }
5391
+ ];
5392
+ function runDetection(ctx, rules = STACK_RULES) {
5393
+ const roles = [];
5394
+ for (const rule of ROLE_RULES) {
5395
+ if (rule.role && matchRule(rule, ctx) && !roles.includes(rule.role)) roles.push(rule.role);
5197
5396
  }
5198
- return [...roles].sort();
5199
- }
5200
- function collectUnsupportedSignals(files) {
5201
- return [
5202
- ...new Set(
5203
- files.map((f) => UNSUPPORTED_MARKERS[path34.basename(f)]).filter((s) => Boolean(s))
5204
- )
5205
- ].sort();
5397
+ const stacks = {};
5398
+ for (const bucket of STACK_BUCKETS) stacks[bucket] = [];
5399
+ for (const rule of rules) {
5400
+ if (!rule.stack || !matchRule(rule, ctx)) continue;
5401
+ const [bucket, name] = rule.stack;
5402
+ stacks[bucket] ??= [];
5403
+ if (!stacks[bucket].includes(name)) stacks[bucket].push(name);
5404
+ }
5405
+ return { roles, stacks };
5206
5406
  }
5207
5407
 
5208
5408
  // src/scanner/render.ts
5209
5409
  import { readFile } from "node:fs/promises";
5210
- import path35 from "node:path";
5410
+ import path36 from "node:path";
5211
5411
  async function buildContentBlob(root, files) {
5212
5412
  const candidates = files.filter(
5213
5413
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -5215,7 +5415,7 @@ async function buildContentBlob(root, files) {
5215
5415
  const slice = candidates.slice(0, 300);
5216
5416
  const parts = await mapWithConcurrency(slice, async (rel) => {
5217
5417
  try {
5218
- return await readFile(path35.join(root, rel), "utf8");
5418
+ return await readFile(path36.join(root, rel), "utf8");
5219
5419
  } catch {
5220
5420
  return "";
5221
5421
  }
@@ -5307,8 +5507,8 @@ var SAFE_FILES = [
5307
5507
  "Gemfile"
5308
5508
  ];
5309
5509
  async function scanProject(root) {
5310
- const pkg = await readJson(path36.join(root, "package.json"));
5311
- const composer = await readJson(path36.join(root, "composer.json"));
5510
+ const pkg = await readJson(path37.join(root, "package.json"));
5511
+ const composer = await readJson(path37.join(root, "composer.json"));
5312
5512
  const files = await listFiles(root, SAFE_FILES);
5313
5513
  const safeFiles = files.filter((f) => !blocked(f));
5314
5514
  const depVersions = dependencyVersionMap(pkg, composer);
@@ -5328,7 +5528,7 @@ async function scanProject(root) {
5328
5528
  const warnings = [];
5329
5529
  const securityRisks = [];
5330
5530
  const crossRepoHints = [];
5331
- const nodeEngine = isRecord2(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
5531
+ const nodeEngine = isRecord(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
5332
5532
  if (nodeEngine && !satisfiesVersion(process.version, nodeEngine)) {
5333
5533
  warnings.push(`Current Node ${process.version} does not satisfy package engine ${nodeEngine}`);
5334
5534
  }
@@ -5338,7 +5538,7 @@ async function scanProject(root) {
5338
5538
  crossRepoHints.push("Monorepo orchestration detected");
5339
5539
  const unsupportedSignals = collectUnsupportedSignals(safeFiles);
5340
5540
  const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
5341
- const repoNameFallback = pkg?.name ? String(pkg.name) : path36.basename((await resolveRoots(root)).mainRoot);
5541
+ const repoNameFallback = pkg?.name ? String(pkg.name) : path37.basename((await resolveRoots(root)).mainRoot);
5342
5542
  const context = {
5343
5543
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5344
5544
  repoName: repoNameFallback,
@@ -5360,14 +5560,15 @@ async function scanProject(root) {
5360
5560
  // src/scanner/read-context.ts
5361
5561
  async function isCacheFresh(root, cachePath) {
5362
5562
  const markerPaths = [
5363
- path37.join(root, "package.json"),
5364
- path37.join(root, WORKSPACE_FILE),
5365
- path37.join(root, REPOS_MANIFEST_FILE)
5563
+ path38.join(root, "package.json"),
5564
+ path38.join(root, "composer.json"),
5565
+ path38.join(root, WORKSPACE_FILE),
5566
+ path38.join(root, REPOS_MANIFEST_FILE)
5366
5567
  ];
5367
- const cacheStat = await fs28.stat(cachePath);
5568
+ const cacheStat = await fs29.stat(cachePath);
5368
5569
  for (const markerPath of markerPaths) {
5369
- if (!await fs28.pathExists(markerPath)) continue;
5370
- const markerStat = await fs28.stat(markerPath);
5570
+ if (!await fs29.pathExists(markerPath)) continue;
5571
+ const markerStat = await fs29.stat(markerPath);
5371
5572
  if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
5372
5573
  }
5373
5574
  return true;
@@ -5468,7 +5669,7 @@ async function runDoctor(options) {
5468
5669
  } else {
5469
5670
  ok(`- HOOKS OK: ${hooks.message}`);
5470
5671
  }
5471
- const rootClaudeMdPath = path38.join(root, "CLAUDE.md");
5672
+ const rootClaudeMdPath = path39.join(root, "CLAUDE.md");
5472
5673
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
5473
5674
  if (!rootClaudeMdContent) {
5474
5675
  flag(
@@ -5511,10 +5712,10 @@ async function runDoctor(options) {
5511
5712
  ok("- CLAUDE.md: import block present");
5512
5713
  }
5513
5714
  for (const target of actualTargets) {
5514
- const resolved = path38.resolve(root, target);
5515
- const relFromRoot = path38.relative(root, resolved);
5516
- const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path38.sep}`) || path38.isAbsolute(relFromRoot);
5517
- if (escapesRoot || !await fs29.pathExists(resolved)) {
5715
+ const resolved = path39.resolve(root, target);
5716
+ const relFromRoot = path39.relative(root, resolved);
5717
+ const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path39.sep}`) || path39.isAbsolute(relFromRoot);
5718
+ if (escapesRoot || !await fs30.pathExists(resolved)) {
5518
5719
  flag(
5519
5720
  `- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
5520
5721
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -5532,7 +5733,7 @@ async function runDoctor(options) {
5532
5733
  );
5533
5734
  }
5534
5735
  const workflowPath = hausPath(root, "WORKFLOW.md");
5535
- const workflowExists = await fs29.pathExists(workflowPath);
5736
+ const workflowExists = await fs30.pathExists(workflowPath);
5536
5737
  if (!workflowExists) {
5537
5738
  flag(
5538
5739
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -5547,15 +5748,15 @@ async function runDoctor(options) {
5547
5748
  } else {
5548
5749
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
5549
5750
  const bodyContent = workflowContent.slice(firstLine.length + 1);
5550
- const cachePath = path38.join(getCacheDir(), "templates/agentic-workflow-standard.md");
5551
- const bundledPath = path38.join(
5751
+ const cachePath = path39.join(getCacheDir(), "templates/agentic-workflow-standard.md");
5752
+ const bundledPath = path39.join(
5552
5753
  packageRoot(),
5553
5754
  "library",
5554
5755
  "global",
5555
5756
  "templates",
5556
5757
  "agentic-workflow-standard.md"
5557
5758
  );
5558
- const templatePath = await fs29.pathExists(cachePath) ? cachePath : bundledPath;
5759
+ const templatePath = await fs30.pathExists(cachePath) ? cachePath : bundledPath;
5559
5760
  const templateContent = await readText(templatePath);
5560
5761
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
5561
5762
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -5576,7 +5777,7 @@ async function runDoctor(options) {
5576
5777
  }
5577
5778
  }
5578
5779
  const workflowConfigPath = hausPath(root, "workflow-config.md");
5579
- const workflowConfigExists = await fs29.pathExists(workflowConfigPath);
5780
+ const workflowConfigExists = await fs30.pathExists(workflowConfigPath);
5580
5781
  if (!workflowConfigExists) {
5581
5782
  flag(
5582
5783
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -5584,7 +5785,7 @@ async function runDoctor(options) {
5584
5785
  "haus apply --write"
5585
5786
  );
5586
5787
  } else {
5587
- const cfg = await fs29.readFile(workflowConfigPath, "utf8");
5788
+ const cfg = await fs30.readFile(workflowConfigPath, "utf8");
5588
5789
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
5589
5790
  if (unfilled > 0) {
5590
5791
  suggest(
@@ -5597,7 +5798,7 @@ async function runDoctor(options) {
5597
5798
  }
5598
5799
  }
5599
5800
  if (workflowExists) {
5600
- const prettierIgnore = await readText(path38.join(root, ".prettierignore")) ?? "";
5801
+ const prettierIgnore = await readText(path39.join(root, ".prettierignore")) ?? "";
5601
5802
  const lines = prettierIgnore.split("\n");
5602
5803
  const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
5603
5804
  if (missing.length > 0) {
@@ -5637,6 +5838,29 @@ async function runDoctor(options) {
5637
5838
  if (finding.level === "ok") ok(finding.line);
5638
5839
  else suggest(finding.line, finding.sentence, finding.fix);
5639
5840
  }
5841
+ const skillRefIssues = await findUnresolvedSkillReferences(root);
5842
+ if (skillRefIssues.length > 0) {
5843
+ const prefixBug = skillRefIssues.filter((i) => i.bareInstalled);
5844
+ const missingDep = skillRefIssues.filter((i) => !i.bareInstalled);
5845
+ const describe = (i) => `${i.file}:${i.line} "${i.reference}"`;
5846
+ if (prefixBug.length > 0) {
5847
+ flag(
5848
+ `- SKILL REFS: ${prefixBug.length} reference(s) use an unresolvable plugin-namespace prefix, though the target skill IS installed (${prefixBug.slice(0, 3).map(describe).join("; ")}${prefixBug.length > 3 ? "; \u2026" : ""})`,
5849
+ `${prefixBug.length} skill reference(s) use a prefix that can't resolve, even though the target skill is installed`,
5850
+ "haus apply --write (re-vendors the skill body with the prefix stripped)"
5851
+ );
5852
+ }
5853
+ if (missingDep.length > 0) {
5854
+ const missingNames = [...new Set(missingDep.map((i) => i.bareName))];
5855
+ flag(
5856
+ `- SKILL REFS: ${missingDep.length} reference(s) point at a skill that is not installed (${missingNames.join(", ")}) (${missingDep.slice(0, 3).map(describe).join("; ")}${missingDep.length > 3 ? "; \u2026" : ""})`,
5857
+ `${missingDep.length} skill reference(s) point at a sub-skill (${missingNames.join(", ")}) that isn't installed`,
5858
+ "find its catalog id and run `haus recommend --include <id>` then `haus apply --write`, or remove the reference"
5859
+ );
5860
+ }
5861
+ } else {
5862
+ ok("- SKILL REFS: OK (no unresolved cross-skill references)");
5863
+ }
5640
5864
  const cacheAgeMs = await getCacheManifestAge();
5641
5865
  if (cacheAgeMs === null) {
5642
5866
  suggest(
@@ -5657,20 +5881,28 @@ async function runDoctor(options) {
5657
5881
  }
5658
5882
  }
5659
5883
  if (recommendation) {
5660
- const lock = await readJson(hausPath(root, "haus.lock.json"));
5661
- const recommendedIds = new Set(
5662
- recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
5663
- );
5664
- const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
5665
- if (orphaned.length > 0) {
5884
+ if (!Array.isArray(recommendation.recommended)) {
5666
5885
  suggest(
5667
- `- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
5668
- `${orphaned.length} installed item(s) are no longer recommended for this project`,
5669
- "review whether they are still needed; haus apply --write leaves them in place until removed from the catalog"
5886
+ `- CATALOG ITEMS: recommendation.json's "recommended" field is not an array \u2014 skipping orphaned-item check`,
5887
+ 'recommendation.json looks malformed (its "recommended" field is not an array)',
5888
+ "run `haus recommend` to regenerate it"
5889
+ );
5890
+ } else {
5891
+ const lock = await readJson(hausPath(root, "haus.lock.json"));
5892
+ const recommendedIds = new Set(
5893
+ recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
5670
5894
  );
5895
+ const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
5896
+ if (orphaned.length > 0) {
5897
+ suggest(
5898
+ `- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
5899
+ `${orphaned.length} installed item(s) are no longer recommended for this project`,
5900
+ "review whether they are still needed; haus apply --write leaves them in place until removed from the catalog"
5901
+ );
5902
+ }
5671
5903
  }
5672
5904
  }
5673
- const pkgJson = await readJson(path38.join(packageRoot(), "package.json"));
5905
+ const pkgJson = await readJson(path39.join(packageRoot(), "package.json"));
5674
5906
  const currentVersion = pkgJson?.version ?? "0.0.0";
5675
5907
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
5676
5908
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -5703,12 +5935,12 @@ async function runDoctor(options) {
5703
5935
  }
5704
5936
 
5705
5937
  // src/commands/update.ts
5706
- import path41 from "node:path";
5938
+ import path42 from "node:path";
5707
5939
 
5708
5940
  // src/claude/refresh-project.ts
5709
- import fs30 from "fs-extra";
5941
+ import fs31 from "fs-extra";
5710
5942
  async function isHausProject(root) {
5711
- if (await fs30.pathExists(hausPath(root, "recommendation.json"))) return true;
5943
+ if (await fs31.pathExists(hausPath(root, "recommendation.json"))) return true;
5712
5944
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
5713
5945
  if (result.status === "ok") return result.value._haus != null;
5714
5946
  return false;
@@ -5720,8 +5952,8 @@ async function refreshProjectApply(root) {
5720
5952
 
5721
5953
  // src/install/apply.ts
5722
5954
  import crypto2 from "node:crypto";
5723
- import path39 from "node:path";
5724
- import fs31 from "fs-extra";
5955
+ import path40 from "node:path";
5956
+ import fs32 from "fs-extra";
5725
5957
 
5726
5958
  // src/install/header.ts
5727
5959
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -5805,59 +6037,66 @@ var SCHEMA_VERSION2 = "1";
5805
6037
  function hashContent(content2) {
5806
6038
  return `sha256-${crypto2.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
5807
6039
  }
6040
+ function destFileMissing(destPath) {
6041
+ try {
6042
+ return !fs32.statSync(destPath).isFile();
6043
+ } catch {
6044
+ return true;
6045
+ }
6046
+ }
5808
6047
  function sourceVersion() {
5809
6048
  try {
5810
- const pkgPath = path39.join(packageRoot(), "package.json");
5811
- const pkg = JSON.parse(fs31.readFileSync(pkgPath, "utf8"));
6049
+ const pkgPath = path40.join(packageRoot(), "package.json");
6050
+ const pkg = JSON.parse(fs32.readFileSync(pkgPath, "utf8"));
5812
6051
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
5813
6052
  } catch {
5814
6053
  return "haus@0.0.0";
5815
6054
  }
5816
6055
  }
5817
6056
  function globalSrcDir() {
5818
- return path39.join(packageRoot(), "library", "global");
6057
+ return path40.join(packageRoot(), "library", "global");
5819
6058
  }
5820
6059
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
5821
- const skillDir = path39.join(skillsDir, skillName);
6060
+ const skillDir = path40.join(skillsDir, skillName);
5822
6061
  const entries = [];
5823
- for (const rel of fs31.readdirSync(skillDir, { recursive: true })) {
6062
+ for (const rel of fs32.readdirSync(skillDir, { recursive: true })) {
5824
6063
  if (rel === "SKILL.md") continue;
5825
- const abs = path39.join(skillDir, rel);
5826
- if (!fs31.statSync(abs).isFile()) continue;
5827
- const posixRel = rel.split(path39.sep).join("/");
6064
+ const abs = path40.join(skillDir, rel);
6065
+ if (!fs32.statSync(abs).isFile()) continue;
6066
+ const posixRel = rel.split(path40.sep).join("/");
5828
6067
  entries.push({
5829
6068
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
5830
- srcRelPath: path39.join("library", "global", "skills", skillName, rel),
5831
- destPath: path39.join(claudeDir, "skills", skillName, rel)
6069
+ srcRelPath: path40.join("library", "global", "skills", skillName, rel),
6070
+ destPath: path40.join(claudeDir, "skills", skillName, rel)
5832
6071
  });
5833
6072
  }
5834
6073
  return entries;
5835
6074
  }
5836
6075
  function collectSourceFiles(srcDir, claudeDir) {
5837
6076
  const entries = [];
5838
- const skillsDir = path39.join(srcDir, "skills");
5839
- if (fs31.pathExistsSync(skillsDir)) {
5840
- for (const skillName of fs31.readdirSync(skillsDir)) {
5841
- const skillFile = path39.join(skillsDir, skillName, "SKILL.md");
5842
- if (fs31.pathExistsSync(skillFile)) {
6077
+ const skillsDir = path40.join(srcDir, "skills");
6078
+ if (fs32.pathExistsSync(skillsDir)) {
6079
+ for (const skillName of fs32.readdirSync(skillsDir)) {
6080
+ const skillFile = path40.join(skillsDir, skillName, "SKILL.md");
6081
+ if (fs32.pathExistsSync(skillFile)) {
5843
6082
  entries.push({
5844
6083
  stableId: `skill.${skillName}`,
5845
- srcRelPath: path39.join("library", "global", "skills", skillName, "SKILL.md"),
5846
- destPath: path39.join(claudeDir, "skills", skillName, "SKILL.md")
6084
+ srcRelPath: path40.join("library", "global", "skills", skillName, "SKILL.md"),
6085
+ destPath: path40.join(claudeDir, "skills", skillName, "SKILL.md")
5847
6086
  });
5848
6087
  entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
5849
6088
  }
5850
6089
  }
5851
6090
  }
5852
- const commandsDir = path39.join(srcDir, "commands");
5853
- if (fs31.pathExistsSync(commandsDir)) {
5854
- for (const fileName of fs31.readdirSync(commandsDir)) {
6091
+ const commandsDir = path40.join(srcDir, "commands");
6092
+ if (fs32.pathExistsSync(commandsDir)) {
6093
+ for (const fileName of fs32.readdirSync(commandsDir)) {
5855
6094
  if (!fileName.endsWith(".md")) continue;
5856
6095
  const commandName = fileName.slice(0, -".md".length);
5857
6096
  entries.push({
5858
6097
  stableId: `command.${commandName}`,
5859
- srcRelPath: path39.join("library", "global", "commands", fileName),
5860
- destPath: path39.join(claudeDir, "commands", fileName)
6098
+ srcRelPath: path40.join("library", "global", "commands", fileName),
6099
+ destPath: path40.join(claudeDir, "commands", fileName)
5861
6100
  });
5862
6101
  }
5863
6102
  }
@@ -5883,7 +6122,7 @@ async function applyInstall(options = {}) {
5883
6122
  };
5884
6123
  const manifestFiles = [];
5885
6124
  for (const entry of sourceFiles) {
5886
- const srcPath = path39.join(packageRoot(), entry.srcRelPath);
6125
+ const srcPath = path40.join(packageRoot(), entry.srcRelPath);
5887
6126
  const rawContent = await readText(srcPath);
5888
6127
  if (rawContent === void 0) {
5889
6128
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -5897,13 +6136,14 @@ async function applyInstall(options = {}) {
5897
6136
  const newHash = hashContent(stamped);
5898
6137
  const existing = manifestByDest.get(entry.destPath);
5899
6138
  if (check) {
5900
- if (existing && existing.hash !== newHash) {
6139
+ const destMissing = destFileMissing(entry.destPath);
6140
+ if (destMissing || existing && existing.hash !== newHash) {
5901
6141
  result.drift = true;
5902
6142
  result.skipped.push(entry.destPath);
5903
6143
  }
5904
6144
  continue;
5905
6145
  }
5906
- const destExists = fs31.pathExistsSync(entry.destPath);
6146
+ const destExists = fs32.pathExistsSync(entry.destPath);
5907
6147
  if (destExists) {
5908
6148
  const currentContent = await readText(entry.destPath);
5909
6149
  if (currentContent !== void 0) {
@@ -5943,7 +6183,7 @@ async function applyInstall(options = {}) {
5943
6183
  }
5944
6184
  let mergedSettings;
5945
6185
  if (!check) {
5946
- const fragmentPath = path39.join(srcDir, "settings-fragments", "hooks.json");
6186
+ const fragmentPath = path40.join(srcDir, "settings-fragments", "hooks.json");
5947
6187
  const fragments = await loadHooksFragment(fragmentPath);
5948
6188
  const settings = await readSettings();
5949
6189
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -5957,13 +6197,13 @@ async function applyInstall(options = {}) {
5957
6197
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
5958
6198
  for (const entry of existingManifest.files) {
5959
6199
  if (currentDestPaths.has(entry.destPath)) continue;
5960
- if (!fs31.pathExistsSync(entry.destPath)) continue;
6200
+ if (!fs32.pathExistsSync(entry.destPath)) continue;
5961
6201
  const content2 = await readText(entry.destPath);
5962
6202
  if (!content2) continue;
5963
6203
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
5964
6204
  const currentHash = hashContent(content2);
5965
6205
  if (hasHeader && currentHash === entry.hash) {
5966
- if (!dryRun) await fs31.remove(entry.destPath);
6206
+ if (!dryRun) await fs32.remove(entry.destPath);
5967
6207
  result.deleted.push(entry.destPath);
5968
6208
  } else {
5969
6209
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -6032,7 +6272,7 @@ function summarizeLockDiff(before, after) {
6032
6272
 
6033
6273
  // src/update/lockfile.ts
6034
6274
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
6035
- import path40 from "node:path";
6275
+ import path41 from "node:path";
6036
6276
  async function readLockSummary(root) {
6037
6277
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
6038
6278
  const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
@@ -6056,19 +6296,21 @@ async function checkLock(root) {
6056
6296
  const ok = lock.length > 0 && hasValidVersions && drift.length === 0;
6057
6297
  return { ok, count: lock.length, catalogRef, drift, driftCount: drift.length };
6058
6298
  }
6059
- async function applyLock(root) {
6060
- const lockPath = hausPath(root, "haus.lock.json");
6061
- let before = "[]";
6299
+ async function readLockRaw(root) {
6062
6300
  try {
6063
- before = await readFile2(lockPath, "utf8");
6301
+ return await readFile2(hausPath(root, "haus.lock.json"), "utf8");
6064
6302
  } catch {
6065
- before = "[]";
6303
+ return "[]";
6066
6304
  }
6305
+ }
6306
+ async function applyLock(root) {
6307
+ const lockPath = hausPath(root, "haus.lock.json");
6308
+ const before = await readLockRaw(root);
6067
6309
  const lock = await readJson(lockPath) ?? [];
6068
6310
  try {
6069
6311
  const backupDir = hausPath(root, "backups");
6070
6312
  await mkdir(backupDir, { recursive: true });
6071
- await copyFile(lockPath, path40.join(backupDir, `haus.lock.${Date.now()}.json`));
6313
+ await copyFile(lockPath, path41.join(backupDir, `haus.lock.${Date.now()}.json`));
6072
6314
  } catch {
6073
6315
  }
6074
6316
  const enriched = await Promise.all(
@@ -6090,7 +6332,7 @@ function diffLock(before, after) {
6090
6332
  }
6091
6333
  async function hasLocalOverrides(root) {
6092
6334
  try {
6093
- await readFile2(path40.join(root, ".claude", "settings.json"), "utf8");
6335
+ await readFile2(path41.join(root, ".claude", "settings.json"), "utf8");
6094
6336
  return true;
6095
6337
  } catch {
6096
6338
  return false;
@@ -6106,7 +6348,7 @@ async function runUpdate(options) {
6106
6348
  return;
6107
6349
  }
6108
6350
  if (options.check) {
6109
- const pkgJson2 = await readJson(path41.join(packageRoot(), "package.json"));
6351
+ const pkgJson2 = await readJson(path42.join(packageRoot(), "package.json"));
6110
6352
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
6111
6353
  const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
6112
6354
  options.fast ? fastLockStatus(root) : checkLock(root),
@@ -6138,10 +6380,12 @@ async function runUpdate(options) {
6138
6380
  )
6139
6381
  );
6140
6382
  const hasHashDrift = !options.fast && status.count > 0 && "ok" in status && !status.ok;
6141
- if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
6383
+ if (hasHashDrift || formerIdMigrations.length > 0 || globalInstallDrift === true) {
6384
+ process.exitCode = 1;
6385
+ }
6142
6386
  return;
6143
6387
  }
6144
- const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6388
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6145
6389
  const currentVersion = pkgJson?.version ?? "0.0.0";
6146
6390
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
6147
6391
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -6153,9 +6397,7 @@ async function runUpdate(options) {
6153
6397
  if (await hasLocalOverrides(root)) {
6154
6398
  log("Existing .claude/settings.json \u2014 haus rules will be merged, not replaced.");
6155
6399
  }
6156
- const { before, after } = await applyLock(root);
6157
- log(diffLock(before, after));
6158
- log(summarizeLockDiff(before, after));
6400
+ const lockBefore = await readLockRaw(root);
6159
6401
  log("Syncing remote catalog...");
6160
6402
  const sync = await syncRemoteCatalog();
6161
6403
  if (sync.newItems.length > 0) {
@@ -6177,13 +6419,16 @@ async function runUpdate(options) {
6177
6419
  }
6178
6420
  await refreshGlobalInstall();
6179
6421
  await refreshProjectFiles(root);
6422
+ const { after } = await applyLock(root);
6423
+ log(diffLock(lockBefore, after));
6424
+ log(summarizeLockDiff(lockBefore, after));
6180
6425
  log("Update applied with backup in .haus-workflow/backups/. Run haus doctor.");
6181
6426
  }
6182
6427
  async function runFromHookCheck(root) {
6183
6428
  try {
6184
6429
  const summary = await readLockSummary(root);
6185
6430
  if (summary.count === 0) return;
6186
- const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6431
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6187
6432
  const currentVersion = pkgJson?.version ?? "0.0.0";
6188
6433
  const [npmVersion, latestCatalogTag] = await Promise.all([
6189
6434
  fetchNpmVersionStatus(currentVersion),
@@ -6333,7 +6578,8 @@ async function runCiGate(options = {}) {
6333
6578
  // src/commands/clone.ts
6334
6579
  init_exec();
6335
6580
  import { existsSync as existsSync2 } from "node:fs";
6336
- import path42 from "node:path";
6581
+ import { readdir } from "node:fs/promises";
6582
+ import path43 from "node:path";
6337
6583
  var GIT_LOCATION_VARS = [
6338
6584
  "GIT_DIR",
6339
6585
  "GIT_WORK_TREE",
@@ -6362,6 +6608,16 @@ function normalizeGitUrl(url) {
6362
6608
  s = s.replace(/\.git$/i, "");
6363
6609
  return s.toLowerCase();
6364
6610
  }
6611
+ async function isEmptyDirectory(dir) {
6612
+ try {
6613
+ const entries = await readdir(dir);
6614
+ return entries.length === 0;
6615
+ } catch (err) {
6616
+ const code = err.code;
6617
+ if (code === "ENOTDIR" || code === "ENOENT") return false;
6618
+ throw err;
6619
+ }
6620
+ }
6365
6621
  async function checkExistingTarget(target, url) {
6366
6622
  const isRepo = await runGit(["-C", target, "rev-parse", "--is-inside-work-tree"], {
6367
6623
  env: cloneEnv(),
@@ -6383,11 +6639,11 @@ async function runClone(url, opts = {}) {
6383
6639
  process.exitCode = 1;
6384
6640
  return;
6385
6641
  }
6386
- const target = path42.resolve(opts.dir?.trim() || repoNameFromUrl(url));
6387
- if (existsSync2(target)) {
6642
+ const target = path43.resolve(opts.dir?.trim() || repoNameFromUrl(url));
6643
+ if (existsSync2(target) && !await isEmptyDirectory(target)) {
6388
6644
  const check = await checkExistingTarget(target, url);
6389
6645
  if (check.kind === "matches") {
6390
- log(`\u2022 ${path42.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
6646
+ log(`\u2022 ${path43.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
6391
6647
  return;
6392
6648
  }
6393
6649
  const message = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}) \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
@@ -6427,7 +6683,7 @@ function readHookPayload() {
6427
6683
  if (!raw) return { payload: {}, malformed: false };
6428
6684
  try {
6429
6685
  const parsed = JSON.parse(raw);
6430
- return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
6686
+ return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
6431
6687
  } catch {
6432
6688
  return { payload: {}, malformed: true };
6433
6689
  }
@@ -6466,7 +6722,7 @@ function readPayload(options) {
6466
6722
  if (options.stdinPayload != null) {
6467
6723
  try {
6468
6724
  const parsed = JSON.parse(options.stdinPayload);
6469
- return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
6725
+ return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
6470
6726
  } catch {
6471
6727
  return { payload: {}, malformed: true };
6472
6728
  }
@@ -6479,15 +6735,14 @@ async function runDecisionsGuard(options = {}) {
6479
6735
  emitPreToolUseDeny("Malformed hook payload");
6480
6736
  return;
6481
6737
  }
6482
- const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
6738
+ const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
6483
6739
  const command = String(toolInput.command ?? "");
6484
6740
  if (!PR_CREATE_PATTERN.test(command)) return;
6485
6741
  const root = process.cwd();
6486
6742
  const baseRef = await resolveBaseRef(root);
6487
6743
  if (!baseRef) return;
6488
6744
  const range = `${baseRef}...HEAD`;
6489
- const logRange = `${baseRef}..HEAD`;
6490
- const commitMessages = (await runGit(["log", "--format=%B", logRange], { cwd: root })).stdout;
6745
+ const commitMessages = (await runGit(["log", "--format=%B", toAncestryLogRange(range)], { cwd: root })).stdout;
6491
6746
  const result = await runDecisionsCheck(root, { range, commitMessages });
6492
6747
  if (result.triggered && !result.satisfied) {
6493
6748
  emitPreToolUseDeny(
@@ -6497,7 +6752,7 @@ async function runDecisionsGuard(options = {}) {
6497
6752
  }
6498
6753
 
6499
6754
  // src/commands/decisions.ts
6500
- import path43 from "node:path";
6755
+ import path44 from "node:path";
6501
6756
 
6502
6757
  // src/decisions/next-number.ts
6503
6758
  async function nextDecisionNumber(root) {
@@ -6572,7 +6827,7 @@ async function runDecisions(sub, arg, options = {}) {
6572
6827
  const root = process.cwd();
6573
6828
  if (sub === "check") {
6574
6829
  const commitMessages = options.range != null ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
6575
- (m) => m.runGit(["log", "--format=%B", options.range], { cwd: root })
6830
+ (m) => m.runGit(["log", "--format=%B", toAncestryLogRange(options.range)], { cwd: root })
6576
6831
  )).stdout : "";
6577
6832
  const result = await runDecisionsCheck(root, {
6578
6833
  staged: options.staged,
@@ -6607,7 +6862,7 @@ async function runDecisions(sub, arg, options = {}) {
6607
6862
  return;
6608
6863
  }
6609
6864
  if (sub === "validate") {
6610
- const target = arg ? path43.resolve(root, arg) : void 0;
6865
+ const target = arg ? path44.resolve(root, arg) : void 0;
6611
6866
  if (!target) {
6612
6867
  error("usage: haus decisions validate <path-to-decision.md>");
6613
6868
  process.exitCode = 1;
@@ -6859,7 +7114,7 @@ async function runGuard(kind, _options) {
6859
7114
  emitPreToolUseDeny("Malformed hook payload");
6860
7115
  return;
6861
7116
  }
6862
- const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
7117
+ const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
6863
7118
  if (kind === "file-access") {
6864
7119
  const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
6865
7120
  const reason2 = guardFileAccess(candidate);
@@ -6876,8 +7131,8 @@ async function runGuard(kind, _options) {
6876
7131
  }
6877
7132
 
6878
7133
  // src/commands/init.ts
6879
- import path45 from "node:path";
6880
- import fs32 from "fs-extra";
7134
+ import path46 from "node:path";
7135
+ import fs33 from "fs-extra";
6881
7136
 
6882
7137
  // src/recommender/git-signal.ts
6883
7138
  init_exec();
@@ -6900,55 +7155,6 @@ async function readChangedFiles(root) {
6900
7155
  return [.../* @__PURE__ */ new Set([...unstaged, ...staged, ...untracked])].sort();
6901
7156
  }
6902
7157
 
6903
- // src/recommender/policies.ts
6904
- var UNSUPPORTED = FORBIDDEN_TAGS;
6905
- function matchRequiresAny(clauses, ctx) {
6906
- for (const clause of clauses) {
6907
- if ("stack" in clause) {
6908
- if (ctx.stackSet.has(clause.stack.toLowerCase())) {
6909
- return { matched: true, signal: `stack:${clause.stack}` };
6910
- }
6911
- } else if ("dependency" in clause) {
6912
- if (ctx.depSet.has(clause.dependency.toLowerCase())) {
6913
- return { matched: true, signal: `dependency:${clause.dependency}` };
6914
- }
6915
- } else if ("packageNamePattern" in clause) {
6916
- const pattern = clause.packageNamePattern.toLowerCase();
6917
- const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
6918
- for (const dep2 of ctx.depSet) {
6919
- if (pattern.endsWith("*") ? dep2.startsWith(prefix) : dep2 === pattern) {
6920
- return {
6921
- matched: true,
6922
- signal: `packageNamePattern:${clause.packageNamePattern}`
6923
- };
6924
- }
6925
- }
6926
- } else if ("role" in clause) {
6927
- if (ctx.roleSet.has(clause.role.toLowerCase())) {
6928
- return { matched: true, signal: `role:${clause.role}` };
6929
- }
6930
- }
6931
- }
6932
- return { matched: false };
6933
- }
6934
- function describeRequiresAny(clauses) {
6935
- return clauses.map((c) => {
6936
- if ("stack" in c) return `stack=${c.stack}`;
6937
- if ("dependency" in c) return `dependency=${c.dependency}`;
6938
- if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
6939
- if ("role" in c) return `role=${c.role}`;
6940
- return "unknown";
6941
- }).join(" | ");
6942
- }
6943
- function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
6944
- const markers = context.unsupportedSignals?.join(", ");
6945
- const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
6946
- `Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
6947
- ] : [];
6948
- const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
6949
- return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
6950
- }
6951
-
6952
7158
  // src/recommender/recommend.ts
6953
7159
  async function recommend(root, context, opts = {}) {
6954
7160
  const items = await loadCatalog(root);
@@ -7265,6 +7471,7 @@ function applyCoInstallSuppression(recommended, skipped) {
7265
7471
  }
7266
7472
  var HARD_SKIP_CODES = /* @__PURE__ */ new Set([
7267
7473
  "former-id",
7474
+ "invalid-source",
7268
7475
  "unsupported-policy",
7269
7476
  "deprecated",
7270
7477
  "curated-not-approved",
@@ -7287,8 +7494,10 @@ function applyManualIncludes(include, items, recommended, skipped) {
7287
7494
  if (recommendedIds.has(id)) continue;
7288
7495
  const skipIdx = skipped.findIndex((s) => s.id === id);
7289
7496
  const skipCode = skipIdx >= 0 ? skipped[skipIdx].skipReasons[0]?.code : void 0;
7290
- if (skipCode && HARD_SKIP_CODES.has(skipCode)) {
7291
- warnings.push(`--include: "${id}" cannot be force-installed (blocked by ${skipCode})`);
7497
+ const failingGateNames = skipIdx >= 0 ? (skipped[skipIdx].gates ?? []).filter((g) => !g.passed).map((g) => g.name) : [];
7498
+ const hardBlock = failingGateNames.find((name) => HARD_SKIP_CODES.has(name));
7499
+ if (hardBlock) {
7500
+ warnings.push(`--include: "${id}" cannot be force-installed (blocked by ${hardBlock})`);
7292
7501
  continue;
7293
7502
  }
7294
7503
  if (skipCode === "requires-any-unsatisfied") {
@@ -7344,8 +7553,8 @@ function buildOptInEligible(items, skipped) {
7344
7553
  }
7345
7554
 
7346
7555
  // src/commands/workspace/discover.ts
7347
- import path44 from "node:path";
7348
- import fg4 from "fast-glob";
7556
+ import path45 from "node:path";
7557
+ import fg5 from "fast-glob";
7349
7558
  import YAML2 from "yaml";
7350
7559
  var DEFAULT_MAX_DEPTH = 3;
7351
7560
  var GIT_MARKER = ["**/.git"];
@@ -7379,16 +7588,16 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7379
7588
  suppressErrors: true
7380
7589
  };
7381
7590
  const [gitMatches, manifestMatches] = await Promise.all([
7382
- fg4(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
7383
- fg4(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
7591
+ fg5(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
7592
+ fg5(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
7384
7593
  ]);
7385
7594
  const gitDirs = /* @__PURE__ */ new Set();
7386
7595
  const manifestDirs = /* @__PURE__ */ new Set();
7387
7596
  for (const match of gitMatches) {
7388
- gitDirs.add(path44.posix.dirname(match));
7597
+ gitDirs.add(path45.posix.dirname(match));
7389
7598
  }
7390
7599
  for (const match of manifestMatches) {
7391
- manifestDirs.add(path44.posix.dirname(match));
7600
+ manifestDirs.add(path45.posix.dirname(match));
7392
7601
  }
7393
7602
  const repoRoots = [...gitDirs];
7394
7603
  const manifestSorted = [...manifestDirs].sort(
@@ -7405,9 +7614,9 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7405
7614
  async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7406
7615
  const repoRoots = (await findRepoRoots(workspaceRoot, maxDepth)).filter((dir) => dir !== ".");
7407
7616
  return mapWithConcurrency(repoRoots, async (relDir) => {
7408
- const absDir = path44.resolve(workspaceRoot, relDir);
7409
- const pkg = await readJson(path44.join(absDir, "package.json"));
7410
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path44.basename(absDir);
7617
+ const absDir = path45.resolve(workspaceRoot, relDir);
7618
+ const pkg = await readJson(path45.join(absDir, "package.json"));
7619
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path45.basename(absDir);
7411
7620
  let role = "auto";
7412
7621
  try {
7413
7622
  const scan = await scanProject(absDir);
@@ -7450,7 +7659,7 @@ function renderWorkspaceYaml(config) {
7450
7659
  });
7451
7660
  }
7452
7661
  async function runDiscover(workspaceRoot, opts = {}) {
7453
- const yamlPath = path44.join(workspaceRoot, "haus.workspace.yaml");
7662
+ const yamlPath = path45.join(workspaceRoot, "haus.workspace.yaml");
7454
7663
  const existingText = await readText(yamlPath);
7455
7664
  const existing = parseWorkspaceConfig(existingText);
7456
7665
  if (existingText && !existing) {
@@ -7628,8 +7837,8 @@ async function runSetupProject(options) {
7628
7837
  // src/commands/init.ts
7629
7838
  async function runInit(options) {
7630
7839
  const root = process.cwd();
7631
- const hausDir = path45.join(root, ".haus-workflow");
7632
- const alreadyInit = await fs32.pathExists(hausDir);
7840
+ const hausDir = path46.join(root, ".haus-workflow");
7841
+ const alreadyInit = await fs33.pathExists(hausDir);
7633
7842
  if (alreadyInit) {
7634
7843
  log("Haus AI already initialized in this project.");
7635
7844
  log("Run `haus setup-project` to reconfigure.");
@@ -7764,8 +7973,8 @@ async function runRefresh() {
7764
7973
  }
7765
7974
 
7766
7975
  // src/install/scaffold.ts
7767
- import path46 from "node:path";
7768
- import fs33 from "fs-extra";
7976
+ import path47 from "node:path";
7977
+ import fs34 from "fs-extra";
7769
7978
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7770
7979
  const scaffolded = [];
7771
7980
  const skipped = [];
@@ -7778,27 +7987,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7778
7987
  skipped.push(name);
7779
7988
  if (outcome === "skipped-exists") skippedExisting.push(name);
7780
7989
  };
7781
- const realRoot = await fs33.realpath(catalogRoot).catch(() => null);
7990
+ const realRoot = await fs34.realpath(catalogRoot).catch(() => null);
7782
7991
  for (const item of items) {
7783
7992
  if (item.type !== "config") continue;
7784
- const sourcePath = path46.resolve(catalogRoot, item.path);
7785
- const rel = path46.relative(catalogRoot, sourcePath);
7786
- const escapes = rel === "" || path46.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
7993
+ const sourcePath = path47.resolve(catalogRoot, item.path);
7994
+ const rel = path47.relative(catalogRoot, sourcePath);
7995
+ const escapes = rel === "" || path47.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
7787
7996
  if (escapes) {
7788
7997
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
7789
7998
  continue;
7790
7999
  }
7791
- const realSource = await fs33.realpath(sourcePath).catch(() => null);
8000
+ const realSource = await fs34.realpath(sourcePath).catch(() => null);
7792
8001
  if (!realRoot || !realSource) {
7793
8002
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
7794
8003
  continue;
7795
8004
  }
7796
- const realRel = path46.relative(realRoot, realSource);
7797
- if (realRel !== "" && (path46.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
8005
+ const realRel = path47.relative(realRoot, realSource);
8006
+ if (realRel !== "" && (path47.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
7798
8007
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
7799
8008
  continue;
7800
8009
  }
7801
- const stat = await fs33.lstat(sourcePath).catch(() => null);
8010
+ const stat = await fs34.lstat(sourcePath).catch(() => null);
7802
8011
  if (!stat) {
7803
8012
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
7804
8013
  continue;
@@ -7808,14 +8017,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7808
8017
  continue;
7809
8018
  }
7810
8019
  if (stat.isFile()) {
7811
- const filename = path46.basename(sourcePath);
7812
- const dest = path46.join(projectRoot, filename);
8020
+ const filename = path47.basename(sourcePath);
8021
+ const dest = path47.join(projectRoot, filename);
7813
8022
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
7814
8023
  } else if (stat.isDirectory()) {
7815
- const entries = await fs33.readdir(sourcePath);
8024
+ const entries = await fs34.readdir(sourcePath);
7816
8025
  for (const entry of entries) {
7817
- const src = path46.join(sourcePath, entry);
7818
- const dest = path46.join(projectRoot, entry);
8026
+ const src = path47.join(sourcePath, entry);
8027
+ const dest = path47.join(projectRoot, entry);
7819
8028
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
7820
8029
  }
7821
8030
  }
@@ -7823,42 +8032,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7823
8032
  return { scaffolded, skipped, skippedExisting };
7824
8033
  }
7825
8034
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
7826
- const destDisplay = path46.relative(projectRoot, dest) || path46.basename(dest);
7827
- const srcStat = await fs33.lstat(src).catch(() => null);
8035
+ const destDisplay = path47.relative(projectRoot, dest) || path47.basename(dest);
8036
+ const srcStat = await fs34.lstat(src).catch(() => null);
7828
8037
  if (!srcStat) {
7829
- warn(`Skipping ${path46.basename(src)}: source not found`);
8038
+ warn(`Skipping ${path47.basename(src)}: source not found`);
7830
8039
  return "skipped-refused";
7831
8040
  }
7832
8041
  if (srcStat.isSymbolicLink()) {
7833
- warn(`Skipping ${path46.basename(src)}: source is a symlink`);
8042
+ warn(`Skipping ${path47.basename(src)}: source is a symlink`);
7834
8043
  return "skipped-refused";
7835
8044
  }
7836
- const exists = await fs33.pathExists(dest);
8045
+ const exists = await fs34.pathExists(dest);
7837
8046
  if (exists && !opts.force) {
7838
8047
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
7839
8048
  return "skipped-exists";
7840
8049
  }
7841
8050
  if (opts.dryRun) {
7842
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path46.basename(dest)} (${itemId})`);
8051
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path47.basename(dest)} (${itemId})`);
7843
8052
  return "scaffolded";
7844
8053
  }
7845
8054
  if (exists && opts.force) {
7846
- await fs33.remove(dest);
8055
+ await fs34.remove(dest);
7847
8056
  }
7848
- await fs33.ensureDir(path46.dirname(dest));
7849
- await fs33.copy(src, dest, {
8057
+ await fs34.ensureDir(path47.dirname(dest));
8058
+ await fs34.copy(src, dest, {
7850
8059
  overwrite: true,
7851
8060
  dereference: false,
7852
8061
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
7853
8062
  filter: (s) => {
7854
8063
  try {
7855
- return !fs33.lstatSync(s).isSymbolicLink();
8064
+ return !fs34.lstatSync(s).isSymbolicLink();
7856
8065
  } catch {
7857
8066
  return false;
7858
8067
  }
7859
8068
  }
7860
8069
  });
7861
- log(`${exists ? "Overwrote" : "Created"} ${path46.basename(dest)} (${itemId})`);
8070
+ log(`${exists ? "Overwrote" : "Created"} ${path47.basename(dest)} (${itemId})`);
7862
8071
  return "scaffolded";
7863
8072
  }
7864
8073
 
@@ -7923,8 +8132,8 @@ async function runScan(options) {
7923
8132
  }
7924
8133
 
7925
8134
  // src/commands/undo.ts
7926
- import path47 from "node:path";
7927
- import fs34 from "fs-extra";
8135
+ import path48 from "node:path";
8136
+ import fs35 from "fs-extra";
7928
8137
 
7929
8138
  // src/claude/managed-paths.ts
7930
8139
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
@@ -7939,7 +8148,7 @@ function coreManagedAbsolutePaths(root) {
7939
8148
  async function collectCoreManagedPaths(root) {
7940
8149
  const existing = [];
7941
8150
  for (const abs of coreManagedAbsolutePaths(root)) {
7942
- if (await fs34.pathExists(abs)) existing.push(abs);
8151
+ if (await fs35.pathExists(abs)) existing.push(abs);
7943
8152
  }
7944
8153
  return existing;
7945
8154
  }
@@ -7951,18 +8160,18 @@ async function collectLockTrackedPaths(root) {
7951
8160
  const relPaths = row.paths ?? [];
7952
8161
  const existingRel = [];
7953
8162
  for (const rel of relPaths) {
7954
- if (await fs34.pathExists(path47.resolve(root, rel))) existingRel.push(rel);
8163
+ if (await fs35.pathExists(path48.resolve(root, rel))) existingRel.push(rel);
7955
8164
  }
7956
8165
  if (existingRel.length === 0) continue;
7957
8166
  if (row.hash === void 0) {
7958
- removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
8167
+ removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
7959
8168
  continue;
7960
8169
  }
7961
8170
  const currentHash = await hashInstalledPaths(root, existingRel);
7962
8171
  if (currentHash === row.hash) {
7963
- removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
8172
+ removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
7964
8173
  } else {
7965
- preserved.push(...existingRel.map((rel) => path47.resolve(root, rel)));
8174
+ preserved.push(...existingRel.map((rel) => path48.resolve(root, rel)));
7966
8175
  log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
7967
8176
  }
7968
8177
  }
@@ -7970,51 +8179,51 @@ async function collectLockTrackedPaths(root) {
7970
8179
  }
7971
8180
  async function settingsHasHausContent(root) {
7972
8181
  const settingsPath = claudePath(root, "settings.json");
7973
- if (!await fs34.pathExists(settingsPath)) return false;
8182
+ if (!await fs35.pathExists(settingsPath)) return false;
7974
8183
  const settings = await readProjectSettings(root);
7975
8184
  return settings._haus != null;
7976
8185
  }
7977
8186
  async function claudeMdHasHausBlock(root) {
7978
- const filePath = path47.join(root, "CLAUDE.md");
7979
- if (!await fs34.pathExists(filePath)) return false;
7980
- const text = await fs34.readFile(filePath, "utf8");
8187
+ const filePath = path48.join(root, "CLAUDE.md");
8188
+ if (!await fs35.pathExists(filePath)) return false;
8189
+ const text = await fs35.readFile(filePath, "utf8");
7981
8190
  return text.includes(BLOCK_BEGIN);
7982
8191
  }
7983
8192
  async function stripProjectSettings(root) {
7984
8193
  const settingsPath = claudePath(root, "settings.json");
7985
- if (!await fs34.pathExists(settingsPath)) return false;
8194
+ if (!await fs35.pathExists(settingsPath)) return false;
7986
8195
  let settings = await readProjectSettings(root);
7987
8196
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
7988
8197
  const hasContent = Object.keys(settings).length > 0;
7989
8198
  if (hasContent) {
7990
8199
  await writeProjectSettings(root, settings);
7991
- log(`Stripped haus rules from ${path47.relative(root, settingsPath)} (user settings preserved).`);
8200
+ log(`Stripped haus rules from ${path48.relative(root, settingsPath)} (user settings preserved).`);
7992
8201
  return true;
7993
8202
  }
7994
- await fs34.remove(settingsPath);
7995
- log(`Removed ${path47.relative(root, settingsPath)} (no user-owned settings remained).`);
8203
+ await fs35.remove(settingsPath);
8204
+ log(`Removed ${path48.relative(root, settingsPath)} (no user-owned settings remained).`);
7996
8205
  return true;
7997
8206
  }
7998
8207
  async function stripRootClaudeMd(root) {
7999
- const filePath = path47.join(root, "CLAUDE.md");
8000
- if (!await fs34.pathExists(filePath)) return false;
8001
- const prev = await fs34.readFile(filePath, "utf8");
8208
+ const filePath = path48.join(root, "CLAUDE.md");
8209
+ if (!await fs35.pathExists(filePath)) return false;
8210
+ const prev = await fs35.readFile(filePath, "utf8");
8002
8211
  if (!prev.includes(BLOCK_BEGIN)) return false;
8003
8212
  const next = stripHausBlock(prev);
8004
8213
  if (next.length === 0) {
8005
- await fs34.remove(filePath);
8214
+ await fs35.remove(filePath);
8006
8215
  log("Removed CLAUDE.md (only contained haus import block).");
8007
8216
  } else {
8008
- await fs34.writeFile(filePath, next, "utf8");
8217
+ await fs35.writeFile(filePath, next, "utf8");
8009
8218
  log("Removed haus import block from CLAUDE.md (user content preserved).");
8010
8219
  }
8011
8220
  return true;
8012
8221
  }
8013
8222
  async function pruneDirIfEmpty(dir) {
8014
- if (!await fs34.pathExists(dir)) return;
8223
+ if (!await fs35.pathExists(dir)) return;
8015
8224
  try {
8016
- const entries = await fs34.readdir(dir);
8017
- if (entries.length === 0) await fs34.remove(dir);
8225
+ const entries = await fs35.readdir(dir);
8226
+ if (entries.length === 0) await fs35.remove(dir);
8018
8227
  } catch {
8019
8228
  }
8020
8229
  }
@@ -8023,13 +8232,13 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
8023
8232
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
8024
8233
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
8025
8234
  for (const abs of managedAbsPaths) {
8026
- if (!await fs34.pathExists(abs)) continue;
8027
- const rel = path47.relative(root, abs);
8028
- const backupPath = path47.join(backupRoot, rel);
8029
- await fs34.ensureDir(path47.dirname(backupPath));
8030
- await fs34.copy(abs, backupPath);
8235
+ if (!await fs35.pathExists(abs)) continue;
8236
+ const rel = path48.relative(root, abs);
8237
+ const backupPath = path48.join(backupRoot, rel);
8238
+ await fs35.ensureDir(path48.dirname(backupPath));
8239
+ await fs35.copy(abs, backupPath);
8031
8240
  }
8032
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path47.relative(root, backupRoot)}.`);
8241
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path48.relative(root, backupRoot)}.`);
8033
8242
  }
8034
8243
  async function runUndo(options) {
8035
8244
  const root = options.root ?? process.cwd();
@@ -8042,7 +8251,7 @@ async function runUndo(options) {
8042
8251
  log("Nothing to remove: no haus-managed files found in this directory.");
8043
8252
  return;
8044
8253
  }
8045
- const relTargets = managed.map((p) => path47.relative(root, p));
8254
+ const relTargets = managed.map((p) => path48.relative(root, p));
8046
8255
  const summaryParts = [...relTargets];
8047
8256
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
8048
8257
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -8064,9 +8273,9 @@ User-owned .claude/ files will be preserved.`
8064
8273
  }
8065
8274
  await backupManagedFilesBeforeUndo(root, managed);
8066
8275
  for (const abs of managed) {
8067
- if (!await fs34.pathExists(abs)) continue;
8068
- await fs34.remove(abs);
8069
- log(`Removed ${path47.relative(root, abs)}`);
8276
+ if (!await fs35.pathExists(abs)) continue;
8277
+ await fs35.remove(abs);
8278
+ log(`Removed ${path48.relative(root, abs)}`);
8070
8279
  }
8071
8280
  if (stripSettings) await stripProjectSettings(root);
8072
8281
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -8077,8 +8286,8 @@ User-owned .claude/ files will be preserved.`
8077
8286
 
8078
8287
  // src/install/uninstall.ts
8079
8288
  import crypto3 from "node:crypto";
8080
- import path48 from "node:path";
8081
- import fs35 from "fs-extra";
8289
+ import path49 from "node:path";
8290
+ import fs36 from "fs-extra";
8082
8291
  async function runUninstall(options = {}) {
8083
8292
  const { force = false } = options;
8084
8293
  const manifest = await readManifest();
@@ -8088,7 +8297,7 @@ async function runUninstall(options = {}) {
8088
8297
  return result;
8089
8298
  }
8090
8299
  for (const entry of manifest.files) {
8091
- const exists = fs35.pathExistsSync(entry.destPath);
8300
+ const exists = fs36.pathExistsSync(entry.destPath);
8092
8301
  if (!exists) continue;
8093
8302
  const content2 = await readText(entry.destPath);
8094
8303
  if (content2 === void 0) continue;
@@ -8106,23 +8315,23 @@ async function runUninstall(options = {}) {
8106
8315
  result.skipped.push(entry.destPath);
8107
8316
  continue;
8108
8317
  }
8109
- await fs35.remove(entry.destPath);
8110
- await pruneEmptyDir(path48.dirname(entry.destPath));
8318
+ await fs36.remove(entry.destPath);
8319
+ await pruneEmptyDir(path49.dirname(entry.destPath));
8111
8320
  result.deleted.push(entry.destPath);
8112
8321
  }
8113
8322
  const settings = await readSettings();
8114
8323
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
8115
8324
  await writeSettings(stripped);
8116
8325
  result.hooksStripped = true;
8117
- const hausDir = path48.join(globalClaudeDir(), "haus");
8326
+ const hausDir = path49.join(globalClaudeDir(), "haus");
8118
8327
  const manifestPath2 = hausManifestPath();
8119
- if (fs35.pathExistsSync(manifestPath2)) {
8120
- await fs35.remove(manifestPath2);
8328
+ if (fs36.pathExistsSync(manifestPath2)) {
8329
+ await fs36.remove(manifestPath2);
8121
8330
  }
8122
- if (fs35.pathExistsSync(hausDir)) {
8331
+ if (fs36.pathExistsSync(hausDir)) {
8123
8332
  try {
8124
- const remaining = await fs35.readdir(hausDir);
8125
- if (remaining.length === 0) await fs35.remove(hausDir);
8333
+ const remaining = await fs36.readdir(hausDir);
8334
+ if (remaining.length === 0) await fs36.remove(hausDir);
8126
8335
  } catch {
8127
8336
  }
8128
8337
  }
@@ -8145,6 +8354,19 @@ function printUninstallResult(result) {
8145
8354
  // src/commands/uninstall.ts
8146
8355
  async function runUninstallCommand(options) {
8147
8356
  try {
8357
+ if (!options.yes) {
8358
+ const manifest = await readManifest();
8359
+ if (manifest) {
8360
+ const fileCount = manifest.files.length;
8361
+ const ok = await confirm(
8362
+ fileCount > 0 ? `Remove ${fileCount} haus-managed file(s) from ~/.claude/ and strip haus hook entries from settings.json?` : "Strip haus hook entries from ~/.claude/settings.json and remove the install manifest?"
8363
+ );
8364
+ if (!ok) {
8365
+ log("Cancelled.");
8366
+ return;
8367
+ }
8368
+ }
8369
+ }
8148
8370
  const result = await runUninstall({ force: options.force });
8149
8371
  printUninstallResult(result);
8150
8372
  log("haus uninstall complete");
@@ -8155,11 +8377,11 @@ async function runUninstallCommand(options) {
8155
8377
  }
8156
8378
 
8157
8379
  // src/commands/validate-catalog.ts
8158
- import path50 from "node:path";
8380
+ import path51 from "node:path";
8159
8381
 
8160
8382
  // src/catalog/validate-core.ts
8161
- import fs36 from "node:fs";
8162
- import path49 from "node:path";
8383
+ import fs37 from "node:fs";
8384
+ import path50 from "node:path";
8163
8385
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
8164
8386
  var ITEM_ID_PATTERN = /^haus\./;
8165
8387
  function auditForbiddenStacks(items) {
@@ -8301,14 +8523,14 @@ function checkRequiredFrontmatter(text, label) {
8301
8523
  }
8302
8524
  function readForAudit(absPath, label, failures) {
8303
8525
  try {
8304
- return fs36.readFileSync(absPath, "utf8");
8526
+ return fs37.readFileSync(absPath, "utf8");
8305
8527
  } catch (err) {
8306
8528
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
8307
8529
  return null;
8308
8530
  }
8309
8531
  }
8310
8532
  function auditTemplateContent(manifestDir, absPath, itemId) {
8311
- const rel = path49.relative(manifestDir, absPath);
8533
+ const rel = path50.relative(manifestDir, absPath);
8312
8534
  const failures = [];
8313
8535
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
8314
8536
  if (text === null) return failures;
@@ -8330,47 +8552,47 @@ function auditShippedFiles(manifestDir, items) {
8330
8552
  const failures = [];
8331
8553
  for (const item of items) {
8332
8554
  if (!item.path || !isSafeCatalogPath(item.path)) continue;
8333
- const absPath = path49.join(manifestDir, item.path);
8555
+ const absPath = path50.join(manifestDir, item.path);
8334
8556
  if (item.type === "skill") {
8335
- const skillMd = path49.join(absPath, "SKILL.md");
8336
- if (!fs36.existsSync(skillMd)) {
8337
- failures.push(`${item.id}: missing ${path49.relative(manifestDir, skillMd)}`);
8557
+ const skillMd = path50.join(absPath, "SKILL.md");
8558
+ if (!fs37.existsSync(skillMd)) {
8559
+ failures.push(`${item.id}: missing ${path50.relative(manifestDir, skillMd)}`);
8338
8560
  continue;
8339
8561
  }
8340
8562
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
8341
8563
  if (text === null) continue;
8342
8564
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
8343
8565
  failures.push(
8344
- ...auditForbiddenTagsInText(text, `${item.id}: ${path49.relative(manifestDir, skillMd)}`)
8566
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path50.relative(manifestDir, skillMd)}`)
8345
8567
  );
8346
8568
  } else if (item.type === "agent") {
8347
- if (!fs36.existsSync(absPath)) {
8569
+ if (!fs37.existsSync(absPath)) {
8348
8570
  failures.push(`${item.id}: missing agent file ${item.path}`);
8349
8571
  continue;
8350
8572
  }
8351
- const rel = path49.relative(manifestDir, absPath);
8573
+ const rel = path50.relative(manifestDir, absPath);
8352
8574
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
8353
8575
  if (text === null) continue;
8354
8576
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
8355
8577
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
8356
8578
  } else if (item.type === "template") {
8357
- if (!fs36.existsSync(absPath)) {
8579
+ if (!fs37.existsSync(absPath)) {
8358
8580
  failures.push(`${item.id}: missing template file ${item.path}`);
8359
8581
  continue;
8360
8582
  }
8361
8583
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
8362
8584
  } else if (item.type === "command") {
8363
- if (!fs36.existsSync(absPath)) {
8585
+ if (!fs37.existsSync(absPath)) {
8364
8586
  failures.push(`${item.id}: missing command file ${item.path}`);
8365
8587
  continue;
8366
8588
  }
8367
- const rel = path49.relative(manifestDir, absPath);
8589
+ const rel = path50.relative(manifestDir, absPath);
8368
8590
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
8369
8591
  if (text === null) continue;
8370
8592
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
8371
8593
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
8372
8594
  } else if (item.type === "config") {
8373
- if (!fs36.existsSync(absPath)) {
8595
+ if (!fs37.existsSync(absPath)) {
8374
8596
  failures.push(`${item.id}: missing config path ${item.path}`);
8375
8597
  }
8376
8598
  }
@@ -8387,7 +8609,7 @@ var CONTENT_ROOTS = ["skills", "agents", "templates", "commands", "configs"];
8387
8609
  function walkAllFiles(dir, fn, failures) {
8388
8610
  let entries;
8389
8611
  try {
8390
- entries = fs36.readdirSync(dir, { withFileTypes: true });
8612
+ entries = fs37.readdirSync(dir, { withFileTypes: true });
8391
8613
  } catch (err) {
8392
8614
  failures.push(
8393
8615
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -8396,7 +8618,7 @@ function walkAllFiles(dir, fn, failures) {
8396
8618
  }
8397
8619
  for (const entry of entries) {
8398
8620
  if (entry.name === ".DS_Store") continue;
8399
- const full = path49.join(dir, entry.name);
8621
+ const full = path50.join(dir, entry.name);
8400
8622
  if (entry.isDirectory()) walkAllFiles(full, fn, failures);
8401
8623
  else fn(full);
8402
8624
  }
@@ -8414,12 +8636,12 @@ function auditDiskOrphans(manifestDir, items) {
8414
8636
  const failures = [];
8415
8637
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
8416
8638
  for (const dir of CONTENT_ROOTS) {
8417
- const abs = path49.join(manifestDir, dir);
8418
- if (!fs36.existsSync(abs)) continue;
8639
+ const abs = path50.join(manifestDir, dir);
8640
+ if (!fs37.existsSync(abs)) continue;
8419
8641
  walkAllFiles(
8420
8642
  abs,
8421
8643
  (file) => {
8422
- const relPath = path49.relative(manifestDir, file).replace(/\\/g, "/");
8644
+ const relPath = path50.relative(manifestDir, file).replace(/\\/g, "/");
8423
8645
  if (!isPathClaimed(relPath, claimedPaths)) {
8424
8646
  failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
8425
8647
  }
@@ -8432,7 +8654,7 @@ function auditDiskOrphans(manifestDir, items) {
8432
8654
  function walkMd(dir, fn, failures) {
8433
8655
  let entries;
8434
8656
  try {
8435
- entries = fs36.readdirSync(dir, { withFileTypes: true });
8657
+ entries = fs37.readdirSync(dir, { withFileTypes: true });
8436
8658
  } catch (err) {
8437
8659
  failures.push(
8438
8660
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -8440,7 +8662,7 @@ function walkMd(dir, fn, failures) {
8440
8662
  return;
8441
8663
  }
8442
8664
  for (const entry of entries) {
8443
- const full = path49.join(dir, entry.name);
8665
+ const full = path50.join(dir, entry.name);
8444
8666
  if (entry.isDirectory()) walkMd(full, fn, failures);
8445
8667
  else if (entry.name.endsWith(".md")) fn(full);
8446
8668
  }
@@ -8449,13 +8671,13 @@ function auditMarkdownContent(manifestDir, items) {
8449
8671
  const failures = [];
8450
8672
  const pathSourceMap = buildItemPathSourceMap(items);
8451
8673
  for (const dir of ["skills", "agents", "templates", "commands"]) {
8452
- const abs = path49.join(manifestDir, dir);
8453
- if (!fs36.existsSync(abs)) continue;
8674
+ const abs = path50.join(manifestDir, dir);
8675
+ if (!fs37.existsSync(abs)) continue;
8454
8676
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
8455
8677
  walkMd(
8456
8678
  abs,
8457
8679
  (file) => {
8458
- const rel = path49.relative(manifestDir, file);
8680
+ const rel = path50.relative(manifestDir, file);
8459
8681
  const text = readForAudit(file, rel, failures);
8460
8682
  if (text === null) return;
8461
8683
  const norm = rel.replace(/\\/g, "/");
@@ -8500,8 +8722,8 @@ async function runValidateCatalog(manifestPath2) {
8500
8722
  process.exitCode = 1;
8501
8723
  return;
8502
8724
  }
8503
- const abs = path50.resolve(process.cwd(), manifestPath2);
8504
- const manifestDir = path50.dirname(abs);
8725
+ const abs = path51.resolve(process.cwd(), manifestPath2);
8726
+ const manifestDir = path51.dirname(abs);
8505
8727
  const data = await readJson(abs);
8506
8728
  if (!data?.items) {
8507
8729
  error(`Could not read catalog manifest at ${abs}`);
@@ -8518,8 +8740,8 @@ async function runValidateCatalog(manifestPath2) {
8518
8740
  }
8519
8741
 
8520
8742
  // src/workspace/worktree/add.ts
8521
- import path57 from "node:path";
8522
- import fs40 from "fs-extra";
8743
+ import path58 from "node:path";
8744
+ import fs41 from "fs-extra";
8523
8745
 
8524
8746
  // src/workspace/worktree/git-worktree.ts
8525
8747
  init_exec();
@@ -8638,13 +8860,13 @@ async function hasUnpushedWork(cwd) {
8638
8860
  }
8639
8861
 
8640
8862
  // src/workspace/worktree/hydrate.ts
8641
- import path53 from "node:path";
8642
- import fs38 from "fs-extra";
8863
+ import path54 from "node:path";
8864
+ import fs39 from "fs-extra";
8643
8865
 
8644
8866
  // src/workspace/worktree/cow-copy.ts
8645
8867
  init_exec();
8646
- import path51 from "node:path";
8647
- import fs37 from "fs-extra";
8868
+ import path52 from "node:path";
8869
+ import fs38 from "fs-extra";
8648
8870
  async function getMacFilesystemType(dir) {
8649
8871
  const df = await runCommand("df", ["-P", dir]);
8650
8872
  if (df.exitCode !== 0) return void 0;
@@ -8675,14 +8897,14 @@ async function detectCowStrategy(dir) {
8675
8897
  return "unknown-platform";
8676
8898
  }
8677
8899
  async function cowCopyDir(src, dest) {
8678
- const srcExists = await fs37.pathExists(src);
8900
+ const srcExists = await fs38.pathExists(src);
8679
8901
  if (!srcExists)
8680
8902
  return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
8681
- const strategy = await detectCowStrategy(path51.dirname(dest));
8903
+ const strategy = await detectCowStrategy(path52.dirname(dest));
8682
8904
  if (strategy === "unsupported" || strategy === "unknown-platform") {
8683
8905
  return { strategy, attempted: false, ok: false };
8684
8906
  }
8685
- await fs37.ensureDir(path51.dirname(dest));
8907
+ await fs38.ensureDir(path52.dirname(dest));
8686
8908
  const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
8687
8909
  const result = await runCommand("cp", args);
8688
8910
  return {
@@ -8696,8 +8918,8 @@ async function cowCopyDir(src, dest) {
8696
8918
  // src/workspace/worktree/install.ts
8697
8919
  init_exec();
8698
8920
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
8699
- import path52 from "node:path";
8700
- import fg5 from "fast-glob";
8921
+ import path53 from "node:path";
8922
+ import fg6 from "fast-glob";
8701
8923
  function detectInstallPlan(signals) {
8702
8924
  if (signals.hasYarnLock) {
8703
8925
  return signals.hasPackageManagerField ? { manager: "yarn", command: "corepack", args: ["yarn", "install"] } : { manager: "yarn", command: "yarn", args: ["install"] };
@@ -8717,11 +8939,11 @@ function detectInstallPlan(signals) {
8717
8939
  return { manager: "none" };
8718
8940
  }
8719
8941
  async function computeLockfileSignals(dir) {
8720
- const hasYarnLock = existsSync3(path52.join(dir, "yarn.lock"));
8721
- const hasPnpmLock = existsSync3(path52.join(dir, "pnpm-lock.yaml"));
8722
- const hasPackageLock = existsSync3(path52.join(dir, "package-lock.json"));
8723
- const hasComposerLock = existsSync3(path52.join(dir, "composer.lock"));
8724
- const dotnetFiles = await fg5(["**/*.csproj", "**/*.sln"], {
8942
+ const hasYarnLock = existsSync3(path53.join(dir, "yarn.lock"));
8943
+ const hasPnpmLock = existsSync3(path53.join(dir, "pnpm-lock.yaml"));
8944
+ const hasPackageLock = existsSync3(path53.join(dir, "package-lock.json"));
8945
+ const hasComposerLock = existsSync3(path53.join(dir, "composer.lock"));
8946
+ const dotnetFiles = await fg6(["**/*.csproj", "**/*.sln"], {
8725
8947
  cwd: dir,
8726
8948
  onlyFiles: true,
8727
8949
  suppressErrors: true,
@@ -8734,7 +8956,7 @@ async function computeLockfileSignals(dir) {
8734
8956
  });
8735
8957
  const hasDotnetProject = dotnetFiles.length > 0;
8736
8958
  let hasPackageManagerField = false;
8737
- const pkgPath = path52.join(dir, "package.json");
8959
+ const pkgPath = path53.join(dir, "package.json");
8738
8960
  if (existsSync3(pkgPath)) {
8739
8961
  try {
8740
8962
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
@@ -8775,13 +8997,13 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
8775
8997
  const start = Date.now();
8776
8998
  const targets = [];
8777
8999
  for (const target of HYDRATION_TARGETS) {
8778
- const src = path53.join(mainMemberPath, target);
8779
- const dest = path53.join(worktreeMemberPath, target);
8780
- if (!await fs38.pathExists(src)) continue;
8781
- const destExists = await fs38.pathExists(dest);
9000
+ const src = path54.join(mainMemberPath, target);
9001
+ const dest = path54.join(worktreeMemberPath, target);
9002
+ if (!await fs39.pathExists(src)) continue;
9003
+ const destExists = await fs39.pathExists(dest);
8782
9004
  if (!opts.force && destExists) continue;
8783
9005
  if (opts.force && destExists) {
8784
- await fs38.remove(dest);
9006
+ await fs39.remove(dest);
8785
9007
  }
8786
9008
  const copy = await cowCopyDir(src, dest);
8787
9009
  targets.push({ target, copy });
@@ -8794,11 +9016,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
8794
9016
 
8795
9017
  // src/workspace/worktree/local-files.ts
8796
9018
  init_exec();
8797
- import path54 from "node:path";
8798
- import fs39 from "fs-extra";
9019
+ import path55 from "node:path";
9020
+ import fs40 from "fs-extra";
8799
9021
  var MACHINE_LOCAL_FILES = [
8800
9022
  "docker-compose.local.yml",
8801
- path54.join(".claude", "settings.local.json")
9023
+ path55.join(".claude", "settings.local.json")
8802
9024
  ];
8803
9025
  async function isTracked(repoRoot, relFile) {
8804
9026
  const gitPath = relFile.replace(/\\/g, "/");
@@ -8808,12 +9030,12 @@ async function isTracked(repoRoot, relFile) {
8808
9030
  async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
8809
9031
  const copied = [];
8810
9032
  for (const rel of MACHINE_LOCAL_FILES) {
8811
- const src = path54.join(mainMemberPath, rel);
8812
- if (!await fs39.pathExists(src)) continue;
9033
+ const src = path55.join(mainMemberPath, rel);
9034
+ if (!await fs40.pathExists(src)) continue;
8813
9035
  if (await isTracked(mainMemberPath, rel)) continue;
8814
- const dest = path54.join(worktreeMemberPath, rel);
8815
- await fs39.ensureDir(path54.dirname(dest));
8816
- await fs39.copy(src, dest, { overwrite: true });
9036
+ const dest = path55.join(worktreeMemberPath, rel);
9037
+ await fs40.ensureDir(path55.dirname(dest));
9038
+ await fs40.copy(src, dest, { overwrite: true });
8817
9039
  copied.push(rel);
8818
9040
  }
8819
9041
  return copied;
@@ -8821,15 +9043,15 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
8821
9043
 
8822
9044
  // src/workspace/worktree/root.ts
8823
9045
  import { existsSync as existsSync4 } from "node:fs";
8824
- import path55 from "node:path";
9046
+ import path56 from "node:path";
8825
9047
  async function resolveWorkspaceForWorktree(start) {
8826
9048
  const rootInfo = await resolveRoots(start);
8827
9049
  if (!rootInfo.isGitRepo) {
8828
9050
  return { ok: false, reason: "Not inside a git repository." };
8829
9051
  }
8830
9052
  const workspaceRoot = rootInfo.mainRoot;
8831
- const hasYaml = existsSync4(path55.join(workspaceRoot, WORKSPACE_FILE));
8832
- const hasManifest = existsSync4(path55.join(workspaceRoot, REPOS_MANIFEST_FILE));
9053
+ const hasYaml = existsSync4(path56.join(workspaceRoot, WORKSPACE_FILE));
9054
+ const hasManifest = existsSync4(path56.join(workspaceRoot, REPOS_MANIFEST_FILE));
8833
9055
  if (!hasYaml && !hasManifest) {
8834
9056
  return {
8835
9057
  ok: false,
@@ -8854,19 +9076,31 @@ function selectMembers(members, only) {
8854
9076
  }
8855
9077
 
8856
9078
  // src/workspace/worktree/state.ts
8857
- import path56 from "node:path";
9079
+ import path57 from "node:path";
8858
9080
  var WORKTREE_STATE_FILE = ".haus-worktree.json";
8859
9081
  function worktreesDir(mainRoot) {
8860
- return path56.join(mainRoot, ".claude", "worktrees");
9082
+ return path57.join(mainRoot, ".claude", "worktrees");
9083
+ }
9084
+ function isValidWorktreeSlug(slug) {
9085
+ if (slug === "" || slug === "." || slug === "..") return false;
9086
+ if (slug.includes("/") || slug.includes("\\")) return false;
9087
+ return /^[A-Za-z0-9._-]+$/.test(slug);
8861
9088
  }
8862
9089
  function worktreePath(mainRoot, slug) {
8863
- return path56.join(worktreesDir(mainRoot), slug);
9090
+ if (!isValidWorktreeSlug(slug)) {
9091
+ throw new Error(
9092
+ `Invalid worktree slug ${JSON.stringify(slug)} \u2014 slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
9093
+ );
9094
+ }
9095
+ return path57.join(worktreesDir(mainRoot), slug);
8864
9096
  }
8865
9097
  async function readWorktreeState(worktreeRoot) {
8866
- return readJson(path56.join(worktreeRoot, WORKTREE_STATE_FILE));
9098
+ const state = await readJson(path57.join(worktreeRoot, WORKTREE_STATE_FILE));
9099
+ if (state == null || typeof state !== "object" || !Array.isArray(state.members)) return void 0;
9100
+ return state;
8867
9101
  }
8868
9102
  async function writeWorktreeState(worktreeRoot, state) {
8869
- await writeJson(path56.join(worktreeRoot, WORKTREE_STATE_FILE), state);
9103
+ await writeJson(path57.join(worktreeRoot, WORKTREE_STATE_FILE), state);
8870
9104
  }
8871
9105
 
8872
9106
  // src/workspace/worktree/add.ts
@@ -8881,7 +9115,7 @@ async function runAdd(opts) {
8881
9115
  }
8882
9116
  const branch = opts.branch ?? opts.slug;
8883
9117
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
8884
- if (await fs40.pathExists(wsWorktreePath)) {
9118
+ if (await fs41.pathExists(wsWorktreePath)) {
8885
9119
  return {
8886
9120
  ok: false,
8887
9121
  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.`
@@ -8900,7 +9134,7 @@ async function runAdd(opts) {
8900
9134
  const memberResults = [];
8901
9135
  for (const member of selected) {
8902
9136
  const start = Date.now();
8903
- const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
9137
+ const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
8904
9138
  const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
8905
9139
  preferDefaultBranchFrom: true
8906
9140
  });
@@ -8930,7 +9164,7 @@ async function runAdd(opts) {
8930
9164
  await mapWithConcurrency(okResults, async (result) => {
8931
9165
  const member = selected.find((m) => m.id === result.member);
8932
9166
  if (!member) return;
8933
- const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
9167
+ const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
8934
9168
  const hydrationStart = Date.now();
8935
9169
  if (opts.hydrate !== false) {
8936
9170
  result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
@@ -8997,10 +9231,10 @@ async function runDryRun(args) {
8997
9231
 
8998
9232
  // src/workspace/worktree/doctor.ts
8999
9233
  import { existsSync as existsSync5 } from "node:fs";
9000
- import path58 from "node:path";
9234
+ import path59 from "node:path";
9001
9235
  var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
9002
9236
  function looksHydrated(memberPath) {
9003
- return HYDRATION_MARKERS.some((marker) => existsSync5(path58.join(memberPath, marker)));
9237
+ return HYDRATION_MARKERS.some((marker) => existsSync5(path59.join(memberPath, marker)));
9004
9238
  }
9005
9239
  async function runDoctor2() {
9006
9240
  const rootInfo = await resolveRoots();
@@ -9014,7 +9248,7 @@ async function runDoctor2() {
9014
9248
  };
9015
9249
  }
9016
9250
  const workspaceRoot = rootInfo.mainRoot;
9017
- const isWorkspace = existsSync5(path58.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path58.join(workspaceRoot, REPOS_MANIFEST_FILE));
9251
+ const isWorkspace = existsSync5(path59.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path59.join(workspaceRoot, REPOS_MANIFEST_FILE));
9018
9252
  if (!isWorkspace) {
9019
9253
  return {
9020
9254
  isWorkspace: false,
@@ -9027,7 +9261,7 @@ async function runDoctor2() {
9027
9261
  };
9028
9262
  }
9029
9263
  const wsWorktreesDir = worktreesDir(workspaceRoot);
9030
- const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path58.sep}`);
9264
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path59.sep}`);
9031
9265
  const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
9032
9266
  const members = await readMembers(rootInfo);
9033
9267
  const problems = [];
@@ -9035,10 +9269,10 @@ async function runDoctor2() {
9035
9269
  let slug;
9036
9270
  if (inWorkspaceWorktree) {
9037
9271
  const wtPath = rootInfo.repoRoot;
9038
- slug = rootInfo.worktreeName ?? path58.basename(wtPath);
9272
+ slug = rootInfo.worktreeName ?? path59.basename(wtPath);
9039
9273
  const state = await readWorktreeState(wtPath);
9040
9274
  for (const member of members) {
9041
- const memberPath = path58.join(wtPath, member.folder);
9275
+ const memberPath = path59.join(wtPath, member.folder);
9042
9276
  const materialized = existsSync5(memberPath);
9043
9277
  const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
9044
9278
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
@@ -9069,16 +9303,16 @@ async function runDoctor2() {
9069
9303
  for (const member of members) {
9070
9304
  const entries = await listWorktrees(member.absPath);
9071
9305
  for (const entry of entries) {
9072
- const entryPath = path58.normalize(entry.path);
9073
- const marker = `${path58.sep}.claude${path58.sep}worktrees${path58.sep}`;
9306
+ const entryPath = path59.normalize(entry.path);
9307
+ const marker = `${path59.sep}.claude${path59.sep}worktrees${path59.sep}`;
9074
9308
  const idx = entryPath.indexOf(marker);
9075
9309
  if (idx === -1) continue;
9076
9310
  const rest = entryPath.slice(idx + marker.length);
9077
- const slugName = rest.split(path58.sep)[0];
9311
+ const slugName = rest.split(path59.sep)[0];
9078
9312
  if (!slugName) continue;
9079
- if (!existsSync5(path58.join(wsWorktreesDir, slugName))) {
9313
+ if (!existsSync5(path59.join(wsWorktreesDir, slugName))) {
9080
9314
  orphans.push(
9081
- `${member.id}: worktree registered at ${entry.path} but ${path58.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
9315
+ `${member.id}: worktree registered at ${entry.path} but ${path59.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
9082
9316
  );
9083
9317
  }
9084
9318
  }
@@ -9089,7 +9323,7 @@ async function runDoctor2() {
9089
9323
 
9090
9324
  // src/workspace/worktree/hydrate-command.ts
9091
9325
  import { existsSync as existsSync6 } from "node:fs";
9092
- import path59 from "node:path";
9326
+ import path60 from "node:path";
9093
9327
  async function runHydrateWorktree(opts) {
9094
9328
  const rootInfo = await resolveRoots();
9095
9329
  if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
@@ -9100,7 +9334,7 @@ async function runHydrateWorktree(opts) {
9100
9334
  }
9101
9335
  const workspaceRoot = rootInfo.mainRoot;
9102
9336
  const wtPath = rootInfo.repoRoot;
9103
- if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path59.sep}`)) {
9337
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path60.sep}`)) {
9104
9338
  return {
9105
9339
  ok: false,
9106
9340
  error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
@@ -9112,13 +9346,13 @@ async function runHydrateWorktree(opts) {
9112
9346
  return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
9113
9347
  }
9114
9348
  const state = await readWorktreeState(wtPath);
9115
- const slug = state?.slug ?? path59.basename(wtPath);
9349
+ const slug = state?.slug ?? path60.basename(wtPath);
9116
9350
  const detectedBranch = await currentBranch(wtPath);
9117
9351
  const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
9118
9352
  if (opts.dryRun) {
9119
9353
  const results2 = await Promise.all(
9120
9354
  selected.map(async (member) => {
9121
- const memberPath = path59.join(wtPath, member.folder);
9355
+ const memberPath = path60.join(wtPath, member.folder);
9122
9356
  if (existsSync6(memberPath)) {
9123
9357
  return { member: member.id, folder: member.folder, status: "planned" };
9124
9358
  }
@@ -9139,7 +9373,7 @@ async function runHydrateWorktree(opts) {
9139
9373
  const results = await mapWithConcurrency(
9140
9374
  selected,
9141
9375
  async (member) => {
9142
- const memberPath = path59.join(wtPath, member.folder);
9376
+ const memberPath = path60.join(wtPath, member.folder);
9143
9377
  let branchAction;
9144
9378
  if (!existsSync6(memberPath)) {
9145
9379
  if (!opts.materialize) {
@@ -9202,26 +9436,26 @@ async function runHydrateWorktree(opts) {
9202
9436
  }
9203
9437
 
9204
9438
  // src/workspace/worktree/list.ts
9205
- import path60 from "node:path";
9206
- import fs41 from "fs-extra";
9439
+ import path61 from "node:path";
9440
+ import fs42 from "fs-extra";
9207
9441
  async function runList2() {
9208
9442
  const resolved = await resolveWorkspaceForWorktree();
9209
9443
  if (!resolved.ok) return { ok: false, error: resolved.reason };
9210
9444
  const { rootInfo, workspaceRoot } = resolved;
9211
9445
  const dir = worktreesDir(workspaceRoot);
9212
- if (!await fs41.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
9213
- const entries = await fs41.readdir(dir, { withFileTypes: true });
9446
+ if (!await fs42.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
9447
+ const entries = await fs42.readdir(dir, { withFileTypes: true });
9214
9448
  const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
9215
9449
  const configuredMembers = await readMembers(rootInfo);
9216
9450
  const worktrees = [];
9217
9451
  for (const slug of slugs) {
9218
- const wtPath = path60.join(dir, slug);
9452
+ const wtPath = path61.join(dir, slug);
9219
9453
  const state = await readWorktreeState(wtPath);
9220
9454
  const members = [];
9221
9455
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
9222
9456
  for (const m of source) {
9223
- const memberPath = path60.join(wtPath, m.folder);
9224
- const materialized = await fs41.pathExists(memberPath);
9457
+ const memberPath = path61.join(wtPath, m.folder);
9458
+ const materialized = await fs42.pathExists(memberPath);
9225
9459
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
9226
9460
  members.push({
9227
9461
  id: m.id,
@@ -9242,14 +9476,14 @@ async function runList2() {
9242
9476
  }
9243
9477
 
9244
9478
  // src/workspace/worktree/remove.ts
9245
- import path61 from "node:path";
9246
- import fs42 from "fs-extra";
9479
+ import path62 from "node:path";
9480
+ import fs43 from "fs-extra";
9247
9481
  async function runRemove(opts) {
9248
9482
  const resolved = await resolveWorkspaceForWorktree();
9249
9483
  if (!resolved.ok) return { ok: false, error: resolved.reason };
9250
9484
  const { rootInfo, workspaceRoot } = resolved;
9251
9485
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
9252
- if (!await fs42.pathExists(wsWorktreePath)) {
9486
+ if (!await fs43.pathExists(wsWorktreePath)) {
9253
9487
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
9254
9488
  }
9255
9489
  const state = await readWorktreeState(wsWorktreePath);
@@ -9269,12 +9503,12 @@ async function runRemove(opts) {
9269
9503
  targets.push({
9270
9504
  repo: configMember?.id ?? stateMember.id,
9271
9505
  ownerRepo,
9272
- path: path61.join(wsWorktreePath, stateMember.folder)
9506
+ path: path62.join(wsWorktreePath, stateMember.folder)
9273
9507
  });
9274
9508
  }
9275
9509
  const existingTargets = [];
9276
9510
  for (const target of targets) {
9277
- if (await fs42.pathExists(target.path)) existingTargets.push(target);
9511
+ if (await fs43.pathExists(target.path)) existingTargets.push(target);
9278
9512
  }
9279
9513
  if (!opts.force) {
9280
9514
  const blockers = [];
@@ -9319,14 +9553,19 @@ async function runRemove(opts) {
9319
9553
  await pruneWorktrees(target.ownerRepo);
9320
9554
  }
9321
9555
  const wsTarget = existingTargets.find((t) => t.repo === "(workspace)");
9556
+ let wsRemoveOk = false;
9322
9557
  if (wsTarget) {
9323
9558
  const wsResult = await removeWorktree(workspaceRoot, wsWorktreePath);
9324
- if (wsResult.ok) removed.push("(workspace)");
9325
- else failed.push({ repo: "(workspace)", error: wsResult.stderr ?? "unknown error" });
9559
+ if (wsResult.ok) {
9560
+ removed.push("(workspace)");
9561
+ wsRemoveOk = true;
9562
+ } else {
9563
+ failed.push({ repo: "(workspace)", error: wsResult.stderr ?? "unknown error" });
9564
+ }
9326
9565
  await pruneWorktrees(workspaceRoot);
9327
9566
  }
9328
- if (await fs42.pathExists(wsWorktreePath)) {
9329
- await fs42.remove(wsWorktreePath);
9567
+ if (wsRemoveOk && await fs43.pathExists(wsWorktreePath)) {
9568
+ await fs43.remove(wsWorktreePath);
9330
9569
  }
9331
9570
  return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
9332
9571
  }
@@ -9541,7 +9780,7 @@ async function runWorktree(action, opts = {}) {
9541
9780
 
9542
9781
  // src/commands/workspace.ts
9543
9782
  import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
9544
- import path70 from "node:path";
9783
+ import path71 from "node:path";
9545
9784
 
9546
9785
  // src/commands/workspace/aggregate.ts
9547
9786
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -9596,18 +9835,18 @@ ${summaries.map(
9596
9835
 
9597
9836
  // src/commands/workspace/doctor.ts
9598
9837
  import { existsSync as existsSync7 } from "node:fs";
9599
- import path63 from "node:path";
9838
+ import path64 from "node:path";
9600
9839
 
9601
9840
  // src/commands/workspace/manifest.ts
9602
9841
  import { readFileSync as readFileSync4 } from "node:fs";
9603
- import path62 from "node:path";
9842
+ import path63 from "node:path";
9604
9843
  var MANIFEST_FILE = "workspace.manifest.json";
9605
9844
  function manifestPath(workspaceRoot) {
9606
9845
  return hausPath(workspaceRoot, MANIFEST_FILE);
9607
9846
  }
9608
9847
  function hausVersion() {
9609
9848
  try {
9610
- const pkg = JSON.parse(readFileSync4(path62.join(packageRoot(), "package.json"), "utf8"));
9849
+ const pkg = JSON.parse(readFileSync4(path63.join(packageRoot(), "package.json"), "utf8"));
9611
9850
  return pkg.version ?? "0.0.0";
9612
9851
  } catch {
9613
9852
  return "0.0.0";
@@ -9688,10 +9927,18 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
9688
9927
  });
9689
9928
  return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
9690
9929
  }
9930
+ if (!Array.isArray(manifest.repos)) {
9931
+ flag({
9932
+ repo: "(workspace)",
9933
+ kind: "no-manifest",
9934
+ detail: 'workspace.manifest.json\'s "repos" field is not an array \u2014 run `haus workspace setup --write` to regenerate it.'
9935
+ });
9936
+ return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
9937
+ }
9691
9938
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
9692
9939
  const catalogRefByRepo = [];
9693
9940
  for (const repo of config.repos) {
9694
- const repoRoot = path63.resolve(workspaceRoot, repo.path);
9941
+ const repoRoot = path64.resolve(workspaceRoot, repo.path);
9695
9942
  const entry = manifestByName.get(repo.name);
9696
9943
  if (!entry) {
9697
9944
  flag({
@@ -9764,7 +10011,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
9764
10011
  });
9765
10012
  continue;
9766
10013
  }
9767
- if (!existsSync7(path63.join(workspaceRoot, entry.path))) {
10014
+ if (!existsSync7(path64.join(workspaceRoot, entry.path))) {
9768
10015
  flag({
9769
10016
  repo: entry.repo,
9770
10017
  kind: "missing-linked-context-copy",
@@ -9807,16 +10054,16 @@ function emit(args) {
9807
10054
  }
9808
10055
 
9809
10056
  // src/workspace/link-context/link.ts
10057
+ import path67 from "node:path";
10058
+ import fs46 from "fs-extra";
10059
+
10060
+ // src/workspace/link-context/plan.ts
9810
10061
  import path66 from "node:path";
9811
10062
  import fs45 from "fs-extra";
9812
10063
 
9813
- // src/workspace/link-context/plan.ts
10064
+ // src/workspace/link-context/scan-source.ts
9814
10065
  import path65 from "node:path";
9815
10066
  import fs44 from "fs-extra";
9816
-
9817
- // src/workspace/link-context/scan-source.ts
9818
- import path64 from "node:path";
9819
- import fs43 from "fs-extra";
9820
10067
  var ASSET_SUBDIR = {
9821
10068
  skill: "skills",
9822
10069
  agent: "agents",
@@ -9824,36 +10071,36 @@ var ASSET_SUBDIR = {
9824
10071
  };
9825
10072
  async function hasLinkableContext(member) {
9826
10073
  for (const subdir of Object.values(ASSET_SUBDIR)) {
9827
- if (await fs43.pathExists(path64.join(member.absPath, ".claude", subdir))) return true;
10074
+ if (await fs44.pathExists(path65.join(member.absPath, ".claude", subdir))) return true;
9828
10075
  }
9829
10076
  return false;
9830
10077
  }
9831
10078
  async function collectSkills(member) {
9832
- const dir = path64.join(member.absPath, ".claude", "skills");
9833
- if (!await fs43.pathExists(dir)) return [];
9834
- const names = await fs43.readdir(dir);
10079
+ const dir = path65.join(member.absPath, ".claude", "skills");
10080
+ if (!await fs44.pathExists(dir)) return [];
10081
+ const names = await fs44.readdir(dir);
9835
10082
  const out = [];
9836
10083
  for (const name of [...names].sort()) {
9837
- const abs = path64.join(dir, name);
9838
- const stat = await fs43.lstat(abs);
10084
+ const abs = path65.join(dir, name);
10085
+ const stat = await fs44.lstat(abs);
9839
10086
  if (!stat.isDirectory()) continue;
9840
- if (!await fs43.pathExists(path64.join(abs, "SKILL.md"))) continue;
9841
- out.push({ type: "skill", name, sourceRelPath: path64.posix.join(".claude", "skills", name) });
10087
+ if (!await fs44.pathExists(path65.join(abs, "SKILL.md"))) continue;
10088
+ out.push({ type: "skill", name, sourceRelPath: path65.posix.join(".claude", "skills", name) });
9842
10089
  }
9843
10090
  return out;
9844
10091
  }
9845
10092
  async function collectFlatMarkdown(member, type) {
9846
10093
  const subdir = ASSET_SUBDIR[type];
9847
- const dir = path64.join(member.absPath, ".claude", subdir);
9848
- if (!await fs43.pathExists(dir)) return [];
9849
- const names = await fs43.readdir(dir);
10094
+ const dir = path65.join(member.absPath, ".claude", subdir);
10095
+ if (!await fs44.pathExists(dir)) return [];
10096
+ const names = await fs44.readdir(dir);
9850
10097
  const out = [];
9851
10098
  for (const fileName of [...names].sort()) {
9852
10099
  if (!fileName.endsWith(".md")) continue;
9853
- const abs = path64.join(dir, fileName);
9854
- if (!(await fs43.lstat(abs)).isFile()) continue;
10100
+ const abs = path65.join(dir, fileName);
10101
+ if (!(await fs44.lstat(abs)).isFile()) continue;
9855
10102
  const name = fileName.slice(0, -".md".length);
9856
- out.push({ type, name, sourceRelPath: path64.posix.join(".claude", subdir, fileName) });
10103
+ out.push({ type, name, sourceRelPath: path65.posix.join(".claude", subdir, fileName) });
9857
10104
  }
9858
10105
  return out;
9859
10106
  }
@@ -9868,9 +10115,9 @@ async function collectSourceAssets(member) {
9868
10115
 
9869
10116
  // src/workspace/link-context/plan.ts
9870
10117
  function repoFolderPrefix(member) {
9871
- const base = path65.posix.basename(member.folder);
10118
+ const base = path66.posix.basename(member.folder);
9872
10119
  if (base && base !== "." && base !== "/") return base;
9873
- const fromAbs = path65.basename(member.absPath);
10120
+ const fromAbs = path66.basename(member.absPath);
9874
10121
  return fromAbs || member.id;
9875
10122
  }
9876
10123
  function assetKey(type, destKey) {
@@ -9885,7 +10132,7 @@ async function buildLinkPlan(members) {
9885
10132
  const byKey = /* @__PURE__ */ new Map();
9886
10133
  const membersById = new Map(members.map((m) => [m.id, m]));
9887
10134
  for (const member of members) {
9888
- if (!await fs44.pathExists(member.absPath)) {
10135
+ if (!await fs45.pathExists(member.absPath)) {
9889
10136
  skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
9890
10137
  continue;
9891
10138
  }
@@ -9927,7 +10174,7 @@ async function buildLinkPlan(members) {
9927
10174
  name: asset.name,
9928
10175
  destKey,
9929
10176
  sourceRelPath: asset.sourceRelPath,
9930
- sourceAbsPath: path65.join(member.absPath, asset.sourceRelPath),
10177
+ sourceAbsPath: path66.join(member.absPath, asset.sourceRelPath),
9931
10178
  sourceHash
9932
10179
  });
9933
10180
  }
@@ -9938,7 +10185,7 @@ async function buildLinkPlan(members) {
9938
10185
  function destRelPathFor(type, destKey) {
9939
10186
  const subdir = `${type}s`;
9940
10187
  const leaf = type === "skill" ? destKey : `${destKey}.md`;
9941
- return path66.posix.join(".claude", subdir, leaf);
10188
+ return path67.posix.join(".claude", subdir, leaf);
9942
10189
  }
9943
10190
  async function runLinkContext(workspaceRoot, opts = {}) {
9944
10191
  const write = opts.write ?? false;
@@ -9964,17 +10211,17 @@ async function runLinkContext(workspaceRoot, opts = {}) {
9964
10211
  const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
9965
10212
  for (const entry of plan.entries) {
9966
10213
  const destRel = destRelPathFor(entry.type, entry.destKey);
9967
- const destAbs = path66.join(workspaceRoot, destRel);
10214
+ const destAbs = path67.join(workspaceRoot, destRel);
9968
10215
  if (write) {
9969
- await fs45.ensureDir(path66.dirname(destAbs));
9970
- if (await fs45.pathExists(destAbs)) await fs45.remove(destAbs);
9971
- await fs45.copy(entry.sourceAbsPath, destAbs, {
10216
+ await fs46.ensureDir(path67.dirname(destAbs));
10217
+ if (await fs46.pathExists(destAbs)) await fs46.remove(destAbs);
10218
+ await fs46.copy(entry.sourceAbsPath, destAbs, {
9972
10219
  overwrite: true,
9973
10220
  errorOnExist: false,
9974
10221
  dereference: false,
9975
10222
  filter: (s) => {
9976
10223
  try {
9977
- return !fs45.lstatSync(s).isSymbolicLink();
10224
+ return !fs46.lstatSync(s).isSymbolicLink();
9978
10225
  } catch {
9979
10226
  return false;
9980
10227
  }
@@ -9998,8 +10245,12 @@ async function runLinkContext(workspaceRoot, opts = {}) {
9998
10245
  if (nextPaths.has(old.path)) continue;
9999
10246
  removed.push(old.path);
10000
10247
  if (write) {
10001
- const abs = path66.join(workspaceRoot, old.path);
10002
- if (await fs45.pathExists(abs)) await fs45.remove(abs);
10248
+ const abs = resolveContainedPath(workspaceRoot, old.path);
10249
+ if (abs === null) {
10250
+ warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
10251
+ continue;
10252
+ }
10253
+ if (await fs46.pathExists(abs)) await fs46.remove(abs);
10003
10254
  }
10004
10255
  }
10005
10256
  if (write) {
@@ -10051,11 +10302,11 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
10051
10302
 
10052
10303
  // src/commands/workspace/setup.ts
10053
10304
  import { existsSync as existsSync8, statSync } from "node:fs";
10054
- import path68 from "node:path";
10305
+ import path69 from "node:path";
10055
10306
 
10056
10307
  // src/claude/write-workspace-claude-md.ts
10057
- import path67 from "node:path";
10058
- import fs46 from "fs-extra";
10308
+ import path68 from "node:path";
10309
+ import fs47 from "fs-extra";
10059
10310
  var WORKSPACE_SENTINELS = { begin: WORKSPACE_BLOCK_BEGIN, end: WORKSPACE_BLOCK_END };
10060
10311
  function buildWorkspaceImportBlock(client, members) {
10061
10312
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
@@ -10074,11 +10325,11 @@ ${WORKSPACE_BLOCK_END}`;
10074
10325
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
10075
10326
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
10076
10327
  const dryRun = opts.dryRun ?? false;
10077
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path67.join(workspaceRoot, "CLAUDE.md");
10328
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path68.join(workspaceRoot, "CLAUDE.md");
10078
10329
  const say = opts.quiet ? () => {
10079
10330
  } : log;
10080
- const existed = await fs46.pathExists(filePath);
10081
- const prev = existed ? await fs46.readFile(filePath, "utf8") : "";
10331
+ const existed = await fs47.pathExists(filePath);
10332
+ const prev = existed ? await fs47.readFile(filePath, "utf8") : "";
10082
10333
  const next = opts.collision ? `${block}
10083
10334
  ` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
10084
10335
  const printable = displayPath(workspaceRoot, filePath);
@@ -10105,20 +10356,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
10105
10356
 
10106
10357
  // src/commands/workspace/setup.ts
10107
10358
  function resolveWorkspaceRoot(start = process.cwd()) {
10108
- let dir = path68.resolve(start);
10359
+ let dir = path69.resolve(start);
10109
10360
  for (; ; ) {
10110
- if (existsSync8(path68.join(dir, WORKSPACE_FILE))) return dir;
10111
- const parent = path68.dirname(dir);
10112
- if (parent === dir) return path68.resolve(start);
10361
+ if (existsSync8(path69.join(dir, WORKSPACE_FILE))) return dir;
10362
+ const parent = path69.dirname(dir);
10363
+ if (parent === dir) return path69.resolve(start);
10113
10364
  dir = parent;
10114
10365
  }
10115
10366
  }
10116
10367
  function isRootRepo(workspaceRoot, repoPath) {
10117
- return path68.resolve(workspaceRoot, repoPath) === path68.resolve(workspaceRoot);
10368
+ return path69.resolve(workspaceRoot, repoPath) === path69.resolve(workspaceRoot);
10118
10369
  }
10119
10370
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
10120
10371
  const apply = options.write ?? false;
10121
- const configText = await readText(path68.join(workspaceRoot, WORKSPACE_FILE));
10372
+ const configText = await readText(path69.join(workspaceRoot, WORKSPACE_FILE));
10122
10373
  if (!configText) {
10123
10374
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
10124
10375
  process.exitCode = 1;
@@ -10145,7 +10396,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10145
10396
  const statuses = [];
10146
10397
  const aggregateInputs = [];
10147
10398
  for (const repo of repos) {
10148
- const repoRoot = path68.resolve(workspaceRoot, repo.path);
10399
+ const repoRoot = path69.resolve(workspaceRoot, repo.path);
10149
10400
  say(`
10150
10401
  \u2192 ${repo.name} (${repo.path})`);
10151
10402
  try {
@@ -10178,7 +10429,15 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10178
10429
  status: "failed",
10179
10430
  error: message
10180
10431
  });
10181
- if (!options.continueOnError) throw err;
10432
+ if (!options.continueOnError) {
10433
+ if (options.json) {
10434
+ const result2 = { workspaceRoot, statuses, written: [] };
10435
+ log(JSON.stringify(result2, null, 2));
10436
+ process.exitCode = 1;
10437
+ return result2;
10438
+ }
10439
+ throw err;
10440
+ }
10182
10441
  error(`Setup failed for ${repo.name}: ${message}`);
10183
10442
  }
10184
10443
  }
@@ -10227,7 +10486,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10227
10486
  const status = statusByName.get(repo.name);
10228
10487
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
10229
10488
  if (status?.status === "ok") {
10230
- const lock = await checkLock(path68.resolve(workspaceRoot, repo.path));
10489
+ const lock = await checkLock(path69.resolve(workspaceRoot, repo.path));
10231
10490
  manifestRepos.push({
10232
10491
  name: repo.name,
10233
10492
  path: repo.path,
@@ -10283,7 +10542,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10283
10542
  try {
10284
10543
  const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
10285
10544
  if (linkResult.ok) {
10286
- written.push(...linkResult.added.map((rel) => path68.join(workspaceRoot, rel)));
10545
+ written.push(...linkResult.added.map((rel) => path69.join(workspaceRoot, rel)));
10287
10546
  if (!options.json) {
10288
10547
  say(
10289
10548
  `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` : ""}.`
@@ -10315,8 +10574,8 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
10315
10574
 
10316
10575
  // src/commands/workspace/undo.ts
10317
10576
  import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
10318
- import path69 from "node:path";
10319
- import fs47 from "fs-extra";
10577
+ import path70 from "node:path";
10578
+ import fs48 from "fs-extra";
10320
10579
  var WORKSPACE_AGGREGATE_FILES = [
10321
10580
  "workspace-summary.json",
10322
10581
  "dependency-ownership-map.json",
@@ -10352,7 +10611,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10352
10611
  }
10353
10612
  let anyRepoFailed = false;
10354
10613
  for (const repo of config.repos) {
10355
- const repoRoot = path69.resolve(workspaceRoot, repo.path);
10614
+ const repoRoot = path70.resolve(workspaceRoot, repo.path);
10356
10615
  log(`
10357
10616
  \u2192 ${repo.name} (${repo.path})`);
10358
10617
  try {
@@ -10367,30 +10626,34 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10367
10626
  }
10368
10627
  const linkedContext = await readLinkedContext(workspaceRoot);
10369
10628
  for (const entry of linkedContext) {
10370
- const abs = path69.join(workspaceRoot, entry.path);
10371
- if (await fs47.pathExists(abs)) {
10372
- await fs47.remove(abs);
10629
+ const abs = resolveContainedPath(workspaceRoot, entry.path);
10630
+ if (abs === null) {
10631
+ warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
10632
+ continue;
10633
+ }
10634
+ if (await fs48.pathExists(abs)) {
10635
+ await fs48.remove(abs);
10373
10636
  log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
10374
10637
  }
10375
10638
  }
10376
10639
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
10377
10640
  const abs = hausPath(workspaceRoot, rel);
10378
- if (await fs47.pathExists(abs)) {
10379
- await fs47.remove(abs);
10380
- log(`Removed ${path69.relative(workspaceRoot, abs)}`);
10641
+ if (await fs48.pathExists(abs)) {
10642
+ await fs48.remove(abs);
10643
+ log(`Removed ${path70.relative(workspaceRoot, abs)}`);
10381
10644
  }
10382
10645
  }
10383
10646
  const manifestFile = manifestPath(workspaceRoot);
10384
- if (await fs47.pathExists(manifestFile)) {
10385
- await fs47.remove(manifestFile);
10386
- log(`Removed ${path69.relative(workspaceRoot, manifestFile)}`);
10647
+ if (await fs48.pathExists(manifestFile)) {
10648
+ await fs48.remove(manifestFile);
10649
+ log(`Removed ${path70.relative(workspaceRoot, manifestFile)}`);
10387
10650
  }
10388
10651
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
10389
- if (await fs47.pathExists(workspaceMdPath)) {
10390
- await fs47.remove(workspaceMdPath);
10391
- log(`Removed ${path69.relative(workspaceRoot, workspaceMdPath)}`);
10652
+ if (await fs48.pathExists(workspaceMdPath)) {
10653
+ await fs48.remove(workspaceMdPath);
10654
+ log(`Removed ${path70.relative(workspaceRoot, workspaceMdPath)}`);
10392
10655
  } else {
10393
- const claudeMdPath = path69.join(workspaceRoot, "CLAUDE.md");
10656
+ const claudeMdPath = path70.join(workspaceRoot, "CLAUDE.md");
10394
10657
  if (existsSync9(claudeMdPath)) {
10395
10658
  const prev = await readText(claudeMdPath);
10396
10659
  if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
@@ -10399,19 +10662,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10399
10662
  end: WORKSPACE_BLOCK_END
10400
10663
  });
10401
10664
  if (next.length === 0) {
10402
- await fs47.remove(claudeMdPath);
10665
+ await fs48.remove(claudeMdPath);
10403
10666
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
10404
10667
  } else {
10405
- await fs47.writeFile(claudeMdPath, next, "utf8");
10668
+ await fs48.writeFile(claudeMdPath, next, "utf8");
10406
10669
  log("Removed haus import block from CLAUDE.md (user content preserved).");
10407
10670
  }
10408
10671
  } else if (prev && hasLegacyWorkspaceBlock(prev)) {
10409
10672
  const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
10410
10673
  if (next.length === 0) {
10411
- await fs47.remove(claudeMdPath);
10674
+ await fs48.remove(claudeMdPath);
10412
10675
  log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
10413
10676
  } else {
10414
- await fs47.writeFile(claudeMdPath, next, "utf8");
10677
+ await fs48.writeFile(claudeMdPath, next, "utf8");
10415
10678
  log(
10416
10679
  "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
10417
10680
  );
@@ -10442,7 +10705,16 @@ function normalizeMaxDepth(maxDepth) {
10442
10705
  warn(`Ignoring invalid --max-depth "${String(maxDepth)}"; using the default depth.`);
10443
10706
  return void 0;
10444
10707
  }
10445
- async function initWorkspace() {
10708
+ async function initWorkspace(options = {}) {
10709
+ if (existsSync10(WORKSPACE_FILE) && !options.yes) {
10710
+ const ok = await confirm(
10711
+ `${WORKSPACE_FILE} already exists \u2014 overwrite with a fresh placeholder? Existing client/repos/relationships will be lost. Run \`haus workspace discover\` instead to merge in newly found repos.`
10712
+ );
10713
+ if (!ok) {
10714
+ log("Cancelled.");
10715
+ return;
10716
+ }
10717
+ }
10446
10718
  await writeText(
10447
10719
  WORKSPACE_FILE,
10448
10720
  `client: unknown
@@ -10456,7 +10728,7 @@ relationships: []
10456
10728
  log("Workspace initialized.");
10457
10729
  }
10458
10730
  async function scanWorkspace(workspaceRoot, opts) {
10459
- const configText = await readText(path70.join(workspaceRoot, WORKSPACE_FILE));
10731
+ const configText = await readText(path71.join(workspaceRoot, WORKSPACE_FILE));
10460
10732
  if (!configText) {
10461
10733
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
10462
10734
  process.exitCode = 1;
@@ -10478,7 +10750,7 @@ async function scanWorkspace(workspaceRoot, opts) {
10478
10750
  const inputs = [];
10479
10751
  const failures = [];
10480
10752
  for (const repo of config.repos) {
10481
- const repoRoot = path70.resolve(workspaceRoot, repo.path);
10753
+ const repoRoot = path71.resolve(workspaceRoot, repo.path);
10482
10754
  try {
10483
10755
  if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
10484
10756
  throw new Error("path is not a directory");
@@ -10504,7 +10776,7 @@ async function scanWorkspace(workspaceRoot, opts) {
10504
10776
  }
10505
10777
  async function runWorkspace(action, options = {}) {
10506
10778
  if (action === "init") {
10507
- await initWorkspace();
10779
+ await initWorkspace({ yes: options.yes });
10508
10780
  return;
10509
10781
  }
10510
10782
  const workspaceRoot = resolveWorkspaceRoot();
@@ -10544,7 +10816,7 @@ async function runWorkspace(action, options = {}) {
10544
10816
  // src/cli.ts
10545
10817
  function cliVersion() {
10546
10818
  try {
10547
- const pkgPath = path71.join(packageRoot(), "package.json");
10819
+ const pkgPath = path72.join(packageRoot(), "package.json");
10548
10820
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
10549
10821
  return pkg.version ?? "0.0.0";
10550
10822
  } catch {
@@ -10554,7 +10826,7 @@ function cliVersion() {
10554
10826
  var program = new Command();
10555
10827
  function validateRuntimeNodeVersion() {
10556
10828
  try {
10557
- const pkgPath = path71.join(packageRoot(), "package.json");
10829
+ const pkgPath = path72.join(packageRoot(), "package.json");
10558
10830
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
10559
10831
  const requiredRange = pkg.engines?.node;
10560
10832
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -10615,7 +10887,7 @@ program.command("install").option("--dry-run").option("--force").option("--check
10615
10887
  program.command("scaffold [ids...]").description(
10616
10888
  "Copy catalog config items (ESLint, Prettier) into the project root. Pass item IDs to scaffold specific items, or omit to scaffold all available config items."
10617
10889
  ).option("--force", "Overwrite existing files").option("--dry-run", "Preview what would be written without changing anything").option("--root <path>", "Project root (defaults to cwd)", process.cwd()).action(runScaffold);
10618
- program.command("uninstall").option("--force").action(runUninstallCommand);
10890
+ program.command("uninstall").option("--force").option("-y, --yes", "Skip confirmation").action(runUninstallCommand);
10619
10891
  var guard = program.command("guard");
10620
10892
  guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
10621
10893
  guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
@@ -10632,7 +10904,7 @@ function registerDecisionsCommand(name) {
10632
10904
  registerDecisionsCommand("decisions");
10633
10905
  registerDecisionsCommand("adr");
10634
10906
  var workspace = program.command("workspace");
10635
- workspace.command("init").action(() => runWorkspace("init"));
10907
+ workspace.command("init").option("-y, --yes", "Skip confirmation when haus.workspace.yaml already exists").action((opts) => runWorkspace("init", opts));
10636
10908
  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));
10637
10909
  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));
10638
10910
  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));