@haus-tech/haus-workflow 1.5.3 → 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,10 +2634,13 @@ 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 -->";
2641
+ var WORKSPACE_BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-workspace-imports v=1 -->";
2642
+ var WORKSPACE_BLOCK_END = "<!-- HAUS:END haus-workspace-imports -->";
2643
+ var DEFAULT_SENTINELS = { begin: BLOCK_BEGIN, end: BLOCK_END };
2482
2644
  var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
2483
2645
  @.haus-workflow/workflow-config.md
2484
2646
  @docs/decisions/README.md`;
@@ -2498,15 +2660,15 @@ function findLineMarker2(content2, marker, from = 0) {
2498
2660
  }
2499
2661
  return null;
2500
2662
  }
2501
- function findImportBlockRange(content2) {
2502
- const begin = findLineMarker2(content2, BLOCK_BEGIN);
2663
+ function findImportBlockRange(content2, sentinels = DEFAULT_SENTINELS) {
2664
+ const begin = findLineMarker2(content2, sentinels.begin);
2503
2665
  if (!begin) return null;
2504
- const end = findLineMarker2(content2, BLOCK_END, begin.end);
2666
+ const end = findLineMarker2(content2, sentinels.end, begin.end);
2505
2667
  if (!end) return null;
2506
2668
  return { beginStart: begin.start, beginEnd: begin.end, endStart: end.start, endEnd: end.end };
2507
2669
  }
2508
- function stripHausBlock(existing) {
2509
- const range = findImportBlockRange(existing);
2670
+ function stripHausBlock(existing, sentinels = DEFAULT_SENTINELS) {
2671
+ const range = findImportBlockRange(existing, sentinels);
2510
2672
  if (range) {
2511
2673
  const before = existing.slice(0, range.beginStart);
2512
2674
  const after = existing.slice(range.endEnd);
@@ -2514,7 +2676,7 @@ function stripHausBlock(existing) {
2514
2676
  return merged.length > 0 ? `${merged}
2515
2677
  ` : "";
2516
2678
  }
2517
- const loneBegin = findLineMarker2(existing, BLOCK_BEGIN);
2679
+ const loneBegin = findLineMarker2(existing, sentinels.begin);
2518
2680
  if (loneBegin) {
2519
2681
  const before = existing.slice(0, loneBegin.start).trimEnd();
2520
2682
  return before.length > 0 ? `${before}
@@ -2522,14 +2684,14 @@ function stripHausBlock(existing) {
2522
2684
  }
2523
2685
  return existing;
2524
2686
  }
2525
- function injectHausBlock(existing, block) {
2526
- const range = findImportBlockRange(existing);
2687
+ function injectHausBlock(existing, block, sentinels = DEFAULT_SENTINELS) {
2688
+ const range = findImportBlockRange(existing, sentinels);
2527
2689
  if (range) {
2528
2690
  const before = existing.slice(0, range.beginStart);
2529
2691
  const after = existing.slice(range.endEnd);
2530
2692
  return `${before}${block}${after}`;
2531
2693
  }
2532
- const loneBegin = findLineMarker2(existing, BLOCK_BEGIN);
2694
+ const loneBegin = findLineMarker2(existing, sentinels.begin);
2533
2695
  if (loneBegin) {
2534
2696
  const before = existing.slice(0, loneBegin.start).trimEnd();
2535
2697
  if (before.length === 0) return `${block}
@@ -2550,7 +2712,7 @@ ${block}
2550
2712
  `;
2551
2713
  }
2552
2714
  async function writeRootClaudeMd(root, dryRun) {
2553
- const filePath = path16.join(root, "CLAUDE.md");
2715
+ const filePath = path17.join(root, "CLAUDE.md");
2554
2716
  const block = buildImportBlock();
2555
2717
  const prev = await fs14.pathExists(filePath) ? await fs14.readFile(filePath, "utf8") : "";
2556
2718
  const next = injectHausBlock(prev, block);
@@ -2559,11 +2721,11 @@ async function writeRootClaudeMd(root, dryRun) {
2559
2721
  }
2560
2722
 
2561
2723
  // src/claude/write-workflow-config.ts
2562
- import path18 from "node:path";
2724
+ import path19 from "node:path";
2563
2725
  import fs16 from "fs-extra";
2564
2726
 
2565
2727
  // src/claude/derive-workflow-config.ts
2566
- import path17 from "node:path";
2728
+ import path18 from "node:path";
2567
2729
  import fs15 from "fs-extra";
2568
2730
  function binCmd(pm, bin, args) {
2569
2731
  const tail = args ? ` ${args}` : "";
@@ -2573,7 +2735,7 @@ function binCmd(pm, bin, args) {
2573
2735
  }
2574
2736
  async function deriveWorkflowConfig(root, ctx) {
2575
2737
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
2576
- const pkg = await readJson(path17.join(root, "package.json"));
2738
+ const pkg = await readJson(path18.join(root, "package.json"));
2577
2739
  const scripts = pkg?.scripts ?? {};
2578
2740
  const deps = new Set(ctx.dependencies);
2579
2741
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -2583,7 +2745,7 @@ async function deriveWorkflowConfig(root, ctx) {
2583
2745
  return null;
2584
2746
  };
2585
2747
  const hasDep = (name) => deps.has(name);
2586
- const exists = (rel) => fs15.pathExistsSync(path17.join(root, rel));
2748
+ const exists = (rel) => fs15.pathExistsSync(path18.join(root, rel));
2587
2749
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
2588
2750
  const hasCypress = hasDep("cypress");
2589
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;
@@ -2658,7 +2820,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2658
2820
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
2659
2821
  ...FALLBACK_CONTEXT,
2660
2822
  root,
2661
- repoName: path18.basename(root)
2823
+ repoName: path19.basename(root)
2662
2824
  };
2663
2825
  const values = await deriveWorkflowConfig(root, ctx);
2664
2826
  if (exists) {
@@ -2703,7 +2865,8 @@ function checkManagedTamper(body, storedHash, templateContent) {
2703
2865
  // src/claude/write-workflow.ts
2704
2866
  var STABLE_ID = "template.workflow";
2705
2867
  function makeWorkflowHeader(pkgVersion, contentHash) {
2706
- 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} -->`;
2707
2870
  }
2708
2871
  async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
2709
2872
  const templateContent = await readWorkflowTemplate({ dryRun });
@@ -2744,12 +2907,12 @@ ${templateContent}`;
2744
2907
  return null;
2745
2908
  }
2746
2909
  if (verdict.status === "legacy-diverged" && !force) {
2747
- const migratedHeader = makeWorkflowHeader(pkgVersion, hashText(normaliseLF(existingContent)));
2910
+ const migratedHeader = makeWorkflowHeader(pkgVersion);
2748
2911
  const migrated = `${migratedHeader}
2749
2912
  ${existingContent}`;
2750
2913
  if (hasTextChanged(existing, migrated)) {
2751
2914
  if (dryRun) {
2752
- log(`${printable}: migrating legacy hash header (body preserved)`);
2915
+ log(`${printable}: migrating legacy header (staying hash-less; body preserved)`);
2753
2916
  } else {
2754
2917
  await writeText(destPath, migrated);
2755
2918
  }
@@ -2796,7 +2959,7 @@ async function listFilesRecursive2(dir) {
2796
2959
  const walk = async (current, relPrefix) => {
2797
2960
  const names = await fs18.readdir(current);
2798
2961
  for (const name of names) {
2799
- const abs = path19.join(current, name);
2962
+ const abs = path20.join(current, name);
2800
2963
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
2801
2964
  const stat = await fs18.lstat(abs);
2802
2965
  if (stat.isSymbolicLink()) {
@@ -2840,8 +3003,8 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
2840
3003
  for (const rel of sourceFiles) {
2841
3004
  await logFileDryRunDiff(
2842
3005
  root,
2843
- path19.join(sourcePath, rel),
2844
- path19.join(destination, rel),
3006
+ path20.join(sourcePath, rel),
3007
+ path20.join(destination, rel),
2845
3008
  itemId,
2846
3009
  say
2847
3010
  );
@@ -2849,7 +3012,7 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
2849
3012
  const sourceFileSet = new Set(sourceFiles);
2850
3013
  for (const rel of destFiles) {
2851
3014
  if (!sourceFileSet.has(rel)) {
2852
- say(`${displayPath(root, path19.join(destination, rel))}: would remove (${itemId})`);
3015
+ say(`${displayPath(root, path20.join(destination, rel))}: would remove (${itemId})`);
2853
3016
  }
2854
3017
  }
2855
3018
  }
@@ -2869,7 +3032,9 @@ async function removeLegacyManagedStub(root, relPathSegments, stub, dryRun, say)
2869
3032
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2870
3033
  const say = opts.quiet ? () => {
2871
3034
  } : log;
2872
- 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 : {
2873
3038
  recommended: [],
2874
3039
  skipped: [],
2875
3040
  warnings: [],
@@ -2879,7 +3044,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2879
3044
  estimatedTokenReductionPct: 0
2880
3045
  };
2881
3046
  const pkgRoot = packageRoot();
2882
- 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";
2883
3048
  const coreFiles = [claudePath(root, "settings.json"), claudePath(root, "rules", "haus.md")];
2884
3049
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
2885
3050
  const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
@@ -2969,6 +3134,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2969
3134
  const { items: manifestItems, contentRoot } = await loadCatalogContext(root);
2970
3135
  const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
2971
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
+ );
2972
3140
  const allMigrations = findFormerIdMigrations(
2973
3141
  prevLock.filter((entry) => Boolean(entry.id)),
2974
3142
  manifestItems
@@ -3029,6 +3197,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3029
3197
  warn(`Skipping ${item.id}: reviewStatus is deprecated`);
3030
3198
  continue;
3031
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
+ }
3032
3211
  if (manifestItem.source === "curated") {
3033
3212
  if (manifestItem.reviewStatus !== "approved") {
3034
3213
  curatedReviewStatusSkips++;
@@ -3053,7 +3232,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3053
3232
  );
3054
3233
  continue;
3055
3234
  }
3056
- const destination = claudePath(root, target, path19.basename(sourcePath));
3235
+ const destination = claudePath(root, target, path20.basename(sourcePath));
3057
3236
  if (await fs18.pathExists(sourcePath)) {
3058
3237
  if (dryRun) {
3059
3238
  if (item.type === "skill") {
@@ -3061,13 +3240,23 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3061
3240
  } else {
3062
3241
  await logFileDryRunDiff(root, sourcePath, destination, item.id, say);
3063
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;
3064
3253
  } else if (item.type === "skill") {
3065
3254
  const skillFiles = (await fs18.readdir(sourcePath, { recursive: true })).filter(
3066
3255
  (f) => typeof f === "string" && f.endsWith(".md")
3067
3256
  );
3068
3257
  let skillValid = true;
3069
3258
  for (const relFile of skillFiles) {
3070
- const mdPath = path19.join(sourcePath, relFile);
3259
+ const mdPath = path20.join(sourcePath, relFile);
3071
3260
  const mdContent = await fs18.readFile(mdPath, "utf8");
3072
3261
  const mdValidation = validateCatalogItem(
3073
3262
  {
@@ -3106,17 +3295,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3106
3295
  warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
3107
3296
  continue;
3108
3297
  }
3109
- await fs18.ensureDir(path19.dirname(destination));
3298
+ await fs18.ensureDir(path20.dirname(destination));
3110
3299
  await fs18.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
3111
3300
  }
3112
3301
  files.push(destination);
3113
- const relPaths = [path19.relative(root, destination)];
3302
+ const relPaths = [path20.relative(root, destination)];
3114
3303
  if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
3115
3304
  const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
3116
3305
  if (sharedRel) {
3117
3306
  superpowersSharedInstalled = true;
3118
3307
  relPaths.push(sharedRel);
3119
- files.push(path19.join(root, sharedRel));
3308
+ files.push(path20.join(root, sharedRel));
3120
3309
  }
3121
3310
  }
3122
3311
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -3134,18 +3323,47 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3134
3323
  );
3135
3324
  }
3136
3325
  await cleanupStaleCatalogItems(root, cleanupManifestById, dryRun, opts.quiet);
3326
+ const refuseDestructiveLockChange = recommendationUnusable && prevLock.length > 0 && !opts.force;
3137
3327
  if (opts.prune) {
3138
- const notOrphaned = new Set(rec.recommended.map((r) => r.id));
3139
- for (const migration of allMigrations) notOrphaned.add(migration.oldId);
3140
- for (const entry of prevLock) {
3141
- if (!entry.id) continue;
3142
- const manifestItem = manifestById.get(entry.id);
3143
- 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);
3144
3341
  }
3145
- await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
3146
3342
  }
3147
- if (dryRun) return [...new Set(files)];
3148
3343
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
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
+ }
3351
+ const prevIds = new Set(
3352
+ prevLock.map((e) => e.id ? migrationByOldId.get(e.id) ?? e.id : void 0).filter((id) => Boolean(id))
3353
+ );
3354
+ const nextIds = new Set(installedItems.map((r) => r.id));
3355
+ const added = [...nextIds].filter((id) => !prevIds.has(id)).sort();
3356
+ const removed = [...prevIds].filter((id) => !nextIds.has(id)).sort();
3357
+ for (const id of added) say(`[dry-run] haus.lock.json would add: ${id}`);
3358
+ for (const id of removed) say(`[dry-run] haus.lock.json would remove: ${id}`);
3359
+ return [...new Set(files)];
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
+ }
3149
3367
  const prevRefById = new Map(
3150
3368
  prevLock.filter((e) => e.id && e.catalogRef).map((e) => [migrationByOldId.get(e.id) ?? e.id, e.catalogRef])
3151
3369
  );
@@ -3179,6 +3397,26 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3179
3397
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
3180
3398
  return [...new Set(files)];
3181
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
+ }
3182
3420
  async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, quiet) {
3183
3421
  const say = quiet ? () => {
3184
3422
  } : log;
@@ -3186,26 +3424,25 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
3186
3424
  const entry = prevLock.find((candidate) => candidate.id === migration.oldId);
3187
3425
  if (!entry) continue;
3188
3426
  const relPaths = entry.paths ?? [];
3189
- const existing = [];
3190
- for (const rel of relPaths) {
3191
- if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3192
- }
3427
+ const existing = await resolveExistingTrackedPaths(root, migration.oldId, relPaths, warn);
3193
3428
  if (existing.length === 0) continue;
3194
3429
  if (entry.hash === void 0) {
3195
3430
  warn(
3196
- `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(", ")}`
3197
3432
  );
3198
3433
  continue;
3199
3434
  }
3200
- const currentHash = await hashInstalledPaths(root, existing);
3435
+ const currentHash = await hashInstalledPaths(
3436
+ root,
3437
+ existing.map((e) => e.rel)
3438
+ );
3201
3439
  if (currentHash !== entry.hash) {
3202
3440
  warn(
3203
- `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(", ")}`
3204
3442
  );
3205
3443
  continue;
3206
3444
  }
3207
- for (const rel of existing) {
3208
- const abs = path19.join(root, rel);
3445
+ for (const { abs } of existing) {
3209
3446
  if (dryRun) {
3210
3447
  say(
3211
3448
  `[dry-run] would remove renamed ${displayPath(root, abs)} (${migration.oldId} \u2192 ${migration.newId})`
@@ -3213,9 +3450,21 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
3213
3450
  continue;
3214
3451
  }
3215
3452
  await fs18.remove(abs);
3216
- await pruneEmptyDir(path19.dirname(abs));
3453
+ await pruneEmptyDir(path20.dirname(abs));
3454
+ }
3455
+ }
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;
3217
3464
  }
3465
+ if (await fs18.pathExists(abs)) existing.push({ rel, abs });
3218
3466
  }
3467
+ return existing;
3219
3468
  }
3220
3469
  async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
3221
3470
  const say = quiet ? () => {
@@ -3230,33 +3479,32 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
3230
3479
  if (!removedFromManifest && !deprecated) continue;
3231
3480
  const relPaths = entry.paths ?? [];
3232
3481
  if (relPaths.length === 0) continue;
3233
- const existing = [];
3234
- for (const rel of relPaths) {
3235
- if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3236
- }
3482
+ const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
3237
3483
  if (existing.length === 0) continue;
3238
3484
  const pruneReason = deprecated ? "deprecated" : "stale";
3239
3485
  if (entry.hash === void 0) {
3240
3486
  warn(
3241
- `${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(", ")}`
3242
3488
  );
3243
3489
  continue;
3244
3490
  }
3245
- const currentHash = await hashInstalledPaths(root, relPaths);
3491
+ const currentHash = await hashInstalledPaths(
3492
+ root,
3493
+ existing.map((e) => e.rel)
3494
+ );
3246
3495
  if (currentHash !== entry.hash) {
3247
3496
  warn(
3248
- `${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(", ")}`
3249
3498
  );
3250
3499
  continue;
3251
3500
  }
3252
- for (const rel of existing) {
3253
- const abs = path19.join(root, rel);
3501
+ for (const { abs } of existing) {
3254
3502
  if (dryRun) {
3255
3503
  say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
3256
3504
  continue;
3257
3505
  }
3258
3506
  await fs18.remove(abs);
3259
- await pruneEmptyDir(path19.dirname(abs));
3507
+ await pruneEmptyDir(path20.dirname(abs));
3260
3508
  say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
3261
3509
  }
3262
3510
  }
@@ -3267,12 +3515,12 @@ async function backupBeforePrune(root, absPaths, say) {
3267
3515
  const backupRoot = hausPath(root, "backups", `prune-${stamp}`);
3268
3516
  for (const abs of absPaths) {
3269
3517
  if (!await fs18.pathExists(abs)) continue;
3270
- const rel = path19.relative(root, abs);
3271
- const backupPath = path19.join(backupRoot, rel);
3272
- 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));
3273
3521
  await fs18.copy(abs, backupPath);
3274
3522
  }
3275
- 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.`);
3276
3524
  }
3277
3525
  async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun, quiet) {
3278
3526
  const say = quiet ? () => {
@@ -3284,21 +3532,21 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3284
3532
  if (!entry.id) continue;
3285
3533
  const relPaths = entry.paths ?? [];
3286
3534
  if (relPaths.length === 0) continue;
3287
- const existing = [];
3288
- for (const rel of relPaths) {
3289
- if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3290
- }
3535
+ const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
3291
3536
  if (existing.length === 0) continue;
3292
3537
  if (entry.hash === void 0) {
3293
3538
  warn(
3294
- `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(", ")}`
3295
3540
  );
3296
3541
  continue;
3297
3542
  }
3298
- const currentHash = await hashInstalledPaths(root, relPaths);
3543
+ const currentHash = await hashInstalledPaths(
3544
+ root,
3545
+ existing.map((e) => e.rel)
3546
+ );
3299
3547
  if (currentHash !== entry.hash) {
3300
3548
  warn(
3301
- `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(", ")}`
3302
3550
  );
3303
3551
  continue;
3304
3552
  }
@@ -3307,21 +3555,18 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3307
3555
  if (toRemove.length === 0) return;
3308
3556
  if (dryRun) {
3309
3557
  for (const { id, existing } of toRemove) {
3310
- for (const rel of existing) {
3311
- 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})`);
3312
3560
  }
3313
3561
  }
3314
3562
  return;
3315
3563
  }
3316
- const allAbsPaths = toRemove.flatMap(
3317
- ({ existing }) => existing.map((rel) => path19.join(root, rel))
3318
- );
3564
+ const allAbsPaths = toRemove.flatMap(({ existing }) => existing.map(({ abs }) => abs));
3319
3565
  await backupBeforePrune(root, allAbsPaths, say);
3320
3566
  for (const { id, existing } of toRemove) {
3321
- for (const rel of existing) {
3322
- const abs = path19.join(root, rel);
3567
+ for (const { abs } of existing) {
3323
3568
  await fs18.remove(abs);
3324
- await pruneEmptyDir(path19.dirname(abs));
3569
+ await pruneEmptyDir(path20.dirname(abs));
3325
3570
  say(`Pruned orphaned ${displayPath(root, abs)} (${id})`);
3326
3571
  }
3327
3572
  }
@@ -3329,7 +3574,7 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3329
3574
 
3330
3575
  // src/claude/write-gitignore.ts
3331
3576
  init_exec();
3332
- import path20 from "node:path";
3577
+ import path21 from "node:path";
3333
3578
  import fs19 from "fs-extra";
3334
3579
  var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
3335
3580
  var GITIGNORE_END = "# HAUS:END haus-managed";
@@ -3444,14 +3689,14 @@ function injectLinkContextGitignoreBlock(existing) {
3444
3689
  );
3445
3690
  }
3446
3691
  async function writeGitignore(root, dryRun) {
3447
- const filePath = path20.join(root, ".gitignore");
3692
+ const filePath = path21.join(root, ".gitignore");
3448
3693
  const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3449
3694
  const next = injectGitignoreBlock(prev);
3450
3695
  await writeManagedText(root, filePath, next, dryRun);
3451
3696
  return filePath;
3452
3697
  }
3453
3698
  async function writeLinkContextGitignore(root, dryRun) {
3454
- const filePath = path20.join(root, ".gitignore");
3699
+ const filePath = path21.join(root, ".gitignore");
3455
3700
  const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3456
3701
  const next = injectLinkContextGitignoreBlock(prev);
3457
3702
  await writeManagedText(root, filePath, next, dryRun);
@@ -3493,15 +3738,15 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
3493
3738
  }
3494
3739
 
3495
3740
  // src/refs/fetch-refs.ts
3496
- import path22 from "node:path";
3741
+ import path23 from "node:path";
3497
3742
  import fs21 from "fs-extra";
3498
3743
 
3499
3744
  // src/refs/cache-meta.ts
3500
- import path21 from "node:path";
3745
+ import path22 from "node:path";
3501
3746
  import fs20 from "fs-extra";
3502
3747
  var META_FILENAME = "cache-meta.json";
3503
3748
  async function readCacheMeta(cacheDir) {
3504
- const metaPath = path21.join(cacheDir, META_FILENAME);
3749
+ const metaPath = path22.join(cacheDir, META_FILENAME);
3505
3750
  try {
3506
3751
  const raw = await fs20.readFile(metaPath, "utf8");
3507
3752
  return JSON.parse(raw);
@@ -3511,7 +3756,7 @@ async function readCacheMeta(cacheDir) {
3511
3756
  }
3512
3757
  async function writeCacheMeta(cacheDir, meta) {
3513
3758
  await fs20.ensureDir(cacheDir);
3514
- const metaPath = path21.join(cacheDir, META_FILENAME);
3759
+ const metaPath = path22.join(cacheDir, META_FILENAME);
3515
3760
  await fs20.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
3516
3761
  }
3517
3762
 
@@ -3532,8 +3777,8 @@ function isLlmsTxtUrl(ref) {
3532
3777
  async function fetchSingleRef(url, cacheDir, meta) {
3533
3778
  const existing = meta[url];
3534
3779
  const headers = {};
3535
- const safeFile = existing?.file && existing.file === path22.basename(existing.file);
3536
- 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));
3537
3782
  if (cachedFileExists) {
3538
3783
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
3539
3784
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -3545,7 +3790,7 @@ async function fetchSingleRef(url, cacheDir, meta) {
3545
3790
  const text = await res.text();
3546
3791
  const file = `${urlToSlug(url)}.md`;
3547
3792
  await fs21.ensureDir(cacheDir);
3548
- await fs21.writeFile(path22.join(cacheDir, file), text, "utf8");
3793
+ await fs21.writeFile(path23.join(cacheDir, file), text, "utf8");
3549
3794
  meta[url] = {
3550
3795
  url,
3551
3796
  etag: res.headers.get("etag") ?? void 0,
@@ -3568,8 +3813,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
3568
3813
  if (keepUrls.has(url)) continue;
3569
3814
  delete meta[url];
3570
3815
  removed++;
3571
- const safeFile = typeof entry.file === "string" && entry.file === path22.basename(entry.file);
3572
- 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(() => {
3573
3818
  });
3574
3819
  }
3575
3820
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -3591,10 +3836,10 @@ async function fetchRefsForItems(items, cacheDir) {
3591
3836
  const outcome = await fetchSingleRef(url, cacheDir, meta);
3592
3837
  if (outcome.result === "fetched") {
3593
3838
  summary.fetched++;
3594
- summary.cachedFiles[url] = path22.join(cacheDir, outcome.file);
3839
+ summary.cachedFiles[url] = path23.join(cacheDir, outcome.file);
3595
3840
  } else if (outcome.result === "unchanged") {
3596
3841
  summary.unchanged++;
3597
- 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);
3598
3843
  } else {
3599
3844
  summary.failed++;
3600
3845
  summary.failedUrls.push(url);
@@ -3617,7 +3862,7 @@ function parseIdList(value) {
3617
3862
  // src/commands/apply.ts
3618
3863
  var STALE_CACHE_THRESHOLD_DAYS = 7;
3619
3864
  async function cacheHasItems() {
3620
- const data = await readJson(path23.join(getCacheDir(), "manifest.json"));
3865
+ const data = await readJson(path24.join(getCacheDir(), "manifest.json"));
3621
3866
  return Array.isArray(data?.items) && data.items.length > 0;
3622
3867
  }
3623
3868
  function installableRecommendedItems(recommended) {
@@ -3768,7 +4013,7 @@ async function runApply(options) {
3768
4013
  }
3769
4014
 
3770
4015
  // src/commands/backups.ts
3771
- import path24 from "node:path";
4016
+ import path25 from "node:path";
3772
4017
  import fs22 from "fs-extra";
3773
4018
 
3774
4019
  // src/utils/prompts.ts
@@ -3805,7 +4050,7 @@ async function listBackups(root) {
3805
4050
  for (const name of names) {
3806
4051
  const kind = classify(name);
3807
4052
  if (!kind) continue;
3808
- const absPath = path24.join(dir, name);
4053
+ const absPath = path25.join(dir, name);
3809
4054
  const stat = await fs22.lstat(absPath);
3810
4055
  if (stat.isSymbolicLink()) continue;
3811
4056
  const expectDirectory = kind !== "lock";
@@ -3835,10 +4080,10 @@ async function runList(root) {
3835
4080
  }
3836
4081
  }
3837
4082
  async function isUnsafeWriteTarget(root, target) {
3838
- const segments = path24.relative(root, target).split(path24.sep).filter(Boolean);
4083
+ const segments = path25.relative(root, target).split(path25.sep).filter(Boolean);
3839
4084
  let current = root;
3840
4085
  for (let i = 0; i < segments.length; i++) {
3841
- current = path24.join(current, segments[i]);
4086
+ current = path25.join(current, segments[i]);
3842
4087
  const isLastSegment = i === segments.length - 1;
3843
4088
  try {
3844
4089
  const stat = await fs22.lstat(current);
@@ -3902,7 +4147,7 @@ async function collectFilesRecursive(dir) {
3902
4147
  const skippedOther = [];
3903
4148
  const names = await fs22.readdir(dir);
3904
4149
  for (const name of names) {
3905
- const abs = path24.join(dir, name);
4150
+ const abs = path25.join(dir, name);
3906
4151
  const stat = await fs22.lstat(abs);
3907
4152
  if (stat.isSymbolicLink()) {
3908
4153
  skippedLinks.push(abs);
@@ -3925,12 +4170,12 @@ async function restoreDirBackup(root, entry, yes) {
3925
4170
  const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
3926
4171
  if (skippedLinks.length > 0) {
3927
4172
  warn(
3928
- `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(", ")}`
3929
4174
  );
3930
4175
  }
3931
4176
  if (skippedOther.length > 0) {
3932
4177
  warn(
3933
- `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(", ")}`
3934
4179
  );
3935
4180
  }
3936
4181
  if (files.length === 0) {
@@ -3940,8 +4185,8 @@ async function restoreDirBackup(root, entry, yes) {
3940
4185
  const safeFiles = [];
3941
4186
  const unsafeTargets = [];
3942
4187
  for (const abs of files) {
3943
- const rel = path24.relative(entry.absPath, abs);
3944
- const target = path24.join(root, rel);
4188
+ const rel = path25.relative(entry.absPath, abs);
4189
+ const target = path25.join(root, rel);
3945
4190
  if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
3946
4191
  else safeFiles.push(abs);
3947
4192
  }
@@ -3958,14 +4203,14 @@ async function restoreDirBackup(root, entry, yes) {
3958
4203
  const vanished = [];
3959
4204
  const toRestore = [];
3960
4205
  for (const abs of safeFiles) {
3961
- const rel = path24.relative(entry.absPath, abs);
4206
+ const rel = path25.relative(entry.absPath, abs);
3962
4207
  const backupFileStat = await lstatIfRegularFile(abs);
3963
4208
  if (!backupFileStat) {
3964
4209
  vanished.push(rel);
3965
4210
  continue;
3966
4211
  }
3967
4212
  toRestore.push(abs);
3968
- const target = path24.join(root, rel);
4213
+ const target = path25.join(root, rel);
3969
4214
  if (await fs22.pathExists(target)) {
3970
4215
  const targetStat = await fs22.stat(target);
3971
4216
  if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
@@ -4006,13 +4251,13 @@ async function restoreDirBackup(root, entry, yes) {
4006
4251
  let restoredCount = 0;
4007
4252
  const skippedAtWrite = [];
4008
4253
  for (const abs of toRestore) {
4009
- const rel = path24.relative(entry.absPath, abs);
4010
- const target = path24.join(root, rel);
4254
+ const rel = path25.relative(entry.absPath, abs);
4255
+ const target = path25.join(root, rel);
4011
4256
  if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
4012
4257
  skippedAtWrite.push(rel);
4013
4258
  continue;
4014
4259
  }
4015
- await fs22.ensureDir(path24.dirname(target));
4260
+ await fs22.ensureDir(path25.dirname(target));
4016
4261
  await fs22.copy(abs, target, { overwrite: true });
4017
4262
  restoredCount++;
4018
4263
  }
@@ -4151,16 +4396,53 @@ async function runCatalogAudit() {
4151
4396
  }
4152
4397
 
4153
4398
  // src/commands/doctor.ts
4154
- import path38 from "node:path";
4155
- 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
+ }
4156
4438
 
4157
4439
  // src/decisions/doctor.ts
4158
- import path29 from "node:path";
4159
- import fs26 from "fs-extra";
4440
+ import path31 from "node:path";
4441
+ import fs27 from "fs-extra";
4160
4442
 
4161
4443
  // src/decisions/check.ts
4162
- import path28 from "node:path";
4163
- import fs25 from "fs-extra";
4444
+ import path30 from "node:path";
4445
+ import fs26 from "fs-extra";
4164
4446
 
4165
4447
  // src/decisions/dep-bump.ts
4166
4448
  var DEP_SECTIONS = [
@@ -4169,15 +4451,15 @@ var DEP_SECTIONS = [
4169
4451
  "peerDependencies",
4170
4452
  "optionalDependencies"
4171
4453
  ];
4172
- function isRecord(value) {
4454
+ function isRecord2(value) {
4173
4455
  return value != null && typeof value === "object" && !Array.isArray(value);
4174
4456
  }
4175
4457
  function extractDepMap(pkg) {
4176
4458
  const map = /* @__PURE__ */ new Map();
4177
- if (!isRecord(pkg)) return map;
4459
+ if (!isRecord2(pkg)) return map;
4178
4460
  for (const section of DEP_SECTIONS) {
4179
4461
  const block = pkg[section];
4180
- if (!isRecord(block)) continue;
4462
+ if (!isRecord2(block)) continue;
4181
4463
  for (const [name, ver] of Object.entries(block)) {
4182
4464
  if (typeof ver === "string") map.set(`${section}:${name}`, ver);
4183
4465
  }
@@ -4203,7 +4485,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
4203
4485
  } catch {
4204
4486
  return false;
4205
4487
  }
4206
- if (!isRecord(before) || !isRecord(after)) return false;
4488
+ if (!isRecord2(before) || !isRecord2(after)) return false;
4207
4489
  const beforeDeps = extractDepMap(before);
4208
4490
  const afterDeps = extractDepMap(after);
4209
4491
  if (beforeDeps.size === 0 && afterDeps.size === 0) return false;
@@ -4219,7 +4501,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
4219
4501
 
4220
4502
  // src/decisions/diff.ts
4221
4503
  init_exec();
4222
- import path25 from "node:path";
4504
+ import path27 from "node:path";
4223
4505
  function isPackageJsonPath(file) {
4224
4506
  return file === "package.json" || file.endsWith("/package.json");
4225
4507
  }
@@ -4246,6 +4528,11 @@ function parseDiffRange(range) {
4246
4528
  }
4247
4529
  return null;
4248
4530
  }
4531
+ function toAncestryLogRange(range) {
4532
+ const parsed = parseDiffRange(range);
4533
+ if (!parsed) return `${range}..HEAD`;
4534
+ return `${parsed.left}..${parsed.right}`;
4535
+ }
4249
4536
  async function getCurrentBranch(root) {
4250
4537
  const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root });
4251
4538
  if (result.exitCode !== 0) return void 0;
@@ -4275,7 +4562,7 @@ async function resolvePackageJsonPair(root, file, opts) {
4275
4562
  return { before: before2, after: after2 };
4276
4563
  }
4277
4564
  const before = await readBlob(root, `${range}:${file}`) ?? empty;
4278
- const after = await readText(path25.join(root, file)) ?? empty;
4565
+ const after = await readText(path27.join(root, file)) ?? empty;
4279
4566
  return { before, after };
4280
4567
  }
4281
4568
  async function collectDiffStats(root, opts) {
@@ -4346,22 +4633,22 @@ function firstMatchingRegex(filePath, patterns) {
4346
4633
  }
4347
4634
 
4348
4635
  // src/decisions/paths.ts
4349
- import path26 from "node:path";
4350
- import fs23 from "fs-extra";
4636
+ import path28 from "node:path";
4637
+ import fs24 from "fs-extra";
4351
4638
  async function resolveDecisionsDir(root) {
4352
- const primary = path26.join(root, DECISIONS_TRIGGERS.decisionsDir);
4353
- if (await fs23.pathExists(primary)) return primary;
4354
- const legacy = path26.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4355
- 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;
4356
4643
  return primary;
4357
4644
  }
4358
4645
  function relativeDecisionPath(decisionsDir, root) {
4359
- return path26.relative(root, decisionsDir).replace(/\\/g, "/");
4646
+ return path28.relative(root, decisionsDir).replace(/\\/g, "/");
4360
4647
  }
4361
4648
 
4362
4649
  // src/decisions/validate.ts
4363
- import path27 from "node:path";
4364
- import fs24 from "fs-extra";
4650
+ import path29 from "node:path";
4651
+ import fs25 from "fs-extra";
4365
4652
  function validateDecisionContent(content2) {
4366
4653
  const errors = [];
4367
4654
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -4390,14 +4677,14 @@ function decisionNumberFromFile(name) {
4390
4677
  return match?.[1] ?? null;
4391
4678
  }
4392
4679
  async function validateIndexSync(decisionsDir, decisionFiles) {
4393
- const readmePath = path27.join(decisionsDir, "README.md");
4394
- if (!await fs24.pathExists(readmePath)) {
4680
+ const readmePath = path29.join(decisionsDir, "README.md");
4681
+ if (!await fs25.pathExists(readmePath)) {
4395
4682
  return ["docs/decisions/README.md is missing"];
4396
4683
  }
4397
4684
  const readme = await readText(readmePath) ?? "";
4398
4685
  const errors = [];
4399
4686
  for (const file of decisionFiles) {
4400
- const num = decisionNumberFromFile(path27.basename(file));
4687
+ const num = decisionNumberFromFile(path29.basename(file));
4401
4688
  if (num && !indexReferencesNumber(readme, num)) {
4402
4689
  errors.push(`README.md missing index row for ADR-${num}`);
4403
4690
  }
@@ -4492,22 +4779,22 @@ async function checkSatisfaction(input2) {
4492
4779
  const errors = [];
4493
4780
  const decisionsPrefix = `${input2.decisionsRel}/`;
4494
4781
  const newDecisionFiles = input2.changedFiles.filter(
4495
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path28.basename(f))
4782
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path30.basename(f))
4496
4783
  );
4497
4784
  if (newDecisionFiles.length === 0) {
4498
4785
  errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
4499
4786
  return errors;
4500
4787
  }
4501
- const decisionsDir = path28.join(input2.root, input2.decisionsRel);
4788
+ const decisionsDir = path30.join(input2.root, input2.decisionsRel);
4502
4789
  for (const rel of newDecisionFiles) {
4503
- const abs = path28.join(input2.root, rel);
4790
+ const abs = path30.join(input2.root, rel);
4504
4791
  const content2 = await readText(abs) ?? "";
4505
4792
  const valid = validateDecisionContent(content2);
4506
4793
  if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
4507
4794
  }
4508
4795
  const indexErrors = await validateIndexSync(
4509
4796
  decisionsDir,
4510
- newDecisionFiles.map((f) => path28.basename(f))
4797
+ newDecisionFiles.map((f) => path30.basename(f))
4511
4798
  );
4512
4799
  errors.push(...indexErrors);
4513
4800
  return errors;
@@ -4563,8 +4850,8 @@ ${opts.commitMessages ?? ""}`;
4563
4850
  changedFiles: stats.files
4564
4851
  });
4565
4852
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
4566
- const legacyExists = await fs25.pathExists(path28.join(root, legacyRel));
4567
- if (legacyExists && !await fs25.pathExists(decisionsDir)) {
4853
+ const legacyExists = await fs26.pathExists(path30.join(root, legacyRel));
4854
+ if (legacyExists && !await fs26.pathExists(decisionsDir)) {
4568
4855
  satisfactionErrors = await checkSatisfaction({
4569
4856
  root,
4570
4857
  decisionsRel: legacyRel,
@@ -4586,18 +4873,18 @@ ${opts.commitMessages ?? ""}`;
4586
4873
  return { triggered: true, satisfied: false, reasons, securityTriggered };
4587
4874
  }
4588
4875
  async function listDecisionNumbers(decisionsDir) {
4589
- if (!await fs25.pathExists(decisionsDir)) return [];
4590
- const entries = await fs25.readdir(decisionsDir);
4876
+ if (!await fs26.pathExists(decisionsDir)) return [];
4877
+ const entries = await fs26.readdir(decisionsDir);
4591
4878
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
4592
4879
  }
4593
4880
 
4594
4881
  // src/decisions/doctor.ts
4595
4882
  async function auditDecisionsLayout(root) {
4596
4883
  const findings = [];
4597
- const primary = path29.join(root, DECISIONS_TRIGGERS.decisionsDir);
4598
- const legacy = path29.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4599
- const hasPrimary = await fs26.pathExists(primary);
4600
- 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);
4601
4888
  if (!hasPrimary && hasLegacy) {
4602
4889
  findings.push({
4603
4890
  level: "advisory",
@@ -4610,8 +4897,8 @@ async function auditDecisionsLayout(root) {
4610
4897
  if (!hasPrimary) return findings;
4611
4898
  const decisionsDir = await resolveDecisionsDir(root);
4612
4899
  const rel = relativeDecisionPath(decisionsDir, root);
4613
- const readmePath = path29.join(decisionsDir, "README.md");
4614
- if (!await fs26.pathExists(readmePath)) {
4900
+ const readmePath = path31.join(decisionsDir, "README.md");
4901
+ if (!await fs27.pathExists(readmePath)) {
4615
4902
  findings.push({
4616
4903
  level: "advisory",
4617
4904
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -4621,7 +4908,7 @@ async function auditDecisionsLayout(root) {
4621
4908
  return findings;
4622
4909
  }
4623
4910
  const readme = await readText(readmePath);
4624
- const entries = (await fs26.readdir(decisionsDir)).filter(
4911
+ const entries = (await fs27.readdir(decisionsDir)).filter(
4625
4912
  (f) => f.endsWith(".md") && f !== "README.md"
4626
4913
  );
4627
4914
  for (const file of entries) {
@@ -4635,7 +4922,7 @@ async function auditDecisionsLayout(root) {
4635
4922
  });
4636
4923
  }
4637
4924
  }
4638
- const claude = await readText(path29.join(root, "CLAUDE.md"));
4925
+ const claude = await readText(path31.join(root, "CLAUDE.md"));
4639
4926
  if (claude && !claude.includes(`@${rel}/README.md`)) {
4640
4927
  findings.push({
4641
4928
  level: "advisory",
@@ -4666,11 +4953,11 @@ async function auditDecisionsLayout(root) {
4666
4953
  }
4667
4954
 
4668
4955
  // src/scanner/read-context.ts
4669
- import path37 from "node:path";
4670
- import fs28 from "fs-extra";
4956
+ import path38 from "node:path";
4957
+ import fs29 from "fs-extra";
4671
4958
 
4672
4959
  // src/commands/workspace/config.ts
4673
- import path30 from "node:path";
4960
+ import path32 from "node:path";
4674
4961
  import YAML from "yaml";
4675
4962
  var WORKSPACE_FILE = "haus.workspace.yaml";
4676
4963
  function parseWorkspaceConfig(text) {
@@ -4693,11 +4980,11 @@ function parseWorkspaceConfig(text) {
4693
4980
  };
4694
4981
  }
4695
4982
  async function readWorkspaceConfig(workspaceRoot) {
4696
- return parseWorkspaceConfig(await readText(path30.join(workspaceRoot, WORKSPACE_FILE)));
4983
+ return parseWorkspaceConfig(await readText(path32.join(workspaceRoot, WORKSPACE_FILE)));
4697
4984
  }
4698
4985
 
4699
4986
  // src/workspace/members.ts
4700
- import path31 from "node:path";
4987
+ import path33 from "node:path";
4701
4988
  var REPOS_MANIFEST_FILE = "repos.manifest.json";
4702
4989
  var REPOS_LOCAL_FILE = "repos.local.json";
4703
4990
  var MemberConfigError = class extends Error {
@@ -4710,14 +4997,14 @@ var MemberConfigError = class extends Error {
4710
4997
  };
4711
4998
  function resolveAbsPath(workspaceRoot, folder, overrides) {
4712
4999
  const override = overrides[folder];
4713
- if (override) return path31.resolve(workspaceRoot, override);
4714
- return path31.resolve(workspaceRoot, folder);
5000
+ if (override) return path33.resolve(workspaceRoot, override);
5001
+ return path33.resolve(workspaceRoot, folder);
4715
5002
  }
4716
5003
  function isSelfMember(workspaceRoot, member) {
4717
- return path31.resolve(member.absPath) === path31.resolve(workspaceRoot);
5004
+ return path33.resolve(member.absPath) === path33.resolve(workspaceRoot);
4718
5005
  }
4719
5006
  async function readPathOverrides(workspaceRoot) {
4720
- const filePath = path31.join(workspaceRoot, REPOS_LOCAL_FILE);
5007
+ const filePath = path33.join(workspaceRoot, REPOS_LOCAL_FILE);
4721
5008
  const result = await readJsonDetailed(filePath);
4722
5009
  if (result.status === "missing") return {};
4723
5010
  if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
@@ -4733,7 +5020,7 @@ async function readPathOverrides(workspaceRoot) {
4733
5020
  return overrides;
4734
5021
  }
4735
5022
  async function readFromWorkspaceYaml(workspaceRoot, overrides) {
4736
- const filePath = path31.join(workspaceRoot, WORKSPACE_FILE);
5023
+ const filePath = path33.join(workspaceRoot, WORKSPACE_FILE);
4737
5024
  const text = await readText(filePath);
4738
5025
  if (text === void 0) return void 0;
4739
5026
  const config = parseWorkspaceConfig(text);
@@ -4746,7 +5033,7 @@ async function readFromWorkspaceYaml(workspaceRoot, overrides) {
4746
5033
  }));
4747
5034
  }
4748
5035
  async function readFromManifest(workspaceRoot, overrides) {
4749
- const filePath = path31.join(workspaceRoot, REPOS_MANIFEST_FILE);
5036
+ const filePath = path33.join(workspaceRoot, REPOS_MANIFEST_FILE);
4750
5037
  const result = await readJsonDetailed(filePath);
4751
5038
  if (result.status === "missing") return void 0;
4752
5039
  if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
@@ -4788,16 +5075,11 @@ async function readMembers(rootInfo) {
4788
5075
  }
4789
5076
 
4790
5077
  // src/scanner/scan-project.ts
4791
- import path36 from "node:path";
4792
-
4793
- // src/utils/audit-checks.ts
4794
- function isRecord2(v) {
4795
- return typeof v === "object" && v !== null;
4796
- }
5078
+ import path37 from "node:path";
4797
5079
 
4798
5080
  // src/utils/git-root.ts
4799
5081
  init_exec();
4800
- import path32 from "node:path";
5082
+ import path34 from "node:path";
4801
5083
  function fallback(start) {
4802
5084
  return {
4803
5085
  cwd: start,
@@ -4813,11 +5095,11 @@ function fallback(start) {
4813
5095
  async function resolveAbsoluteGitPath(flag, cwd, repoRoot) {
4814
5096
  const withFormat = await runGit(["rev-parse", "--path-format=absolute", flag], { cwd });
4815
5097
  const raw = withFormat.exitCode === 0 ? withFormat.stdout.trim() : "";
4816
- if (raw && path32.isAbsolute(raw)) return path32.resolve(raw);
5098
+ if (raw && path34.isAbsolute(raw)) return path34.resolve(raw);
4817
5099
  const plain = await runGit(["rev-parse", flag], { cwd });
4818
5100
  const plainRaw = plain.exitCode === 0 ? plain.stdout.trim() : "";
4819
5101
  if (!plainRaw) return "";
4820
- return path32.isAbsolute(plainRaw) ? path32.resolve(plainRaw) : path32.resolve(repoRoot, plainRaw);
5102
+ return path34.isAbsolute(plainRaw) ? path34.resolve(plainRaw) : path34.resolve(repoRoot, plainRaw);
4821
5103
  }
4822
5104
  async function resolveRoots(start) {
4823
5105
  const cwd = start ?? process.cwd();
@@ -4826,15 +5108,15 @@ async function resolveRoots(start) {
4826
5108
  if (toplevel.exitCode !== 0) return fallback(cwd);
4827
5109
  const toplevelRaw = toplevel.stdout.trim();
4828
5110
  if (!toplevelRaw) return fallback(cwd);
4829
- const repoRoot = path32.resolve(toplevelRaw);
5111
+ const repoRoot = path34.resolve(toplevelRaw);
4830
5112
  const [gitDir, gitCommonDir] = await Promise.all([
4831
5113
  resolveAbsoluteGitPath("--git-dir", cwd, repoRoot),
4832
5114
  resolveAbsoluteGitPath("--git-common-dir", cwd, repoRoot)
4833
5115
  ]);
4834
5116
  if (!gitDir || !gitCommonDir) return fallback(cwd);
4835
5117
  const isLinkedWorktree = gitDir !== gitCommonDir;
4836
- const mainRoot = isLinkedWorktree && path32.basename(gitCommonDir) === ".git" ? path32.dirname(gitCommonDir) : repoRoot;
4837
- 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;
4838
5120
  return {
4839
5121
  cwd,
4840
5122
  repoRoot,
@@ -4879,8 +5161,8 @@ function resolveMajorVersion(range) {
4879
5161
  }
4880
5162
 
4881
5163
  // src/scanner/detect-package-manager.ts
4882
- import path33 from "node:path";
4883
- import fs27 from "fs-extra";
5164
+ import path35 from "node:path";
5165
+ import fs28 from "fs-extra";
4884
5166
  function detectPackageManager(root, packageManagerField) {
4885
5167
  const field = String(packageManagerField ?? "").trim();
4886
5168
  if (field.startsWith("yarn@")) {
@@ -4898,9 +5180,9 @@ function detectPackageManager(root, packageManagerField) {
4898
5180
  if (satisfiesVersion(version, ">=9")) return "npm";
4899
5181
  return "unknown";
4900
5182
  }
4901
- if (fs27.existsSync(path33.join(root, "yarn.lock"))) return "yarn";
4902
- if (fs27.existsSync(path33.join(root, "pnpm-lock.yaml"))) return "pnpm";
4903
- 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";
4904
5186
  return "unknown";
4905
5187
  }
4906
5188
 
@@ -5017,6 +5299,7 @@ var STACK_RULES = [
5017
5299
  stack: ["frontend", "shadcn"],
5018
5300
  all: [fileEndsWith("components.json"), dep("class-variance-authority")]
5019
5301
  },
5302
+ { stack: ["frontend", "radix"], any: [depPrefix("@radix-ui/")] },
5020
5303
  { stack: ["tooling", "typescript5"], any: [depMajor("typescript", 5)] },
5021
5304
  { stack: ["backend", "sanity"], any: [dep("sanity"), dep("next-sanity"), dep("@sanity/client")] },
5022
5305
  { stack: ["backend", "strapi"], any: [dep("@strapi/strapi"), depPrefix("@strapi/")] },
@@ -5111,90 +5394,20 @@ function runDetection(ctx, rules = STACK_RULES) {
5111
5394
  for (const rule of ROLE_RULES) {
5112
5395
  if (rule.role && matchRule(rule, ctx) && !roles.includes(rule.role)) roles.push(rule.role);
5113
5396
  }
5114
- const stacks = {};
5115
- for (const bucket of STACK_BUCKETS) stacks[bucket] = [];
5116
- for (const rule of rules) {
5117
- if (!rule.stack || !matchRule(rule, ctx)) continue;
5118
- const [bucket, name] = rule.stack;
5119
- stacks[bucket] ??= [];
5120
- if (!stacks[bucket].includes(name)) stacks[bucket].push(name);
5121
- }
5122
- return { roles, stacks };
5123
- }
5124
-
5125
- // src/scanner/detection.ts
5126
- import path34 from "node:path";
5127
- var UNSUPPORTED_MARKERS = {
5128
- "requirements.txt": "python",
5129
- "pyproject.toml": "python",
5130
- "go.mod": "go",
5131
- "Cargo.toml": "rust",
5132
- "pom.xml": "java",
5133
- "build.gradle": "java",
5134
- "build.gradle.kts": "java",
5135
- Gemfile: "ruby"
5136
- };
5137
- var WEAK_STACK_SIGNALS = /* @__PURE__ */ new Set(["missing-prettier", "missing-eslint"]);
5138
- function computeDetectionStatus(roles, stacks, unsupportedSignals) {
5139
- const hasRealStack = Object.entries(stacks).some(
5140
- ([bucket, names]) => bucket !== "packageManagers" && names.some((n) => !WEAK_STACK_SIGNALS.has(n))
5141
- );
5142
- const hasRealSignal = roles.length > 0 || hasRealStack;
5143
- if (!hasRealSignal) return "unknown";
5144
- return unsupportedSignals.length > 0 ? "partial" : "supported";
5145
- }
5146
- function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
5147
- const markers = unsupportedSignals.join(", ");
5148
- const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
5149
- 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." : "";
5150
- return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
5151
- }
5152
- function blocked(rel) {
5153
- return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
5154
- }
5155
- function dependencyVersionMap(pkg, composer) {
5156
- const versions = /* @__PURE__ */ new Map();
5157
- const pushObj = (obj) => {
5158
- if (!isRecord2(obj)) return;
5159
- for (const key of Object.keys(obj)) {
5160
- if (versions.has(key)) continue;
5161
- const value = obj[key];
5162
- versions.set(key, typeof value === "string" ? value : "");
5163
- }
5164
- };
5165
- pushObj(pkg?.dependencies);
5166
- pushObj(pkg?.devDependencies);
5167
- pushObj(composer?.require);
5168
- pushObj(composer?.["require-dev"]);
5169
- return versions;
5170
- }
5171
- function finalizeRoles(registryRoles, deps, files) {
5172
- const roles = new Set(registryRoles);
5173
- const hasWpConfig = files.some((f) => f.endsWith("wp-config.php"));
5174
- const hasBedrockLayout = files.some((f) => f.includes("web/app")) || deps.includes("roots/wordpress");
5175
- if (hasWpConfig && hasBedrockLayout) {
5176
- roles.add("wordpress-bedrock-site");
5177
- roles.add("wordpress-site");
5178
- } else if (hasWpConfig) {
5179
- roles.add("wordpress-vanilla-site");
5180
- roles.add("wordpress-site");
5181
- } else if (deps.includes("roots/wordpress")) {
5182
- roles.add("wordpress-bedrock-site");
5183
- roles.add("wordpress-site");
5184
- }
5185
- return [...roles].sort();
5186
- }
5187
- function collectUnsupportedSignals(files) {
5188
- return [
5189
- ...new Set(
5190
- files.map((f) => UNSUPPORTED_MARKERS[path34.basename(f)]).filter((s) => Boolean(s))
5191
- )
5192
- ].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 };
5193
5406
  }
5194
5407
 
5195
5408
  // src/scanner/render.ts
5196
5409
  import { readFile } from "node:fs/promises";
5197
- import path35 from "node:path";
5410
+ import path36 from "node:path";
5198
5411
  async function buildContentBlob(root, files) {
5199
5412
  const candidates = files.filter(
5200
5413
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -5202,7 +5415,7 @@ async function buildContentBlob(root, files) {
5202
5415
  const slice = candidates.slice(0, 300);
5203
5416
  const parts = await mapWithConcurrency(slice, async (rel) => {
5204
5417
  try {
5205
- return await readFile(path35.join(root, rel), "utf8");
5418
+ return await readFile(path36.join(root, rel), "utf8");
5206
5419
  } catch {
5207
5420
  return "";
5208
5421
  }
@@ -5294,8 +5507,8 @@ var SAFE_FILES = [
5294
5507
  "Gemfile"
5295
5508
  ];
5296
5509
  async function scanProject(root) {
5297
- const pkg = await readJson(path36.join(root, "package.json"));
5298
- 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"));
5299
5512
  const files = await listFiles(root, SAFE_FILES);
5300
5513
  const safeFiles = files.filter((f) => !blocked(f));
5301
5514
  const depVersions = dependencyVersionMap(pkg, composer);
@@ -5315,7 +5528,7 @@ async function scanProject(root) {
5315
5528
  const warnings = [];
5316
5529
  const securityRisks = [];
5317
5530
  const crossRepoHints = [];
5318
- const nodeEngine = isRecord2(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
5531
+ const nodeEngine = isRecord(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
5319
5532
  if (nodeEngine && !satisfiesVersion(process.version, nodeEngine)) {
5320
5533
  warnings.push(`Current Node ${process.version} does not satisfy package engine ${nodeEngine}`);
5321
5534
  }
@@ -5325,7 +5538,7 @@ async function scanProject(root) {
5325
5538
  crossRepoHints.push("Monorepo orchestration detected");
5326
5539
  const unsupportedSignals = collectUnsupportedSignals(safeFiles);
5327
5540
  const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
5328
- 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);
5329
5542
  const context = {
5330
5543
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5331
5544
  repoName: repoNameFallback,
@@ -5347,14 +5560,15 @@ async function scanProject(root) {
5347
5560
  // src/scanner/read-context.ts
5348
5561
  async function isCacheFresh(root, cachePath) {
5349
5562
  const markerPaths = [
5350
- path37.join(root, "package.json"),
5351
- path37.join(root, WORKSPACE_FILE),
5352
- 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)
5353
5567
  ];
5354
- const cacheStat = await fs28.stat(cachePath);
5568
+ const cacheStat = await fs29.stat(cachePath);
5355
5569
  for (const markerPath of markerPaths) {
5356
- if (!await fs28.pathExists(markerPath)) continue;
5357
- const markerStat = await fs28.stat(markerPath);
5570
+ if (!await fs29.pathExists(markerPath)) continue;
5571
+ const markerStat = await fs29.stat(markerPath);
5358
5572
  if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
5359
5573
  }
5360
5574
  return true;
@@ -5455,7 +5669,7 @@ async function runDoctor(options) {
5455
5669
  } else {
5456
5670
  ok(`- HOOKS OK: ${hooks.message}`);
5457
5671
  }
5458
- const rootClaudeMdPath = path38.join(root, "CLAUDE.md");
5672
+ const rootClaudeMdPath = path39.join(root, "CLAUDE.md");
5459
5673
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
5460
5674
  if (!rootClaudeMdContent) {
5461
5675
  flag(
@@ -5479,13 +5693,31 @@ async function runDoctor(options) {
5479
5693
  "haus apply --write"
5480
5694
  );
5481
5695
  } else {
5482
- ok("- CLAUDE.md: import block present");
5483
5696
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
5484
- const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
5485
- for (const target of importTargets) {
5486
- if (!await fs29.pathExists(hausPath(root, target))) {
5697
+ const importLinePattern = /^@(\S+)\s*$/gm;
5698
+ const actualTargets = [...block.matchAll(importLinePattern)].map((m) => m[1]);
5699
+ const expectedTargets = [...IMPORT_CONTENT.matchAll(importLinePattern)].map((m) => m[1]);
5700
+ const missingTargets = expectedTargets.filter((t) => !actualTargets.includes(t));
5701
+ const foreignTargets = actualTargets.filter((t) => !expectedTargets.includes(t));
5702
+ if (missingTargets.length > 0 || foreignTargets.length > 0) {
5703
+ const parts = [];
5704
+ if (missingTargets.length > 0) parts.push(`missing @${missingTargets.join(", @")}`);
5705
+ if (foreignTargets.length > 0) parts.push(`unexpected @${foreignTargets.join(", @")}`);
5706
+ flag(
5707
+ `- CLAUDE.md: haus import block content does not match expected imports (${parts.join("; ")}) (run \`haus apply --write\` to repair)`,
5708
+ "The haus import block in CLAUDE.md has been altered and no longer imports the expected files",
5709
+ "haus apply --write"
5710
+ );
5711
+ } else {
5712
+ ok("- CLAUDE.md: import block present");
5713
+ }
5714
+ for (const target of actualTargets) {
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)) {
5487
5719
  flag(
5488
- `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
5720
+ `- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
5489
5721
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
5490
5722
  "haus apply --write"
5491
5723
  );
@@ -5493,8 +5725,15 @@ async function runDoctor(options) {
5493
5725
  }
5494
5726
  }
5495
5727
  }
5728
+ if (rootClaudeMdContent && rootClaudeMdContent.includes(WORKSPACE_BLOCK_BEGIN) && !rootClaudeMdContent.includes(BLOCK_BEGIN)) {
5729
+ flag(
5730
+ "- CLAUDE.md: haus-workspace-imports block present but the per-repo haus-imports block is missing (run `haus apply --write`)",
5731
+ "The workspace CLAUDE.md has the workspace import block but not the per-repo one alongside it",
5732
+ "haus apply --write"
5733
+ );
5734
+ }
5496
5735
  const workflowPath = hausPath(root, "WORKFLOW.md");
5497
- const workflowExists = await fs29.pathExists(workflowPath);
5736
+ const workflowExists = await fs30.pathExists(workflowPath);
5498
5737
  if (!workflowExists) {
5499
5738
  flag(
5500
5739
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -5509,15 +5748,15 @@ async function runDoctor(options) {
5509
5748
  } else {
5510
5749
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
5511
5750
  const bodyContent = workflowContent.slice(firstLine.length + 1);
5512
- const cachePath = path38.join(getCacheDir(), "templates/agentic-workflow-standard.md");
5513
- const bundledPath = path38.join(
5751
+ const cachePath = path39.join(getCacheDir(), "templates/agentic-workflow-standard.md");
5752
+ const bundledPath = path39.join(
5514
5753
  packageRoot(),
5515
5754
  "library",
5516
5755
  "global",
5517
5756
  "templates",
5518
5757
  "agentic-workflow-standard.md"
5519
5758
  );
5520
- const templatePath = await fs29.pathExists(cachePath) ? cachePath : bundledPath;
5759
+ const templatePath = await fs30.pathExists(cachePath) ? cachePath : bundledPath;
5521
5760
  const templateContent = await readText(templatePath);
5522
5761
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
5523
5762
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -5538,7 +5777,7 @@ async function runDoctor(options) {
5538
5777
  }
5539
5778
  }
5540
5779
  const workflowConfigPath = hausPath(root, "workflow-config.md");
5541
- const workflowConfigExists = await fs29.pathExists(workflowConfigPath);
5780
+ const workflowConfigExists = await fs30.pathExists(workflowConfigPath);
5542
5781
  if (!workflowConfigExists) {
5543
5782
  flag(
5544
5783
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -5546,7 +5785,7 @@ async function runDoctor(options) {
5546
5785
  "haus apply --write"
5547
5786
  );
5548
5787
  } else {
5549
- const cfg = await fs29.readFile(workflowConfigPath, "utf8");
5788
+ const cfg = await fs30.readFile(workflowConfigPath, "utf8");
5550
5789
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
5551
5790
  if (unfilled > 0) {
5552
5791
  suggest(
@@ -5559,7 +5798,7 @@ async function runDoctor(options) {
5559
5798
  }
5560
5799
  }
5561
5800
  if (workflowExists) {
5562
- const prettierIgnore = await readText(path38.join(root, ".prettierignore")) ?? "";
5801
+ const prettierIgnore = await readText(path39.join(root, ".prettierignore")) ?? "";
5563
5802
  const lines = prettierIgnore.split("\n");
5564
5803
  const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
5565
5804
  if (missing.length > 0) {
@@ -5599,6 +5838,29 @@ async function runDoctor(options) {
5599
5838
  if (finding.level === "ok") ok(finding.line);
5600
5839
  else suggest(finding.line, finding.sentence, finding.fix);
5601
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
+ }
5602
5864
  const cacheAgeMs = await getCacheManifestAge();
5603
5865
  if (cacheAgeMs === null) {
5604
5866
  suggest(
@@ -5619,20 +5881,28 @@ async function runDoctor(options) {
5619
5881
  }
5620
5882
  }
5621
5883
  if (recommendation) {
5622
- const lock = await readJson(hausPath(root, "haus.lock.json"));
5623
- const recommendedIds = new Set(
5624
- recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
5625
- );
5626
- const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
5627
- if (orphaned.length > 0) {
5884
+ if (!Array.isArray(recommendation.recommended)) {
5628
5885
  suggest(
5629
- `- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
5630
- `${orphaned.length} installed item(s) are no longer recommended for this project`,
5631
- "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")
5632
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
+ }
5633
5903
  }
5634
5904
  }
5635
- const pkgJson = await readJson(path38.join(packageRoot(), "package.json"));
5905
+ const pkgJson = await readJson(path39.join(packageRoot(), "package.json"));
5636
5906
  const currentVersion = pkgJson?.version ?? "0.0.0";
5637
5907
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
5638
5908
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -5665,12 +5935,12 @@ async function runDoctor(options) {
5665
5935
  }
5666
5936
 
5667
5937
  // src/commands/update.ts
5668
- import path41 from "node:path";
5938
+ import path42 from "node:path";
5669
5939
 
5670
5940
  // src/claude/refresh-project.ts
5671
- import fs30 from "fs-extra";
5941
+ import fs31 from "fs-extra";
5672
5942
  async function isHausProject(root) {
5673
- if (await fs30.pathExists(hausPath(root, "recommendation.json"))) return true;
5943
+ if (await fs31.pathExists(hausPath(root, "recommendation.json"))) return true;
5674
5944
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
5675
5945
  if (result.status === "ok") return result.value._haus != null;
5676
5946
  return false;
@@ -5682,8 +5952,8 @@ async function refreshProjectApply(root) {
5682
5952
 
5683
5953
  // src/install/apply.ts
5684
5954
  import crypto2 from "node:crypto";
5685
- import path39 from "node:path";
5686
- import fs31 from "fs-extra";
5955
+ import path40 from "node:path";
5956
+ import fs32 from "fs-extra";
5687
5957
 
5688
5958
  // src/install/header.ts
5689
5959
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -5767,59 +6037,66 @@ var SCHEMA_VERSION2 = "1";
5767
6037
  function hashContent(content2) {
5768
6038
  return `sha256-${crypto2.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
5769
6039
  }
6040
+ function destFileMissing(destPath) {
6041
+ try {
6042
+ return !fs32.statSync(destPath).isFile();
6043
+ } catch {
6044
+ return true;
6045
+ }
6046
+ }
5770
6047
  function sourceVersion() {
5771
6048
  try {
5772
- const pkgPath = path39.join(packageRoot(), "package.json");
5773
- 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"));
5774
6051
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
5775
6052
  } catch {
5776
6053
  return "haus@0.0.0";
5777
6054
  }
5778
6055
  }
5779
6056
  function globalSrcDir() {
5780
- return path39.join(packageRoot(), "library", "global");
6057
+ return path40.join(packageRoot(), "library", "global");
5781
6058
  }
5782
6059
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
5783
- const skillDir = path39.join(skillsDir, skillName);
6060
+ const skillDir = path40.join(skillsDir, skillName);
5784
6061
  const entries = [];
5785
- for (const rel of fs31.readdirSync(skillDir, { recursive: true })) {
6062
+ for (const rel of fs32.readdirSync(skillDir, { recursive: true })) {
5786
6063
  if (rel === "SKILL.md") continue;
5787
- const abs = path39.join(skillDir, rel);
5788
- if (!fs31.statSync(abs).isFile()) continue;
5789
- 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("/");
5790
6067
  entries.push({
5791
6068
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
5792
- srcRelPath: path39.join("library", "global", "skills", skillName, rel),
5793
- destPath: path39.join(claudeDir, "skills", skillName, rel)
6069
+ srcRelPath: path40.join("library", "global", "skills", skillName, rel),
6070
+ destPath: path40.join(claudeDir, "skills", skillName, rel)
5794
6071
  });
5795
6072
  }
5796
6073
  return entries;
5797
6074
  }
5798
6075
  function collectSourceFiles(srcDir, claudeDir) {
5799
6076
  const entries = [];
5800
- const skillsDir = path39.join(srcDir, "skills");
5801
- if (fs31.pathExistsSync(skillsDir)) {
5802
- for (const skillName of fs31.readdirSync(skillsDir)) {
5803
- const skillFile = path39.join(skillsDir, skillName, "SKILL.md");
5804
- 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)) {
5805
6082
  entries.push({
5806
6083
  stableId: `skill.${skillName}`,
5807
- srcRelPath: path39.join("library", "global", "skills", skillName, "SKILL.md"),
5808
- 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")
5809
6086
  });
5810
6087
  entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
5811
6088
  }
5812
6089
  }
5813
6090
  }
5814
- const commandsDir = path39.join(srcDir, "commands");
5815
- if (fs31.pathExistsSync(commandsDir)) {
5816
- 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)) {
5817
6094
  if (!fileName.endsWith(".md")) continue;
5818
6095
  const commandName = fileName.slice(0, -".md".length);
5819
6096
  entries.push({
5820
6097
  stableId: `command.${commandName}`,
5821
- srcRelPath: path39.join("library", "global", "commands", fileName),
5822
- destPath: path39.join(claudeDir, "commands", fileName)
6098
+ srcRelPath: path40.join("library", "global", "commands", fileName),
6099
+ destPath: path40.join(claudeDir, "commands", fileName)
5823
6100
  });
5824
6101
  }
5825
6102
  }
@@ -5845,7 +6122,7 @@ async function applyInstall(options = {}) {
5845
6122
  };
5846
6123
  const manifestFiles = [];
5847
6124
  for (const entry of sourceFiles) {
5848
- const srcPath = path39.join(packageRoot(), entry.srcRelPath);
6125
+ const srcPath = path40.join(packageRoot(), entry.srcRelPath);
5849
6126
  const rawContent = await readText(srcPath);
5850
6127
  if (rawContent === void 0) {
5851
6128
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -5859,13 +6136,14 @@ async function applyInstall(options = {}) {
5859
6136
  const newHash = hashContent(stamped);
5860
6137
  const existing = manifestByDest.get(entry.destPath);
5861
6138
  if (check) {
5862
- if (existing && existing.hash !== newHash) {
6139
+ const destMissing = destFileMissing(entry.destPath);
6140
+ if (destMissing || existing && existing.hash !== newHash) {
5863
6141
  result.drift = true;
5864
6142
  result.skipped.push(entry.destPath);
5865
6143
  }
5866
6144
  continue;
5867
6145
  }
5868
- const destExists = fs31.pathExistsSync(entry.destPath);
6146
+ const destExists = fs32.pathExistsSync(entry.destPath);
5869
6147
  if (destExists) {
5870
6148
  const currentContent = await readText(entry.destPath);
5871
6149
  if (currentContent !== void 0) {
@@ -5905,7 +6183,7 @@ async function applyInstall(options = {}) {
5905
6183
  }
5906
6184
  let mergedSettings;
5907
6185
  if (!check) {
5908
- const fragmentPath = path39.join(srcDir, "settings-fragments", "hooks.json");
6186
+ const fragmentPath = path40.join(srcDir, "settings-fragments", "hooks.json");
5909
6187
  const fragments = await loadHooksFragment(fragmentPath);
5910
6188
  const settings = await readSettings();
5911
6189
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -5919,13 +6197,13 @@ async function applyInstall(options = {}) {
5919
6197
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
5920
6198
  for (const entry of existingManifest.files) {
5921
6199
  if (currentDestPaths.has(entry.destPath)) continue;
5922
- if (!fs31.pathExistsSync(entry.destPath)) continue;
6200
+ if (!fs32.pathExistsSync(entry.destPath)) continue;
5923
6201
  const content2 = await readText(entry.destPath);
5924
6202
  if (!content2) continue;
5925
6203
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
5926
6204
  const currentHash = hashContent(content2);
5927
6205
  if (hasHeader && currentHash === entry.hash) {
5928
- if (!dryRun) await fs31.remove(entry.destPath);
6206
+ if (!dryRun) await fs32.remove(entry.destPath);
5929
6207
  result.deleted.push(entry.destPath);
5930
6208
  } else {
5931
6209
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -5994,7 +6272,7 @@ function summarizeLockDiff(before, after) {
5994
6272
 
5995
6273
  // src/update/lockfile.ts
5996
6274
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
5997
- import path40 from "node:path";
6275
+ import path41 from "node:path";
5998
6276
  async function readLockSummary(root) {
5999
6277
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
6000
6278
  const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
@@ -6018,19 +6296,21 @@ async function checkLock(root) {
6018
6296
  const ok = lock.length > 0 && hasValidVersions && drift.length === 0;
6019
6297
  return { ok, count: lock.length, catalogRef, drift, driftCount: drift.length };
6020
6298
  }
6021
- async function applyLock(root) {
6022
- const lockPath = hausPath(root, "haus.lock.json");
6023
- let before = "[]";
6299
+ async function readLockRaw(root) {
6024
6300
  try {
6025
- before = await readFile2(lockPath, "utf8");
6301
+ return await readFile2(hausPath(root, "haus.lock.json"), "utf8");
6026
6302
  } catch {
6027
- before = "[]";
6303
+ return "[]";
6028
6304
  }
6305
+ }
6306
+ async function applyLock(root) {
6307
+ const lockPath = hausPath(root, "haus.lock.json");
6308
+ const before = await readLockRaw(root);
6029
6309
  const lock = await readJson(lockPath) ?? [];
6030
6310
  try {
6031
6311
  const backupDir = hausPath(root, "backups");
6032
6312
  await mkdir(backupDir, { recursive: true });
6033
- await copyFile(lockPath, path40.join(backupDir, `haus.lock.${Date.now()}.json`));
6313
+ await copyFile(lockPath, path41.join(backupDir, `haus.lock.${Date.now()}.json`));
6034
6314
  } catch {
6035
6315
  }
6036
6316
  const enriched = await Promise.all(
@@ -6052,7 +6332,7 @@ function diffLock(before, after) {
6052
6332
  }
6053
6333
  async function hasLocalOverrides(root) {
6054
6334
  try {
6055
- await readFile2(path40.join(root, ".claude", "settings.json"), "utf8");
6335
+ await readFile2(path41.join(root, ".claude", "settings.json"), "utf8");
6056
6336
  return true;
6057
6337
  } catch {
6058
6338
  return false;
@@ -6068,7 +6348,7 @@ async function runUpdate(options) {
6068
6348
  return;
6069
6349
  }
6070
6350
  if (options.check) {
6071
- const pkgJson2 = await readJson(path41.join(packageRoot(), "package.json"));
6351
+ const pkgJson2 = await readJson(path42.join(packageRoot(), "package.json"));
6072
6352
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
6073
6353
  const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
6074
6354
  options.fast ? fastLockStatus(root) : checkLock(root),
@@ -6100,10 +6380,12 @@ async function runUpdate(options) {
6100
6380
  )
6101
6381
  );
6102
6382
  const hasHashDrift = !options.fast && status.count > 0 && "ok" in status && !status.ok;
6103
- if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
6383
+ if (hasHashDrift || formerIdMigrations.length > 0 || globalInstallDrift === true) {
6384
+ process.exitCode = 1;
6385
+ }
6104
6386
  return;
6105
6387
  }
6106
- const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6388
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6107
6389
  const currentVersion = pkgJson?.version ?? "0.0.0";
6108
6390
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
6109
6391
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -6115,9 +6397,7 @@ async function runUpdate(options) {
6115
6397
  if (await hasLocalOverrides(root)) {
6116
6398
  log("Existing .claude/settings.json \u2014 haus rules will be merged, not replaced.");
6117
6399
  }
6118
- const { before, after } = await applyLock(root);
6119
- log(diffLock(before, after));
6120
- log(summarizeLockDiff(before, after));
6400
+ const lockBefore = await readLockRaw(root);
6121
6401
  log("Syncing remote catalog...");
6122
6402
  const sync = await syncRemoteCatalog();
6123
6403
  if (sync.newItems.length > 0) {
@@ -6139,13 +6419,16 @@ async function runUpdate(options) {
6139
6419
  }
6140
6420
  await refreshGlobalInstall();
6141
6421
  await refreshProjectFiles(root);
6422
+ const { after } = await applyLock(root);
6423
+ log(diffLock(lockBefore, after));
6424
+ log(summarizeLockDiff(lockBefore, after));
6142
6425
  log("Update applied with backup in .haus-workflow/backups/. Run haus doctor.");
6143
6426
  }
6144
6427
  async function runFromHookCheck(root) {
6145
6428
  try {
6146
6429
  const summary = await readLockSummary(root);
6147
6430
  if (summary.count === 0) return;
6148
- const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6431
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6149
6432
  const currentVersion = pkgJson?.version ?? "0.0.0";
6150
6433
  const [npmVersion, latestCatalogTag] = await Promise.all([
6151
6434
  fetchNpmVersionStatus(currentVersion),
@@ -6295,7 +6578,8 @@ async function runCiGate(options = {}) {
6295
6578
  // src/commands/clone.ts
6296
6579
  init_exec();
6297
6580
  import { existsSync as existsSync2 } from "node:fs";
6298
- import path42 from "node:path";
6581
+ import { readdir } from "node:fs/promises";
6582
+ import path43 from "node:path";
6299
6583
  var GIT_LOCATION_VARS = [
6300
6584
  "GIT_DIR",
6301
6585
  "GIT_WORK_TREE",
@@ -6324,6 +6608,16 @@ function normalizeGitUrl(url) {
6324
6608
  s = s.replace(/\.git$/i, "");
6325
6609
  return s.toLowerCase();
6326
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
+ }
6327
6621
  async function checkExistingTarget(target, url) {
6328
6622
  const isRepo = await runGit(["-C", target, "rev-parse", "--is-inside-work-tree"], {
6329
6623
  env: cloneEnv(),
@@ -6345,11 +6639,11 @@ async function runClone(url, opts = {}) {
6345
6639
  process.exitCode = 1;
6346
6640
  return;
6347
6641
  }
6348
- const target = path42.resolve(opts.dir?.trim() || repoNameFromUrl(url));
6349
- if (existsSync2(target)) {
6642
+ const target = path43.resolve(opts.dir?.trim() || repoNameFromUrl(url));
6643
+ if (existsSync2(target) && !await isEmptyDirectory(target)) {
6350
6644
  const check = await checkExistingTarget(target, url);
6351
6645
  if (check.kind === "matches") {
6352
- 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`);
6353
6647
  return;
6354
6648
  }
6355
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].`;
@@ -6389,7 +6683,7 @@ function readHookPayload() {
6389
6683
  if (!raw) return { payload: {}, malformed: false };
6390
6684
  try {
6391
6685
  const parsed = JSON.parse(raw);
6392
- return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
6686
+ return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
6393
6687
  } catch {
6394
6688
  return { payload: {}, malformed: true };
6395
6689
  }
@@ -6428,7 +6722,7 @@ function readPayload(options) {
6428
6722
  if (options.stdinPayload != null) {
6429
6723
  try {
6430
6724
  const parsed = JSON.parse(options.stdinPayload);
6431
- return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
6725
+ return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
6432
6726
  } catch {
6433
6727
  return { payload: {}, malformed: true };
6434
6728
  }
@@ -6441,15 +6735,14 @@ async function runDecisionsGuard(options = {}) {
6441
6735
  emitPreToolUseDeny("Malformed hook payload");
6442
6736
  return;
6443
6737
  }
6444
- const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
6738
+ const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
6445
6739
  const command = String(toolInput.command ?? "");
6446
6740
  if (!PR_CREATE_PATTERN.test(command)) return;
6447
6741
  const root = process.cwd();
6448
6742
  const baseRef = await resolveBaseRef(root);
6449
6743
  if (!baseRef) return;
6450
6744
  const range = `${baseRef}...HEAD`;
6451
- const logRange = `${baseRef}..HEAD`;
6452
- const commitMessages = (await runGit(["log", "--format=%B", logRange], { cwd: root })).stdout;
6745
+ const commitMessages = (await runGit(["log", "--format=%B", toAncestryLogRange(range)], { cwd: root })).stdout;
6453
6746
  const result = await runDecisionsCheck(root, { range, commitMessages });
6454
6747
  if (result.triggered && !result.satisfied) {
6455
6748
  emitPreToolUseDeny(
@@ -6459,7 +6752,7 @@ async function runDecisionsGuard(options = {}) {
6459
6752
  }
6460
6753
 
6461
6754
  // src/commands/decisions.ts
6462
- import path43 from "node:path";
6755
+ import path44 from "node:path";
6463
6756
 
6464
6757
  // src/decisions/next-number.ts
6465
6758
  async function nextDecisionNumber(root) {
@@ -6534,7 +6827,7 @@ async function runDecisions(sub, arg, options = {}) {
6534
6827
  const root = process.cwd();
6535
6828
  if (sub === "check") {
6536
6829
  const commitMessages = options.range != null ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
6537
- (m) => m.runGit(["log", "--format=%B", options.range], { cwd: root })
6830
+ (m) => m.runGit(["log", "--format=%B", toAncestryLogRange(options.range)], { cwd: root })
6538
6831
  )).stdout : "";
6539
6832
  const result = await runDecisionsCheck(root, {
6540
6833
  staged: options.staged,
@@ -6569,7 +6862,7 @@ async function runDecisions(sub, arg, options = {}) {
6569
6862
  return;
6570
6863
  }
6571
6864
  if (sub === "validate") {
6572
- const target = arg ? path43.resolve(root, arg) : void 0;
6865
+ const target = arg ? path44.resolve(root, arg) : void 0;
6573
6866
  if (!target) {
6574
6867
  error("usage: haus decisions validate <path-to-decision.md>");
6575
6868
  process.exitCode = 1;
@@ -6821,7 +7114,7 @@ async function runGuard(kind, _options) {
6821
7114
  emitPreToolUseDeny("Malformed hook payload");
6822
7115
  return;
6823
7116
  }
6824
- const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
7117
+ const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
6825
7118
  if (kind === "file-access") {
6826
7119
  const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
6827
7120
  const reason2 = guardFileAccess(candidate);
@@ -6838,8 +7131,8 @@ async function runGuard(kind, _options) {
6838
7131
  }
6839
7132
 
6840
7133
  // src/commands/init.ts
6841
- import path45 from "node:path";
6842
- import fs32 from "fs-extra";
7134
+ import path46 from "node:path";
7135
+ import fs33 from "fs-extra";
6843
7136
 
6844
7137
  // src/recommender/git-signal.ts
6845
7138
  init_exec();
@@ -6862,55 +7155,6 @@ async function readChangedFiles(root) {
6862
7155
  return [.../* @__PURE__ */ new Set([...unstaged, ...staged, ...untracked])].sort();
6863
7156
  }
6864
7157
 
6865
- // src/recommender/policies.ts
6866
- var UNSUPPORTED = FORBIDDEN_TAGS;
6867
- function matchRequiresAny(clauses, ctx) {
6868
- for (const clause of clauses) {
6869
- if ("stack" in clause) {
6870
- if (ctx.stackSet.has(clause.stack.toLowerCase())) {
6871
- return { matched: true, signal: `stack:${clause.stack}` };
6872
- }
6873
- } else if ("dependency" in clause) {
6874
- if (ctx.depSet.has(clause.dependency.toLowerCase())) {
6875
- return { matched: true, signal: `dependency:${clause.dependency}` };
6876
- }
6877
- } else if ("packageNamePattern" in clause) {
6878
- const pattern = clause.packageNamePattern.toLowerCase();
6879
- const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
6880
- for (const dep2 of ctx.depSet) {
6881
- if (pattern.endsWith("*") ? dep2.startsWith(prefix) : dep2 === pattern) {
6882
- return {
6883
- matched: true,
6884
- signal: `packageNamePattern:${clause.packageNamePattern}`
6885
- };
6886
- }
6887
- }
6888
- } else if ("role" in clause) {
6889
- if (ctx.roleSet.has(clause.role.toLowerCase())) {
6890
- return { matched: true, signal: `role:${clause.role}` };
6891
- }
6892
- }
6893
- }
6894
- return { matched: false };
6895
- }
6896
- function describeRequiresAny(clauses) {
6897
- return clauses.map((c) => {
6898
- if ("stack" in c) return `stack=${c.stack}`;
6899
- if ("dependency" in c) return `dependency=${c.dependency}`;
6900
- if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
6901
- if ("role" in c) return `role=${c.role}`;
6902
- return "unknown";
6903
- }).join(" | ");
6904
- }
6905
- function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
6906
- const markers = context.unsupportedSignals?.join(", ");
6907
- const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
6908
- `Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
6909
- ] : [];
6910
- const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
6911
- return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
6912
- }
6913
-
6914
7158
  // src/recommender/recommend.ts
6915
7159
  async function recommend(root, context, opts = {}) {
6916
7160
  const items = await loadCatalog(root);
@@ -7227,6 +7471,7 @@ function applyCoInstallSuppression(recommended, skipped) {
7227
7471
  }
7228
7472
  var HARD_SKIP_CODES = /* @__PURE__ */ new Set([
7229
7473
  "former-id",
7474
+ "invalid-source",
7230
7475
  "unsupported-policy",
7231
7476
  "deprecated",
7232
7477
  "curated-not-approved",
@@ -7249,8 +7494,10 @@ function applyManualIncludes(include, items, recommended, skipped) {
7249
7494
  if (recommendedIds.has(id)) continue;
7250
7495
  const skipIdx = skipped.findIndex((s) => s.id === id);
7251
7496
  const skipCode = skipIdx >= 0 ? skipped[skipIdx].skipReasons[0]?.code : void 0;
7252
- if (skipCode && HARD_SKIP_CODES.has(skipCode)) {
7253
- 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})`);
7254
7501
  continue;
7255
7502
  }
7256
7503
  if (skipCode === "requires-any-unsatisfied") {
@@ -7306,8 +7553,8 @@ function buildOptInEligible(items, skipped) {
7306
7553
  }
7307
7554
 
7308
7555
  // src/commands/workspace/discover.ts
7309
- import path44 from "node:path";
7310
- import fg4 from "fast-glob";
7556
+ import path45 from "node:path";
7557
+ import fg5 from "fast-glob";
7311
7558
  import YAML2 from "yaml";
7312
7559
  var DEFAULT_MAX_DEPTH = 3;
7313
7560
  var GIT_MARKER = ["**/.git"];
@@ -7341,16 +7588,16 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7341
7588
  suppressErrors: true
7342
7589
  };
7343
7590
  const [gitMatches, manifestMatches] = await Promise.all([
7344
- fg4(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
7345
- fg4(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
7591
+ fg5(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
7592
+ fg5(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
7346
7593
  ]);
7347
7594
  const gitDirs = /* @__PURE__ */ new Set();
7348
7595
  const manifestDirs = /* @__PURE__ */ new Set();
7349
7596
  for (const match of gitMatches) {
7350
- gitDirs.add(path44.posix.dirname(match));
7597
+ gitDirs.add(path45.posix.dirname(match));
7351
7598
  }
7352
7599
  for (const match of manifestMatches) {
7353
- manifestDirs.add(path44.posix.dirname(match));
7600
+ manifestDirs.add(path45.posix.dirname(match));
7354
7601
  }
7355
7602
  const repoRoots = [...gitDirs];
7356
7603
  const manifestSorted = [...manifestDirs].sort(
@@ -7367,9 +7614,9 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7367
7614
  async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7368
7615
  const repoRoots = (await findRepoRoots(workspaceRoot, maxDepth)).filter((dir) => dir !== ".");
7369
7616
  return mapWithConcurrency(repoRoots, async (relDir) => {
7370
- const absDir = path44.resolve(workspaceRoot, relDir);
7371
- const pkg = await readJson(path44.join(absDir, "package.json"));
7372
- 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);
7373
7620
  let role = "auto";
7374
7621
  try {
7375
7622
  const scan = await scanProject(absDir);
@@ -7412,7 +7659,7 @@ function renderWorkspaceYaml(config) {
7412
7659
  });
7413
7660
  }
7414
7661
  async function runDiscover(workspaceRoot, opts = {}) {
7415
- const yamlPath = path44.join(workspaceRoot, "haus.workspace.yaml");
7662
+ const yamlPath = path45.join(workspaceRoot, "haus.workspace.yaml");
7416
7663
  const existingText = await readText(yamlPath);
7417
7664
  const existing = parseWorkspaceConfig(existingText);
7418
7665
  if (existingText && !existing) {
@@ -7459,6 +7706,11 @@ async function hasMultipleSiblingRepos(root) {
7459
7706
  }
7460
7707
 
7461
7708
  // src/claude/setup-core.ts
7709
+ async function readPriorManualIncludes(root) {
7710
+ const prior = await readJson(hausPath(root, "recommendation.json"));
7711
+ if (!prior?.recommended) return [];
7712
+ return prior.recommended.filter((entry) => entry.selectionMode === "manual").map((entry) => entry.id);
7713
+ }
7462
7714
  async function runSetupCore(root, opts) {
7463
7715
  const { json, apply, dryRun, quiet, confirm: confirm2, force } = opts;
7464
7716
  const say = quiet ? () => {
@@ -7473,7 +7725,8 @@ async function runSetupCore(root, opts) {
7473
7725
  }
7474
7726
  const siblingWarning = await hasMultipleSiblingRepos(root) ? [SIBLING_REPO_WARNING] : [];
7475
7727
  const context = await readContextOrScan(root);
7476
- const recommendation = await recommend(root, context);
7728
+ const priorManualIncludes = await readPriorManualIncludes(root);
7729
+ const recommendation = await recommend(root, context, { include: priorManualIncludes });
7477
7730
  if (recommendation.recommended.length === 0 && !force) {
7478
7731
  const warningLines2 = [
7479
7732
  .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
@@ -7584,8 +7837,8 @@ async function runSetupProject(options) {
7584
7837
  // src/commands/init.ts
7585
7838
  async function runInit(options) {
7586
7839
  const root = process.cwd();
7587
- const hausDir = path45.join(root, ".haus-workflow");
7588
- const alreadyInit = await fs32.pathExists(hausDir);
7840
+ const hausDir = path46.join(root, ".haus-workflow");
7841
+ const alreadyInit = await fs33.pathExists(hausDir);
7589
7842
  if (alreadyInit) {
7590
7843
  log("Haus AI already initialized in this project.");
7591
7844
  log("Run `haus setup-project` to reconfigure.");
@@ -7720,8 +7973,8 @@ async function runRefresh() {
7720
7973
  }
7721
7974
 
7722
7975
  // src/install/scaffold.ts
7723
- import path46 from "node:path";
7724
- import fs33 from "fs-extra";
7976
+ import path47 from "node:path";
7977
+ import fs34 from "fs-extra";
7725
7978
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7726
7979
  const scaffolded = [];
7727
7980
  const skipped = [];
@@ -7734,27 +7987,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7734
7987
  skipped.push(name);
7735
7988
  if (outcome === "skipped-exists") skippedExisting.push(name);
7736
7989
  };
7737
- const realRoot = await fs33.realpath(catalogRoot).catch(() => null);
7990
+ const realRoot = await fs34.realpath(catalogRoot).catch(() => null);
7738
7991
  for (const item of items) {
7739
7992
  if (item.type !== "config") continue;
7740
- const sourcePath = path46.resolve(catalogRoot, item.path);
7741
- const rel = path46.relative(catalogRoot, sourcePath);
7742
- 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 === "..");
7743
7996
  if (escapes) {
7744
7997
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
7745
7998
  continue;
7746
7999
  }
7747
- const realSource = await fs33.realpath(sourcePath).catch(() => null);
8000
+ const realSource = await fs34.realpath(sourcePath).catch(() => null);
7748
8001
  if (!realRoot || !realSource) {
7749
8002
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
7750
8003
  continue;
7751
8004
  }
7752
- const realRel = path46.relative(realRoot, realSource);
7753
- if (realRel !== "" && (path46.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
8005
+ const realRel = path47.relative(realRoot, realSource);
8006
+ if (realRel !== "" && (path47.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
7754
8007
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
7755
8008
  continue;
7756
8009
  }
7757
- const stat = await fs33.lstat(sourcePath).catch(() => null);
8010
+ const stat = await fs34.lstat(sourcePath).catch(() => null);
7758
8011
  if (!stat) {
7759
8012
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
7760
8013
  continue;
@@ -7764,14 +8017,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7764
8017
  continue;
7765
8018
  }
7766
8019
  if (stat.isFile()) {
7767
- const filename = path46.basename(sourcePath);
7768
- const dest = path46.join(projectRoot, filename);
8020
+ const filename = path47.basename(sourcePath);
8021
+ const dest = path47.join(projectRoot, filename);
7769
8022
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
7770
8023
  } else if (stat.isDirectory()) {
7771
- const entries = await fs33.readdir(sourcePath);
8024
+ const entries = await fs34.readdir(sourcePath);
7772
8025
  for (const entry of entries) {
7773
- const src = path46.join(sourcePath, entry);
7774
- const dest = path46.join(projectRoot, entry);
8026
+ const src = path47.join(sourcePath, entry);
8027
+ const dest = path47.join(projectRoot, entry);
7775
8028
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
7776
8029
  }
7777
8030
  }
@@ -7779,42 +8032,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7779
8032
  return { scaffolded, skipped, skippedExisting };
7780
8033
  }
7781
8034
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
7782
- const destDisplay = path46.relative(projectRoot, dest) || path46.basename(dest);
7783
- 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);
7784
8037
  if (!srcStat) {
7785
- warn(`Skipping ${path46.basename(src)}: source not found`);
8038
+ warn(`Skipping ${path47.basename(src)}: source not found`);
7786
8039
  return "skipped-refused";
7787
8040
  }
7788
8041
  if (srcStat.isSymbolicLink()) {
7789
- warn(`Skipping ${path46.basename(src)}: source is a symlink`);
8042
+ warn(`Skipping ${path47.basename(src)}: source is a symlink`);
7790
8043
  return "skipped-refused";
7791
8044
  }
7792
- const exists = await fs33.pathExists(dest);
8045
+ const exists = await fs34.pathExists(dest);
7793
8046
  if (exists && !opts.force) {
7794
8047
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
7795
8048
  return "skipped-exists";
7796
8049
  }
7797
8050
  if (opts.dryRun) {
7798
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path46.basename(dest)} (${itemId})`);
8051
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path47.basename(dest)} (${itemId})`);
7799
8052
  return "scaffolded";
7800
8053
  }
7801
8054
  if (exists && opts.force) {
7802
- await fs33.remove(dest);
8055
+ await fs34.remove(dest);
7803
8056
  }
7804
- await fs33.ensureDir(path46.dirname(dest));
7805
- await fs33.copy(src, dest, {
8057
+ await fs34.ensureDir(path47.dirname(dest));
8058
+ await fs34.copy(src, dest, {
7806
8059
  overwrite: true,
7807
8060
  dereference: false,
7808
8061
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
7809
8062
  filter: (s) => {
7810
8063
  try {
7811
- return !fs33.lstatSync(s).isSymbolicLink();
8064
+ return !fs34.lstatSync(s).isSymbolicLink();
7812
8065
  } catch {
7813
8066
  return false;
7814
8067
  }
7815
8068
  }
7816
8069
  });
7817
- log(`${exists ? "Overwrote" : "Created"} ${path46.basename(dest)} (${itemId})`);
8070
+ log(`${exists ? "Overwrote" : "Created"} ${path47.basename(dest)} (${itemId})`);
7818
8071
  return "scaffolded";
7819
8072
  }
7820
8073
 
@@ -7879,8 +8132,8 @@ async function runScan(options) {
7879
8132
  }
7880
8133
 
7881
8134
  // src/commands/undo.ts
7882
- import path47 from "node:path";
7883
- import fs34 from "fs-extra";
8135
+ import path48 from "node:path";
8136
+ import fs35 from "fs-extra";
7884
8137
 
7885
8138
  // src/claude/managed-paths.ts
7886
8139
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
@@ -7895,7 +8148,7 @@ function coreManagedAbsolutePaths(root) {
7895
8148
  async function collectCoreManagedPaths(root) {
7896
8149
  const existing = [];
7897
8150
  for (const abs of coreManagedAbsolutePaths(root)) {
7898
- if (await fs34.pathExists(abs)) existing.push(abs);
8151
+ if (await fs35.pathExists(abs)) existing.push(abs);
7899
8152
  }
7900
8153
  return existing;
7901
8154
  }
@@ -7907,18 +8160,18 @@ async function collectLockTrackedPaths(root) {
7907
8160
  const relPaths = row.paths ?? [];
7908
8161
  const existingRel = [];
7909
8162
  for (const rel of relPaths) {
7910
- if (await fs34.pathExists(path47.resolve(root, rel))) existingRel.push(rel);
8163
+ if (await fs35.pathExists(path48.resolve(root, rel))) existingRel.push(rel);
7911
8164
  }
7912
8165
  if (existingRel.length === 0) continue;
7913
8166
  if (row.hash === void 0) {
7914
- removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
8167
+ removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
7915
8168
  continue;
7916
8169
  }
7917
8170
  const currentHash = await hashInstalledPaths(root, existingRel);
7918
8171
  if (currentHash === row.hash) {
7919
- removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
8172
+ removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
7920
8173
  } else {
7921
- preserved.push(...existingRel.map((rel) => path47.resolve(root, rel)));
8174
+ preserved.push(...existingRel.map((rel) => path48.resolve(root, rel)));
7922
8175
  log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
7923
8176
  }
7924
8177
  }
@@ -7926,51 +8179,51 @@ async function collectLockTrackedPaths(root) {
7926
8179
  }
7927
8180
  async function settingsHasHausContent(root) {
7928
8181
  const settingsPath = claudePath(root, "settings.json");
7929
- if (!await fs34.pathExists(settingsPath)) return false;
8182
+ if (!await fs35.pathExists(settingsPath)) return false;
7930
8183
  const settings = await readProjectSettings(root);
7931
8184
  return settings._haus != null;
7932
8185
  }
7933
8186
  async function claudeMdHasHausBlock(root) {
7934
- const filePath = path47.join(root, "CLAUDE.md");
7935
- if (!await fs34.pathExists(filePath)) return false;
7936
- 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");
7937
8190
  return text.includes(BLOCK_BEGIN);
7938
8191
  }
7939
8192
  async function stripProjectSettings(root) {
7940
8193
  const settingsPath = claudePath(root, "settings.json");
7941
- if (!await fs34.pathExists(settingsPath)) return false;
8194
+ if (!await fs35.pathExists(settingsPath)) return false;
7942
8195
  let settings = await readProjectSettings(root);
7943
8196
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
7944
8197
  const hasContent = Object.keys(settings).length > 0;
7945
8198
  if (hasContent) {
7946
8199
  await writeProjectSettings(root, settings);
7947
- 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).`);
7948
8201
  return true;
7949
8202
  }
7950
- await fs34.remove(settingsPath);
7951
- 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).`);
7952
8205
  return true;
7953
8206
  }
7954
8207
  async function stripRootClaudeMd(root) {
7955
- const filePath = path47.join(root, "CLAUDE.md");
7956
- if (!await fs34.pathExists(filePath)) return false;
7957
- 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");
7958
8211
  if (!prev.includes(BLOCK_BEGIN)) return false;
7959
8212
  const next = stripHausBlock(prev);
7960
8213
  if (next.length === 0) {
7961
- await fs34.remove(filePath);
8214
+ await fs35.remove(filePath);
7962
8215
  log("Removed CLAUDE.md (only contained haus import block).");
7963
8216
  } else {
7964
- await fs34.writeFile(filePath, next, "utf8");
8217
+ await fs35.writeFile(filePath, next, "utf8");
7965
8218
  log("Removed haus import block from CLAUDE.md (user content preserved).");
7966
8219
  }
7967
8220
  return true;
7968
8221
  }
7969
8222
  async function pruneDirIfEmpty(dir) {
7970
- if (!await fs34.pathExists(dir)) return;
8223
+ if (!await fs35.pathExists(dir)) return;
7971
8224
  try {
7972
- const entries = await fs34.readdir(dir);
7973
- if (entries.length === 0) await fs34.remove(dir);
8225
+ const entries = await fs35.readdir(dir);
8226
+ if (entries.length === 0) await fs35.remove(dir);
7974
8227
  } catch {
7975
8228
  }
7976
8229
  }
@@ -7979,13 +8232,13 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
7979
8232
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7980
8233
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
7981
8234
  for (const abs of managedAbsPaths) {
7982
- if (!await fs34.pathExists(abs)) continue;
7983
- const rel = path47.relative(root, abs);
7984
- const backupPath = path47.join(backupRoot, rel);
7985
- await fs34.ensureDir(path47.dirname(backupPath));
7986
- 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);
7987
8240
  }
7988
- 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)}.`);
7989
8242
  }
7990
8243
  async function runUndo(options) {
7991
8244
  const root = options.root ?? process.cwd();
@@ -7998,7 +8251,7 @@ async function runUndo(options) {
7998
8251
  log("Nothing to remove: no haus-managed files found in this directory.");
7999
8252
  return;
8000
8253
  }
8001
- const relTargets = managed.map((p) => path47.relative(root, p));
8254
+ const relTargets = managed.map((p) => path48.relative(root, p));
8002
8255
  const summaryParts = [...relTargets];
8003
8256
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
8004
8257
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -8020,9 +8273,9 @@ User-owned .claude/ files will be preserved.`
8020
8273
  }
8021
8274
  await backupManagedFilesBeforeUndo(root, managed);
8022
8275
  for (const abs of managed) {
8023
- if (!await fs34.pathExists(abs)) continue;
8024
- await fs34.remove(abs);
8025
- 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)}`);
8026
8279
  }
8027
8280
  if (stripSettings) await stripProjectSettings(root);
8028
8281
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -8033,8 +8286,8 @@ User-owned .claude/ files will be preserved.`
8033
8286
 
8034
8287
  // src/install/uninstall.ts
8035
8288
  import crypto3 from "node:crypto";
8036
- import path48 from "node:path";
8037
- import fs35 from "fs-extra";
8289
+ import path49 from "node:path";
8290
+ import fs36 from "fs-extra";
8038
8291
  async function runUninstall(options = {}) {
8039
8292
  const { force = false } = options;
8040
8293
  const manifest = await readManifest();
@@ -8044,7 +8297,7 @@ async function runUninstall(options = {}) {
8044
8297
  return result;
8045
8298
  }
8046
8299
  for (const entry of manifest.files) {
8047
- const exists = fs35.pathExistsSync(entry.destPath);
8300
+ const exists = fs36.pathExistsSync(entry.destPath);
8048
8301
  if (!exists) continue;
8049
8302
  const content2 = await readText(entry.destPath);
8050
8303
  if (content2 === void 0) continue;
@@ -8062,23 +8315,23 @@ async function runUninstall(options = {}) {
8062
8315
  result.skipped.push(entry.destPath);
8063
8316
  continue;
8064
8317
  }
8065
- await fs35.remove(entry.destPath);
8066
- await pruneEmptyDir(path48.dirname(entry.destPath));
8318
+ await fs36.remove(entry.destPath);
8319
+ await pruneEmptyDir(path49.dirname(entry.destPath));
8067
8320
  result.deleted.push(entry.destPath);
8068
8321
  }
8069
8322
  const settings = await readSettings();
8070
8323
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
8071
8324
  await writeSettings(stripped);
8072
8325
  result.hooksStripped = true;
8073
- const hausDir = path48.join(globalClaudeDir(), "haus");
8326
+ const hausDir = path49.join(globalClaudeDir(), "haus");
8074
8327
  const manifestPath2 = hausManifestPath();
8075
- if (fs35.pathExistsSync(manifestPath2)) {
8076
- await fs35.remove(manifestPath2);
8328
+ if (fs36.pathExistsSync(manifestPath2)) {
8329
+ await fs36.remove(manifestPath2);
8077
8330
  }
8078
- if (fs35.pathExistsSync(hausDir)) {
8331
+ if (fs36.pathExistsSync(hausDir)) {
8079
8332
  try {
8080
- const remaining = await fs35.readdir(hausDir);
8081
- if (remaining.length === 0) await fs35.remove(hausDir);
8333
+ const remaining = await fs36.readdir(hausDir);
8334
+ if (remaining.length === 0) await fs36.remove(hausDir);
8082
8335
  } catch {
8083
8336
  }
8084
8337
  }
@@ -8101,6 +8354,19 @@ function printUninstallResult(result) {
8101
8354
  // src/commands/uninstall.ts
8102
8355
  async function runUninstallCommand(options) {
8103
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
+ }
8104
8370
  const result = await runUninstall({ force: options.force });
8105
8371
  printUninstallResult(result);
8106
8372
  log("haus uninstall complete");
@@ -8111,11 +8377,11 @@ async function runUninstallCommand(options) {
8111
8377
  }
8112
8378
 
8113
8379
  // src/commands/validate-catalog.ts
8114
- import path50 from "node:path";
8380
+ import path51 from "node:path";
8115
8381
 
8116
8382
  // src/catalog/validate-core.ts
8117
- import fs36 from "node:fs";
8118
- import path49 from "node:path";
8383
+ import fs37 from "node:fs";
8384
+ import path50 from "node:path";
8119
8385
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
8120
8386
  var ITEM_ID_PATTERN = /^haus\./;
8121
8387
  function auditForbiddenStacks(items) {
@@ -8257,14 +8523,14 @@ function checkRequiredFrontmatter(text, label) {
8257
8523
  }
8258
8524
  function readForAudit(absPath, label, failures) {
8259
8525
  try {
8260
- return fs36.readFileSync(absPath, "utf8");
8526
+ return fs37.readFileSync(absPath, "utf8");
8261
8527
  } catch (err) {
8262
8528
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
8263
8529
  return null;
8264
8530
  }
8265
8531
  }
8266
8532
  function auditTemplateContent(manifestDir, absPath, itemId) {
8267
- const rel = path49.relative(manifestDir, absPath);
8533
+ const rel = path50.relative(manifestDir, absPath);
8268
8534
  const failures = [];
8269
8535
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
8270
8536
  if (text === null) return failures;
@@ -8286,47 +8552,47 @@ function auditShippedFiles(manifestDir, items) {
8286
8552
  const failures = [];
8287
8553
  for (const item of items) {
8288
8554
  if (!item.path || !isSafeCatalogPath(item.path)) continue;
8289
- const absPath = path49.join(manifestDir, item.path);
8555
+ const absPath = path50.join(manifestDir, item.path);
8290
8556
  if (item.type === "skill") {
8291
- const skillMd = path49.join(absPath, "SKILL.md");
8292
- if (!fs36.existsSync(skillMd)) {
8293
- 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)}`);
8294
8560
  continue;
8295
8561
  }
8296
8562
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
8297
8563
  if (text === null) continue;
8298
8564
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
8299
8565
  failures.push(
8300
- ...auditForbiddenTagsInText(text, `${item.id}: ${path49.relative(manifestDir, skillMd)}`)
8566
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path50.relative(manifestDir, skillMd)}`)
8301
8567
  );
8302
8568
  } else if (item.type === "agent") {
8303
- if (!fs36.existsSync(absPath)) {
8569
+ if (!fs37.existsSync(absPath)) {
8304
8570
  failures.push(`${item.id}: missing agent file ${item.path}`);
8305
8571
  continue;
8306
8572
  }
8307
- const rel = path49.relative(manifestDir, absPath);
8573
+ const rel = path50.relative(manifestDir, absPath);
8308
8574
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
8309
8575
  if (text === null) continue;
8310
8576
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
8311
8577
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
8312
8578
  } else if (item.type === "template") {
8313
- if (!fs36.existsSync(absPath)) {
8579
+ if (!fs37.existsSync(absPath)) {
8314
8580
  failures.push(`${item.id}: missing template file ${item.path}`);
8315
8581
  continue;
8316
8582
  }
8317
8583
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
8318
8584
  } else if (item.type === "command") {
8319
- if (!fs36.existsSync(absPath)) {
8585
+ if (!fs37.existsSync(absPath)) {
8320
8586
  failures.push(`${item.id}: missing command file ${item.path}`);
8321
8587
  continue;
8322
8588
  }
8323
- const rel = path49.relative(manifestDir, absPath);
8589
+ const rel = path50.relative(manifestDir, absPath);
8324
8590
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
8325
8591
  if (text === null) continue;
8326
8592
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
8327
8593
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
8328
8594
  } else if (item.type === "config") {
8329
- if (!fs36.existsSync(absPath)) {
8595
+ if (!fs37.existsSync(absPath)) {
8330
8596
  failures.push(`${item.id}: missing config path ${item.path}`);
8331
8597
  }
8332
8598
  }
@@ -8343,7 +8609,7 @@ var CONTENT_ROOTS = ["skills", "agents", "templates", "commands", "configs"];
8343
8609
  function walkAllFiles(dir, fn, failures) {
8344
8610
  let entries;
8345
8611
  try {
8346
- entries = fs36.readdirSync(dir, { withFileTypes: true });
8612
+ entries = fs37.readdirSync(dir, { withFileTypes: true });
8347
8613
  } catch (err) {
8348
8614
  failures.push(
8349
8615
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -8352,7 +8618,7 @@ function walkAllFiles(dir, fn, failures) {
8352
8618
  }
8353
8619
  for (const entry of entries) {
8354
8620
  if (entry.name === ".DS_Store") continue;
8355
- const full = path49.join(dir, entry.name);
8621
+ const full = path50.join(dir, entry.name);
8356
8622
  if (entry.isDirectory()) walkAllFiles(full, fn, failures);
8357
8623
  else fn(full);
8358
8624
  }
@@ -8370,12 +8636,12 @@ function auditDiskOrphans(manifestDir, items) {
8370
8636
  const failures = [];
8371
8637
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
8372
8638
  for (const dir of CONTENT_ROOTS) {
8373
- const abs = path49.join(manifestDir, dir);
8374
- if (!fs36.existsSync(abs)) continue;
8639
+ const abs = path50.join(manifestDir, dir);
8640
+ if (!fs37.existsSync(abs)) continue;
8375
8641
  walkAllFiles(
8376
8642
  abs,
8377
8643
  (file) => {
8378
- const relPath = path49.relative(manifestDir, file).replace(/\\/g, "/");
8644
+ const relPath = path50.relative(manifestDir, file).replace(/\\/g, "/");
8379
8645
  if (!isPathClaimed(relPath, claimedPaths)) {
8380
8646
  failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
8381
8647
  }
@@ -8388,7 +8654,7 @@ function auditDiskOrphans(manifestDir, items) {
8388
8654
  function walkMd(dir, fn, failures) {
8389
8655
  let entries;
8390
8656
  try {
8391
- entries = fs36.readdirSync(dir, { withFileTypes: true });
8657
+ entries = fs37.readdirSync(dir, { withFileTypes: true });
8392
8658
  } catch (err) {
8393
8659
  failures.push(
8394
8660
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -8396,7 +8662,7 @@ function walkMd(dir, fn, failures) {
8396
8662
  return;
8397
8663
  }
8398
8664
  for (const entry of entries) {
8399
- const full = path49.join(dir, entry.name);
8665
+ const full = path50.join(dir, entry.name);
8400
8666
  if (entry.isDirectory()) walkMd(full, fn, failures);
8401
8667
  else if (entry.name.endsWith(".md")) fn(full);
8402
8668
  }
@@ -8405,13 +8671,13 @@ function auditMarkdownContent(manifestDir, items) {
8405
8671
  const failures = [];
8406
8672
  const pathSourceMap = buildItemPathSourceMap(items);
8407
8673
  for (const dir of ["skills", "agents", "templates", "commands"]) {
8408
- const abs = path49.join(manifestDir, dir);
8409
- if (!fs36.existsSync(abs)) continue;
8674
+ const abs = path50.join(manifestDir, dir);
8675
+ if (!fs37.existsSync(abs)) continue;
8410
8676
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
8411
8677
  walkMd(
8412
8678
  abs,
8413
8679
  (file) => {
8414
- const rel = path49.relative(manifestDir, file);
8680
+ const rel = path50.relative(manifestDir, file);
8415
8681
  const text = readForAudit(file, rel, failures);
8416
8682
  if (text === null) return;
8417
8683
  const norm = rel.replace(/\\/g, "/");
@@ -8456,8 +8722,8 @@ async function runValidateCatalog(manifestPath2) {
8456
8722
  process.exitCode = 1;
8457
8723
  return;
8458
8724
  }
8459
- const abs = path50.resolve(process.cwd(), manifestPath2);
8460
- const manifestDir = path50.dirname(abs);
8725
+ const abs = path51.resolve(process.cwd(), manifestPath2);
8726
+ const manifestDir = path51.dirname(abs);
8461
8727
  const data = await readJson(abs);
8462
8728
  if (!data?.items) {
8463
8729
  error(`Could not read catalog manifest at ${abs}`);
@@ -8474,8 +8740,8 @@ async function runValidateCatalog(manifestPath2) {
8474
8740
  }
8475
8741
 
8476
8742
  // src/workspace/worktree/add.ts
8477
- import path57 from "node:path";
8478
- import fs40 from "fs-extra";
8743
+ import path58 from "node:path";
8744
+ import fs41 from "fs-extra";
8479
8745
 
8480
8746
  // src/workspace/worktree/git-worktree.ts
8481
8747
  init_exec();
@@ -8594,13 +8860,13 @@ async function hasUnpushedWork(cwd) {
8594
8860
  }
8595
8861
 
8596
8862
  // src/workspace/worktree/hydrate.ts
8597
- import path53 from "node:path";
8598
- import fs38 from "fs-extra";
8863
+ import path54 from "node:path";
8864
+ import fs39 from "fs-extra";
8599
8865
 
8600
8866
  // src/workspace/worktree/cow-copy.ts
8601
8867
  init_exec();
8602
- import path51 from "node:path";
8603
- import fs37 from "fs-extra";
8868
+ import path52 from "node:path";
8869
+ import fs38 from "fs-extra";
8604
8870
  async function getMacFilesystemType(dir) {
8605
8871
  const df = await runCommand("df", ["-P", dir]);
8606
8872
  if (df.exitCode !== 0) return void 0;
@@ -8631,14 +8897,14 @@ async function detectCowStrategy(dir) {
8631
8897
  return "unknown-platform";
8632
8898
  }
8633
8899
  async function cowCopyDir(src, dest) {
8634
- const srcExists = await fs37.pathExists(src);
8900
+ const srcExists = await fs38.pathExists(src);
8635
8901
  if (!srcExists)
8636
8902
  return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
8637
- const strategy = await detectCowStrategy(path51.dirname(dest));
8903
+ const strategy = await detectCowStrategy(path52.dirname(dest));
8638
8904
  if (strategy === "unsupported" || strategy === "unknown-platform") {
8639
8905
  return { strategy, attempted: false, ok: false };
8640
8906
  }
8641
- await fs37.ensureDir(path51.dirname(dest));
8907
+ await fs38.ensureDir(path52.dirname(dest));
8642
8908
  const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
8643
8909
  const result = await runCommand("cp", args);
8644
8910
  return {
@@ -8652,8 +8918,8 @@ async function cowCopyDir(src, dest) {
8652
8918
  // src/workspace/worktree/install.ts
8653
8919
  init_exec();
8654
8920
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
8655
- import path52 from "node:path";
8656
- import fg5 from "fast-glob";
8921
+ import path53 from "node:path";
8922
+ import fg6 from "fast-glob";
8657
8923
  function detectInstallPlan(signals) {
8658
8924
  if (signals.hasYarnLock) {
8659
8925
  return signals.hasPackageManagerField ? { manager: "yarn", command: "corepack", args: ["yarn", "install"] } : { manager: "yarn", command: "yarn", args: ["install"] };
@@ -8673,11 +8939,11 @@ function detectInstallPlan(signals) {
8673
8939
  return { manager: "none" };
8674
8940
  }
8675
8941
  async function computeLockfileSignals(dir) {
8676
- const hasYarnLock = existsSync3(path52.join(dir, "yarn.lock"));
8677
- const hasPnpmLock = existsSync3(path52.join(dir, "pnpm-lock.yaml"));
8678
- const hasPackageLock = existsSync3(path52.join(dir, "package-lock.json"));
8679
- const hasComposerLock = existsSync3(path52.join(dir, "composer.lock"));
8680
- 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"], {
8681
8947
  cwd: dir,
8682
8948
  onlyFiles: true,
8683
8949
  suppressErrors: true,
@@ -8690,7 +8956,7 @@ async function computeLockfileSignals(dir) {
8690
8956
  });
8691
8957
  const hasDotnetProject = dotnetFiles.length > 0;
8692
8958
  let hasPackageManagerField = false;
8693
- const pkgPath = path52.join(dir, "package.json");
8959
+ const pkgPath = path53.join(dir, "package.json");
8694
8960
  if (existsSync3(pkgPath)) {
8695
8961
  try {
8696
8962
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
@@ -8731,13 +8997,13 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
8731
8997
  const start = Date.now();
8732
8998
  const targets = [];
8733
8999
  for (const target of HYDRATION_TARGETS) {
8734
- const src = path53.join(mainMemberPath, target);
8735
- const dest = path53.join(worktreeMemberPath, target);
8736
- if (!await fs38.pathExists(src)) continue;
8737
- 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);
8738
9004
  if (!opts.force && destExists) continue;
8739
9005
  if (opts.force && destExists) {
8740
- await fs38.remove(dest);
9006
+ await fs39.remove(dest);
8741
9007
  }
8742
9008
  const copy = await cowCopyDir(src, dest);
8743
9009
  targets.push({ target, copy });
@@ -8750,11 +9016,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
8750
9016
 
8751
9017
  // src/workspace/worktree/local-files.ts
8752
9018
  init_exec();
8753
- import path54 from "node:path";
8754
- import fs39 from "fs-extra";
9019
+ import path55 from "node:path";
9020
+ import fs40 from "fs-extra";
8755
9021
  var MACHINE_LOCAL_FILES = [
8756
9022
  "docker-compose.local.yml",
8757
- path54.join(".claude", "settings.local.json")
9023
+ path55.join(".claude", "settings.local.json")
8758
9024
  ];
8759
9025
  async function isTracked(repoRoot, relFile) {
8760
9026
  const gitPath = relFile.replace(/\\/g, "/");
@@ -8764,12 +9030,12 @@ async function isTracked(repoRoot, relFile) {
8764
9030
  async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
8765
9031
  const copied = [];
8766
9032
  for (const rel of MACHINE_LOCAL_FILES) {
8767
- const src = path54.join(mainMemberPath, rel);
8768
- if (!await fs39.pathExists(src)) continue;
9033
+ const src = path55.join(mainMemberPath, rel);
9034
+ if (!await fs40.pathExists(src)) continue;
8769
9035
  if (await isTracked(mainMemberPath, rel)) continue;
8770
- const dest = path54.join(worktreeMemberPath, rel);
8771
- await fs39.ensureDir(path54.dirname(dest));
8772
- 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 });
8773
9039
  copied.push(rel);
8774
9040
  }
8775
9041
  return copied;
@@ -8777,15 +9043,15 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
8777
9043
 
8778
9044
  // src/workspace/worktree/root.ts
8779
9045
  import { existsSync as existsSync4 } from "node:fs";
8780
- import path55 from "node:path";
9046
+ import path56 from "node:path";
8781
9047
  async function resolveWorkspaceForWorktree(start) {
8782
9048
  const rootInfo = await resolveRoots(start);
8783
9049
  if (!rootInfo.isGitRepo) {
8784
9050
  return { ok: false, reason: "Not inside a git repository." };
8785
9051
  }
8786
9052
  const workspaceRoot = rootInfo.mainRoot;
8787
- const hasYaml = existsSync4(path55.join(workspaceRoot, WORKSPACE_FILE));
8788
- 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));
8789
9055
  if (!hasYaml && !hasManifest) {
8790
9056
  return {
8791
9057
  ok: false,
@@ -8810,19 +9076,31 @@ function selectMembers(members, only) {
8810
9076
  }
8811
9077
 
8812
9078
  // src/workspace/worktree/state.ts
8813
- import path56 from "node:path";
9079
+ import path57 from "node:path";
8814
9080
  var WORKTREE_STATE_FILE = ".haus-worktree.json";
8815
9081
  function worktreesDir(mainRoot) {
8816
- 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);
8817
9088
  }
8818
9089
  function worktreePath(mainRoot, slug) {
8819
- 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);
8820
9096
  }
8821
9097
  async function readWorktreeState(worktreeRoot) {
8822
- 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;
8823
9101
  }
8824
9102
  async function writeWorktreeState(worktreeRoot, state) {
8825
- await writeJson(path56.join(worktreeRoot, WORKTREE_STATE_FILE), state);
9103
+ await writeJson(path57.join(worktreeRoot, WORKTREE_STATE_FILE), state);
8826
9104
  }
8827
9105
 
8828
9106
  // src/workspace/worktree/add.ts
@@ -8837,7 +9115,7 @@ async function runAdd(opts) {
8837
9115
  }
8838
9116
  const branch = opts.branch ?? opts.slug;
8839
9117
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
8840
- if (await fs40.pathExists(wsWorktreePath)) {
9118
+ if (await fs41.pathExists(wsWorktreePath)) {
8841
9119
  return {
8842
9120
  ok: false,
8843
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.`
@@ -8856,7 +9134,7 @@ async function runAdd(opts) {
8856
9134
  const memberResults = [];
8857
9135
  for (const member of selected) {
8858
9136
  const start = Date.now();
8859
- const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
9137
+ const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
8860
9138
  const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
8861
9139
  preferDefaultBranchFrom: true
8862
9140
  });
@@ -8886,7 +9164,7 @@ async function runAdd(opts) {
8886
9164
  await mapWithConcurrency(okResults, async (result) => {
8887
9165
  const member = selected.find((m) => m.id === result.member);
8888
9166
  if (!member) return;
8889
- const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
9167
+ const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
8890
9168
  const hydrationStart = Date.now();
8891
9169
  if (opts.hydrate !== false) {
8892
9170
  result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
@@ -8953,10 +9231,10 @@ async function runDryRun(args) {
8953
9231
 
8954
9232
  // src/workspace/worktree/doctor.ts
8955
9233
  import { existsSync as existsSync5 } from "node:fs";
8956
- import path58 from "node:path";
9234
+ import path59 from "node:path";
8957
9235
  var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
8958
9236
  function looksHydrated(memberPath) {
8959
- return HYDRATION_MARKERS.some((marker) => existsSync5(path58.join(memberPath, marker)));
9237
+ return HYDRATION_MARKERS.some((marker) => existsSync5(path59.join(memberPath, marker)));
8960
9238
  }
8961
9239
  async function runDoctor2() {
8962
9240
  const rootInfo = await resolveRoots();
@@ -8970,7 +9248,7 @@ async function runDoctor2() {
8970
9248
  };
8971
9249
  }
8972
9250
  const workspaceRoot = rootInfo.mainRoot;
8973
- 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));
8974
9252
  if (!isWorkspace) {
8975
9253
  return {
8976
9254
  isWorkspace: false,
@@ -8983,7 +9261,7 @@ async function runDoctor2() {
8983
9261
  };
8984
9262
  }
8985
9263
  const wsWorktreesDir = worktreesDir(workspaceRoot);
8986
- const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path58.sep}`);
9264
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path59.sep}`);
8987
9265
  const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
8988
9266
  const members = await readMembers(rootInfo);
8989
9267
  const problems = [];
@@ -8991,10 +9269,10 @@ async function runDoctor2() {
8991
9269
  let slug;
8992
9270
  if (inWorkspaceWorktree) {
8993
9271
  const wtPath = rootInfo.repoRoot;
8994
- slug = rootInfo.worktreeName ?? path58.basename(wtPath);
9272
+ slug = rootInfo.worktreeName ?? path59.basename(wtPath);
8995
9273
  const state = await readWorktreeState(wtPath);
8996
9274
  for (const member of members) {
8997
- const memberPath = path58.join(wtPath, member.folder);
9275
+ const memberPath = path59.join(wtPath, member.folder);
8998
9276
  const materialized = existsSync5(memberPath);
8999
9277
  const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
9000
9278
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
@@ -9025,16 +9303,16 @@ async function runDoctor2() {
9025
9303
  for (const member of members) {
9026
9304
  const entries = await listWorktrees(member.absPath);
9027
9305
  for (const entry of entries) {
9028
- const entryPath = path58.normalize(entry.path);
9029
- 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}`;
9030
9308
  const idx = entryPath.indexOf(marker);
9031
9309
  if (idx === -1) continue;
9032
9310
  const rest = entryPath.slice(idx + marker.length);
9033
- const slugName = rest.split(path58.sep)[0];
9311
+ const slugName = rest.split(path59.sep)[0];
9034
9312
  if (!slugName) continue;
9035
- if (!existsSync5(path58.join(wsWorktreesDir, slugName))) {
9313
+ if (!existsSync5(path59.join(wsWorktreesDir, slugName))) {
9036
9314
  orphans.push(
9037
- `${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\`.`
9038
9316
  );
9039
9317
  }
9040
9318
  }
@@ -9045,7 +9323,7 @@ async function runDoctor2() {
9045
9323
 
9046
9324
  // src/workspace/worktree/hydrate-command.ts
9047
9325
  import { existsSync as existsSync6 } from "node:fs";
9048
- import path59 from "node:path";
9326
+ import path60 from "node:path";
9049
9327
  async function runHydrateWorktree(opts) {
9050
9328
  const rootInfo = await resolveRoots();
9051
9329
  if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
@@ -9056,7 +9334,7 @@ async function runHydrateWorktree(opts) {
9056
9334
  }
9057
9335
  const workspaceRoot = rootInfo.mainRoot;
9058
9336
  const wtPath = rootInfo.repoRoot;
9059
- if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path59.sep}`)) {
9337
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path60.sep}`)) {
9060
9338
  return {
9061
9339
  ok: false,
9062
9340
  error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
@@ -9068,13 +9346,13 @@ async function runHydrateWorktree(opts) {
9068
9346
  return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
9069
9347
  }
9070
9348
  const state = await readWorktreeState(wtPath);
9071
- const slug = state?.slug ?? path59.basename(wtPath);
9349
+ const slug = state?.slug ?? path60.basename(wtPath);
9072
9350
  const detectedBranch = await currentBranch(wtPath);
9073
9351
  const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
9074
9352
  if (opts.dryRun) {
9075
9353
  const results2 = await Promise.all(
9076
9354
  selected.map(async (member) => {
9077
- const memberPath = path59.join(wtPath, member.folder);
9355
+ const memberPath = path60.join(wtPath, member.folder);
9078
9356
  if (existsSync6(memberPath)) {
9079
9357
  return { member: member.id, folder: member.folder, status: "planned" };
9080
9358
  }
@@ -9095,7 +9373,7 @@ async function runHydrateWorktree(opts) {
9095
9373
  const results = await mapWithConcurrency(
9096
9374
  selected,
9097
9375
  async (member) => {
9098
- const memberPath = path59.join(wtPath, member.folder);
9376
+ const memberPath = path60.join(wtPath, member.folder);
9099
9377
  let branchAction;
9100
9378
  if (!existsSync6(memberPath)) {
9101
9379
  if (!opts.materialize) {
@@ -9158,26 +9436,26 @@ async function runHydrateWorktree(opts) {
9158
9436
  }
9159
9437
 
9160
9438
  // src/workspace/worktree/list.ts
9161
- import path60 from "node:path";
9162
- import fs41 from "fs-extra";
9439
+ import path61 from "node:path";
9440
+ import fs42 from "fs-extra";
9163
9441
  async function runList2() {
9164
9442
  const resolved = await resolveWorkspaceForWorktree();
9165
9443
  if (!resolved.ok) return { ok: false, error: resolved.reason };
9166
9444
  const { rootInfo, workspaceRoot } = resolved;
9167
9445
  const dir = worktreesDir(workspaceRoot);
9168
- if (!await fs41.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
9169
- 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 });
9170
9448
  const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
9171
9449
  const configuredMembers = await readMembers(rootInfo);
9172
9450
  const worktrees = [];
9173
9451
  for (const slug of slugs) {
9174
- const wtPath = path60.join(dir, slug);
9452
+ const wtPath = path61.join(dir, slug);
9175
9453
  const state = await readWorktreeState(wtPath);
9176
9454
  const members = [];
9177
9455
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
9178
9456
  for (const m of source) {
9179
- const memberPath = path60.join(wtPath, m.folder);
9180
- const materialized = await fs41.pathExists(memberPath);
9457
+ const memberPath = path61.join(wtPath, m.folder);
9458
+ const materialized = await fs42.pathExists(memberPath);
9181
9459
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
9182
9460
  members.push({
9183
9461
  id: m.id,
@@ -9198,14 +9476,14 @@ async function runList2() {
9198
9476
  }
9199
9477
 
9200
9478
  // src/workspace/worktree/remove.ts
9201
- import path61 from "node:path";
9202
- import fs42 from "fs-extra";
9479
+ import path62 from "node:path";
9480
+ import fs43 from "fs-extra";
9203
9481
  async function runRemove(opts) {
9204
9482
  const resolved = await resolveWorkspaceForWorktree();
9205
9483
  if (!resolved.ok) return { ok: false, error: resolved.reason };
9206
9484
  const { rootInfo, workspaceRoot } = resolved;
9207
9485
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
9208
- if (!await fs42.pathExists(wsWorktreePath)) {
9486
+ if (!await fs43.pathExists(wsWorktreePath)) {
9209
9487
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
9210
9488
  }
9211
9489
  const state = await readWorktreeState(wsWorktreePath);
@@ -9225,12 +9503,12 @@ async function runRemove(opts) {
9225
9503
  targets.push({
9226
9504
  repo: configMember?.id ?? stateMember.id,
9227
9505
  ownerRepo,
9228
- path: path61.join(wsWorktreePath, stateMember.folder)
9506
+ path: path62.join(wsWorktreePath, stateMember.folder)
9229
9507
  });
9230
9508
  }
9231
9509
  const existingTargets = [];
9232
9510
  for (const target of targets) {
9233
- if (await fs42.pathExists(target.path)) existingTargets.push(target);
9511
+ if (await fs43.pathExists(target.path)) existingTargets.push(target);
9234
9512
  }
9235
9513
  if (!opts.force) {
9236
9514
  const blockers = [];
@@ -9275,14 +9553,19 @@ async function runRemove(opts) {
9275
9553
  await pruneWorktrees(target.ownerRepo);
9276
9554
  }
9277
9555
  const wsTarget = existingTargets.find((t) => t.repo === "(workspace)");
9556
+ let wsRemoveOk = false;
9278
9557
  if (wsTarget) {
9279
9558
  const wsResult = await removeWorktree(workspaceRoot, wsWorktreePath);
9280
- if (wsResult.ok) removed.push("(workspace)");
9281
- 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
+ }
9282
9565
  await pruneWorktrees(workspaceRoot);
9283
9566
  }
9284
- if (await fs42.pathExists(wsWorktreePath)) {
9285
- await fs42.remove(wsWorktreePath);
9567
+ if (wsRemoveOk && await fs43.pathExists(wsWorktreePath)) {
9568
+ await fs43.remove(wsWorktreePath);
9286
9569
  }
9287
9570
  return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
9288
9571
  }
@@ -9497,7 +9780,7 @@ async function runWorktree(action, opts = {}) {
9497
9780
 
9498
9781
  // src/commands/workspace.ts
9499
9782
  import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
9500
- import path70 from "node:path";
9783
+ import path71 from "node:path";
9501
9784
 
9502
9785
  // src/commands/workspace/aggregate.ts
9503
9786
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -9552,18 +9835,18 @@ ${summaries.map(
9552
9835
 
9553
9836
  // src/commands/workspace/doctor.ts
9554
9837
  import { existsSync as existsSync7 } from "node:fs";
9555
- import path63 from "node:path";
9838
+ import path64 from "node:path";
9556
9839
 
9557
9840
  // src/commands/workspace/manifest.ts
9558
9841
  import { readFileSync as readFileSync4 } from "node:fs";
9559
- import path62 from "node:path";
9842
+ import path63 from "node:path";
9560
9843
  var MANIFEST_FILE = "workspace.manifest.json";
9561
9844
  function manifestPath(workspaceRoot) {
9562
9845
  return hausPath(workspaceRoot, MANIFEST_FILE);
9563
9846
  }
9564
9847
  function hausVersion() {
9565
9848
  try {
9566
- const pkg = JSON.parse(readFileSync4(path62.join(packageRoot(), "package.json"), "utf8"));
9849
+ const pkg = JSON.parse(readFileSync4(path63.join(packageRoot(), "package.json"), "utf8"));
9567
9850
  return pkg.version ?? "0.0.0";
9568
9851
  } catch {
9569
9852
  return "0.0.0";
@@ -9644,10 +9927,18 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
9644
9927
  });
9645
9928
  return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
9646
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
+ }
9647
9938
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
9648
9939
  const catalogRefByRepo = [];
9649
9940
  for (const repo of config.repos) {
9650
- const repoRoot = path63.resolve(workspaceRoot, repo.path);
9941
+ const repoRoot = path64.resolve(workspaceRoot, repo.path);
9651
9942
  const entry = manifestByName.get(repo.name);
9652
9943
  if (!entry) {
9653
9944
  flag({
@@ -9720,7 +10011,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
9720
10011
  });
9721
10012
  continue;
9722
10013
  }
9723
- if (!existsSync7(path63.join(workspaceRoot, entry.path))) {
10014
+ if (!existsSync7(path64.join(workspaceRoot, entry.path))) {
9724
10015
  flag({
9725
10016
  repo: entry.repo,
9726
10017
  kind: "missing-linked-context-copy",
@@ -9763,16 +10054,16 @@ function emit(args) {
9763
10054
  }
9764
10055
 
9765
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
9766
10061
  import path66 from "node:path";
9767
10062
  import fs45 from "fs-extra";
9768
10063
 
9769
- // src/workspace/link-context/plan.ts
10064
+ // src/workspace/link-context/scan-source.ts
9770
10065
  import path65 from "node:path";
9771
10066
  import fs44 from "fs-extra";
9772
-
9773
- // src/workspace/link-context/scan-source.ts
9774
- import path64 from "node:path";
9775
- import fs43 from "fs-extra";
9776
10067
  var ASSET_SUBDIR = {
9777
10068
  skill: "skills",
9778
10069
  agent: "agents",
@@ -9780,36 +10071,36 @@ var ASSET_SUBDIR = {
9780
10071
  };
9781
10072
  async function hasLinkableContext(member) {
9782
10073
  for (const subdir of Object.values(ASSET_SUBDIR)) {
9783
- 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;
9784
10075
  }
9785
10076
  return false;
9786
10077
  }
9787
10078
  async function collectSkills(member) {
9788
- const dir = path64.join(member.absPath, ".claude", "skills");
9789
- if (!await fs43.pathExists(dir)) return [];
9790
- 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);
9791
10082
  const out = [];
9792
10083
  for (const name of [...names].sort()) {
9793
- const abs = path64.join(dir, name);
9794
- const stat = await fs43.lstat(abs);
10084
+ const abs = path65.join(dir, name);
10085
+ const stat = await fs44.lstat(abs);
9795
10086
  if (!stat.isDirectory()) continue;
9796
- if (!await fs43.pathExists(path64.join(abs, "SKILL.md"))) continue;
9797
- 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) });
9798
10089
  }
9799
10090
  return out;
9800
10091
  }
9801
10092
  async function collectFlatMarkdown(member, type) {
9802
10093
  const subdir = ASSET_SUBDIR[type];
9803
- const dir = path64.join(member.absPath, ".claude", subdir);
9804
- if (!await fs43.pathExists(dir)) return [];
9805
- 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);
9806
10097
  const out = [];
9807
10098
  for (const fileName of [...names].sort()) {
9808
10099
  if (!fileName.endsWith(".md")) continue;
9809
- const abs = path64.join(dir, fileName);
9810
- if (!(await fs43.lstat(abs)).isFile()) continue;
10100
+ const abs = path65.join(dir, fileName);
10101
+ if (!(await fs44.lstat(abs)).isFile()) continue;
9811
10102
  const name = fileName.slice(0, -".md".length);
9812
- out.push({ type, name, sourceRelPath: path64.posix.join(".claude", subdir, fileName) });
10103
+ out.push({ type, name, sourceRelPath: path65.posix.join(".claude", subdir, fileName) });
9813
10104
  }
9814
10105
  return out;
9815
10106
  }
@@ -9824,9 +10115,9 @@ async function collectSourceAssets(member) {
9824
10115
 
9825
10116
  // src/workspace/link-context/plan.ts
9826
10117
  function repoFolderPrefix(member) {
9827
- const base = path65.posix.basename(member.folder);
10118
+ const base = path66.posix.basename(member.folder);
9828
10119
  if (base && base !== "." && base !== "/") return base;
9829
- const fromAbs = path65.basename(member.absPath);
10120
+ const fromAbs = path66.basename(member.absPath);
9830
10121
  return fromAbs || member.id;
9831
10122
  }
9832
10123
  function assetKey(type, destKey) {
@@ -9841,7 +10132,7 @@ async function buildLinkPlan(members) {
9841
10132
  const byKey = /* @__PURE__ */ new Map();
9842
10133
  const membersById = new Map(members.map((m) => [m.id, m]));
9843
10134
  for (const member of members) {
9844
- if (!await fs44.pathExists(member.absPath)) {
10135
+ if (!await fs45.pathExists(member.absPath)) {
9845
10136
  skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
9846
10137
  continue;
9847
10138
  }
@@ -9883,7 +10174,7 @@ async function buildLinkPlan(members) {
9883
10174
  name: asset.name,
9884
10175
  destKey,
9885
10176
  sourceRelPath: asset.sourceRelPath,
9886
- sourceAbsPath: path65.join(member.absPath, asset.sourceRelPath),
10177
+ sourceAbsPath: path66.join(member.absPath, asset.sourceRelPath),
9887
10178
  sourceHash
9888
10179
  });
9889
10180
  }
@@ -9894,7 +10185,7 @@ async function buildLinkPlan(members) {
9894
10185
  function destRelPathFor(type, destKey) {
9895
10186
  const subdir = `${type}s`;
9896
10187
  const leaf = type === "skill" ? destKey : `${destKey}.md`;
9897
- return path66.posix.join(".claude", subdir, leaf);
10188
+ return path67.posix.join(".claude", subdir, leaf);
9898
10189
  }
9899
10190
  async function runLinkContext(workspaceRoot, opts = {}) {
9900
10191
  const write = opts.write ?? false;
@@ -9920,17 +10211,17 @@ async function runLinkContext(workspaceRoot, opts = {}) {
9920
10211
  const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
9921
10212
  for (const entry of plan.entries) {
9922
10213
  const destRel = destRelPathFor(entry.type, entry.destKey);
9923
- const destAbs = path66.join(workspaceRoot, destRel);
10214
+ const destAbs = path67.join(workspaceRoot, destRel);
9924
10215
  if (write) {
9925
- await fs45.ensureDir(path66.dirname(destAbs));
9926
- if (await fs45.pathExists(destAbs)) await fs45.remove(destAbs);
9927
- 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, {
9928
10219
  overwrite: true,
9929
10220
  errorOnExist: false,
9930
10221
  dereference: false,
9931
10222
  filter: (s) => {
9932
10223
  try {
9933
- return !fs45.lstatSync(s).isSymbolicLink();
10224
+ return !fs46.lstatSync(s).isSymbolicLink();
9934
10225
  } catch {
9935
10226
  return false;
9936
10227
  }
@@ -9954,8 +10245,12 @@ async function runLinkContext(workspaceRoot, opts = {}) {
9954
10245
  if (nextPaths.has(old.path)) continue;
9955
10246
  removed.push(old.path);
9956
10247
  if (write) {
9957
- const abs = path66.join(workspaceRoot, old.path);
9958
- 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);
9959
10254
  }
9960
10255
  }
9961
10256
  if (write) {
@@ -10007,11 +10302,12 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
10007
10302
 
10008
10303
  // src/commands/workspace/setup.ts
10009
10304
  import { existsSync as existsSync8, statSync } from "node:fs";
10010
- import path68 from "node:path";
10305
+ import path69 from "node:path";
10011
10306
 
10012
10307
  // src/claude/write-workspace-claude-md.ts
10013
- import path67 from "node:path";
10014
- import fs46 from "fs-extra";
10308
+ import path68 from "node:path";
10309
+ import fs47 from "fs-extra";
10310
+ var WORKSPACE_SENTINELS = { begin: WORKSPACE_BLOCK_BEGIN, end: WORKSPACE_BLOCK_END };
10015
10311
  function buildWorkspaceImportBlock(client, members) {
10016
10312
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
10017
10313
  const body = [
@@ -10022,20 +10318,20 @@ function buildWorkspaceImportBlock(client, members) {
10022
10318
  "Member repos:",
10023
10319
  ...memberLines
10024
10320
  ].join("\n");
10025
- return `${BLOCK_BEGIN}
10321
+ return `${WORKSPACE_BLOCK_BEGIN}
10026
10322
  ${body}
10027
- ${BLOCK_END}`;
10323
+ ${WORKSPACE_BLOCK_END}`;
10028
10324
  }
10029
10325
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
10030
10326
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
10031
10327
  const dryRun = opts.dryRun ?? false;
10032
- 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");
10033
10329
  const say = opts.quiet ? () => {
10034
10330
  } : log;
10035
- const existed = await fs46.pathExists(filePath);
10036
- const prev = existed ? await fs46.readFile(filePath, "utf8") : "";
10331
+ const existed = await fs47.pathExists(filePath);
10332
+ const prev = existed ? await fs47.readFile(filePath, "utf8") : "";
10037
10333
  const next = opts.collision ? `${block}
10038
- ` : injectHausBlock(prev, block);
10334
+ ` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
10039
10335
  const printable = displayPath(workspaceRoot, filePath);
10040
10336
  if (dryRun) {
10041
10337
  if (!existed) {
@@ -10060,20 +10356,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
10060
10356
 
10061
10357
  // src/commands/workspace/setup.ts
10062
10358
  function resolveWorkspaceRoot(start = process.cwd()) {
10063
- let dir = path68.resolve(start);
10359
+ let dir = path69.resolve(start);
10064
10360
  for (; ; ) {
10065
- if (existsSync8(path68.join(dir, WORKSPACE_FILE))) return dir;
10066
- const parent = path68.dirname(dir);
10067
- 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);
10068
10364
  dir = parent;
10069
10365
  }
10070
10366
  }
10071
10367
  function isRootRepo(workspaceRoot, repoPath) {
10072
- return path68.resolve(workspaceRoot, repoPath) === path68.resolve(workspaceRoot);
10368
+ return path69.resolve(workspaceRoot, repoPath) === path69.resolve(workspaceRoot);
10073
10369
  }
10074
10370
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
10075
10371
  const apply = options.write ?? false;
10076
- const configText = await readText(path68.join(workspaceRoot, WORKSPACE_FILE));
10372
+ const configText = await readText(path69.join(workspaceRoot, WORKSPACE_FILE));
10077
10373
  if (!configText) {
10078
10374
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
10079
10375
  process.exitCode = 1;
@@ -10100,7 +10396,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10100
10396
  const statuses = [];
10101
10397
  const aggregateInputs = [];
10102
10398
  for (const repo of repos) {
10103
- const repoRoot = path68.resolve(workspaceRoot, repo.path);
10399
+ const repoRoot = path69.resolve(workspaceRoot, repo.path);
10104
10400
  say(`
10105
10401
  \u2192 ${repo.name} (${repo.path})`);
10106
10402
  try {
@@ -10133,7 +10429,15 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10133
10429
  status: "failed",
10134
10430
  error: message
10135
10431
  });
10136
- 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
+ }
10137
10441
  error(`Setup failed for ${repo.name}: ${message}`);
10138
10442
  }
10139
10443
  }
@@ -10147,6 +10451,17 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10147
10451
  config.relationships
10148
10452
  );
10149
10453
  written.push(...artifacts);
10454
+ } else {
10455
+ const sayAggregate = options.json ? () => {
10456
+ } : log;
10457
+ for (const rel of [
10458
+ "workspace-summary.json",
10459
+ "dependency-ownership-map.json",
10460
+ "cross-repo-summary.md",
10461
+ "workspace-context-map.json"
10462
+ ]) {
10463
+ sayAggregate(`[dry-run] would write .haus-workflow/${rel}`);
10464
+ }
10150
10465
  }
10151
10466
  const docPath = await writeWorkspaceClaudeMd(workspaceRoot, {
10152
10467
  client: config.client,
@@ -10171,7 +10486,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10171
10486
  const status = statusByName.get(repo.name);
10172
10487
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
10173
10488
  if (status?.status === "ok") {
10174
- const lock = await checkLock(path68.resolve(workspaceRoot, repo.path));
10489
+ const lock = await checkLock(path69.resolve(workspaceRoot, repo.path));
10175
10490
  manifestRepos.push({
10176
10491
  name: repo.name,
10177
10492
  path: repo.path,
@@ -10227,7 +10542,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10227
10542
  try {
10228
10543
  const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
10229
10544
  if (linkResult.ok) {
10230
- written.push(...linkResult.added.map((rel) => path68.join(workspaceRoot, rel)));
10545
+ written.push(...linkResult.added.map((rel) => path69.join(workspaceRoot, rel)));
10231
10546
  if (!options.json) {
10232
10547
  say(
10233
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` : ""}.`
@@ -10259,14 +10574,22 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
10259
10574
 
10260
10575
  // src/commands/workspace/undo.ts
10261
10576
  import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
10262
- import path69 from "node:path";
10263
- import fs47 from "fs-extra";
10577
+ import path70 from "node:path";
10578
+ import fs48 from "fs-extra";
10264
10579
  var WORKSPACE_AGGREGATE_FILES = [
10265
10580
  "workspace-summary.json",
10266
10581
  "dependency-ownership-map.json",
10267
10582
  "cross-repo-summary.md",
10268
10583
  "workspace-context-map.json"
10269
10584
  ];
10585
+ function hasLegacyWorkspaceBlock(content2) {
10586
+ const beginIdx = content2.indexOf(BLOCK_BEGIN);
10587
+ if (beginIdx < 0) return false;
10588
+ const endIdx = content2.indexOf(BLOCK_END, beginIdx + BLOCK_BEGIN.length);
10589
+ if (endIdx < 0) return false;
10590
+ const block = content2.slice(beginIdx, endIdx + BLOCK_END.length);
10591
+ return block.includes("@.haus-workflow/cross-repo-summary.md") && block.includes("Member repos:");
10592
+ }
10270
10593
  async function runWorkspaceUndo(workspaceRoot, options = {}) {
10271
10594
  const config = await readWorkspaceConfig(workspaceRoot);
10272
10595
  if (!config) {
@@ -10288,7 +10611,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10288
10611
  }
10289
10612
  let anyRepoFailed = false;
10290
10613
  for (const repo of config.repos) {
10291
- const repoRoot = path69.resolve(workspaceRoot, repo.path);
10614
+ const repoRoot = path70.resolve(workspaceRoot, repo.path);
10292
10615
  log(`
10293
10616
  \u2192 ${repo.name} (${repo.path})`);
10294
10617
  try {
@@ -10303,41 +10626,59 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10303
10626
  }
10304
10627
  const linkedContext = await readLinkedContext(workspaceRoot);
10305
10628
  for (const entry of linkedContext) {
10306
- const abs = path69.join(workspaceRoot, entry.path);
10307
- if (await fs47.pathExists(abs)) {
10308
- 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);
10309
10636
  log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
10310
10637
  }
10311
10638
  }
10312
10639
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
10313
10640
  const abs = hausPath(workspaceRoot, rel);
10314
- if (await fs47.pathExists(abs)) {
10315
- await fs47.remove(abs);
10316
- log(`Removed ${path69.relative(workspaceRoot, abs)}`);
10641
+ if (await fs48.pathExists(abs)) {
10642
+ await fs48.remove(abs);
10643
+ log(`Removed ${path70.relative(workspaceRoot, abs)}`);
10317
10644
  }
10318
10645
  }
10319
10646
  const manifestFile = manifestPath(workspaceRoot);
10320
- if (await fs47.pathExists(manifestFile)) {
10321
- await fs47.remove(manifestFile);
10322
- log(`Removed ${path69.relative(workspaceRoot, manifestFile)}`);
10647
+ if (await fs48.pathExists(manifestFile)) {
10648
+ await fs48.remove(manifestFile);
10649
+ log(`Removed ${path70.relative(workspaceRoot, manifestFile)}`);
10323
10650
  }
10324
10651
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
10325
- if (await fs47.pathExists(workspaceMdPath)) {
10326
- await fs47.remove(workspaceMdPath);
10327
- log(`Removed ${path69.relative(workspaceRoot, workspaceMdPath)}`);
10652
+ if (await fs48.pathExists(workspaceMdPath)) {
10653
+ await fs48.remove(workspaceMdPath);
10654
+ log(`Removed ${path70.relative(workspaceRoot, workspaceMdPath)}`);
10328
10655
  } else {
10329
- const claudeMdPath = path69.join(workspaceRoot, "CLAUDE.md");
10656
+ const claudeMdPath = path70.join(workspaceRoot, "CLAUDE.md");
10330
10657
  if (existsSync9(claudeMdPath)) {
10331
10658
  const prev = await readText(claudeMdPath);
10332
- if (prev?.includes(BLOCK_BEGIN)) {
10333
- const next = stripHausBlock(prev);
10659
+ if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
10660
+ const next = stripHausBlock(prev, {
10661
+ begin: WORKSPACE_BLOCK_BEGIN,
10662
+ end: WORKSPACE_BLOCK_END
10663
+ });
10334
10664
  if (next.length === 0) {
10335
- await fs47.remove(claudeMdPath);
10665
+ await fs48.remove(claudeMdPath);
10336
10666
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
10337
10667
  } else {
10338
- await fs47.writeFile(claudeMdPath, next, "utf8");
10668
+ await fs48.writeFile(claudeMdPath, next, "utf8");
10339
10669
  log("Removed haus import block from CLAUDE.md (user content preserved).");
10340
10670
  }
10671
+ } else if (prev && hasLegacyWorkspaceBlock(prev)) {
10672
+ const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
10673
+ if (next.length === 0) {
10674
+ await fs48.remove(claudeMdPath);
10675
+ log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
10676
+ } else {
10677
+ await fs48.writeFile(claudeMdPath, next, "utf8");
10678
+ log(
10679
+ "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
10680
+ );
10681
+ }
10341
10682
  }
10342
10683
  }
10343
10684
  }
@@ -10364,7 +10705,16 @@ function normalizeMaxDepth(maxDepth) {
10364
10705
  warn(`Ignoring invalid --max-depth "${String(maxDepth)}"; using the default depth.`);
10365
10706
  return void 0;
10366
10707
  }
10367
- 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
+ }
10368
10718
  await writeText(
10369
10719
  WORKSPACE_FILE,
10370
10720
  `client: unknown
@@ -10378,7 +10728,7 @@ relationships: []
10378
10728
  log("Workspace initialized.");
10379
10729
  }
10380
10730
  async function scanWorkspace(workspaceRoot, opts) {
10381
- const configText = await readText(path70.join(workspaceRoot, WORKSPACE_FILE));
10731
+ const configText = await readText(path71.join(workspaceRoot, WORKSPACE_FILE));
10382
10732
  if (!configText) {
10383
10733
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
10384
10734
  process.exitCode = 1;
@@ -10400,7 +10750,7 @@ async function scanWorkspace(workspaceRoot, opts) {
10400
10750
  const inputs = [];
10401
10751
  const failures = [];
10402
10752
  for (const repo of config.repos) {
10403
- const repoRoot = path70.resolve(workspaceRoot, repo.path);
10753
+ const repoRoot = path71.resolve(workspaceRoot, repo.path);
10404
10754
  try {
10405
10755
  if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
10406
10756
  throw new Error("path is not a directory");
@@ -10426,7 +10776,7 @@ async function scanWorkspace(workspaceRoot, opts) {
10426
10776
  }
10427
10777
  async function runWorkspace(action, options = {}) {
10428
10778
  if (action === "init") {
10429
- await initWorkspace();
10779
+ await initWorkspace({ yes: options.yes });
10430
10780
  return;
10431
10781
  }
10432
10782
  const workspaceRoot = resolveWorkspaceRoot();
@@ -10466,7 +10816,7 @@ async function runWorkspace(action, options = {}) {
10466
10816
  // src/cli.ts
10467
10817
  function cliVersion() {
10468
10818
  try {
10469
- const pkgPath = path71.join(packageRoot(), "package.json");
10819
+ const pkgPath = path72.join(packageRoot(), "package.json");
10470
10820
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
10471
10821
  return pkg.version ?? "0.0.0";
10472
10822
  } catch {
@@ -10476,7 +10826,7 @@ function cliVersion() {
10476
10826
  var program = new Command();
10477
10827
  function validateRuntimeNodeVersion() {
10478
10828
  try {
10479
- const pkgPath = path71.join(packageRoot(), "package.json");
10829
+ const pkgPath = path72.join(packageRoot(), "package.json");
10480
10830
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
10481
10831
  const requiredRange = pkg.engines?.node;
10482
10832
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -10537,7 +10887,7 @@ program.command("install").option("--dry-run").option("--force").option("--check
10537
10887
  program.command("scaffold [ids...]").description(
10538
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."
10539
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);
10540
- program.command("uninstall").option("--force").action(runUninstallCommand);
10890
+ program.command("uninstall").option("--force").option("-y, --yes", "Skip confirmation").action(runUninstallCommand);
10541
10891
  var guard = program.command("guard");
10542
10892
  guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
10543
10893
  guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
@@ -10554,7 +10904,7 @@ function registerDecisionsCommand(name) {
10554
10904
  registerDecisionsCommand("decisions");
10555
10905
  registerDecisionsCommand("adr");
10556
10906
  var workspace = program.command("workspace");
10557
- 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));
10558
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));
10559
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));
10560
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));