@haus-tech/haus-workflow 1.5.4 → 1.5.6

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);
@@ -685,10 +693,17 @@ function validateReferences(itemId, references) {
685
693
  if (references === void 0) return null;
686
694
  if (!isStringArray(references)) return `${itemId}: references must be a string array`;
687
695
  for (const ref of references) {
688
- if (/^https?:\/\//i.test(ref)) {
689
- if (!/^https:\/\//i.test(ref)) {
690
- return `${itemId}: reference must be https:// URL: ${ref}`;
691
- }
696
+ let parsed;
697
+ try {
698
+ parsed = new URL(ref);
699
+ } catch {
700
+ return `${itemId}: reference is not a valid URL: ${ref}`;
701
+ }
702
+ if (parsed.protocol === "http:") {
703
+ return `${itemId}: reference uses insecure http:// URL: ${ref}`;
704
+ }
705
+ if (parsed.protocol !== "https:") {
706
+ return `${itemId}: reference must be https:// URL: ${ref}`;
692
707
  }
693
708
  }
694
709
  return null;
@@ -743,16 +758,29 @@ function validateCuratedProvenance(item) {
743
758
  if (!RISK_LEVELS.has(item.riskLevel)) {
744
759
  return `${item.id}: invalid riskLevel "${item.riskLevel}"`;
745
760
  }
746
- if (item.useMode !== void 0 && !USE_MODES.has(String(item.useMode))) {
761
+ if (!isNonEmptyString(item.originSourceId)) {
762
+ return `${item.id}: curated item missing originSourceId`;
763
+ }
764
+ if (!isNonEmptyString(item.originUrl)) {
765
+ return `${item.id}: curated item missing originUrl`;
766
+ }
767
+ if (!isNonEmptyString(item.useMode)) {
768
+ return `${item.id}: curated item missing useMode`;
769
+ }
770
+ if (!USE_MODES.has(item.useMode)) {
747
771
  return `${item.id}: invalid useMode "${item.useMode}"`;
748
772
  }
749
- if (item.licenseConfidence !== void 0 && !LICENSE_CONFIDENCES.has(String(item.licenseConfidence))) {
773
+ if (!isNonEmptyString(item.license)) {
774
+ return `${item.id}: curated item missing license`;
775
+ }
776
+ if (!isNonEmptyString(item.licenseConfidence)) {
777
+ return `${item.id}: curated item missing licenseConfidence`;
778
+ }
779
+ if (!LICENSE_CONFIDENCES.has(item.licenseConfidence)) {
750
780
  return `${item.id}: invalid licenseConfidence "${item.licenseConfidence}"`;
751
781
  }
752
- if (item.originUrl !== void 0) {
753
- if (typeof item.originUrl !== "string" || !item.originUrl.startsWith("https://")) {
754
- return `${item.id}: originUrl must be an https:// URL`;
755
- }
782
+ if (typeof item.originUrl !== "string" || !item.originUrl.startsWith("https://")) {
783
+ return `${item.id}: originUrl must be an https:// URL`;
756
784
  }
757
785
  return null;
758
786
  }
@@ -774,9 +802,6 @@ function isRequiresAnyClause(value) {
774
802
  if (!REQUIRES_ANY_KEYS.includes(key)) return false;
775
803
  return typeof value[key] === "string";
776
804
  }
777
- function isRequiresAnyArray(value) {
778
- return Array.isArray(value) && value.every(isRequiresAnyClause);
779
- }
780
805
  function safeParse(json) {
781
806
  return JSON.parse(json, (key, value) => {
782
807
  if (POLLUTION_KEYS.has(key)) return void 0;
@@ -803,6 +828,7 @@ function parseManifest(json) {
803
828
  const items = [];
804
829
  const seenIds = /* @__PURE__ */ new Set();
805
830
  const seenPaths = /* @__PURE__ */ new Set();
831
+ const warnings = [];
806
832
  for (let i = 0; i < root.items.length; i++) {
807
833
  const raw = root.items[i];
808
834
  if (!raw || typeof raw !== "object") {
@@ -837,8 +863,21 @@ function parseManifest(json) {
837
863
  if (!isStringArray2(item.repoRoles)) {
838
864
  return { ok: false, error: `${item.id}: repoRoles must be a string array` };
839
865
  }
840
- if (item.requiresAny !== void 0 && !isRequiresAnyArray(item.requiresAny)) {
841
- return { ok: false, error: `${item.id}: requiresAny must be an array of clause objects` };
866
+ if (item.requiresAny !== void 0) {
867
+ if (!Array.isArray(item.requiresAny)) {
868
+ return { ok: false, error: `${item.id}: requiresAny must be an array of clause objects` };
869
+ }
870
+ const kept = [];
871
+ for (const clause of item.requiresAny) {
872
+ if (isRequiresAnyClause(clause)) {
873
+ kept.push(clause);
874
+ } else {
875
+ warnings.push(
876
+ `${item.id}: dropping unrecognized requiresAny clause (catalog schema evolved ahead of this CLI version) \u2014 ${JSON.stringify(clause)}`
877
+ );
878
+ }
879
+ }
880
+ item.requiresAny = kept;
842
881
  }
843
882
  if (typeof item.tokenEstimate !== "number" || !Number.isFinite(item.tokenEstimate)) {
844
883
  return { ok: false, error: `${item.id}: tokenEstimate must be a finite number` };
@@ -855,7 +894,7 @@ function parseManifest(json) {
855
894
  if (formerIdFailures.length > 0) {
856
895
  return { ok: false, error: formerIdFailures[0] };
857
896
  }
858
- return { ok: true, manifest: { version: root.version, items } };
897
+ return { ok: true, manifest: { version: root.version, items }, warnings };
859
898
  }
860
899
 
861
900
  // src/catalog/remote-catalog/manifest.ts
@@ -868,6 +907,7 @@ async function fetchRemoteManifest() {
868
907
  warn(`Remote manifest failed schema validation: ${parsed.error}`);
869
908
  return null;
870
909
  }
910
+ for (const warning of parsed.warnings) warn(warning);
871
911
  if (!parsed.manifest.items.length) return null;
872
912
  return parsed.manifest;
873
913
  }
@@ -929,6 +969,7 @@ var validation_rules_default = {
929
969
  "haus",
930
970
  "security",
931
971
  "quality",
972
+ "a11y",
932
973
  "frontend",
933
974
  "backend",
934
975
  "testing",
@@ -1552,7 +1593,7 @@ function summarizeGitignoredHausDirs(paths) {
1552
1593
  }
1553
1594
 
1554
1595
  // src/claude/write-claude-files.ts
1555
- import path19 from "node:path";
1596
+ import path20 from "node:path";
1556
1597
  import fs18 from "fs-extra";
1557
1598
 
1558
1599
  // src/catalog/former-ids.ts
@@ -1608,8 +1649,199 @@ function findOrphanedLockEntries(lock, recommendedIds) {
1608
1649
  );
1609
1650
  }
1610
1651
 
1611
- // src/update/hash-installed.ts
1652
+ // src/scanner/detection.ts
1612
1653
  import path10 from "node:path";
1654
+
1655
+ // src/security/sensitive-paths.ts
1656
+ var DENY_PATHS = [
1657
+ "*.pem",
1658
+ "*.key",
1659
+ "*.p12",
1660
+ "*.pfx",
1661
+ "id_rsa",
1662
+ "id_ed25519",
1663
+ "customer-data",
1664
+ "secrets",
1665
+ "certs"
1666
+ ];
1667
+ var DENY_DIRS = /* @__PURE__ */ new Set(["customer-data", "secrets", "certs"]);
1668
+ var DENY_PATH_REGEXES = [
1669
+ /(^|\/)[^/]+\.pem$/i,
1670
+ /(^|\/)[^/]+\.key$/i,
1671
+ /(^|\/)[^/]+\.p12$/i,
1672
+ /(^|\/)[^/]+\.pfx$/i,
1673
+ /(^|\/)id_rsa$/i,
1674
+ /(^|\/)id_ed25519$/i,
1675
+ /(^|\/)customer-data(\/|$)/i,
1676
+ /(^|\/)secrets(\/|$)/i,
1677
+ /(^|\/)certs(\/|$)/i
1678
+ ];
1679
+ function normalizePathForMatch(candidate) {
1680
+ return candidate.replace(/\\/g, "/").replace(/\/+/g, "/").trim();
1681
+ }
1682
+ var ASK_PATHS = [
1683
+ { pattern: ".env", tools: ["Edit", "Write"] },
1684
+ { pattern: ".env.*", tools: ["Edit", "Write"] },
1685
+ { pattern: "storage/logs/**", tools: ["Edit", "Write"] },
1686
+ { pattern: "exports/**", tools: ["Edit", "Write"] },
1687
+ { pattern: "*.dump", tools: ["Read", "Edit", "Write"] },
1688
+ { pattern: "*.backup", tools: ["Read", "Edit", "Write"] },
1689
+ { pattern: "*.bak", tools: ["Read", "Edit", "Write"] },
1690
+ { pattern: "wp-content/uploads/**", tools: ["Read", "Edit", "Write"] },
1691
+ { pattern: "uploads/**", tools: ["Read", "Edit", "Write"] }
1692
+ ];
1693
+ var SENSITIVE_PATH_REGEXES = [
1694
+ /^\.env(\.|$)/,
1695
+ /(^|\/)\.env(\.|$)/,
1696
+ /\.pem$/,
1697
+ /\.key$/,
1698
+ /\.p12$/,
1699
+ /\.pfx$/,
1700
+ /\.sql$/,
1701
+ /\.dump$/,
1702
+ /customer-data/,
1703
+ /exports/,
1704
+ /certs/,
1705
+ /secrets/,
1706
+ /(^|\/)storage\/logs(\/|$)/,
1707
+ /(^|\/)wp-content\/uploads(\/|$)/,
1708
+ /(^|\/)uploads(\/|$)/
1709
+ ];
1710
+ var SENSITIVE_ITEM_KEYWORDS = [
1711
+ ".env",
1712
+ "secrets",
1713
+ "certs",
1714
+ "customer-data",
1715
+ "exports",
1716
+ ".pem",
1717
+ ".key"
1718
+ ];
1719
+
1720
+ // src/utils/audit-checks.ts
1721
+ function isRecord(v) {
1722
+ return typeof v === "object" && v !== null;
1723
+ }
1724
+
1725
+ // src/scanner/detection.ts
1726
+ var UNSUPPORTED_MARKERS = {
1727
+ "requirements.txt": "python",
1728
+ "pyproject.toml": "python",
1729
+ "go.mod": "go",
1730
+ "Cargo.toml": "rust",
1731
+ "pom.xml": "java",
1732
+ "build.gradle": "java",
1733
+ "build.gradle.kts": "java",
1734
+ Gemfile: "ruby"
1735
+ };
1736
+ var WEAK_STACK_SIGNALS = /* @__PURE__ */ new Set(["missing-prettier", "missing-eslint"]);
1737
+ function computeDetectionStatus(roles, stacks, unsupportedSignals) {
1738
+ const hasRealStack = Object.entries(stacks).some(
1739
+ ([bucket, names]) => bucket !== "packageManagers" && names.some((n) => !WEAK_STACK_SIGNALS.has(n))
1740
+ );
1741
+ const hasRealSignal = roles.length > 0 || hasRealStack;
1742
+ if (!hasRealSignal) return "unknown";
1743
+ return unsupportedSignals.length > 0 ? "partial" : "supported";
1744
+ }
1745
+ function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
1746
+ const markers = unsupportedSignals.join(", ");
1747
+ const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
1748
+ 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." : "";
1749
+ return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
1750
+ }
1751
+ function blocked(rel) {
1752
+ return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
1753
+ }
1754
+ function dependencyVersionMap(pkg, composer) {
1755
+ const versions = /* @__PURE__ */ new Map();
1756
+ const pushObj = (obj) => {
1757
+ if (!isRecord(obj)) return;
1758
+ for (const key of Object.keys(obj)) {
1759
+ if (versions.has(key)) continue;
1760
+ const value = obj[key];
1761
+ versions.set(key, typeof value === "string" ? value : "");
1762
+ }
1763
+ };
1764
+ pushObj(pkg?.dependencies);
1765
+ pushObj(pkg?.devDependencies);
1766
+ pushObj(composer?.require);
1767
+ pushObj(composer?.["require-dev"]);
1768
+ return versions;
1769
+ }
1770
+ function finalizeRoles(registryRoles, deps, files) {
1771
+ const roles = new Set(registryRoles);
1772
+ const hasWpConfig = files.some((f) => f.endsWith("wp-config.php"));
1773
+ const hasBedrockLayout = files.some((f) => f.includes("web/app")) || deps.includes("roots/wordpress");
1774
+ if (hasWpConfig && hasBedrockLayout) {
1775
+ roles.add("wordpress-bedrock-site");
1776
+ roles.add("wordpress-site");
1777
+ } else if (hasWpConfig) {
1778
+ roles.add("wordpress-vanilla-site");
1779
+ roles.add("wordpress-site");
1780
+ } else if (deps.includes("roots/wordpress")) {
1781
+ roles.add("wordpress-bedrock-site");
1782
+ roles.add("wordpress-site");
1783
+ }
1784
+ return [...roles].sort();
1785
+ }
1786
+ function collectUnsupportedSignals(files) {
1787
+ return [
1788
+ ...new Set(
1789
+ files.map((f) => UNSUPPORTED_MARKERS[path10.basename(f)]).filter((s) => Boolean(s))
1790
+ )
1791
+ ].sort();
1792
+ }
1793
+
1794
+ // src/recommender/policies.ts
1795
+ var UNSUPPORTED = FORBIDDEN_TAGS;
1796
+ function matchRequiresAny(clauses, ctx) {
1797
+ for (const clause of clauses) {
1798
+ if ("stack" in clause) {
1799
+ if (ctx.stackSet.has(clause.stack.toLowerCase())) {
1800
+ return { matched: true, signal: `stack:${clause.stack}` };
1801
+ }
1802
+ } else if ("dependency" in clause) {
1803
+ if (ctx.depSet.has(clause.dependency.toLowerCase())) {
1804
+ return { matched: true, signal: `dependency:${clause.dependency}` };
1805
+ }
1806
+ } else if ("packageNamePattern" in clause) {
1807
+ const pattern = clause.packageNamePattern.toLowerCase();
1808
+ const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
1809
+ for (const dep2 of ctx.depSet) {
1810
+ if (pattern.endsWith("*") ? dep2.startsWith(prefix) : dep2 === pattern) {
1811
+ return {
1812
+ matched: true,
1813
+ signal: `packageNamePattern:${clause.packageNamePattern}`
1814
+ };
1815
+ }
1816
+ }
1817
+ } else if ("role" in clause) {
1818
+ if (ctx.roleSet.has(clause.role.toLowerCase())) {
1819
+ return { matched: true, signal: `role:${clause.role}` };
1820
+ }
1821
+ }
1822
+ }
1823
+ return { matched: false };
1824
+ }
1825
+ function describeRequiresAny(clauses) {
1826
+ return clauses.map((c) => {
1827
+ if ("stack" in c) return `stack=${c.stack}`;
1828
+ if ("dependency" in c) return `dependency=${c.dependency}`;
1829
+ if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
1830
+ if ("role" in c) return `role=${c.role}`;
1831
+ return "unknown";
1832
+ }).join(" | ");
1833
+ }
1834
+ function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
1835
+ const markers = context.unsupportedSignals?.join(", ");
1836
+ const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
1837
+ `Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
1838
+ ] : [];
1839
+ const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
1840
+ return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
1841
+ }
1842
+
1843
+ // src/update/hash-installed.ts
1844
+ import path11 from "node:path";
1613
1845
  import fg2 from "fast-glob";
1614
1846
  import fs7 from "fs-extra";
1615
1847
 
@@ -1666,7 +1898,7 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
1666
1898
  const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
1667
1899
  const fileDigests = [];
1668
1900
  for (const rel of normalized) {
1669
- const abs = path10.join(root, rel);
1901
+ const abs = path11.join(root, rel);
1670
1902
  if (!await fs7.pathExists(abs)) continue;
1671
1903
  const stat = followSymlinks ? await fs7.stat(abs) : await fs7.lstat(abs);
1672
1904
  if (!followSymlinks && stat.isSymbolicLink()) continue;
@@ -1683,9 +1915,9 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
1683
1915
  followSymbolicLinks: followSymlinks
1684
1916
  });
1685
1917
  for (const sub of inner.sort()) {
1686
- const absFile = path10.join(abs, sub);
1918
+ const absFile = path11.join(abs, sub);
1687
1919
  if (!followSymlinks && (await fs7.lstat(absFile)).isSymbolicLink()) continue;
1688
- const relFile = path10.join(rel, sub).replace(/\\/g, "/");
1920
+ const relFile = path11.join(rel, sub).replace(/\\/g, "/");
1689
1921
  const body = await fs7.readFile(absFile);
1690
1922
  fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
1691
1923
  }
@@ -1757,18 +1989,18 @@ function buildAllowRules() {
1757
1989
  }
1758
1990
 
1759
1991
  // src/install/settings-merge.ts
1760
- import path12 from "node:path";
1992
+ import path13 from "node:path";
1761
1993
  import fs9 from "fs-extra";
1762
1994
 
1763
1995
  // src/install/manifest.ts
1764
1996
  import os3 from "node:os";
1765
- import path11 from "node:path";
1997
+ import path12 from "node:path";
1766
1998
  var MANIFEST_SCHEMA = "haus-install-manifest/1";
1767
1999
  function globalClaudeDir() {
1768
- return path11.join(os3.homedir(), ".claude");
2000
+ return path12.join(os3.homedir(), ".claude");
1769
2001
  }
1770
2002
  function hausManifestPath() {
1771
- return path11.join(globalClaudeDir(), "haus", "install-manifest.json");
2003
+ return path12.join(globalClaudeDir(), "haus", "install-manifest.json");
1772
2004
  }
1773
2005
  async function readManifest() {
1774
2006
  return readJson(hausManifestPath());
@@ -1788,7 +2020,7 @@ function buildManifest(source, files, hooks) {
1788
2020
 
1789
2021
  // src/install/settings-merge.ts
1790
2022
  function settingsJsonPath() {
1791
- return path12.join(globalClaudeDir(), "settings.json");
2023
+ return path13.join(globalClaudeDir(), "settings.json");
1792
2024
  }
1793
2025
  async function readSettings() {
1794
2026
  const filePath = settingsJsonPath();
@@ -2017,71 +2249,6 @@ var ASK_COMMANDS = [
2017
2249
  "php artisan migrate --force"
2018
2250
  ];
2019
2251
 
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
2252
  // src/security/ask-rules.ts
2086
2253
  function buildAskRules() {
2087
2254
  const rules = [];
@@ -2103,8 +2270,8 @@ function buildDenyRules() {
2103
2270
  for (const command of DENY_COMMANDS) {
2104
2271
  rules.push(`Bash(${command}:*)`);
2105
2272
  }
2106
- for (const path72 of DENY_PATHS) {
2107
- const pattern = DENY_DIRS.has(path72) ? `${path72}/**` : path72;
2273
+ for (const path73 of DENY_PATHS) {
2274
+ const pattern = DENY_DIRS.has(path73) ? `${path73}/**` : path73;
2108
2275
  for (const tool of FILE_TOOLS) {
2109
2276
  rules.push(`${tool}(${pattern})`);
2110
2277
  }
@@ -2183,24 +2350,28 @@ async function applyProjectSettingsMerge(root) {
2183
2350
  }
2184
2351
 
2185
2352
  // src/claude/superpowers-install.ts
2186
- import path13 from "node:path";
2353
+ import path14 from "node:path";
2187
2354
  import fg3 from "fast-glob";
2188
2355
  import fs10 from "fs-extra";
2189
2356
  var SUPERPOWERS_ORIGIN_SOURCE_ID = "superpowers-pcvelz";
2190
2357
  var PATH_REWRITES = [
2191
2358
  ["skills/shared/", ".claude/skills/shared/"]
2192
2359
  ];
2360
+ var PREFIX_REWRITES = ["superpowers-extended-cc:"];
2193
2361
  function rewriteSuperpowersMarkdown(text) {
2194
2362
  let out = text;
2195
2363
  for (const [from, to] of PATH_REWRITES) {
2196
2364
  out = out.split(from).join(to);
2197
2365
  }
2366
+ for (const prefix of PREFIX_REWRITES) {
2367
+ out = out.split(prefix).join("");
2368
+ }
2198
2369
  return out;
2199
2370
  }
2200
2371
  async function rewriteMarkdownTree(dir) {
2201
2372
  const files = await fg3("**/*.md", { cwd: dir, onlyFiles: true, dot: true });
2202
2373
  for (const rel of files) {
2203
- const abs = path13.join(dir, rel);
2374
+ const abs = path14.join(dir, rel);
2204
2375
  const text = await fs10.readFile(abs, "utf8");
2205
2376
  const rewritten = rewriteSuperpowersMarkdown(text);
2206
2377
  if (rewritten !== text) {
@@ -2208,10 +2379,24 @@ async function rewriteMarkdownTree(dir) {
2208
2379
  }
2209
2380
  }
2210
2381
  }
2382
+ async function extraDestFiles(sourcePath, destination) {
2383
+ const [sourceFiles, destFiles] = await Promise.all([
2384
+ fg3("**/*", { cwd: sourcePath, onlyFiles: true, dot: true }),
2385
+ fg3("**/*", { cwd: destination, onlyFiles: true, dot: true })
2386
+ ]);
2387
+ const sourceSet = new Set(sourceFiles);
2388
+ return destFiles.filter((f) => !sourceSet.has(f));
2389
+ }
2211
2390
  async function installCatalogSkill(sourcePath, destination, opts) {
2212
2391
  if (opts.dryRun) return;
2213
- await fs10.ensureDir(path13.dirname(destination));
2392
+ await fs10.ensureDir(path14.dirname(destination));
2214
2393
  if (await fs10.pathExists(destination)) {
2394
+ const extra = await extraDestFiles(sourcePath, destination);
2395
+ if (extra.length > 0) {
2396
+ warn(
2397
+ `${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
2398
+ );
2399
+ }
2215
2400
  await fs10.remove(destination);
2216
2401
  }
2217
2402
  await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
@@ -2220,17 +2405,23 @@ async function installCatalogSkill(sourcePath, destination, opts) {
2220
2405
  }
2221
2406
  }
2222
2407
  async function installSuperpowersShared(contentRoot, projectRoot, dryRun) {
2223
- const source = path13.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
2408
+ const source = path14.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
2224
2409
  if (!await fs10.pathExists(source)) return null;
2225
2410
  const destination = claudePath(projectRoot, "skills", "shared");
2226
- if (dryRun) return path13.relative(projectRoot, destination);
2227
- await fs10.ensureDir(path13.dirname(destination));
2411
+ if (dryRun) return path14.relative(projectRoot, destination);
2412
+ await fs10.ensureDir(path14.dirname(destination));
2228
2413
  if (await fs10.pathExists(destination)) {
2414
+ const extra = await extraDestFiles(source, destination);
2415
+ if (extra.length > 0) {
2416
+ warn(
2417
+ `${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
2418
+ );
2419
+ }
2229
2420
  await fs10.remove(destination);
2230
2421
  }
2231
2422
  await fs10.copy(source, destination, { overwrite: true, errorOnExist: false });
2232
2423
  await rewriteMarkdownTree(destination);
2233
- return path13.relative(projectRoot, destination);
2424
+ return path14.relative(projectRoot, destination);
2234
2425
  }
2235
2426
 
2236
2427
  // src/claude/verify-hooks-contract.ts
@@ -2345,7 +2536,7 @@ async function verifyProjectSettingsHooksContract(root) {
2345
2536
  }
2346
2537
 
2347
2538
  // src/claude/write-decisions-seed.ts
2348
- import path14 from "node:path";
2539
+ import path15 from "node:path";
2349
2540
  import fs12 from "fs-extra";
2350
2541
 
2351
2542
  // library/catalog/decisions-triggers.json
@@ -2389,8 +2580,8 @@ Why we chose X \u2014 short, dated, write-once decision log for humans and agent
2389
2580
  | --- | ----- | -------------- | ------ |
2390
2581
  `;
2391
2582
  async function writeDecisionsSeed(root, dryRun) {
2392
- const decisionsDir = path14.join(root, DECISIONS_TRIGGERS.decisionsDir);
2393
- const readmePath = path14.join(decisionsDir, "README.md");
2583
+ const decisionsDir = path15.join(root, DECISIONS_TRIGGERS.decisionsDir);
2584
+ const readmePath = path15.join(decisionsDir, "README.md");
2394
2585
  if (await fs12.pathExists(readmePath)) return null;
2395
2586
  if (!dryRun) await fs12.ensureDir(decisionsDir);
2396
2587
  await writeManagedText(root, readmePath, SEED_README, dryRun);
@@ -2398,7 +2589,7 @@ async function writeDecisionsSeed(root, dryRun) {
2398
2589
  }
2399
2590
 
2400
2591
  // src/claude/write-prettierignore.ts
2401
- import path15 from "node:path";
2592
+ import path16 from "node:path";
2402
2593
  import fs13 from "fs-extra";
2403
2594
  var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
2404
2595
  var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
@@ -2467,7 +2658,7 @@ ${block}
2467
2658
  `;
2468
2659
  }
2469
2660
  async function writePrettierIgnore(root, dryRun) {
2470
- const filePath = path15.join(root, ".prettierignore");
2661
+ const filePath = path16.join(root, ".prettierignore");
2471
2662
  const prev = await fs13.pathExists(filePath) ? await fs13.readFile(filePath, "utf8") : "";
2472
2663
  const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
2473
2664
  await writeManagedText(root, filePath, next, dryRun);
@@ -2475,7 +2666,7 @@ async function writePrettierIgnore(root, dryRun) {
2475
2666
  }
2476
2667
 
2477
2668
  // src/claude/write-root-claude-md.ts
2478
- import path16 from "node:path";
2669
+ import path17 from "node:path";
2479
2670
  import fs14 from "fs-extra";
2480
2671
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
2481
2672
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
@@ -2553,7 +2744,7 @@ ${block}
2553
2744
  `;
2554
2745
  }
2555
2746
  async function writeRootClaudeMd(root, dryRun) {
2556
- const filePath = path16.join(root, "CLAUDE.md");
2747
+ const filePath = path17.join(root, "CLAUDE.md");
2557
2748
  const block = buildImportBlock();
2558
2749
  const prev = await fs14.pathExists(filePath) ? await fs14.readFile(filePath, "utf8") : "";
2559
2750
  const next = injectHausBlock(prev, block);
@@ -2562,11 +2753,11 @@ async function writeRootClaudeMd(root, dryRun) {
2562
2753
  }
2563
2754
 
2564
2755
  // src/claude/write-workflow-config.ts
2565
- import path18 from "node:path";
2756
+ import path19 from "node:path";
2566
2757
  import fs16 from "fs-extra";
2567
2758
 
2568
2759
  // src/claude/derive-workflow-config.ts
2569
- import path17 from "node:path";
2760
+ import path18 from "node:path";
2570
2761
  import fs15 from "fs-extra";
2571
2762
  function binCmd(pm, bin, args) {
2572
2763
  const tail = args ? ` ${args}` : "";
@@ -2576,7 +2767,7 @@ function binCmd(pm, bin, args) {
2576
2767
  }
2577
2768
  async function deriveWorkflowConfig(root, ctx) {
2578
2769
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
2579
- const pkg = await readJson(path17.join(root, "package.json"));
2770
+ const pkg = await readJson(path18.join(root, "package.json"));
2580
2771
  const scripts = pkg?.scripts ?? {};
2581
2772
  const deps = new Set(ctx.dependencies);
2582
2773
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -2586,7 +2777,7 @@ async function deriveWorkflowConfig(root, ctx) {
2586
2777
  return null;
2587
2778
  };
2588
2779
  const hasDep = (name) => deps.has(name);
2589
- const exists = (rel) => fs15.pathExistsSync(path17.join(root, rel));
2780
+ const exists = (rel) => fs15.pathExistsSync(path18.join(root, rel));
2590
2781
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
2591
2782
  const hasCypress = hasDep("cypress");
2592
2783
  const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
@@ -2661,7 +2852,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2661
2852
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
2662
2853
  ...FALLBACK_CONTEXT,
2663
2854
  root,
2664
- repoName: path18.basename(root)
2855
+ repoName: path19.basename(root)
2665
2856
  };
2666
2857
  const values = await deriveWorkflowConfig(root, ctx);
2667
2858
  if (exists) {
@@ -2706,7 +2897,8 @@ function checkManagedTamper(body, storedHash, templateContent) {
2706
2897
  // src/claude/write-workflow.ts
2707
2898
  var STABLE_ID = "template.workflow";
2708
2899
  function makeWorkflowHeader(pkgVersion, contentHash) {
2709
- return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
2900
+ const hashAttr = contentHash === void 0 ? "" : ` hash=${contentHash}`;
2901
+ return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion}${hashAttr} -->`;
2710
2902
  }
2711
2903
  async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
2712
2904
  const templateContent = await readWorkflowTemplate({ dryRun });
@@ -2747,12 +2939,12 @@ ${templateContent}`;
2747
2939
  return null;
2748
2940
  }
2749
2941
  if (verdict.status === "legacy-diverged" && !force) {
2750
- const migratedHeader = makeWorkflowHeader(pkgVersion, hashText(normaliseLF(existingContent)));
2942
+ const migratedHeader = makeWorkflowHeader(pkgVersion);
2751
2943
  const migrated = `${migratedHeader}
2752
2944
  ${existingContent}`;
2753
2945
  if (hasTextChanged(existing, migrated)) {
2754
2946
  if (dryRun) {
2755
- log(`${printable}: migrating legacy hash header (body preserved)`);
2947
+ log(`${printable}: migrating legacy header (staying hash-less; body preserved)`);
2756
2948
  } else {
2757
2949
  await writeText(destPath, migrated);
2758
2950
  }
@@ -2799,7 +2991,7 @@ async function listFilesRecursive2(dir) {
2799
2991
  const walk = async (current, relPrefix) => {
2800
2992
  const names = await fs18.readdir(current);
2801
2993
  for (const name of names) {
2802
- const abs = path19.join(current, name);
2994
+ const abs = path20.join(current, name);
2803
2995
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
2804
2996
  const stat = await fs18.lstat(abs);
2805
2997
  if (stat.isSymbolicLink()) {
@@ -2843,8 +3035,8 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
2843
3035
  for (const rel of sourceFiles) {
2844
3036
  await logFileDryRunDiff(
2845
3037
  root,
2846
- path19.join(sourcePath, rel),
2847
- path19.join(destination, rel),
3038
+ path20.join(sourcePath, rel),
3039
+ path20.join(destination, rel),
2848
3040
  itemId,
2849
3041
  say
2850
3042
  );
@@ -2852,7 +3044,7 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
2852
3044
  const sourceFileSet = new Set(sourceFiles);
2853
3045
  for (const rel of destFiles) {
2854
3046
  if (!sourceFileSet.has(rel)) {
2855
- say(`${displayPath(root, path19.join(destination, rel))}: would remove (${itemId})`);
3047
+ say(`${displayPath(root, path20.join(destination, rel))}: would remove (${itemId})`);
2856
3048
  }
2857
3049
  }
2858
3050
  }
@@ -2872,7 +3064,9 @@ async function removeLegacyManagedStub(root, relPathSegments, stub, dryRun, say)
2872
3064
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2873
3065
  const say = opts.quiet ? () => {
2874
3066
  } : log;
2875
- const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
3067
+ const recResult = await readJsonDetailed(hausPath(root, "recommendation.json"));
3068
+ const recommendationUnusable = recResult.status !== "ok";
3069
+ const rec = recResult.status === "ok" ? recResult.value : {
2876
3070
  recommended: [],
2877
3071
  skipped: [],
2878
3072
  warnings: [],
@@ -2882,7 +3076,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2882
3076
  estimatedTokenReductionPct: 0
2883
3077
  };
2884
3078
  const pkgRoot = packageRoot();
2885
- const hausVersion2 = (await readJson(path19.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
3079
+ const hausVersion2 = (await readJson(path20.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2886
3080
  const coreFiles = [claudePath(root, "settings.json"), claudePath(root, "rules", "haus.md")];
2887
3081
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
2888
3082
  const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
@@ -2972,6 +3166,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2972
3166
  const { items: manifestItems, contentRoot } = await loadCatalogContext(root);
2973
3167
  const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
2974
3168
  const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
3169
+ const prevLockById = new Map(
3170
+ prevLock.filter((entry) => Boolean(entry.id)).map((entry) => [entry.id, entry])
3171
+ );
2975
3172
  const allMigrations = findFormerIdMigrations(
2976
3173
  prevLock.filter((entry) => Boolean(entry.id)),
2977
3174
  manifestItems
@@ -3032,6 +3229,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3032
3229
  warn(`Skipping ${item.id}: reviewStatus is deprecated`);
3033
3230
  continue;
3034
3231
  }
3232
+ const manifestItemTags = (manifestItem.tags ?? []).map((t) => t.toLowerCase());
3233
+ if (UNSUPPORTED.some((x) => manifestItemTags.includes(x))) {
3234
+ warn(`Skipping ${item.id}: unsupported stack policy`);
3235
+ continue;
3236
+ }
3237
+ const sensitiveInId = SENSITIVE_ITEM_KEYWORDS.some((x) => item.id.toLowerCase().includes(x));
3238
+ const sensitiveInTags = SENSITIVE_ITEM_KEYWORDS.some((x) => manifestItemTags.includes(x));
3239
+ if (sensitiveInId || sensitiveInTags) {
3240
+ warn(`Skipping ${item.id}: sensitive content policy block`);
3241
+ continue;
3242
+ }
3035
3243
  if (manifestItem.source === "curated") {
3036
3244
  if (manifestItem.reviewStatus !== "approved") {
3037
3245
  curatedReviewStatusSkips++;
@@ -3056,7 +3264,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3056
3264
  );
3057
3265
  continue;
3058
3266
  }
3059
- const destination = claudePath(root, target, path19.basename(sourcePath));
3267
+ const destination = claudePath(root, target, path20.basename(sourcePath));
3060
3268
  if (await fs18.pathExists(sourcePath)) {
3061
3269
  if (dryRun) {
3062
3270
  if (item.type === "skill") {
@@ -3064,13 +3272,23 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3064
3272
  } else {
3065
3273
  await logFileDryRunDiff(root, sourcePath, destination, item.id, say);
3066
3274
  }
3275
+ } else if (await skipLocallyEditedItem(
3276
+ root,
3277
+ item.id,
3278
+ prevLockById.get(item.id),
3279
+ opts.force,
3280
+ files,
3281
+ installedPathsByItem,
3282
+ installedIds
3283
+ )) {
3284
+ continue;
3067
3285
  } else if (item.type === "skill") {
3068
3286
  const skillFiles = (await fs18.readdir(sourcePath, { recursive: true })).filter(
3069
3287
  (f) => typeof f === "string" && f.endsWith(".md")
3070
3288
  );
3071
3289
  let skillValid = true;
3072
3290
  for (const relFile of skillFiles) {
3073
- const mdPath = path19.join(sourcePath, relFile);
3291
+ const mdPath = path20.join(sourcePath, relFile);
3074
3292
  const mdContent = await fs18.readFile(mdPath, "utf8");
3075
3293
  const mdValidation = validateCatalogItem(
3076
3294
  {
@@ -3109,17 +3327,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3109
3327
  warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
3110
3328
  continue;
3111
3329
  }
3112
- await fs18.ensureDir(path19.dirname(destination));
3330
+ await fs18.ensureDir(path20.dirname(destination));
3113
3331
  await fs18.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
3114
3332
  }
3115
3333
  files.push(destination);
3116
- const relPaths = [path19.relative(root, destination)];
3334
+ const relPaths = [path20.relative(root, destination)];
3117
3335
  if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
3118
3336
  const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
3119
3337
  if (sharedRel) {
3120
3338
  superpowersSharedInstalled = true;
3121
3339
  relPaths.push(sharedRel);
3122
- files.push(path19.join(root, sharedRel));
3340
+ files.push(path20.join(root, sharedRel));
3123
3341
  }
3124
3342
  }
3125
3343
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -3137,18 +3355,31 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3137
3355
  );
3138
3356
  }
3139
3357
  await cleanupStaleCatalogItems(root, cleanupManifestById, dryRun, opts.quiet);
3358
+ const refuseDestructiveLockChange = recommendationUnusable && prevLock.length > 0 && !opts.force;
3140
3359
  if (opts.prune) {
3141
- const notOrphaned = new Set(rec.recommended.map((r) => r.id));
3142
- for (const migration of allMigrations) notOrphaned.add(migration.oldId);
3143
- for (const entry of prevLock) {
3144
- if (!entry.id) continue;
3145
- const manifestItem = manifestById.get(entry.id);
3146
- if (!manifestItem || manifestItem.reviewStatus === "deprecated") notOrphaned.add(entry.id);
3360
+ if (refuseDestructiveLockChange) {
3361
+ warn(
3362
+ "recommendation.json is missing or unreadable \u2014 refusing --prune (every installed item would look orphaned). Run `haus recommend` first, or pass --force to override."
3363
+ );
3364
+ } else {
3365
+ const notOrphaned = new Set(rec.recommended.map((r) => r.id));
3366
+ for (const migration of allMigrations) notOrphaned.add(migration.oldId);
3367
+ for (const entry of prevLock) {
3368
+ if (!entry.id) continue;
3369
+ const manifestItem = manifestById.get(entry.id);
3370
+ if (!manifestItem || manifestItem.reviewStatus === "deprecated") notOrphaned.add(entry.id);
3371
+ }
3372
+ await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
3147
3373
  }
3148
- await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
3149
3374
  }
3150
3375
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
3151
3376
  if (dryRun) {
3377
+ if (refuseDestructiveLockChange) {
3378
+ say(
3379
+ "[dry-run] recommendation.json is missing or unreadable \u2014 haus.lock.json would be left unchanged. Run `haus recommend` first, or pass --force."
3380
+ );
3381
+ return [...new Set(files)];
3382
+ }
3152
3383
  const prevIds = new Set(
3153
3384
  prevLock.map((e) => e.id ? migrationByOldId.get(e.id) ?? e.id : void 0).filter((id) => Boolean(id))
3154
3385
  );
@@ -3159,6 +3390,12 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3159
3390
  for (const id of removed) say(`[dry-run] haus.lock.json would remove: ${id}`);
3160
3391
  return [...new Set(files)];
3161
3392
  }
3393
+ if (refuseDestructiveLockChange) {
3394
+ warn(
3395
+ "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."
3396
+ );
3397
+ return [...new Set(files)];
3398
+ }
3162
3399
  const prevRefById = new Map(
3163
3400
  prevLock.filter((e) => e.id && e.catalogRef).map((e) => [migrationByOldId.get(e.id) ?? e.id, e.catalogRef])
3164
3401
  );
@@ -3192,6 +3429,26 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
3192
3429
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
3193
3430
  return [...new Set(files)];
3194
3431
  }
3432
+ async function skipLocallyEditedItem(root, itemId, prevEntry, force, files, installedPathsByItem, installedIds) {
3433
+ if (!prevEntry || prevEntry.hash === void 0 || force) return false;
3434
+ const existing = await resolveExistingTrackedPaths(root, itemId, prevEntry.paths ?? [], warn);
3435
+ if (existing.length === 0) return false;
3436
+ const currentHash = await hashInstalledPaths(
3437
+ root,
3438
+ existing.map((e) => e.rel)
3439
+ );
3440
+ if (currentHash === prevEntry.hash) return false;
3441
+ warn(
3442
+ `User edited ${itemId} (skipping reinstall): ${existing.map((e) => e.rel).join(", ")} \u2014 use --force to overwrite`
3443
+ );
3444
+ files.push(...existing.map((e) => e.abs));
3445
+ installedPathsByItem.set(
3446
+ itemId,
3447
+ existing.map((e) => e.rel)
3448
+ );
3449
+ installedIds.add(itemId);
3450
+ return true;
3451
+ }
3195
3452
  async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, quiet) {
3196
3453
  const say = quiet ? () => {
3197
3454
  } : log;
@@ -3199,26 +3456,25 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
3199
3456
  const entry = prevLock.find((candidate) => candidate.id === migration.oldId);
3200
3457
  if (!entry) continue;
3201
3458
  const relPaths = entry.paths ?? [];
3202
- const existing = [];
3203
- for (const rel of relPaths) {
3204
- if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3205
- }
3459
+ const existing = await resolveExistingTrackedPaths(root, migration.oldId, relPaths, warn);
3206
3460
  if (existing.length === 0) continue;
3207
3461
  if (entry.hash === void 0) {
3208
3462
  warn(
3209
- `Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.join(", ")}`
3463
+ `Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
3210
3464
  );
3211
3465
  continue;
3212
3466
  }
3213
- const currentHash = await hashInstalledPaths(root, existing);
3467
+ const currentHash = await hashInstalledPaths(
3468
+ root,
3469
+ existing.map((e) => e.rel)
3470
+ );
3214
3471
  if (currentHash !== entry.hash) {
3215
3472
  warn(
3216
- `Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.join(", ")}`
3473
+ `Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
3217
3474
  );
3218
3475
  continue;
3219
3476
  }
3220
- for (const rel of existing) {
3221
- const abs = path19.join(root, rel);
3477
+ for (const { abs } of existing) {
3222
3478
  if (dryRun) {
3223
3479
  say(
3224
3480
  `[dry-run] would remove renamed ${displayPath(root, abs)} (${migration.oldId} \u2192 ${migration.newId})`
@@ -3226,9 +3482,21 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
3226
3482
  continue;
3227
3483
  }
3228
3484
  await fs18.remove(abs);
3229
- await pruneEmptyDir(path19.dirname(abs));
3485
+ await pruneEmptyDir(path20.dirname(abs));
3486
+ }
3487
+ }
3488
+ }
3489
+ async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn) {
3490
+ const existing = [];
3491
+ for (const rel of relPaths) {
3492
+ const abs = resolveContainedPath(root, rel);
3493
+ if (abs === null) {
3494
+ warnFn(`Refusing to touch ${itemId}'s recorded path outside the project root: ${rel}`);
3495
+ continue;
3230
3496
  }
3497
+ if (await fs18.pathExists(abs)) existing.push({ rel, abs });
3231
3498
  }
3499
+ return existing;
3232
3500
  }
3233
3501
  async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
3234
3502
  const say = quiet ? () => {
@@ -3243,33 +3511,32 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
3243
3511
  if (!removedFromManifest && !deprecated) continue;
3244
3512
  const relPaths = entry.paths ?? [];
3245
3513
  if (relPaths.length === 0) continue;
3246
- const existing = [];
3247
- for (const rel of relPaths) {
3248
- if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3249
- }
3514
+ const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
3250
3515
  if (existing.length === 0) continue;
3251
3516
  const pruneReason = deprecated ? "deprecated" : "stale";
3252
3517
  if (entry.hash === void 0) {
3253
3518
  warn(
3254
- `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
3519
+ `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3255
3520
  );
3256
3521
  continue;
3257
3522
  }
3258
- const currentHash = await hashInstalledPaths(root, relPaths);
3523
+ const currentHash = await hashInstalledPaths(
3524
+ root,
3525
+ existing.map((e) => e.rel)
3526
+ );
3259
3527
  if (currentHash !== entry.hash) {
3260
3528
  warn(
3261
- `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
3529
+ `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3262
3530
  );
3263
3531
  continue;
3264
3532
  }
3265
- for (const rel of existing) {
3266
- const abs = path19.join(root, rel);
3533
+ for (const { abs } of existing) {
3267
3534
  if (dryRun) {
3268
3535
  say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
3269
3536
  continue;
3270
3537
  }
3271
3538
  await fs18.remove(abs);
3272
- await pruneEmptyDir(path19.dirname(abs));
3539
+ await pruneEmptyDir(path20.dirname(abs));
3273
3540
  say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
3274
3541
  }
3275
3542
  }
@@ -3280,12 +3547,12 @@ async function backupBeforePrune(root, absPaths, say) {
3280
3547
  const backupRoot = hausPath(root, "backups", `prune-${stamp}`);
3281
3548
  for (const abs of absPaths) {
3282
3549
  if (!await fs18.pathExists(abs)) continue;
3283
- const rel = path19.relative(root, abs);
3284
- const backupPath = path19.join(backupRoot, rel);
3285
- await fs18.ensureDir(path19.dirname(backupPath));
3550
+ const rel = path20.relative(root, abs);
3551
+ const backupPath = path20.join(backupRoot, rel);
3552
+ await fs18.ensureDir(path20.dirname(backupPath));
3286
3553
  await fs18.copy(abs, backupPath);
3287
3554
  }
3288
- say(`Backed up ${absPaths.length} path(s) to ${path19.relative(root, backupRoot)} before pruning.`);
3555
+ say(`Backed up ${absPaths.length} path(s) to ${path20.relative(root, backupRoot)} before pruning.`);
3289
3556
  }
3290
3557
  async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun, quiet) {
3291
3558
  const say = quiet ? () => {
@@ -3297,21 +3564,21 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3297
3564
  if (!entry.id) continue;
3298
3565
  const relPaths = entry.paths ?? [];
3299
3566
  if (relPaths.length === 0) continue;
3300
- const existing = [];
3301
- for (const rel of relPaths) {
3302
- if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3303
- }
3567
+ const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
3304
3568
  if (existing.length === 0) continue;
3305
3569
  if (entry.hash === void 0) {
3306
3570
  warn(
3307
- `Orphaned catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
3571
+ `Orphaned catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3308
3572
  );
3309
3573
  continue;
3310
3574
  }
3311
- const currentHash = await hashInstalledPaths(root, relPaths);
3575
+ const currentHash = await hashInstalledPaths(
3576
+ root,
3577
+ existing.map((e) => e.rel)
3578
+ );
3312
3579
  if (currentHash !== entry.hash) {
3313
3580
  warn(
3314
- `Orphaned catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
3581
+ `Orphaned catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
3315
3582
  );
3316
3583
  continue;
3317
3584
  }
@@ -3320,21 +3587,18 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3320
3587
  if (toRemove.length === 0) return;
3321
3588
  if (dryRun) {
3322
3589
  for (const { id, existing } of toRemove) {
3323
- for (const rel of existing) {
3324
- say(`[dry-run] would prune orphaned ${displayPath(root, path19.join(root, rel))} (${id})`);
3590
+ for (const { abs } of existing) {
3591
+ say(`[dry-run] would prune orphaned ${displayPath(root, abs)} (${id})`);
3325
3592
  }
3326
3593
  }
3327
3594
  return;
3328
3595
  }
3329
- const allAbsPaths = toRemove.flatMap(
3330
- ({ existing }) => existing.map((rel) => path19.join(root, rel))
3331
- );
3596
+ const allAbsPaths = toRemove.flatMap(({ existing }) => existing.map(({ abs }) => abs));
3332
3597
  await backupBeforePrune(root, allAbsPaths, say);
3333
3598
  for (const { id, existing } of toRemove) {
3334
- for (const rel of existing) {
3335
- const abs = path19.join(root, rel);
3599
+ for (const { abs } of existing) {
3336
3600
  await fs18.remove(abs);
3337
- await pruneEmptyDir(path19.dirname(abs));
3601
+ await pruneEmptyDir(path20.dirname(abs));
3338
3602
  say(`Pruned orphaned ${displayPath(root, abs)} (${id})`);
3339
3603
  }
3340
3604
  }
@@ -3342,7 +3606,7 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
3342
3606
 
3343
3607
  // src/claude/write-gitignore.ts
3344
3608
  init_exec();
3345
- import path20 from "node:path";
3609
+ import path21 from "node:path";
3346
3610
  import fs19 from "fs-extra";
3347
3611
  var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
3348
3612
  var GITIGNORE_END = "# HAUS:END haus-managed";
@@ -3457,14 +3721,14 @@ function injectLinkContextGitignoreBlock(existing) {
3457
3721
  );
3458
3722
  }
3459
3723
  async function writeGitignore(root, dryRun) {
3460
- const filePath = path20.join(root, ".gitignore");
3724
+ const filePath = path21.join(root, ".gitignore");
3461
3725
  const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3462
3726
  const next = injectGitignoreBlock(prev);
3463
3727
  await writeManagedText(root, filePath, next, dryRun);
3464
3728
  return filePath;
3465
3729
  }
3466
3730
  async function writeLinkContextGitignore(root, dryRun) {
3467
- const filePath = path20.join(root, ".gitignore");
3731
+ const filePath = path21.join(root, ".gitignore");
3468
3732
  const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3469
3733
  const next = injectLinkContextGitignoreBlock(prev);
3470
3734
  await writeManagedText(root, filePath, next, dryRun);
@@ -3506,15 +3770,15 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
3506
3770
  }
3507
3771
 
3508
3772
  // src/refs/fetch-refs.ts
3509
- import path22 from "node:path";
3773
+ import path23 from "node:path";
3510
3774
  import fs21 from "fs-extra";
3511
3775
 
3512
3776
  // src/refs/cache-meta.ts
3513
- import path21 from "node:path";
3777
+ import path22 from "node:path";
3514
3778
  import fs20 from "fs-extra";
3515
3779
  var META_FILENAME = "cache-meta.json";
3516
3780
  async function readCacheMeta(cacheDir) {
3517
- const metaPath = path21.join(cacheDir, META_FILENAME);
3781
+ const metaPath = path22.join(cacheDir, META_FILENAME);
3518
3782
  try {
3519
3783
  const raw = await fs20.readFile(metaPath, "utf8");
3520
3784
  return JSON.parse(raw);
@@ -3524,7 +3788,7 @@ async function readCacheMeta(cacheDir) {
3524
3788
  }
3525
3789
  async function writeCacheMeta(cacheDir, meta) {
3526
3790
  await fs20.ensureDir(cacheDir);
3527
- const metaPath = path21.join(cacheDir, META_FILENAME);
3791
+ const metaPath = path22.join(cacheDir, META_FILENAME);
3528
3792
  await fs20.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
3529
3793
  }
3530
3794
 
@@ -3545,8 +3809,8 @@ function isLlmsTxtUrl(ref) {
3545
3809
  async function fetchSingleRef(url, cacheDir, meta) {
3546
3810
  const existing = meta[url];
3547
3811
  const headers = {};
3548
- const safeFile = existing?.file && existing.file === path22.basename(existing.file);
3549
- const cachedFileExists = safeFile && await fs21.pathExists(path22.join(cacheDir, existing.file));
3812
+ const safeFile = existing?.file && existing.file === path23.basename(existing.file);
3813
+ const cachedFileExists = safeFile && await fs21.pathExists(path23.join(cacheDir, existing.file));
3550
3814
  if (cachedFileExists) {
3551
3815
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
3552
3816
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -3558,7 +3822,7 @@ async function fetchSingleRef(url, cacheDir, meta) {
3558
3822
  const text = await res.text();
3559
3823
  const file = `${urlToSlug(url)}.md`;
3560
3824
  await fs21.ensureDir(cacheDir);
3561
- await fs21.writeFile(path22.join(cacheDir, file), text, "utf8");
3825
+ await fs21.writeFile(path23.join(cacheDir, file), text, "utf8");
3562
3826
  meta[url] = {
3563
3827
  url,
3564
3828
  etag: res.headers.get("etag") ?? void 0,
@@ -3581,8 +3845,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
3581
3845
  if (keepUrls.has(url)) continue;
3582
3846
  delete meta[url];
3583
3847
  removed++;
3584
- const safeFile = typeof entry.file === "string" && entry.file === path22.basename(entry.file);
3585
- if (safeFile) await fs21.remove(path22.join(cacheDir, entry.file)).catch(() => {
3848
+ const safeFile = typeof entry.file === "string" && entry.file === path23.basename(entry.file);
3849
+ if (safeFile) await fs21.remove(path23.join(cacheDir, entry.file)).catch(() => {
3586
3850
  });
3587
3851
  }
3588
3852
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -3604,10 +3868,10 @@ async function fetchRefsForItems(items, cacheDir) {
3604
3868
  const outcome = await fetchSingleRef(url, cacheDir, meta);
3605
3869
  if (outcome.result === "fetched") {
3606
3870
  summary.fetched++;
3607
- summary.cachedFiles[url] = path22.join(cacheDir, outcome.file);
3871
+ summary.cachedFiles[url] = path23.join(cacheDir, outcome.file);
3608
3872
  } else if (outcome.result === "unchanged") {
3609
3873
  summary.unchanged++;
3610
- if (meta[url]?.file) summary.cachedFiles[url] = path22.join(cacheDir, meta[url].file);
3874
+ if (meta[url]?.file) summary.cachedFiles[url] = path23.join(cacheDir, meta[url].file);
3611
3875
  } else {
3612
3876
  summary.failed++;
3613
3877
  summary.failedUrls.push(url);
@@ -3630,7 +3894,7 @@ function parseIdList(value) {
3630
3894
  // src/commands/apply.ts
3631
3895
  var STALE_CACHE_THRESHOLD_DAYS = 7;
3632
3896
  async function cacheHasItems() {
3633
- const data = await readJson(path23.join(getCacheDir(), "manifest.json"));
3897
+ const data = await readJson(path24.join(getCacheDir(), "manifest.json"));
3634
3898
  return Array.isArray(data?.items) && data.items.length > 0;
3635
3899
  }
3636
3900
  function installableRecommendedItems(recommended) {
@@ -3781,7 +4045,7 @@ async function runApply(options) {
3781
4045
  }
3782
4046
 
3783
4047
  // src/commands/backups.ts
3784
- import path24 from "node:path";
4048
+ import path25 from "node:path";
3785
4049
  import fs22 from "fs-extra";
3786
4050
 
3787
4051
  // src/utils/prompts.ts
@@ -3818,7 +4082,7 @@ async function listBackups(root) {
3818
4082
  for (const name of names) {
3819
4083
  const kind = classify(name);
3820
4084
  if (!kind) continue;
3821
- const absPath = path24.join(dir, name);
4085
+ const absPath = path25.join(dir, name);
3822
4086
  const stat = await fs22.lstat(absPath);
3823
4087
  if (stat.isSymbolicLink()) continue;
3824
4088
  const expectDirectory = kind !== "lock";
@@ -3848,10 +4112,10 @@ async function runList(root) {
3848
4112
  }
3849
4113
  }
3850
4114
  async function isUnsafeWriteTarget(root, target) {
3851
- const segments = path24.relative(root, target).split(path24.sep).filter(Boolean);
4115
+ const segments = path25.relative(root, target).split(path25.sep).filter(Boolean);
3852
4116
  let current = root;
3853
4117
  for (let i = 0; i < segments.length; i++) {
3854
- current = path24.join(current, segments[i]);
4118
+ current = path25.join(current, segments[i]);
3855
4119
  const isLastSegment = i === segments.length - 1;
3856
4120
  try {
3857
4121
  const stat = await fs22.lstat(current);
@@ -3915,7 +4179,7 @@ async function collectFilesRecursive(dir) {
3915
4179
  const skippedOther = [];
3916
4180
  const names = await fs22.readdir(dir);
3917
4181
  for (const name of names) {
3918
- const abs = path24.join(dir, name);
4182
+ const abs = path25.join(dir, name);
3919
4183
  const stat = await fs22.lstat(abs);
3920
4184
  if (stat.isSymbolicLink()) {
3921
4185
  skippedLinks.push(abs);
@@ -3938,12 +4202,12 @@ async function restoreDirBackup(root, entry, yes) {
3938
4202
  const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
3939
4203
  if (skippedLinks.length > 0) {
3940
4204
  warn(
3941
- `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
4205
+ `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path25.relative(entry.absPath, abs)).join(", ")}`
3942
4206
  );
3943
4207
  }
3944
4208
  if (skippedOther.length > 0) {
3945
4209
  warn(
3946
- `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
4210
+ `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path25.relative(entry.absPath, abs)).join(", ")}`
3947
4211
  );
3948
4212
  }
3949
4213
  if (files.length === 0) {
@@ -3953,8 +4217,8 @@ async function restoreDirBackup(root, entry, yes) {
3953
4217
  const safeFiles = [];
3954
4218
  const unsafeTargets = [];
3955
4219
  for (const abs of files) {
3956
- const rel = path24.relative(entry.absPath, abs);
3957
- const target = path24.join(root, rel);
4220
+ const rel = path25.relative(entry.absPath, abs);
4221
+ const target = path25.join(root, rel);
3958
4222
  if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
3959
4223
  else safeFiles.push(abs);
3960
4224
  }
@@ -3971,14 +4235,14 @@ async function restoreDirBackup(root, entry, yes) {
3971
4235
  const vanished = [];
3972
4236
  const toRestore = [];
3973
4237
  for (const abs of safeFiles) {
3974
- const rel = path24.relative(entry.absPath, abs);
4238
+ const rel = path25.relative(entry.absPath, abs);
3975
4239
  const backupFileStat = await lstatIfRegularFile(abs);
3976
4240
  if (!backupFileStat) {
3977
4241
  vanished.push(rel);
3978
4242
  continue;
3979
4243
  }
3980
4244
  toRestore.push(abs);
3981
- const target = path24.join(root, rel);
4245
+ const target = path25.join(root, rel);
3982
4246
  if (await fs22.pathExists(target)) {
3983
4247
  const targetStat = await fs22.stat(target);
3984
4248
  if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
@@ -4019,13 +4283,13 @@ async function restoreDirBackup(root, entry, yes) {
4019
4283
  let restoredCount = 0;
4020
4284
  const skippedAtWrite = [];
4021
4285
  for (const abs of toRestore) {
4022
- const rel = path24.relative(entry.absPath, abs);
4023
- const target = path24.join(root, rel);
4286
+ const rel = path25.relative(entry.absPath, abs);
4287
+ const target = path25.join(root, rel);
4024
4288
  if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
4025
4289
  skippedAtWrite.push(rel);
4026
4290
  continue;
4027
4291
  }
4028
- await fs22.ensureDir(path24.dirname(target));
4292
+ await fs22.ensureDir(path25.dirname(target));
4029
4293
  await fs22.copy(abs, target, { overwrite: true });
4030
4294
  restoredCount++;
4031
4295
  }
@@ -4164,16 +4428,53 @@ async function runCatalogAudit() {
4164
4428
  }
4165
4429
 
4166
4430
  // src/commands/doctor.ts
4167
- import path38 from "node:path";
4168
- import fs29 from "fs-extra";
4431
+ import path39 from "node:path";
4432
+ import fs30 from "fs-extra";
4433
+
4434
+ // src/claude/skill-reference-check.ts
4435
+ import path26 from "node:path";
4436
+ import fg4 from "fast-glob";
4437
+ import fs23 from "fs-extra";
4438
+ var REFERENCE_PATTERN = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+)+):([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b(?!:\/\/)/g;
4439
+ function stripFencedCodeBlocks(text) {
4440
+ return text.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, " "));
4441
+ }
4442
+ async function findUnresolvedSkillReferences(root) {
4443
+ const skillsRoot = claudePath(root, "skills");
4444
+ if (!await fs23.pathExists(skillsRoot)) return [];
4445
+ const skillDirs = (await fs23.readdir(skillsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== "shared").map((entry) => entry.name);
4446
+ const installedSkillNames = new Set(skillDirs);
4447
+ const files = await fg4("*/**/*.md", { cwd: skillsRoot, onlyFiles: true, dot: true });
4448
+ const issues = [];
4449
+ for (const rel of files) {
4450
+ if (rel.startsWith("shared/")) continue;
4451
+ const abs = path26.join(skillsRoot, rel);
4452
+ const raw = await fs23.readFile(abs, "utf8");
4453
+ const lines = stripFencedCodeBlocks(raw).split("\n");
4454
+ lines.forEach((line2, idx) => {
4455
+ for (const match of line2.matchAll(REFERENCE_PATTERN)) {
4456
+ const [reference, , bareName] = match;
4457
+ if (!reference || !bareName) continue;
4458
+ issues.push({
4459
+ file: path26.join(".claude/skills", rel),
4460
+ line: idx + 1,
4461
+ reference,
4462
+ bareName,
4463
+ bareInstalled: installedSkillNames.has(bareName)
4464
+ });
4465
+ }
4466
+ });
4467
+ }
4468
+ return issues;
4469
+ }
4169
4470
 
4170
4471
  // src/decisions/doctor.ts
4171
- import path29 from "node:path";
4172
- import fs26 from "fs-extra";
4472
+ import path31 from "node:path";
4473
+ import fs27 from "fs-extra";
4173
4474
 
4174
4475
  // src/decisions/check.ts
4175
- import path28 from "node:path";
4176
- import fs25 from "fs-extra";
4476
+ import path30 from "node:path";
4477
+ import fs26 from "fs-extra";
4177
4478
 
4178
4479
  // src/decisions/dep-bump.ts
4179
4480
  var DEP_SECTIONS = [
@@ -4182,15 +4483,15 @@ var DEP_SECTIONS = [
4182
4483
  "peerDependencies",
4183
4484
  "optionalDependencies"
4184
4485
  ];
4185
- function isRecord(value) {
4486
+ function isRecord2(value) {
4186
4487
  return value != null && typeof value === "object" && !Array.isArray(value);
4187
4488
  }
4188
4489
  function extractDepMap(pkg) {
4189
4490
  const map = /* @__PURE__ */ new Map();
4190
- if (!isRecord(pkg)) return map;
4491
+ if (!isRecord2(pkg)) return map;
4191
4492
  for (const section of DEP_SECTIONS) {
4192
4493
  const block = pkg[section];
4193
- if (!isRecord(block)) continue;
4494
+ if (!isRecord2(block)) continue;
4194
4495
  for (const [name, ver] of Object.entries(block)) {
4195
4496
  if (typeof ver === "string") map.set(`${section}:${name}`, ver);
4196
4497
  }
@@ -4216,7 +4517,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
4216
4517
  } catch {
4217
4518
  return false;
4218
4519
  }
4219
- if (!isRecord(before) || !isRecord(after)) return false;
4520
+ if (!isRecord2(before) || !isRecord2(after)) return false;
4220
4521
  const beforeDeps = extractDepMap(before);
4221
4522
  const afterDeps = extractDepMap(after);
4222
4523
  if (beforeDeps.size === 0 && afterDeps.size === 0) return false;
@@ -4232,7 +4533,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
4232
4533
 
4233
4534
  // src/decisions/diff.ts
4234
4535
  init_exec();
4235
- import path25 from "node:path";
4536
+ import path27 from "node:path";
4236
4537
  function isPackageJsonPath(file) {
4237
4538
  return file === "package.json" || file.endsWith("/package.json");
4238
4539
  }
@@ -4259,6 +4560,11 @@ function parseDiffRange(range) {
4259
4560
  }
4260
4561
  return null;
4261
4562
  }
4563
+ function toAncestryLogRange(range) {
4564
+ const parsed = parseDiffRange(range);
4565
+ if (!parsed) return `${range}..HEAD`;
4566
+ return `${parsed.left}..${parsed.right}`;
4567
+ }
4262
4568
  async function getCurrentBranch(root) {
4263
4569
  const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root });
4264
4570
  if (result.exitCode !== 0) return void 0;
@@ -4288,7 +4594,7 @@ async function resolvePackageJsonPair(root, file, opts) {
4288
4594
  return { before: before2, after: after2 };
4289
4595
  }
4290
4596
  const before = await readBlob(root, `${range}:${file}`) ?? empty;
4291
- const after = await readText(path25.join(root, file)) ?? empty;
4597
+ const after = await readText(path27.join(root, file)) ?? empty;
4292
4598
  return { before, after };
4293
4599
  }
4294
4600
  async function collectDiffStats(root, opts) {
@@ -4359,22 +4665,22 @@ function firstMatchingRegex(filePath, patterns) {
4359
4665
  }
4360
4666
 
4361
4667
  // src/decisions/paths.ts
4362
- import path26 from "node:path";
4363
- import fs23 from "fs-extra";
4668
+ import path28 from "node:path";
4669
+ import fs24 from "fs-extra";
4364
4670
  async function resolveDecisionsDir(root) {
4365
- const primary = path26.join(root, DECISIONS_TRIGGERS.decisionsDir);
4366
- if (await fs23.pathExists(primary)) return primary;
4367
- const legacy = path26.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4368
- if (await fs23.pathExists(legacy)) return legacy;
4671
+ const primary = path28.join(root, DECISIONS_TRIGGERS.decisionsDir);
4672
+ if (await fs24.pathExists(primary)) return primary;
4673
+ const legacy = path28.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4674
+ if (await fs24.pathExists(legacy)) return legacy;
4369
4675
  return primary;
4370
4676
  }
4371
4677
  function relativeDecisionPath(decisionsDir, root) {
4372
- return path26.relative(root, decisionsDir).replace(/\\/g, "/");
4678
+ return path28.relative(root, decisionsDir).replace(/\\/g, "/");
4373
4679
  }
4374
4680
 
4375
4681
  // src/decisions/validate.ts
4376
- import path27 from "node:path";
4377
- import fs24 from "fs-extra";
4682
+ import path29 from "node:path";
4683
+ import fs25 from "fs-extra";
4378
4684
  function validateDecisionContent(content2) {
4379
4685
  const errors = [];
4380
4686
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -4403,14 +4709,14 @@ function decisionNumberFromFile(name) {
4403
4709
  return match?.[1] ?? null;
4404
4710
  }
4405
4711
  async function validateIndexSync(decisionsDir, decisionFiles) {
4406
- const readmePath = path27.join(decisionsDir, "README.md");
4407
- if (!await fs24.pathExists(readmePath)) {
4712
+ const readmePath = path29.join(decisionsDir, "README.md");
4713
+ if (!await fs25.pathExists(readmePath)) {
4408
4714
  return ["docs/decisions/README.md is missing"];
4409
4715
  }
4410
4716
  const readme = await readText(readmePath) ?? "";
4411
4717
  const errors = [];
4412
4718
  for (const file of decisionFiles) {
4413
- const num = decisionNumberFromFile(path27.basename(file));
4719
+ const num = decisionNumberFromFile(path29.basename(file));
4414
4720
  if (num && !indexReferencesNumber(readme, num)) {
4415
4721
  errors.push(`README.md missing index row for ADR-${num}`);
4416
4722
  }
@@ -4505,22 +4811,22 @@ async function checkSatisfaction(input2) {
4505
4811
  const errors = [];
4506
4812
  const decisionsPrefix = `${input2.decisionsRel}/`;
4507
4813
  const newDecisionFiles = input2.changedFiles.filter(
4508
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path28.basename(f))
4814
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path30.basename(f))
4509
4815
  );
4510
4816
  if (newDecisionFiles.length === 0) {
4511
4817
  errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
4512
4818
  return errors;
4513
4819
  }
4514
- const decisionsDir = path28.join(input2.root, input2.decisionsRel);
4820
+ const decisionsDir = path30.join(input2.root, input2.decisionsRel);
4515
4821
  for (const rel of newDecisionFiles) {
4516
- const abs = path28.join(input2.root, rel);
4822
+ const abs = path30.join(input2.root, rel);
4517
4823
  const content2 = await readText(abs) ?? "";
4518
4824
  const valid = validateDecisionContent(content2);
4519
4825
  if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
4520
4826
  }
4521
4827
  const indexErrors = await validateIndexSync(
4522
4828
  decisionsDir,
4523
- newDecisionFiles.map((f) => path28.basename(f))
4829
+ newDecisionFiles.map((f) => path30.basename(f))
4524
4830
  );
4525
4831
  errors.push(...indexErrors);
4526
4832
  return errors;
@@ -4576,8 +4882,8 @@ ${opts.commitMessages ?? ""}`;
4576
4882
  changedFiles: stats.files
4577
4883
  });
4578
4884
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
4579
- const legacyExists = await fs25.pathExists(path28.join(root, legacyRel));
4580
- if (legacyExists && !await fs25.pathExists(decisionsDir)) {
4885
+ const legacyExists = await fs26.pathExists(path30.join(root, legacyRel));
4886
+ if (legacyExists && !await fs26.pathExists(decisionsDir)) {
4581
4887
  satisfactionErrors = await checkSatisfaction({
4582
4888
  root,
4583
4889
  decisionsRel: legacyRel,
@@ -4599,18 +4905,18 @@ ${opts.commitMessages ?? ""}`;
4599
4905
  return { triggered: true, satisfied: false, reasons, securityTriggered };
4600
4906
  }
4601
4907
  async function listDecisionNumbers(decisionsDir) {
4602
- if (!await fs25.pathExists(decisionsDir)) return [];
4603
- const entries = await fs25.readdir(decisionsDir);
4908
+ if (!await fs26.pathExists(decisionsDir)) return [];
4909
+ const entries = await fs26.readdir(decisionsDir);
4604
4910
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
4605
4911
  }
4606
4912
 
4607
4913
  // src/decisions/doctor.ts
4608
4914
  async function auditDecisionsLayout(root) {
4609
4915
  const findings = [];
4610
- const primary = path29.join(root, DECISIONS_TRIGGERS.decisionsDir);
4611
- const legacy = path29.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4612
- const hasPrimary = await fs26.pathExists(primary);
4613
- const hasLegacy = await fs26.pathExists(legacy);
4916
+ const primary = path31.join(root, DECISIONS_TRIGGERS.decisionsDir);
4917
+ const legacy = path31.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4918
+ const hasPrimary = await fs27.pathExists(primary);
4919
+ const hasLegacy = await fs27.pathExists(legacy);
4614
4920
  if (!hasPrimary && hasLegacy) {
4615
4921
  findings.push({
4616
4922
  level: "advisory",
@@ -4623,8 +4929,8 @@ async function auditDecisionsLayout(root) {
4623
4929
  if (!hasPrimary) return findings;
4624
4930
  const decisionsDir = await resolveDecisionsDir(root);
4625
4931
  const rel = relativeDecisionPath(decisionsDir, root);
4626
- const readmePath = path29.join(decisionsDir, "README.md");
4627
- if (!await fs26.pathExists(readmePath)) {
4932
+ const readmePath = path31.join(decisionsDir, "README.md");
4933
+ if (!await fs27.pathExists(readmePath)) {
4628
4934
  findings.push({
4629
4935
  level: "advisory",
4630
4936
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -4634,7 +4940,7 @@ async function auditDecisionsLayout(root) {
4634
4940
  return findings;
4635
4941
  }
4636
4942
  const readme = await readText(readmePath);
4637
- const entries = (await fs26.readdir(decisionsDir)).filter(
4943
+ const entries = (await fs27.readdir(decisionsDir)).filter(
4638
4944
  (f) => f.endsWith(".md") && f !== "README.md"
4639
4945
  );
4640
4946
  for (const file of entries) {
@@ -4648,7 +4954,7 @@ async function auditDecisionsLayout(root) {
4648
4954
  });
4649
4955
  }
4650
4956
  }
4651
- const claude = await readText(path29.join(root, "CLAUDE.md"));
4957
+ const claude = await readText(path31.join(root, "CLAUDE.md"));
4652
4958
  if (claude && !claude.includes(`@${rel}/README.md`)) {
4653
4959
  findings.push({
4654
4960
  level: "advisory",
@@ -4679,11 +4985,11 @@ async function auditDecisionsLayout(root) {
4679
4985
  }
4680
4986
 
4681
4987
  // src/scanner/read-context.ts
4682
- import path37 from "node:path";
4683
- import fs28 from "fs-extra";
4988
+ import path38 from "node:path";
4989
+ import fs29 from "fs-extra";
4684
4990
 
4685
4991
  // src/commands/workspace/config.ts
4686
- import path30 from "node:path";
4992
+ import path32 from "node:path";
4687
4993
  import YAML from "yaml";
4688
4994
  var WORKSPACE_FILE = "haus.workspace.yaml";
4689
4995
  function parseWorkspaceConfig(text) {
@@ -4706,11 +5012,11 @@ function parseWorkspaceConfig(text) {
4706
5012
  };
4707
5013
  }
4708
5014
  async function readWorkspaceConfig(workspaceRoot) {
4709
- return parseWorkspaceConfig(await readText(path30.join(workspaceRoot, WORKSPACE_FILE)));
5015
+ return parseWorkspaceConfig(await readText(path32.join(workspaceRoot, WORKSPACE_FILE)));
4710
5016
  }
4711
5017
 
4712
5018
  // src/workspace/members.ts
4713
- import path31 from "node:path";
5019
+ import path33 from "node:path";
4714
5020
  var REPOS_MANIFEST_FILE = "repos.manifest.json";
4715
5021
  var REPOS_LOCAL_FILE = "repos.local.json";
4716
5022
  var MemberConfigError = class extends Error {
@@ -4723,14 +5029,14 @@ var MemberConfigError = class extends Error {
4723
5029
  };
4724
5030
  function resolveAbsPath(workspaceRoot, folder, overrides) {
4725
5031
  const override = overrides[folder];
4726
- if (override) return path31.resolve(workspaceRoot, override);
4727
- return path31.resolve(workspaceRoot, folder);
5032
+ if (override) return path33.resolve(workspaceRoot, override);
5033
+ return path33.resolve(workspaceRoot, folder);
4728
5034
  }
4729
5035
  function isSelfMember(workspaceRoot, member) {
4730
- return path31.resolve(member.absPath) === path31.resolve(workspaceRoot);
5036
+ return path33.resolve(member.absPath) === path33.resolve(workspaceRoot);
4731
5037
  }
4732
5038
  async function readPathOverrides(workspaceRoot) {
4733
- const filePath = path31.join(workspaceRoot, REPOS_LOCAL_FILE);
5039
+ const filePath = path33.join(workspaceRoot, REPOS_LOCAL_FILE);
4734
5040
  const result = await readJsonDetailed(filePath);
4735
5041
  if (result.status === "missing") return {};
4736
5042
  if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
@@ -4746,7 +5052,7 @@ async function readPathOverrides(workspaceRoot) {
4746
5052
  return overrides;
4747
5053
  }
4748
5054
  async function readFromWorkspaceYaml(workspaceRoot, overrides) {
4749
- const filePath = path31.join(workspaceRoot, WORKSPACE_FILE);
5055
+ const filePath = path33.join(workspaceRoot, WORKSPACE_FILE);
4750
5056
  const text = await readText(filePath);
4751
5057
  if (text === void 0) return void 0;
4752
5058
  const config = parseWorkspaceConfig(text);
@@ -4759,7 +5065,7 @@ async function readFromWorkspaceYaml(workspaceRoot, overrides) {
4759
5065
  }));
4760
5066
  }
4761
5067
  async function readFromManifest(workspaceRoot, overrides) {
4762
- const filePath = path31.join(workspaceRoot, REPOS_MANIFEST_FILE);
5068
+ const filePath = path33.join(workspaceRoot, REPOS_MANIFEST_FILE);
4763
5069
  const result = await readJsonDetailed(filePath);
4764
5070
  if (result.status === "missing") return void 0;
4765
5071
  if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
@@ -4801,16 +5107,11 @@ async function readMembers(rootInfo) {
4801
5107
  }
4802
5108
 
4803
5109
  // src/scanner/scan-project.ts
4804
- import path36 from "node:path";
4805
-
4806
- // src/utils/audit-checks.ts
4807
- function isRecord2(v) {
4808
- return typeof v === "object" && v !== null;
4809
- }
5110
+ import path37 from "node:path";
4810
5111
 
4811
5112
  // src/utils/git-root.ts
4812
5113
  init_exec();
4813
- import path32 from "node:path";
5114
+ import path34 from "node:path";
4814
5115
  function fallback(start) {
4815
5116
  return {
4816
5117
  cwd: start,
@@ -4826,11 +5127,11 @@ function fallback(start) {
4826
5127
  async function resolveAbsoluteGitPath(flag, cwd, repoRoot) {
4827
5128
  const withFormat = await runGit(["rev-parse", "--path-format=absolute", flag], { cwd });
4828
5129
  const raw = withFormat.exitCode === 0 ? withFormat.stdout.trim() : "";
4829
- if (raw && path32.isAbsolute(raw)) return path32.resolve(raw);
5130
+ if (raw && path34.isAbsolute(raw)) return path34.resolve(raw);
4830
5131
  const plain = await runGit(["rev-parse", flag], { cwd });
4831
5132
  const plainRaw = plain.exitCode === 0 ? plain.stdout.trim() : "";
4832
5133
  if (!plainRaw) return "";
4833
- return path32.isAbsolute(plainRaw) ? path32.resolve(plainRaw) : path32.resolve(repoRoot, plainRaw);
5134
+ return path34.isAbsolute(plainRaw) ? path34.resolve(plainRaw) : path34.resolve(repoRoot, plainRaw);
4834
5135
  }
4835
5136
  async function resolveRoots(start) {
4836
5137
  const cwd = start ?? process.cwd();
@@ -4839,15 +5140,15 @@ async function resolveRoots(start) {
4839
5140
  if (toplevel.exitCode !== 0) return fallback(cwd);
4840
5141
  const toplevelRaw = toplevel.stdout.trim();
4841
5142
  if (!toplevelRaw) return fallback(cwd);
4842
- const repoRoot = path32.resolve(toplevelRaw);
5143
+ const repoRoot = path34.resolve(toplevelRaw);
4843
5144
  const [gitDir, gitCommonDir] = await Promise.all([
4844
5145
  resolveAbsoluteGitPath("--git-dir", cwd, repoRoot),
4845
5146
  resolveAbsoluteGitPath("--git-common-dir", cwd, repoRoot)
4846
5147
  ]);
4847
5148
  if (!gitDir || !gitCommonDir) return fallback(cwd);
4848
5149
  const isLinkedWorktree = gitDir !== gitCommonDir;
4849
- const mainRoot = isLinkedWorktree && path32.basename(gitCommonDir) === ".git" ? path32.dirname(gitCommonDir) : repoRoot;
4850
- const worktreeName = isLinkedWorktree ? path32.basename(gitDir) : null;
5150
+ const mainRoot = isLinkedWorktree && path34.basename(gitCommonDir) === ".git" ? path34.dirname(gitCommonDir) : repoRoot;
5151
+ const worktreeName = isLinkedWorktree ? path34.basename(gitDir) : null;
4851
5152
  return {
4852
5153
  cwd,
4853
5154
  repoRoot,
@@ -4892,8 +5193,8 @@ function resolveMajorVersion(range) {
4892
5193
  }
4893
5194
 
4894
5195
  // src/scanner/detect-package-manager.ts
4895
- import path33 from "node:path";
4896
- import fs27 from "fs-extra";
5196
+ import path35 from "node:path";
5197
+ import fs28 from "fs-extra";
4897
5198
  function detectPackageManager(root, packageManagerField) {
4898
5199
  const field = String(packageManagerField ?? "").trim();
4899
5200
  if (field.startsWith("yarn@")) {
@@ -4911,9 +5212,9 @@ function detectPackageManager(root, packageManagerField) {
4911
5212
  if (satisfiesVersion(version, ">=9")) return "npm";
4912
5213
  return "unknown";
4913
5214
  }
4914
- if (fs27.existsSync(path33.join(root, "yarn.lock"))) return "yarn";
4915
- if (fs27.existsSync(path33.join(root, "pnpm-lock.yaml"))) return "pnpm";
4916
- if (fs27.existsSync(path33.join(root, "package-lock.json"))) return "npm";
5215
+ if (fs28.existsSync(path35.join(root, "yarn.lock"))) return "yarn";
5216
+ if (fs28.existsSync(path35.join(root, "pnpm-lock.yaml"))) return "pnpm";
5217
+ if (fs28.existsSync(path35.join(root, "package-lock.json"))) return "npm";
4917
5218
  return "unknown";
4918
5219
  }
4919
5220
 
@@ -5030,6 +5331,7 @@ var STACK_RULES = [
5030
5331
  stack: ["frontend", "shadcn"],
5031
5332
  all: [fileEndsWith("components.json"), dep("class-variance-authority")]
5032
5333
  },
5334
+ { stack: ["frontend", "radix"], any: [depPrefix("@radix-ui/")] },
5033
5335
  { stack: ["tooling", "typescript5"], any: [depMajor("typescript", 5)] },
5034
5336
  { stack: ["backend", "sanity"], any: [dep("sanity"), dep("next-sanity"), dep("@sanity/client")] },
5035
5337
  { stack: ["backend", "strapi"], any: [dep("@strapi/strapi"), depPrefix("@strapi/")] },
@@ -5100,114 +5402,44 @@ var STACK_RULES = [
5100
5402
  { stack: ["testing", "playwright"], any: [dep("@playwright/test")] },
5101
5403
  { stack: ["testing", "storybook"], any: [fileIncludes(".storybook")] },
5102
5404
  { stack: ["testing", "testing-library"], any: [depPrefix("@testing-library/")] },
5103
- { stack: ["testing", "phpunit"], any: [fileEndsWith("phpunit.xml")] },
5104
- { stack: ["testing", "storybook"], any: [depPrefix("@storybook/")] },
5105
- { stack: ["testing", "vitest"], any: [dep("vitest")] },
5106
- { stack: ["testing", "jest"], any: [dep("jest"), dep("jest-environment-jsdom")] },
5107
- { stack: ["databases", "postgresql"], any: [dep("pg")] },
5108
- { stack: ["databases", "mariadb"], any: [dep("mariadb"), dep("mysql2")] },
5109
- { stack: ["databases", "mysql"], any: [dep("mysql"), dep("mysql2")] },
5110
- { stack: ["databases", "mssql"], any: [dep("mssql")] },
5111
- { stack: ["databases", "elasticsearch"], any: [dep("@elastic/elasticsearch")] },
5112
- { stack: ["databases", "redis"], any: [dep("predis/predis"), dep("ioredis"), dep("redis")] },
5113
- { stack: ["auth", "oidc"], any: [content("openid")] },
5114
- { stack: ["auth", "azure-ad"], any: [content("AZURE_AD")] },
5115
- { stack: ["auth", "bankid"], any: [content("BANKID")] },
5116
- {
5117
- stack: ["auth", "saml2"],
5118
- any: [dep("24slides/laravel-saml2"), dep("aacotroneo/laravel-saml2")]
5119
- },
5120
- { stack: ["auth", "next-auth"], any: [dep("next-auth"), dep("@auth/core")] }
5121
- ];
5122
- function runDetection(ctx, rules = STACK_RULES) {
5123
- const roles = [];
5124
- for (const rule of ROLE_RULES) {
5125
- if (rule.role && matchRule(rule, ctx) && !roles.includes(rule.role)) roles.push(rule.role);
5126
- }
5127
- const stacks = {};
5128
- for (const bucket of STACK_BUCKETS) stacks[bucket] = [];
5129
- for (const rule of rules) {
5130
- if (!rule.stack || !matchRule(rule, ctx)) continue;
5131
- const [bucket, name] = rule.stack;
5132
- stacks[bucket] ??= [];
5133
- if (!stacks[bucket].includes(name)) stacks[bucket].push(name);
5134
- }
5135
- return { roles, stacks };
5136
- }
5137
-
5138
- // src/scanner/detection.ts
5139
- import path34 from "node:path";
5140
- var UNSUPPORTED_MARKERS = {
5141
- "requirements.txt": "python",
5142
- "pyproject.toml": "python",
5143
- "go.mod": "go",
5144
- "Cargo.toml": "rust",
5145
- "pom.xml": "java",
5146
- "build.gradle": "java",
5147
- "build.gradle.kts": "java",
5148
- Gemfile: "ruby"
5149
- };
5150
- var WEAK_STACK_SIGNALS = /* @__PURE__ */ new Set(["missing-prettier", "missing-eslint"]);
5151
- function computeDetectionStatus(roles, stacks, unsupportedSignals) {
5152
- const hasRealStack = Object.entries(stacks).some(
5153
- ([bucket, names]) => bucket !== "packageManagers" && names.some((n) => !WEAK_STACK_SIGNALS.has(n))
5154
- );
5155
- const hasRealSignal = roles.length > 0 || hasRealStack;
5156
- if (!hasRealSignal) return "unknown";
5157
- return unsupportedSignals.length > 0 ? "partial" : "supported";
5158
- }
5159
- function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
5160
- const markers = unsupportedSignals.join(", ");
5161
- const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
5162
- const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree \u2014 sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
5163
- return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
5164
- }
5165
- function blocked(rel) {
5166
- return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
5167
- }
5168
- function dependencyVersionMap(pkg, composer) {
5169
- const versions = /* @__PURE__ */ new Map();
5170
- const pushObj = (obj) => {
5171
- if (!isRecord2(obj)) return;
5172
- for (const key of Object.keys(obj)) {
5173
- if (versions.has(key)) continue;
5174
- const value = obj[key];
5175
- versions.set(key, typeof value === "string" ? value : "");
5176
- }
5177
- };
5178
- pushObj(pkg?.dependencies);
5179
- pushObj(pkg?.devDependencies);
5180
- pushObj(composer?.require);
5181
- pushObj(composer?.["require-dev"]);
5182
- return versions;
5183
- }
5184
- function finalizeRoles(registryRoles, deps, files) {
5185
- const roles = new Set(registryRoles);
5186
- const hasWpConfig = files.some((f) => f.endsWith("wp-config.php"));
5187
- const hasBedrockLayout = files.some((f) => f.includes("web/app")) || deps.includes("roots/wordpress");
5188
- if (hasWpConfig && hasBedrockLayout) {
5189
- roles.add("wordpress-bedrock-site");
5190
- roles.add("wordpress-site");
5191
- } else if (hasWpConfig) {
5192
- roles.add("wordpress-vanilla-site");
5193
- roles.add("wordpress-site");
5194
- } else if (deps.includes("roots/wordpress")) {
5195
- roles.add("wordpress-bedrock-site");
5196
- roles.add("wordpress-site");
5405
+ { stack: ["testing", "phpunit"], any: [fileEndsWith("phpunit.xml")] },
5406
+ { stack: ["testing", "storybook"], any: [depPrefix("@storybook/")] },
5407
+ { stack: ["testing", "vitest"], any: [dep("vitest")] },
5408
+ { stack: ["testing", "jest"], any: [dep("jest"), dep("jest-environment-jsdom")] },
5409
+ { stack: ["databases", "postgresql"], any: [dep("pg")] },
5410
+ { stack: ["databases", "mariadb"], any: [dep("mariadb"), dep("mysql2")] },
5411
+ { stack: ["databases", "mysql"], any: [dep("mysql"), dep("mysql2")] },
5412
+ { stack: ["databases", "mssql"], any: [dep("mssql")] },
5413
+ { stack: ["databases", "elasticsearch"], any: [dep("@elastic/elasticsearch")] },
5414
+ { stack: ["databases", "redis"], any: [dep("predis/predis"), dep("ioredis"), dep("redis")] },
5415
+ { stack: ["auth", "oidc"], any: [content("openid")] },
5416
+ { stack: ["auth", "azure-ad"], any: [content("AZURE_AD")] },
5417
+ { stack: ["auth", "bankid"], any: [content("BANKID")] },
5418
+ {
5419
+ stack: ["auth", "saml2"],
5420
+ any: [dep("24slides/laravel-saml2"), dep("aacotroneo/laravel-saml2")]
5421
+ },
5422
+ { stack: ["auth", "next-auth"], any: [dep("next-auth"), dep("@auth/core")] }
5423
+ ];
5424
+ function runDetection(ctx, rules = STACK_RULES) {
5425
+ const roles = [];
5426
+ for (const rule of ROLE_RULES) {
5427
+ if (rule.role && matchRule(rule, ctx) && !roles.includes(rule.role)) roles.push(rule.role);
5197
5428
  }
5198
- return [...roles].sort();
5199
- }
5200
- function collectUnsupportedSignals(files) {
5201
- return [
5202
- ...new Set(
5203
- files.map((f) => UNSUPPORTED_MARKERS[path34.basename(f)]).filter((s) => Boolean(s))
5204
- )
5205
- ].sort();
5429
+ const stacks = {};
5430
+ for (const bucket of STACK_BUCKETS) stacks[bucket] = [];
5431
+ for (const rule of rules) {
5432
+ if (!rule.stack || !matchRule(rule, ctx)) continue;
5433
+ const [bucket, name] = rule.stack;
5434
+ stacks[bucket] ??= [];
5435
+ if (!stacks[bucket].includes(name)) stacks[bucket].push(name);
5436
+ }
5437
+ return { roles, stacks };
5206
5438
  }
5207
5439
 
5208
5440
  // src/scanner/render.ts
5209
5441
  import { readFile } from "node:fs/promises";
5210
- import path35 from "node:path";
5442
+ import path36 from "node:path";
5211
5443
  async function buildContentBlob(root, files) {
5212
5444
  const candidates = files.filter(
5213
5445
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -5215,7 +5447,7 @@ async function buildContentBlob(root, files) {
5215
5447
  const slice = candidates.slice(0, 300);
5216
5448
  const parts = await mapWithConcurrency(slice, async (rel) => {
5217
5449
  try {
5218
- return await readFile(path35.join(root, rel), "utf8");
5450
+ return await readFile(path36.join(root, rel), "utf8");
5219
5451
  } catch {
5220
5452
  return "";
5221
5453
  }
@@ -5307,8 +5539,8 @@ var SAFE_FILES = [
5307
5539
  "Gemfile"
5308
5540
  ];
5309
5541
  async function scanProject(root) {
5310
- const pkg = await readJson(path36.join(root, "package.json"));
5311
- const composer = await readJson(path36.join(root, "composer.json"));
5542
+ const pkg = await readJson(path37.join(root, "package.json"));
5543
+ const composer = await readJson(path37.join(root, "composer.json"));
5312
5544
  const files = await listFiles(root, SAFE_FILES);
5313
5545
  const safeFiles = files.filter((f) => !blocked(f));
5314
5546
  const depVersions = dependencyVersionMap(pkg, composer);
@@ -5328,7 +5560,7 @@ async function scanProject(root) {
5328
5560
  const warnings = [];
5329
5561
  const securityRisks = [];
5330
5562
  const crossRepoHints = [];
5331
- const nodeEngine = isRecord2(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
5563
+ const nodeEngine = isRecord(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
5332
5564
  if (nodeEngine && !satisfiesVersion(process.version, nodeEngine)) {
5333
5565
  warnings.push(`Current Node ${process.version} does not satisfy package engine ${nodeEngine}`);
5334
5566
  }
@@ -5338,7 +5570,7 @@ async function scanProject(root) {
5338
5570
  crossRepoHints.push("Monorepo orchestration detected");
5339
5571
  const unsupportedSignals = collectUnsupportedSignals(safeFiles);
5340
5572
  const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
5341
- const repoNameFallback = pkg?.name ? String(pkg.name) : path36.basename((await resolveRoots(root)).mainRoot);
5573
+ const repoNameFallback = pkg?.name ? String(pkg.name) : path37.basename((await resolveRoots(root)).mainRoot);
5342
5574
  const context = {
5343
5575
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5344
5576
  repoName: repoNameFallback,
@@ -5360,14 +5592,15 @@ async function scanProject(root) {
5360
5592
  // src/scanner/read-context.ts
5361
5593
  async function isCacheFresh(root, cachePath) {
5362
5594
  const markerPaths = [
5363
- path37.join(root, "package.json"),
5364
- path37.join(root, WORKSPACE_FILE),
5365
- path37.join(root, REPOS_MANIFEST_FILE)
5595
+ path38.join(root, "package.json"),
5596
+ path38.join(root, "composer.json"),
5597
+ path38.join(root, WORKSPACE_FILE),
5598
+ path38.join(root, REPOS_MANIFEST_FILE)
5366
5599
  ];
5367
- const cacheStat = await fs28.stat(cachePath);
5600
+ const cacheStat = await fs29.stat(cachePath);
5368
5601
  for (const markerPath of markerPaths) {
5369
- if (!await fs28.pathExists(markerPath)) continue;
5370
- const markerStat = await fs28.stat(markerPath);
5602
+ if (!await fs29.pathExists(markerPath)) continue;
5603
+ const markerStat = await fs29.stat(markerPath);
5371
5604
  if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
5372
5605
  }
5373
5606
  return true;
@@ -5468,7 +5701,7 @@ async function runDoctor(options) {
5468
5701
  } else {
5469
5702
  ok(`- HOOKS OK: ${hooks.message}`);
5470
5703
  }
5471
- const rootClaudeMdPath = path38.join(root, "CLAUDE.md");
5704
+ const rootClaudeMdPath = path39.join(root, "CLAUDE.md");
5472
5705
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
5473
5706
  if (!rootClaudeMdContent) {
5474
5707
  flag(
@@ -5511,10 +5744,10 @@ async function runDoctor(options) {
5511
5744
  ok("- CLAUDE.md: import block present");
5512
5745
  }
5513
5746
  for (const target of actualTargets) {
5514
- const resolved = path38.resolve(root, target);
5515
- const relFromRoot = path38.relative(root, resolved);
5516
- const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path38.sep}`) || path38.isAbsolute(relFromRoot);
5517
- if (escapesRoot || !await fs29.pathExists(resolved)) {
5747
+ const resolved = path39.resolve(root, target);
5748
+ const relFromRoot = path39.relative(root, resolved);
5749
+ const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path39.sep}`) || path39.isAbsolute(relFromRoot);
5750
+ if (escapesRoot || !await fs30.pathExists(resolved)) {
5518
5751
  flag(
5519
5752
  `- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
5520
5753
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -5532,7 +5765,7 @@ async function runDoctor(options) {
5532
5765
  );
5533
5766
  }
5534
5767
  const workflowPath = hausPath(root, "WORKFLOW.md");
5535
- const workflowExists = await fs29.pathExists(workflowPath);
5768
+ const workflowExists = await fs30.pathExists(workflowPath);
5536
5769
  if (!workflowExists) {
5537
5770
  flag(
5538
5771
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -5547,15 +5780,15 @@ async function runDoctor(options) {
5547
5780
  } else {
5548
5781
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
5549
5782
  const bodyContent = workflowContent.slice(firstLine.length + 1);
5550
- const cachePath = path38.join(getCacheDir(), "templates/agentic-workflow-standard.md");
5551
- const bundledPath = path38.join(
5783
+ const cachePath = path39.join(getCacheDir(), "templates/agentic-workflow-standard.md");
5784
+ const bundledPath = path39.join(
5552
5785
  packageRoot(),
5553
5786
  "library",
5554
5787
  "global",
5555
5788
  "templates",
5556
5789
  "agentic-workflow-standard.md"
5557
5790
  );
5558
- const templatePath = await fs29.pathExists(cachePath) ? cachePath : bundledPath;
5791
+ const templatePath = await fs30.pathExists(cachePath) ? cachePath : bundledPath;
5559
5792
  const templateContent = await readText(templatePath);
5560
5793
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
5561
5794
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -5576,7 +5809,7 @@ async function runDoctor(options) {
5576
5809
  }
5577
5810
  }
5578
5811
  const workflowConfigPath = hausPath(root, "workflow-config.md");
5579
- const workflowConfigExists = await fs29.pathExists(workflowConfigPath);
5812
+ const workflowConfigExists = await fs30.pathExists(workflowConfigPath);
5580
5813
  if (!workflowConfigExists) {
5581
5814
  flag(
5582
5815
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -5584,7 +5817,7 @@ async function runDoctor(options) {
5584
5817
  "haus apply --write"
5585
5818
  );
5586
5819
  } else {
5587
- const cfg = await fs29.readFile(workflowConfigPath, "utf8");
5820
+ const cfg = await fs30.readFile(workflowConfigPath, "utf8");
5588
5821
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
5589
5822
  if (unfilled > 0) {
5590
5823
  suggest(
@@ -5597,7 +5830,7 @@ async function runDoctor(options) {
5597
5830
  }
5598
5831
  }
5599
5832
  if (workflowExists) {
5600
- const prettierIgnore = await readText(path38.join(root, ".prettierignore")) ?? "";
5833
+ const prettierIgnore = await readText(path39.join(root, ".prettierignore")) ?? "";
5601
5834
  const lines = prettierIgnore.split("\n");
5602
5835
  const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
5603
5836
  if (missing.length > 0) {
@@ -5637,6 +5870,29 @@ async function runDoctor(options) {
5637
5870
  if (finding.level === "ok") ok(finding.line);
5638
5871
  else suggest(finding.line, finding.sentence, finding.fix);
5639
5872
  }
5873
+ const skillRefIssues = await findUnresolvedSkillReferences(root);
5874
+ if (skillRefIssues.length > 0) {
5875
+ const prefixBug = skillRefIssues.filter((i) => i.bareInstalled);
5876
+ const missingDep = skillRefIssues.filter((i) => !i.bareInstalled);
5877
+ const describe = (i) => `${i.file}:${i.line} "${i.reference}"`;
5878
+ if (prefixBug.length > 0) {
5879
+ flag(
5880
+ `- 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" : ""})`,
5881
+ `${prefixBug.length} skill reference(s) use a prefix that can't resolve, even though the target skill is installed`,
5882
+ "haus apply --write (re-vendors the skill body with the prefix stripped)"
5883
+ );
5884
+ }
5885
+ if (missingDep.length > 0) {
5886
+ const missingNames = [...new Set(missingDep.map((i) => i.bareName))];
5887
+ flag(
5888
+ `- 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" : ""})`,
5889
+ `${missingDep.length} skill reference(s) point at a sub-skill (${missingNames.join(", ")}) that isn't installed`,
5890
+ "find its catalog id and run `haus recommend --include <id>` then `haus apply --write`, or remove the reference"
5891
+ );
5892
+ }
5893
+ } else {
5894
+ ok("- SKILL REFS: OK (no unresolved cross-skill references)");
5895
+ }
5640
5896
  const cacheAgeMs = await getCacheManifestAge();
5641
5897
  if (cacheAgeMs === null) {
5642
5898
  suggest(
@@ -5657,20 +5913,28 @@ async function runDoctor(options) {
5657
5913
  }
5658
5914
  }
5659
5915
  if (recommendation) {
5660
- const lock = await readJson(hausPath(root, "haus.lock.json"));
5661
- const recommendedIds = new Set(
5662
- recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
5663
- );
5664
- const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
5665
- if (orphaned.length > 0) {
5916
+ if (!Array.isArray(recommendation.recommended)) {
5666
5917
  suggest(
5667
- `- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
5668
- `${orphaned.length} installed item(s) are no longer recommended for this project`,
5669
- "review whether they are still needed; haus apply --write leaves them in place until removed from the catalog"
5918
+ `- CATALOG ITEMS: recommendation.json's "recommended" field is not an array \u2014 skipping orphaned-item check`,
5919
+ 'recommendation.json looks malformed (its "recommended" field is not an array)',
5920
+ "run `haus recommend` to regenerate it"
5921
+ );
5922
+ } else {
5923
+ const lock = await readJson(hausPath(root, "haus.lock.json"));
5924
+ const recommendedIds = new Set(
5925
+ recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
5670
5926
  );
5927
+ const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
5928
+ if (orphaned.length > 0) {
5929
+ suggest(
5930
+ `- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
5931
+ `${orphaned.length} installed item(s) are no longer recommended for this project`,
5932
+ "review whether they are still needed; haus apply --write leaves them in place until removed from the catalog"
5933
+ );
5934
+ }
5671
5935
  }
5672
5936
  }
5673
- const pkgJson = await readJson(path38.join(packageRoot(), "package.json"));
5937
+ const pkgJson = await readJson(path39.join(packageRoot(), "package.json"));
5674
5938
  const currentVersion = pkgJson?.version ?? "0.0.0";
5675
5939
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
5676
5940
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -5703,12 +5967,12 @@ async function runDoctor(options) {
5703
5967
  }
5704
5968
 
5705
5969
  // src/commands/update.ts
5706
- import path41 from "node:path";
5970
+ import path42 from "node:path";
5707
5971
 
5708
5972
  // src/claude/refresh-project.ts
5709
- import fs30 from "fs-extra";
5973
+ import fs31 from "fs-extra";
5710
5974
  async function isHausProject(root) {
5711
- if (await fs30.pathExists(hausPath(root, "recommendation.json"))) return true;
5975
+ if (await fs31.pathExists(hausPath(root, "recommendation.json"))) return true;
5712
5976
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
5713
5977
  if (result.status === "ok") return result.value._haus != null;
5714
5978
  return false;
@@ -5720,8 +5984,8 @@ async function refreshProjectApply(root) {
5720
5984
 
5721
5985
  // src/install/apply.ts
5722
5986
  import crypto2 from "node:crypto";
5723
- import path39 from "node:path";
5724
- import fs31 from "fs-extra";
5987
+ import path40 from "node:path";
5988
+ import fs32 from "fs-extra";
5725
5989
 
5726
5990
  // src/install/header.ts
5727
5991
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -5805,59 +6069,66 @@ var SCHEMA_VERSION2 = "1";
5805
6069
  function hashContent(content2) {
5806
6070
  return `sha256-${crypto2.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
5807
6071
  }
6072
+ function destFileMissing(destPath) {
6073
+ try {
6074
+ return !fs32.statSync(destPath).isFile();
6075
+ } catch {
6076
+ return true;
6077
+ }
6078
+ }
5808
6079
  function sourceVersion() {
5809
6080
  try {
5810
- const pkgPath = path39.join(packageRoot(), "package.json");
5811
- const pkg = JSON.parse(fs31.readFileSync(pkgPath, "utf8"));
6081
+ const pkgPath = path40.join(packageRoot(), "package.json");
6082
+ const pkg = JSON.parse(fs32.readFileSync(pkgPath, "utf8"));
5812
6083
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
5813
6084
  } catch {
5814
6085
  return "haus@0.0.0";
5815
6086
  }
5816
6087
  }
5817
6088
  function globalSrcDir() {
5818
- return path39.join(packageRoot(), "library", "global");
6089
+ return path40.join(packageRoot(), "library", "global");
5819
6090
  }
5820
6091
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
5821
- const skillDir = path39.join(skillsDir, skillName);
6092
+ const skillDir = path40.join(skillsDir, skillName);
5822
6093
  const entries = [];
5823
- for (const rel of fs31.readdirSync(skillDir, { recursive: true })) {
6094
+ for (const rel of fs32.readdirSync(skillDir, { recursive: true })) {
5824
6095
  if (rel === "SKILL.md") continue;
5825
- const abs = path39.join(skillDir, rel);
5826
- if (!fs31.statSync(abs).isFile()) continue;
5827
- const posixRel = rel.split(path39.sep).join("/");
6096
+ const abs = path40.join(skillDir, rel);
6097
+ if (!fs32.statSync(abs).isFile()) continue;
6098
+ const posixRel = rel.split(path40.sep).join("/");
5828
6099
  entries.push({
5829
6100
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
5830
- srcRelPath: path39.join("library", "global", "skills", skillName, rel),
5831
- destPath: path39.join(claudeDir, "skills", skillName, rel)
6101
+ srcRelPath: path40.join("library", "global", "skills", skillName, rel),
6102
+ destPath: path40.join(claudeDir, "skills", skillName, rel)
5832
6103
  });
5833
6104
  }
5834
6105
  return entries;
5835
6106
  }
5836
6107
  function collectSourceFiles(srcDir, claudeDir) {
5837
6108
  const entries = [];
5838
- const skillsDir = path39.join(srcDir, "skills");
5839
- if (fs31.pathExistsSync(skillsDir)) {
5840
- for (const skillName of fs31.readdirSync(skillsDir)) {
5841
- const skillFile = path39.join(skillsDir, skillName, "SKILL.md");
5842
- if (fs31.pathExistsSync(skillFile)) {
6109
+ const skillsDir = path40.join(srcDir, "skills");
6110
+ if (fs32.pathExistsSync(skillsDir)) {
6111
+ for (const skillName of fs32.readdirSync(skillsDir)) {
6112
+ const skillFile = path40.join(skillsDir, skillName, "SKILL.md");
6113
+ if (fs32.pathExistsSync(skillFile)) {
5843
6114
  entries.push({
5844
6115
  stableId: `skill.${skillName}`,
5845
- srcRelPath: path39.join("library", "global", "skills", skillName, "SKILL.md"),
5846
- destPath: path39.join(claudeDir, "skills", skillName, "SKILL.md")
6116
+ srcRelPath: path40.join("library", "global", "skills", skillName, "SKILL.md"),
6117
+ destPath: path40.join(claudeDir, "skills", skillName, "SKILL.md")
5847
6118
  });
5848
6119
  entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
5849
6120
  }
5850
6121
  }
5851
6122
  }
5852
- const commandsDir = path39.join(srcDir, "commands");
5853
- if (fs31.pathExistsSync(commandsDir)) {
5854
- for (const fileName of fs31.readdirSync(commandsDir)) {
6123
+ const commandsDir = path40.join(srcDir, "commands");
6124
+ if (fs32.pathExistsSync(commandsDir)) {
6125
+ for (const fileName of fs32.readdirSync(commandsDir)) {
5855
6126
  if (!fileName.endsWith(".md")) continue;
5856
6127
  const commandName = fileName.slice(0, -".md".length);
5857
6128
  entries.push({
5858
6129
  stableId: `command.${commandName}`,
5859
- srcRelPath: path39.join("library", "global", "commands", fileName),
5860
- destPath: path39.join(claudeDir, "commands", fileName)
6130
+ srcRelPath: path40.join("library", "global", "commands", fileName),
6131
+ destPath: path40.join(claudeDir, "commands", fileName)
5861
6132
  });
5862
6133
  }
5863
6134
  }
@@ -5883,7 +6154,7 @@ async function applyInstall(options = {}) {
5883
6154
  };
5884
6155
  const manifestFiles = [];
5885
6156
  for (const entry of sourceFiles) {
5886
- const srcPath = path39.join(packageRoot(), entry.srcRelPath);
6157
+ const srcPath = path40.join(packageRoot(), entry.srcRelPath);
5887
6158
  const rawContent = await readText(srcPath);
5888
6159
  if (rawContent === void 0) {
5889
6160
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -5897,13 +6168,14 @@ async function applyInstall(options = {}) {
5897
6168
  const newHash = hashContent(stamped);
5898
6169
  const existing = manifestByDest.get(entry.destPath);
5899
6170
  if (check) {
5900
- if (existing && existing.hash !== newHash) {
6171
+ const destMissing = destFileMissing(entry.destPath);
6172
+ if (destMissing || existing && existing.hash !== newHash) {
5901
6173
  result.drift = true;
5902
6174
  result.skipped.push(entry.destPath);
5903
6175
  }
5904
6176
  continue;
5905
6177
  }
5906
- const destExists = fs31.pathExistsSync(entry.destPath);
6178
+ const destExists = fs32.pathExistsSync(entry.destPath);
5907
6179
  if (destExists) {
5908
6180
  const currentContent = await readText(entry.destPath);
5909
6181
  if (currentContent !== void 0) {
@@ -5943,7 +6215,7 @@ async function applyInstall(options = {}) {
5943
6215
  }
5944
6216
  let mergedSettings;
5945
6217
  if (!check) {
5946
- const fragmentPath = path39.join(srcDir, "settings-fragments", "hooks.json");
6218
+ const fragmentPath = path40.join(srcDir, "settings-fragments", "hooks.json");
5947
6219
  const fragments = await loadHooksFragment(fragmentPath);
5948
6220
  const settings = await readSettings();
5949
6221
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -5957,13 +6229,13 @@ async function applyInstall(options = {}) {
5957
6229
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
5958
6230
  for (const entry of existingManifest.files) {
5959
6231
  if (currentDestPaths.has(entry.destPath)) continue;
5960
- if (!fs31.pathExistsSync(entry.destPath)) continue;
6232
+ if (!fs32.pathExistsSync(entry.destPath)) continue;
5961
6233
  const content2 = await readText(entry.destPath);
5962
6234
  if (!content2) continue;
5963
6235
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
5964
6236
  const currentHash = hashContent(content2);
5965
6237
  if (hasHeader && currentHash === entry.hash) {
5966
- if (!dryRun) await fs31.remove(entry.destPath);
6238
+ if (!dryRun) await fs32.remove(entry.destPath);
5967
6239
  result.deleted.push(entry.destPath);
5968
6240
  } else {
5969
6241
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -6032,7 +6304,7 @@ function summarizeLockDiff(before, after) {
6032
6304
 
6033
6305
  // src/update/lockfile.ts
6034
6306
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
6035
- import path40 from "node:path";
6307
+ import path41 from "node:path";
6036
6308
  async function readLockSummary(root) {
6037
6309
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
6038
6310
  const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
@@ -6056,19 +6328,21 @@ async function checkLock(root) {
6056
6328
  const ok = lock.length > 0 && hasValidVersions && drift.length === 0;
6057
6329
  return { ok, count: lock.length, catalogRef, drift, driftCount: drift.length };
6058
6330
  }
6059
- async function applyLock(root) {
6060
- const lockPath = hausPath(root, "haus.lock.json");
6061
- let before = "[]";
6331
+ async function readLockRaw(root) {
6062
6332
  try {
6063
- before = await readFile2(lockPath, "utf8");
6333
+ return await readFile2(hausPath(root, "haus.lock.json"), "utf8");
6064
6334
  } catch {
6065
- before = "[]";
6335
+ return "[]";
6066
6336
  }
6337
+ }
6338
+ async function applyLock(root) {
6339
+ const lockPath = hausPath(root, "haus.lock.json");
6340
+ const before = await readLockRaw(root);
6067
6341
  const lock = await readJson(lockPath) ?? [];
6068
6342
  try {
6069
6343
  const backupDir = hausPath(root, "backups");
6070
6344
  await mkdir(backupDir, { recursive: true });
6071
- await copyFile(lockPath, path40.join(backupDir, `haus.lock.${Date.now()}.json`));
6345
+ await copyFile(lockPath, path41.join(backupDir, `haus.lock.${Date.now()}.json`));
6072
6346
  } catch {
6073
6347
  }
6074
6348
  const enriched = await Promise.all(
@@ -6090,7 +6364,7 @@ function diffLock(before, after) {
6090
6364
  }
6091
6365
  async function hasLocalOverrides(root) {
6092
6366
  try {
6093
- await readFile2(path40.join(root, ".claude", "settings.json"), "utf8");
6367
+ await readFile2(path41.join(root, ".claude", "settings.json"), "utf8");
6094
6368
  return true;
6095
6369
  } catch {
6096
6370
  return false;
@@ -6106,7 +6380,7 @@ async function runUpdate(options) {
6106
6380
  return;
6107
6381
  }
6108
6382
  if (options.check) {
6109
- const pkgJson2 = await readJson(path41.join(packageRoot(), "package.json"));
6383
+ const pkgJson2 = await readJson(path42.join(packageRoot(), "package.json"));
6110
6384
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
6111
6385
  const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
6112
6386
  options.fast ? fastLockStatus(root) : checkLock(root),
@@ -6138,10 +6412,12 @@ async function runUpdate(options) {
6138
6412
  )
6139
6413
  );
6140
6414
  const hasHashDrift = !options.fast && status.count > 0 && "ok" in status && !status.ok;
6141
- if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
6415
+ if (hasHashDrift || formerIdMigrations.length > 0 || globalInstallDrift === true) {
6416
+ process.exitCode = 1;
6417
+ }
6142
6418
  return;
6143
6419
  }
6144
- const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6420
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6145
6421
  const currentVersion = pkgJson?.version ?? "0.0.0";
6146
6422
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
6147
6423
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -6153,9 +6429,7 @@ async function runUpdate(options) {
6153
6429
  if (await hasLocalOverrides(root)) {
6154
6430
  log("Existing .claude/settings.json \u2014 haus rules will be merged, not replaced.");
6155
6431
  }
6156
- const { before, after } = await applyLock(root);
6157
- log(diffLock(before, after));
6158
- log(summarizeLockDiff(before, after));
6432
+ const lockBefore = await readLockRaw(root);
6159
6433
  log("Syncing remote catalog...");
6160
6434
  const sync = await syncRemoteCatalog();
6161
6435
  if (sync.newItems.length > 0) {
@@ -6177,13 +6451,16 @@ async function runUpdate(options) {
6177
6451
  }
6178
6452
  await refreshGlobalInstall();
6179
6453
  await refreshProjectFiles(root);
6454
+ const { after } = await applyLock(root);
6455
+ log(diffLock(lockBefore, after));
6456
+ log(summarizeLockDiff(lockBefore, after));
6180
6457
  log("Update applied with backup in .haus-workflow/backups/. Run haus doctor.");
6181
6458
  }
6182
6459
  async function runFromHookCheck(root) {
6183
6460
  try {
6184
6461
  const summary = await readLockSummary(root);
6185
6462
  if (summary.count === 0) return;
6186
- const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6463
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6187
6464
  const currentVersion = pkgJson?.version ?? "0.0.0";
6188
6465
  const [npmVersion, latestCatalogTag] = await Promise.all([
6189
6466
  fetchNpmVersionStatus(currentVersion),
@@ -6333,7 +6610,8 @@ async function runCiGate(options = {}) {
6333
6610
  // src/commands/clone.ts
6334
6611
  init_exec();
6335
6612
  import { existsSync as existsSync2 } from "node:fs";
6336
- import path42 from "node:path";
6613
+ import { readdir } from "node:fs/promises";
6614
+ import path43 from "node:path";
6337
6615
  var GIT_LOCATION_VARS = [
6338
6616
  "GIT_DIR",
6339
6617
  "GIT_WORK_TREE",
@@ -6362,6 +6640,16 @@ function normalizeGitUrl(url) {
6362
6640
  s = s.replace(/\.git$/i, "");
6363
6641
  return s.toLowerCase();
6364
6642
  }
6643
+ async function isEmptyDirectory(dir) {
6644
+ try {
6645
+ const entries = await readdir(dir);
6646
+ return entries.length === 0;
6647
+ } catch (err) {
6648
+ const code = err.code;
6649
+ if (code === "ENOTDIR" || code === "ENOENT") return false;
6650
+ throw err;
6651
+ }
6652
+ }
6365
6653
  async function checkExistingTarget(target, url) {
6366
6654
  const isRepo = await runGit(["-C", target, "rev-parse", "--is-inside-work-tree"], {
6367
6655
  env: cloneEnv(),
@@ -6383,11 +6671,11 @@ async function runClone(url, opts = {}) {
6383
6671
  process.exitCode = 1;
6384
6672
  return;
6385
6673
  }
6386
- const target = path42.resolve(opts.dir?.trim() || repoNameFromUrl(url));
6387
- if (existsSync2(target)) {
6674
+ const target = path43.resolve(opts.dir?.trim() || repoNameFromUrl(url));
6675
+ if (existsSync2(target) && !await isEmptyDirectory(target)) {
6388
6676
  const check = await checkExistingTarget(target, url);
6389
6677
  if (check.kind === "matches") {
6390
- log(`\u2022 ${path42.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
6678
+ log(`\u2022 ${path43.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
6391
6679
  return;
6392
6680
  }
6393
6681
  const message = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}) \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
@@ -6427,7 +6715,7 @@ function readHookPayload() {
6427
6715
  if (!raw) return { payload: {}, malformed: false };
6428
6716
  try {
6429
6717
  const parsed = JSON.parse(raw);
6430
- return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
6718
+ return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
6431
6719
  } catch {
6432
6720
  return { payload: {}, malformed: true };
6433
6721
  }
@@ -6466,7 +6754,7 @@ function readPayload(options) {
6466
6754
  if (options.stdinPayload != null) {
6467
6755
  try {
6468
6756
  const parsed = JSON.parse(options.stdinPayload);
6469
- return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
6757
+ return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
6470
6758
  } catch {
6471
6759
  return { payload: {}, malformed: true };
6472
6760
  }
@@ -6479,15 +6767,14 @@ async function runDecisionsGuard(options = {}) {
6479
6767
  emitPreToolUseDeny("Malformed hook payload");
6480
6768
  return;
6481
6769
  }
6482
- const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
6770
+ const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
6483
6771
  const command = String(toolInput.command ?? "");
6484
6772
  if (!PR_CREATE_PATTERN.test(command)) return;
6485
6773
  const root = process.cwd();
6486
6774
  const baseRef = await resolveBaseRef(root);
6487
6775
  if (!baseRef) return;
6488
6776
  const range = `${baseRef}...HEAD`;
6489
- const logRange = `${baseRef}..HEAD`;
6490
- const commitMessages = (await runGit(["log", "--format=%B", logRange], { cwd: root })).stdout;
6777
+ const commitMessages = (await runGit(["log", "--format=%B", toAncestryLogRange(range)], { cwd: root })).stdout;
6491
6778
  const result = await runDecisionsCheck(root, { range, commitMessages });
6492
6779
  if (result.triggered && !result.satisfied) {
6493
6780
  emitPreToolUseDeny(
@@ -6497,7 +6784,7 @@ async function runDecisionsGuard(options = {}) {
6497
6784
  }
6498
6785
 
6499
6786
  // src/commands/decisions.ts
6500
- import path43 from "node:path";
6787
+ import path44 from "node:path";
6501
6788
 
6502
6789
  // src/decisions/next-number.ts
6503
6790
  async function nextDecisionNumber(root) {
@@ -6572,7 +6859,7 @@ async function runDecisions(sub, arg, options = {}) {
6572
6859
  const root = process.cwd();
6573
6860
  if (sub === "check") {
6574
6861
  const commitMessages = options.range != null ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
6575
- (m) => m.runGit(["log", "--format=%B", options.range], { cwd: root })
6862
+ (m) => m.runGit(["log", "--format=%B", toAncestryLogRange(options.range)], { cwd: root })
6576
6863
  )).stdout : "";
6577
6864
  const result = await runDecisionsCheck(root, {
6578
6865
  staged: options.staged,
@@ -6607,7 +6894,7 @@ async function runDecisions(sub, arg, options = {}) {
6607
6894
  return;
6608
6895
  }
6609
6896
  if (sub === "validate") {
6610
- const target = arg ? path43.resolve(root, arg) : void 0;
6897
+ const target = arg ? path44.resolve(root, arg) : void 0;
6611
6898
  if (!target) {
6612
6899
  error("usage: haus decisions validate <path-to-decision.md>");
6613
6900
  process.exitCode = 1;
@@ -6859,7 +7146,7 @@ async function runGuard(kind, _options) {
6859
7146
  emitPreToolUseDeny("Malformed hook payload");
6860
7147
  return;
6861
7148
  }
6862
- const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
7149
+ const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
6863
7150
  if (kind === "file-access") {
6864
7151
  const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
6865
7152
  const reason2 = guardFileAccess(candidate);
@@ -6876,8 +7163,8 @@ async function runGuard(kind, _options) {
6876
7163
  }
6877
7164
 
6878
7165
  // src/commands/init.ts
6879
- import path45 from "node:path";
6880
- import fs32 from "fs-extra";
7166
+ import path46 from "node:path";
7167
+ import fs33 from "fs-extra";
6881
7168
 
6882
7169
  // src/recommender/git-signal.ts
6883
7170
  init_exec();
@@ -6900,55 +7187,6 @@ async function readChangedFiles(root) {
6900
7187
  return [.../* @__PURE__ */ new Set([...unstaged, ...staged, ...untracked])].sort();
6901
7188
  }
6902
7189
 
6903
- // src/recommender/policies.ts
6904
- var UNSUPPORTED = FORBIDDEN_TAGS;
6905
- function matchRequiresAny(clauses, ctx) {
6906
- for (const clause of clauses) {
6907
- if ("stack" in clause) {
6908
- if (ctx.stackSet.has(clause.stack.toLowerCase())) {
6909
- return { matched: true, signal: `stack:${clause.stack}` };
6910
- }
6911
- } else if ("dependency" in clause) {
6912
- if (ctx.depSet.has(clause.dependency.toLowerCase())) {
6913
- return { matched: true, signal: `dependency:${clause.dependency}` };
6914
- }
6915
- } else if ("packageNamePattern" in clause) {
6916
- const pattern = clause.packageNamePattern.toLowerCase();
6917
- const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
6918
- for (const dep2 of ctx.depSet) {
6919
- if (pattern.endsWith("*") ? dep2.startsWith(prefix) : dep2 === pattern) {
6920
- return {
6921
- matched: true,
6922
- signal: `packageNamePattern:${clause.packageNamePattern}`
6923
- };
6924
- }
6925
- }
6926
- } else if ("role" in clause) {
6927
- if (ctx.roleSet.has(clause.role.toLowerCase())) {
6928
- return { matched: true, signal: `role:${clause.role}` };
6929
- }
6930
- }
6931
- }
6932
- return { matched: false };
6933
- }
6934
- function describeRequiresAny(clauses) {
6935
- return clauses.map((c) => {
6936
- if ("stack" in c) return `stack=${c.stack}`;
6937
- if ("dependency" in c) return `dependency=${c.dependency}`;
6938
- if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
6939
- if ("role" in c) return `role=${c.role}`;
6940
- return "unknown";
6941
- }).join(" | ");
6942
- }
6943
- function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
6944
- const markers = context.unsupportedSignals?.join(", ");
6945
- const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
6946
- `Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
6947
- ] : [];
6948
- const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
6949
- return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
6950
- }
6951
-
6952
7190
  // src/recommender/recommend.ts
6953
7191
  async function recommend(root, context, opts = {}) {
6954
7192
  const items = await loadCatalog(root);
@@ -7265,6 +7503,7 @@ function applyCoInstallSuppression(recommended, skipped) {
7265
7503
  }
7266
7504
  var HARD_SKIP_CODES = /* @__PURE__ */ new Set([
7267
7505
  "former-id",
7506
+ "invalid-source",
7268
7507
  "unsupported-policy",
7269
7508
  "deprecated",
7270
7509
  "curated-not-approved",
@@ -7287,8 +7526,10 @@ function applyManualIncludes(include, items, recommended, skipped) {
7287
7526
  if (recommendedIds.has(id)) continue;
7288
7527
  const skipIdx = skipped.findIndex((s) => s.id === id);
7289
7528
  const skipCode = skipIdx >= 0 ? skipped[skipIdx].skipReasons[0]?.code : void 0;
7290
- if (skipCode && HARD_SKIP_CODES.has(skipCode)) {
7291
- warnings.push(`--include: "${id}" cannot be force-installed (blocked by ${skipCode})`);
7529
+ const failingGateNames = skipIdx >= 0 ? (skipped[skipIdx].gates ?? []).filter((g) => !g.passed).map((g) => g.name) : [];
7530
+ const hardBlock = failingGateNames.find((name) => HARD_SKIP_CODES.has(name));
7531
+ if (hardBlock) {
7532
+ warnings.push(`--include: "${id}" cannot be force-installed (blocked by ${hardBlock})`);
7292
7533
  continue;
7293
7534
  }
7294
7535
  if (skipCode === "requires-any-unsatisfied") {
@@ -7344,8 +7585,8 @@ function buildOptInEligible(items, skipped) {
7344
7585
  }
7345
7586
 
7346
7587
  // src/commands/workspace/discover.ts
7347
- import path44 from "node:path";
7348
- import fg4 from "fast-glob";
7588
+ import path45 from "node:path";
7589
+ import fg5 from "fast-glob";
7349
7590
  import YAML2 from "yaml";
7350
7591
  var DEFAULT_MAX_DEPTH = 3;
7351
7592
  var GIT_MARKER = ["**/.git"];
@@ -7379,16 +7620,16 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7379
7620
  suppressErrors: true
7380
7621
  };
7381
7622
  const [gitMatches, manifestMatches] = await Promise.all([
7382
- fg4(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
7383
- fg4(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
7623
+ fg5(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
7624
+ fg5(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
7384
7625
  ]);
7385
7626
  const gitDirs = /* @__PURE__ */ new Set();
7386
7627
  const manifestDirs = /* @__PURE__ */ new Set();
7387
7628
  for (const match of gitMatches) {
7388
- gitDirs.add(path44.posix.dirname(match));
7629
+ gitDirs.add(path45.posix.dirname(match));
7389
7630
  }
7390
7631
  for (const match of manifestMatches) {
7391
- manifestDirs.add(path44.posix.dirname(match));
7632
+ manifestDirs.add(path45.posix.dirname(match));
7392
7633
  }
7393
7634
  const repoRoots = [...gitDirs];
7394
7635
  const manifestSorted = [...manifestDirs].sort(
@@ -7405,9 +7646,9 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7405
7646
  async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
7406
7647
  const repoRoots = (await findRepoRoots(workspaceRoot, maxDepth)).filter((dir) => dir !== ".");
7407
7648
  return mapWithConcurrency(repoRoots, async (relDir) => {
7408
- const absDir = path44.resolve(workspaceRoot, relDir);
7409
- const pkg = await readJson(path44.join(absDir, "package.json"));
7410
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path44.basename(absDir);
7649
+ const absDir = path45.resolve(workspaceRoot, relDir);
7650
+ const pkg = await readJson(path45.join(absDir, "package.json"));
7651
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path45.basename(absDir);
7411
7652
  let role = "auto";
7412
7653
  try {
7413
7654
  const scan = await scanProject(absDir);
@@ -7450,7 +7691,7 @@ function renderWorkspaceYaml(config) {
7450
7691
  });
7451
7692
  }
7452
7693
  async function runDiscover(workspaceRoot, opts = {}) {
7453
- const yamlPath = path44.join(workspaceRoot, "haus.workspace.yaml");
7694
+ const yamlPath = path45.join(workspaceRoot, "haus.workspace.yaml");
7454
7695
  const existingText = await readText(yamlPath);
7455
7696
  const existing = parseWorkspaceConfig(existingText);
7456
7697
  if (existingText && !existing) {
@@ -7628,8 +7869,8 @@ async function runSetupProject(options) {
7628
7869
  // src/commands/init.ts
7629
7870
  async function runInit(options) {
7630
7871
  const root = process.cwd();
7631
- const hausDir = path45.join(root, ".haus-workflow");
7632
- const alreadyInit = await fs32.pathExists(hausDir);
7872
+ const hausDir = path46.join(root, ".haus-workflow");
7873
+ const alreadyInit = await fs33.pathExists(hausDir);
7633
7874
  if (alreadyInit) {
7634
7875
  log("Haus AI already initialized in this project.");
7635
7876
  log("Run `haus setup-project` to reconfigure.");
@@ -7764,8 +8005,8 @@ async function runRefresh() {
7764
8005
  }
7765
8006
 
7766
8007
  // src/install/scaffold.ts
7767
- import path46 from "node:path";
7768
- import fs33 from "fs-extra";
8008
+ import path47 from "node:path";
8009
+ import fs34 from "fs-extra";
7769
8010
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7770
8011
  const scaffolded = [];
7771
8012
  const skipped = [];
@@ -7778,27 +8019,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7778
8019
  skipped.push(name);
7779
8020
  if (outcome === "skipped-exists") skippedExisting.push(name);
7780
8021
  };
7781
- const realRoot = await fs33.realpath(catalogRoot).catch(() => null);
8022
+ const realRoot = await fs34.realpath(catalogRoot).catch(() => null);
7782
8023
  for (const item of items) {
7783
8024
  if (item.type !== "config") continue;
7784
- const sourcePath = path46.resolve(catalogRoot, item.path);
7785
- const rel = path46.relative(catalogRoot, sourcePath);
7786
- const escapes = rel === "" || path46.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
8025
+ const sourcePath = path47.resolve(catalogRoot, item.path);
8026
+ const rel = path47.relative(catalogRoot, sourcePath);
8027
+ const escapes = rel === "" || path47.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
7787
8028
  if (escapes) {
7788
8029
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
7789
8030
  continue;
7790
8031
  }
7791
- const realSource = await fs33.realpath(sourcePath).catch(() => null);
8032
+ const realSource = await fs34.realpath(sourcePath).catch(() => null);
7792
8033
  if (!realRoot || !realSource) {
7793
8034
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
7794
8035
  continue;
7795
8036
  }
7796
- const realRel = path46.relative(realRoot, realSource);
7797
- if (realRel !== "" && (path46.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
8037
+ const realRel = path47.relative(realRoot, realSource);
8038
+ if (realRel !== "" && (path47.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
7798
8039
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
7799
8040
  continue;
7800
8041
  }
7801
- const stat = await fs33.lstat(sourcePath).catch(() => null);
8042
+ const stat = await fs34.lstat(sourcePath).catch(() => null);
7802
8043
  if (!stat) {
7803
8044
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
7804
8045
  continue;
@@ -7808,14 +8049,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7808
8049
  continue;
7809
8050
  }
7810
8051
  if (stat.isFile()) {
7811
- const filename = path46.basename(sourcePath);
7812
- const dest = path46.join(projectRoot, filename);
8052
+ const filename = path47.basename(sourcePath);
8053
+ const dest = path47.join(projectRoot, filename);
7813
8054
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
7814
8055
  } else if (stat.isDirectory()) {
7815
- const entries = await fs33.readdir(sourcePath);
8056
+ const entries = await fs34.readdir(sourcePath);
7816
8057
  for (const entry of entries) {
7817
- const src = path46.join(sourcePath, entry);
7818
- const dest = path46.join(projectRoot, entry);
8058
+ const src = path47.join(sourcePath, entry);
8059
+ const dest = path47.join(projectRoot, entry);
7819
8060
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
7820
8061
  }
7821
8062
  }
@@ -7823,42 +8064,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
7823
8064
  return { scaffolded, skipped, skippedExisting };
7824
8065
  }
7825
8066
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
7826
- const destDisplay = path46.relative(projectRoot, dest) || path46.basename(dest);
7827
- const srcStat = await fs33.lstat(src).catch(() => null);
8067
+ const destDisplay = path47.relative(projectRoot, dest) || path47.basename(dest);
8068
+ const srcStat = await fs34.lstat(src).catch(() => null);
7828
8069
  if (!srcStat) {
7829
- warn(`Skipping ${path46.basename(src)}: source not found`);
8070
+ warn(`Skipping ${path47.basename(src)}: source not found`);
7830
8071
  return "skipped-refused";
7831
8072
  }
7832
8073
  if (srcStat.isSymbolicLink()) {
7833
- warn(`Skipping ${path46.basename(src)}: source is a symlink`);
8074
+ warn(`Skipping ${path47.basename(src)}: source is a symlink`);
7834
8075
  return "skipped-refused";
7835
8076
  }
7836
- const exists = await fs33.pathExists(dest);
8077
+ const exists = await fs34.pathExists(dest);
7837
8078
  if (exists && !opts.force) {
7838
8079
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
7839
8080
  return "skipped-exists";
7840
8081
  }
7841
8082
  if (opts.dryRun) {
7842
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path46.basename(dest)} (${itemId})`);
8083
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path47.basename(dest)} (${itemId})`);
7843
8084
  return "scaffolded";
7844
8085
  }
7845
8086
  if (exists && opts.force) {
7846
- await fs33.remove(dest);
8087
+ await fs34.remove(dest);
7847
8088
  }
7848
- await fs33.ensureDir(path46.dirname(dest));
7849
- await fs33.copy(src, dest, {
8089
+ await fs34.ensureDir(path47.dirname(dest));
8090
+ await fs34.copy(src, dest, {
7850
8091
  overwrite: true,
7851
8092
  dereference: false,
7852
8093
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
7853
8094
  filter: (s) => {
7854
8095
  try {
7855
- return !fs33.lstatSync(s).isSymbolicLink();
8096
+ return !fs34.lstatSync(s).isSymbolicLink();
7856
8097
  } catch {
7857
8098
  return false;
7858
8099
  }
7859
8100
  }
7860
8101
  });
7861
- log(`${exists ? "Overwrote" : "Created"} ${path46.basename(dest)} (${itemId})`);
8102
+ log(`${exists ? "Overwrote" : "Created"} ${path47.basename(dest)} (${itemId})`);
7862
8103
  return "scaffolded";
7863
8104
  }
7864
8105
 
@@ -7923,8 +8164,8 @@ async function runScan(options) {
7923
8164
  }
7924
8165
 
7925
8166
  // src/commands/undo.ts
7926
- import path47 from "node:path";
7927
- import fs34 from "fs-extra";
8167
+ import path48 from "node:path";
8168
+ import fs35 from "fs-extra";
7928
8169
 
7929
8170
  // src/claude/managed-paths.ts
7930
8171
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
@@ -7939,7 +8180,7 @@ function coreManagedAbsolutePaths(root) {
7939
8180
  async function collectCoreManagedPaths(root) {
7940
8181
  const existing = [];
7941
8182
  for (const abs of coreManagedAbsolutePaths(root)) {
7942
- if (await fs34.pathExists(abs)) existing.push(abs);
8183
+ if (await fs35.pathExists(abs)) existing.push(abs);
7943
8184
  }
7944
8185
  return existing;
7945
8186
  }
@@ -7951,18 +8192,18 @@ async function collectLockTrackedPaths(root) {
7951
8192
  const relPaths = row.paths ?? [];
7952
8193
  const existingRel = [];
7953
8194
  for (const rel of relPaths) {
7954
- if (await fs34.pathExists(path47.resolve(root, rel))) existingRel.push(rel);
8195
+ if (await fs35.pathExists(path48.resolve(root, rel))) existingRel.push(rel);
7955
8196
  }
7956
8197
  if (existingRel.length === 0) continue;
7957
8198
  if (row.hash === void 0) {
7958
- removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
8199
+ removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
7959
8200
  continue;
7960
8201
  }
7961
8202
  const currentHash = await hashInstalledPaths(root, existingRel);
7962
8203
  if (currentHash === row.hash) {
7963
- removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
8204
+ removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
7964
8205
  } else {
7965
- preserved.push(...existingRel.map((rel) => path47.resolve(root, rel)));
8206
+ preserved.push(...existingRel.map((rel) => path48.resolve(root, rel)));
7966
8207
  log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
7967
8208
  }
7968
8209
  }
@@ -7970,51 +8211,51 @@ async function collectLockTrackedPaths(root) {
7970
8211
  }
7971
8212
  async function settingsHasHausContent(root) {
7972
8213
  const settingsPath = claudePath(root, "settings.json");
7973
- if (!await fs34.pathExists(settingsPath)) return false;
8214
+ if (!await fs35.pathExists(settingsPath)) return false;
7974
8215
  const settings = await readProjectSettings(root);
7975
8216
  return settings._haus != null;
7976
8217
  }
7977
8218
  async function claudeMdHasHausBlock(root) {
7978
- const filePath = path47.join(root, "CLAUDE.md");
7979
- if (!await fs34.pathExists(filePath)) return false;
7980
- const text = await fs34.readFile(filePath, "utf8");
8219
+ const filePath = path48.join(root, "CLAUDE.md");
8220
+ if (!await fs35.pathExists(filePath)) return false;
8221
+ const text = await fs35.readFile(filePath, "utf8");
7981
8222
  return text.includes(BLOCK_BEGIN);
7982
8223
  }
7983
8224
  async function stripProjectSettings(root) {
7984
8225
  const settingsPath = claudePath(root, "settings.json");
7985
- if (!await fs34.pathExists(settingsPath)) return false;
8226
+ if (!await fs35.pathExists(settingsPath)) return false;
7986
8227
  let settings = await readProjectSettings(root);
7987
8228
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
7988
8229
  const hasContent = Object.keys(settings).length > 0;
7989
8230
  if (hasContent) {
7990
8231
  await writeProjectSettings(root, settings);
7991
- log(`Stripped haus rules from ${path47.relative(root, settingsPath)} (user settings preserved).`);
8232
+ log(`Stripped haus rules from ${path48.relative(root, settingsPath)} (user settings preserved).`);
7992
8233
  return true;
7993
8234
  }
7994
- await fs34.remove(settingsPath);
7995
- log(`Removed ${path47.relative(root, settingsPath)} (no user-owned settings remained).`);
8235
+ await fs35.remove(settingsPath);
8236
+ log(`Removed ${path48.relative(root, settingsPath)} (no user-owned settings remained).`);
7996
8237
  return true;
7997
8238
  }
7998
8239
  async function stripRootClaudeMd(root) {
7999
- const filePath = path47.join(root, "CLAUDE.md");
8000
- if (!await fs34.pathExists(filePath)) return false;
8001
- const prev = await fs34.readFile(filePath, "utf8");
8240
+ const filePath = path48.join(root, "CLAUDE.md");
8241
+ if (!await fs35.pathExists(filePath)) return false;
8242
+ const prev = await fs35.readFile(filePath, "utf8");
8002
8243
  if (!prev.includes(BLOCK_BEGIN)) return false;
8003
8244
  const next = stripHausBlock(prev);
8004
8245
  if (next.length === 0) {
8005
- await fs34.remove(filePath);
8246
+ await fs35.remove(filePath);
8006
8247
  log("Removed CLAUDE.md (only contained haus import block).");
8007
8248
  } else {
8008
- await fs34.writeFile(filePath, next, "utf8");
8249
+ await fs35.writeFile(filePath, next, "utf8");
8009
8250
  log("Removed haus import block from CLAUDE.md (user content preserved).");
8010
8251
  }
8011
8252
  return true;
8012
8253
  }
8013
8254
  async function pruneDirIfEmpty(dir) {
8014
- if (!await fs34.pathExists(dir)) return;
8255
+ if (!await fs35.pathExists(dir)) return;
8015
8256
  try {
8016
- const entries = await fs34.readdir(dir);
8017
- if (entries.length === 0) await fs34.remove(dir);
8257
+ const entries = await fs35.readdir(dir);
8258
+ if (entries.length === 0) await fs35.remove(dir);
8018
8259
  } catch {
8019
8260
  }
8020
8261
  }
@@ -8023,13 +8264,13 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
8023
8264
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
8024
8265
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
8025
8266
  for (const abs of managedAbsPaths) {
8026
- if (!await fs34.pathExists(abs)) continue;
8027
- const rel = path47.relative(root, abs);
8028
- const backupPath = path47.join(backupRoot, rel);
8029
- await fs34.ensureDir(path47.dirname(backupPath));
8030
- await fs34.copy(abs, backupPath);
8267
+ if (!await fs35.pathExists(abs)) continue;
8268
+ const rel = path48.relative(root, abs);
8269
+ const backupPath = path48.join(backupRoot, rel);
8270
+ await fs35.ensureDir(path48.dirname(backupPath));
8271
+ await fs35.copy(abs, backupPath);
8031
8272
  }
8032
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path47.relative(root, backupRoot)}.`);
8273
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path48.relative(root, backupRoot)}.`);
8033
8274
  }
8034
8275
  async function runUndo(options) {
8035
8276
  const root = options.root ?? process.cwd();
@@ -8042,7 +8283,7 @@ async function runUndo(options) {
8042
8283
  log("Nothing to remove: no haus-managed files found in this directory.");
8043
8284
  return;
8044
8285
  }
8045
- const relTargets = managed.map((p) => path47.relative(root, p));
8286
+ const relTargets = managed.map((p) => path48.relative(root, p));
8046
8287
  const summaryParts = [...relTargets];
8047
8288
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
8048
8289
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -8064,9 +8305,9 @@ User-owned .claude/ files will be preserved.`
8064
8305
  }
8065
8306
  await backupManagedFilesBeforeUndo(root, managed);
8066
8307
  for (const abs of managed) {
8067
- if (!await fs34.pathExists(abs)) continue;
8068
- await fs34.remove(abs);
8069
- log(`Removed ${path47.relative(root, abs)}`);
8308
+ if (!await fs35.pathExists(abs)) continue;
8309
+ await fs35.remove(abs);
8310
+ log(`Removed ${path48.relative(root, abs)}`);
8070
8311
  }
8071
8312
  if (stripSettings) await stripProjectSettings(root);
8072
8313
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -8077,8 +8318,8 @@ User-owned .claude/ files will be preserved.`
8077
8318
 
8078
8319
  // src/install/uninstall.ts
8079
8320
  import crypto3 from "node:crypto";
8080
- import path48 from "node:path";
8081
- import fs35 from "fs-extra";
8321
+ import path49 from "node:path";
8322
+ import fs36 from "fs-extra";
8082
8323
  async function runUninstall(options = {}) {
8083
8324
  const { force = false } = options;
8084
8325
  const manifest = await readManifest();
@@ -8088,7 +8329,7 @@ async function runUninstall(options = {}) {
8088
8329
  return result;
8089
8330
  }
8090
8331
  for (const entry of manifest.files) {
8091
- const exists = fs35.pathExistsSync(entry.destPath);
8332
+ const exists = fs36.pathExistsSync(entry.destPath);
8092
8333
  if (!exists) continue;
8093
8334
  const content2 = await readText(entry.destPath);
8094
8335
  if (content2 === void 0) continue;
@@ -8106,23 +8347,23 @@ async function runUninstall(options = {}) {
8106
8347
  result.skipped.push(entry.destPath);
8107
8348
  continue;
8108
8349
  }
8109
- await fs35.remove(entry.destPath);
8110
- await pruneEmptyDir(path48.dirname(entry.destPath));
8350
+ await fs36.remove(entry.destPath);
8351
+ await pruneEmptyDir(path49.dirname(entry.destPath));
8111
8352
  result.deleted.push(entry.destPath);
8112
8353
  }
8113
8354
  const settings = await readSettings();
8114
8355
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
8115
8356
  await writeSettings(stripped);
8116
8357
  result.hooksStripped = true;
8117
- const hausDir = path48.join(globalClaudeDir(), "haus");
8358
+ const hausDir = path49.join(globalClaudeDir(), "haus");
8118
8359
  const manifestPath2 = hausManifestPath();
8119
- if (fs35.pathExistsSync(manifestPath2)) {
8120
- await fs35.remove(manifestPath2);
8360
+ if (fs36.pathExistsSync(manifestPath2)) {
8361
+ await fs36.remove(manifestPath2);
8121
8362
  }
8122
- if (fs35.pathExistsSync(hausDir)) {
8363
+ if (fs36.pathExistsSync(hausDir)) {
8123
8364
  try {
8124
- const remaining = await fs35.readdir(hausDir);
8125
- if (remaining.length === 0) await fs35.remove(hausDir);
8365
+ const remaining = await fs36.readdir(hausDir);
8366
+ if (remaining.length === 0) await fs36.remove(hausDir);
8126
8367
  } catch {
8127
8368
  }
8128
8369
  }
@@ -8145,6 +8386,19 @@ function printUninstallResult(result) {
8145
8386
  // src/commands/uninstall.ts
8146
8387
  async function runUninstallCommand(options) {
8147
8388
  try {
8389
+ if (!options.yes) {
8390
+ const manifest = await readManifest();
8391
+ if (manifest) {
8392
+ const fileCount = manifest.files.length;
8393
+ const ok = await confirm(
8394
+ 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?"
8395
+ );
8396
+ if (!ok) {
8397
+ log("Cancelled.");
8398
+ return;
8399
+ }
8400
+ }
8401
+ }
8148
8402
  const result = await runUninstall({ force: options.force });
8149
8403
  printUninstallResult(result);
8150
8404
  log("haus uninstall complete");
@@ -8155,11 +8409,11 @@ async function runUninstallCommand(options) {
8155
8409
  }
8156
8410
 
8157
8411
  // src/commands/validate-catalog.ts
8158
- import path50 from "node:path";
8412
+ import path51 from "node:path";
8159
8413
 
8160
8414
  // src/catalog/validate-core.ts
8161
- import fs36 from "node:fs";
8162
- import path49 from "node:path";
8415
+ import fs37 from "node:fs";
8416
+ import path50 from "node:path";
8163
8417
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
8164
8418
  var ITEM_ID_PATTERN = /^haus\./;
8165
8419
  function auditForbiddenStacks(items) {
@@ -8301,14 +8555,14 @@ function checkRequiredFrontmatter(text, label) {
8301
8555
  }
8302
8556
  function readForAudit(absPath, label, failures) {
8303
8557
  try {
8304
- return fs36.readFileSync(absPath, "utf8");
8558
+ return fs37.readFileSync(absPath, "utf8");
8305
8559
  } catch (err) {
8306
8560
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
8307
8561
  return null;
8308
8562
  }
8309
8563
  }
8310
8564
  function auditTemplateContent(manifestDir, absPath, itemId) {
8311
- const rel = path49.relative(manifestDir, absPath);
8565
+ const rel = path50.relative(manifestDir, absPath);
8312
8566
  const failures = [];
8313
8567
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
8314
8568
  if (text === null) return failures;
@@ -8330,47 +8584,47 @@ function auditShippedFiles(manifestDir, items) {
8330
8584
  const failures = [];
8331
8585
  for (const item of items) {
8332
8586
  if (!item.path || !isSafeCatalogPath(item.path)) continue;
8333
- const absPath = path49.join(manifestDir, item.path);
8587
+ const absPath = path50.join(manifestDir, item.path);
8334
8588
  if (item.type === "skill") {
8335
- const skillMd = path49.join(absPath, "SKILL.md");
8336
- if (!fs36.existsSync(skillMd)) {
8337
- failures.push(`${item.id}: missing ${path49.relative(manifestDir, skillMd)}`);
8589
+ const skillMd = path50.join(absPath, "SKILL.md");
8590
+ if (!fs37.existsSync(skillMd)) {
8591
+ failures.push(`${item.id}: missing ${path50.relative(manifestDir, skillMd)}`);
8338
8592
  continue;
8339
8593
  }
8340
8594
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
8341
8595
  if (text === null) continue;
8342
8596
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
8343
8597
  failures.push(
8344
- ...auditForbiddenTagsInText(text, `${item.id}: ${path49.relative(manifestDir, skillMd)}`)
8598
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path50.relative(manifestDir, skillMd)}`)
8345
8599
  );
8346
8600
  } else if (item.type === "agent") {
8347
- if (!fs36.existsSync(absPath)) {
8601
+ if (!fs37.existsSync(absPath)) {
8348
8602
  failures.push(`${item.id}: missing agent file ${item.path}`);
8349
8603
  continue;
8350
8604
  }
8351
- const rel = path49.relative(manifestDir, absPath);
8605
+ const rel = path50.relative(manifestDir, absPath);
8352
8606
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
8353
8607
  if (text === null) continue;
8354
8608
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
8355
8609
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
8356
8610
  } else if (item.type === "template") {
8357
- if (!fs36.existsSync(absPath)) {
8611
+ if (!fs37.existsSync(absPath)) {
8358
8612
  failures.push(`${item.id}: missing template file ${item.path}`);
8359
8613
  continue;
8360
8614
  }
8361
8615
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
8362
8616
  } else if (item.type === "command") {
8363
- if (!fs36.existsSync(absPath)) {
8617
+ if (!fs37.existsSync(absPath)) {
8364
8618
  failures.push(`${item.id}: missing command file ${item.path}`);
8365
8619
  continue;
8366
8620
  }
8367
- const rel = path49.relative(manifestDir, absPath);
8621
+ const rel = path50.relative(manifestDir, absPath);
8368
8622
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
8369
8623
  if (text === null) continue;
8370
8624
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
8371
8625
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
8372
8626
  } else if (item.type === "config") {
8373
- if (!fs36.existsSync(absPath)) {
8627
+ if (!fs37.existsSync(absPath)) {
8374
8628
  failures.push(`${item.id}: missing config path ${item.path}`);
8375
8629
  }
8376
8630
  }
@@ -8387,7 +8641,7 @@ var CONTENT_ROOTS = ["skills", "agents", "templates", "commands", "configs"];
8387
8641
  function walkAllFiles(dir, fn, failures) {
8388
8642
  let entries;
8389
8643
  try {
8390
- entries = fs36.readdirSync(dir, { withFileTypes: true });
8644
+ entries = fs37.readdirSync(dir, { withFileTypes: true });
8391
8645
  } catch (err) {
8392
8646
  failures.push(
8393
8647
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -8396,7 +8650,7 @@ function walkAllFiles(dir, fn, failures) {
8396
8650
  }
8397
8651
  for (const entry of entries) {
8398
8652
  if (entry.name === ".DS_Store") continue;
8399
- const full = path49.join(dir, entry.name);
8653
+ const full = path50.join(dir, entry.name);
8400
8654
  if (entry.isDirectory()) walkAllFiles(full, fn, failures);
8401
8655
  else fn(full);
8402
8656
  }
@@ -8414,12 +8668,12 @@ function auditDiskOrphans(manifestDir, items) {
8414
8668
  const failures = [];
8415
8669
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
8416
8670
  for (const dir of CONTENT_ROOTS) {
8417
- const abs = path49.join(manifestDir, dir);
8418
- if (!fs36.existsSync(abs)) continue;
8671
+ const abs = path50.join(manifestDir, dir);
8672
+ if (!fs37.existsSync(abs)) continue;
8419
8673
  walkAllFiles(
8420
8674
  abs,
8421
8675
  (file) => {
8422
- const relPath = path49.relative(manifestDir, file).replace(/\\/g, "/");
8676
+ const relPath = path50.relative(manifestDir, file).replace(/\\/g, "/");
8423
8677
  if (!isPathClaimed(relPath, claimedPaths)) {
8424
8678
  failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
8425
8679
  }
@@ -8432,7 +8686,7 @@ function auditDiskOrphans(manifestDir, items) {
8432
8686
  function walkMd(dir, fn, failures) {
8433
8687
  let entries;
8434
8688
  try {
8435
- entries = fs36.readdirSync(dir, { withFileTypes: true });
8689
+ entries = fs37.readdirSync(dir, { withFileTypes: true });
8436
8690
  } catch (err) {
8437
8691
  failures.push(
8438
8692
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -8440,7 +8694,7 @@ function walkMd(dir, fn, failures) {
8440
8694
  return;
8441
8695
  }
8442
8696
  for (const entry of entries) {
8443
- const full = path49.join(dir, entry.name);
8697
+ const full = path50.join(dir, entry.name);
8444
8698
  if (entry.isDirectory()) walkMd(full, fn, failures);
8445
8699
  else if (entry.name.endsWith(".md")) fn(full);
8446
8700
  }
@@ -8449,13 +8703,13 @@ function auditMarkdownContent(manifestDir, items) {
8449
8703
  const failures = [];
8450
8704
  const pathSourceMap = buildItemPathSourceMap(items);
8451
8705
  for (const dir of ["skills", "agents", "templates", "commands"]) {
8452
- const abs = path49.join(manifestDir, dir);
8453
- if (!fs36.existsSync(abs)) continue;
8706
+ const abs = path50.join(manifestDir, dir);
8707
+ if (!fs37.existsSync(abs)) continue;
8454
8708
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
8455
8709
  walkMd(
8456
8710
  abs,
8457
8711
  (file) => {
8458
- const rel = path49.relative(manifestDir, file);
8712
+ const rel = path50.relative(manifestDir, file);
8459
8713
  const text = readForAudit(file, rel, failures);
8460
8714
  if (text === null) return;
8461
8715
  const norm = rel.replace(/\\/g, "/");
@@ -8500,8 +8754,8 @@ async function runValidateCatalog(manifestPath2) {
8500
8754
  process.exitCode = 1;
8501
8755
  return;
8502
8756
  }
8503
- const abs = path50.resolve(process.cwd(), manifestPath2);
8504
- const manifestDir = path50.dirname(abs);
8757
+ const abs = path51.resolve(process.cwd(), manifestPath2);
8758
+ const manifestDir = path51.dirname(abs);
8505
8759
  const data = await readJson(abs);
8506
8760
  if (!data?.items) {
8507
8761
  error(`Could not read catalog manifest at ${abs}`);
@@ -8518,8 +8772,8 @@ async function runValidateCatalog(manifestPath2) {
8518
8772
  }
8519
8773
 
8520
8774
  // src/workspace/worktree/add.ts
8521
- import path57 from "node:path";
8522
- import fs40 from "fs-extra";
8775
+ import path58 from "node:path";
8776
+ import fs41 from "fs-extra";
8523
8777
 
8524
8778
  // src/workspace/worktree/git-worktree.ts
8525
8779
  init_exec();
@@ -8638,13 +8892,13 @@ async function hasUnpushedWork(cwd) {
8638
8892
  }
8639
8893
 
8640
8894
  // src/workspace/worktree/hydrate.ts
8641
- import path53 from "node:path";
8642
- import fs38 from "fs-extra";
8895
+ import path54 from "node:path";
8896
+ import fs39 from "fs-extra";
8643
8897
 
8644
8898
  // src/workspace/worktree/cow-copy.ts
8645
8899
  init_exec();
8646
- import path51 from "node:path";
8647
- import fs37 from "fs-extra";
8900
+ import path52 from "node:path";
8901
+ import fs38 from "fs-extra";
8648
8902
  async function getMacFilesystemType(dir) {
8649
8903
  const df = await runCommand("df", ["-P", dir]);
8650
8904
  if (df.exitCode !== 0) return void 0;
@@ -8675,14 +8929,14 @@ async function detectCowStrategy(dir) {
8675
8929
  return "unknown-platform";
8676
8930
  }
8677
8931
  async function cowCopyDir(src, dest) {
8678
- const srcExists = await fs37.pathExists(src);
8932
+ const srcExists = await fs38.pathExists(src);
8679
8933
  if (!srcExists)
8680
8934
  return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
8681
- const strategy = await detectCowStrategy(path51.dirname(dest));
8935
+ const strategy = await detectCowStrategy(path52.dirname(dest));
8682
8936
  if (strategy === "unsupported" || strategy === "unknown-platform") {
8683
8937
  return { strategy, attempted: false, ok: false };
8684
8938
  }
8685
- await fs37.ensureDir(path51.dirname(dest));
8939
+ await fs38.ensureDir(path52.dirname(dest));
8686
8940
  const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
8687
8941
  const result = await runCommand("cp", args);
8688
8942
  return {
@@ -8696,8 +8950,8 @@ async function cowCopyDir(src, dest) {
8696
8950
  // src/workspace/worktree/install.ts
8697
8951
  init_exec();
8698
8952
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
8699
- import path52 from "node:path";
8700
- import fg5 from "fast-glob";
8953
+ import path53 from "node:path";
8954
+ import fg6 from "fast-glob";
8701
8955
  function detectInstallPlan(signals) {
8702
8956
  if (signals.hasYarnLock) {
8703
8957
  return signals.hasPackageManagerField ? { manager: "yarn", command: "corepack", args: ["yarn", "install"] } : { manager: "yarn", command: "yarn", args: ["install"] };
@@ -8717,11 +8971,11 @@ function detectInstallPlan(signals) {
8717
8971
  return { manager: "none" };
8718
8972
  }
8719
8973
  async function computeLockfileSignals(dir) {
8720
- const hasYarnLock = existsSync3(path52.join(dir, "yarn.lock"));
8721
- const hasPnpmLock = existsSync3(path52.join(dir, "pnpm-lock.yaml"));
8722
- const hasPackageLock = existsSync3(path52.join(dir, "package-lock.json"));
8723
- const hasComposerLock = existsSync3(path52.join(dir, "composer.lock"));
8724
- const dotnetFiles = await fg5(["**/*.csproj", "**/*.sln"], {
8974
+ const hasYarnLock = existsSync3(path53.join(dir, "yarn.lock"));
8975
+ const hasPnpmLock = existsSync3(path53.join(dir, "pnpm-lock.yaml"));
8976
+ const hasPackageLock = existsSync3(path53.join(dir, "package-lock.json"));
8977
+ const hasComposerLock = existsSync3(path53.join(dir, "composer.lock"));
8978
+ const dotnetFiles = await fg6(["**/*.csproj", "**/*.sln"], {
8725
8979
  cwd: dir,
8726
8980
  onlyFiles: true,
8727
8981
  suppressErrors: true,
@@ -8734,7 +8988,7 @@ async function computeLockfileSignals(dir) {
8734
8988
  });
8735
8989
  const hasDotnetProject = dotnetFiles.length > 0;
8736
8990
  let hasPackageManagerField = false;
8737
- const pkgPath = path52.join(dir, "package.json");
8991
+ const pkgPath = path53.join(dir, "package.json");
8738
8992
  if (existsSync3(pkgPath)) {
8739
8993
  try {
8740
8994
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
@@ -8775,13 +9029,13 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
8775
9029
  const start = Date.now();
8776
9030
  const targets = [];
8777
9031
  for (const target of HYDRATION_TARGETS) {
8778
- const src = path53.join(mainMemberPath, target);
8779
- const dest = path53.join(worktreeMemberPath, target);
8780
- if (!await fs38.pathExists(src)) continue;
8781
- const destExists = await fs38.pathExists(dest);
9032
+ const src = path54.join(mainMemberPath, target);
9033
+ const dest = path54.join(worktreeMemberPath, target);
9034
+ if (!await fs39.pathExists(src)) continue;
9035
+ const destExists = await fs39.pathExists(dest);
8782
9036
  if (!opts.force && destExists) continue;
8783
9037
  if (opts.force && destExists) {
8784
- await fs38.remove(dest);
9038
+ await fs39.remove(dest);
8785
9039
  }
8786
9040
  const copy = await cowCopyDir(src, dest);
8787
9041
  targets.push({ target, copy });
@@ -8794,11 +9048,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
8794
9048
 
8795
9049
  // src/workspace/worktree/local-files.ts
8796
9050
  init_exec();
8797
- import path54 from "node:path";
8798
- import fs39 from "fs-extra";
9051
+ import path55 from "node:path";
9052
+ import fs40 from "fs-extra";
8799
9053
  var MACHINE_LOCAL_FILES = [
8800
9054
  "docker-compose.local.yml",
8801
- path54.join(".claude", "settings.local.json")
9055
+ path55.join(".claude", "settings.local.json")
8802
9056
  ];
8803
9057
  async function isTracked(repoRoot, relFile) {
8804
9058
  const gitPath = relFile.replace(/\\/g, "/");
@@ -8808,12 +9062,12 @@ async function isTracked(repoRoot, relFile) {
8808
9062
  async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
8809
9063
  const copied = [];
8810
9064
  for (const rel of MACHINE_LOCAL_FILES) {
8811
- const src = path54.join(mainMemberPath, rel);
8812
- if (!await fs39.pathExists(src)) continue;
9065
+ const src = path55.join(mainMemberPath, rel);
9066
+ if (!await fs40.pathExists(src)) continue;
8813
9067
  if (await isTracked(mainMemberPath, rel)) continue;
8814
- const dest = path54.join(worktreeMemberPath, rel);
8815
- await fs39.ensureDir(path54.dirname(dest));
8816
- await fs39.copy(src, dest, { overwrite: true });
9068
+ const dest = path55.join(worktreeMemberPath, rel);
9069
+ await fs40.ensureDir(path55.dirname(dest));
9070
+ await fs40.copy(src, dest, { overwrite: true });
8817
9071
  copied.push(rel);
8818
9072
  }
8819
9073
  return copied;
@@ -8821,15 +9075,15 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
8821
9075
 
8822
9076
  // src/workspace/worktree/root.ts
8823
9077
  import { existsSync as existsSync4 } from "node:fs";
8824
- import path55 from "node:path";
9078
+ import path56 from "node:path";
8825
9079
  async function resolveWorkspaceForWorktree(start) {
8826
9080
  const rootInfo = await resolveRoots(start);
8827
9081
  if (!rootInfo.isGitRepo) {
8828
9082
  return { ok: false, reason: "Not inside a git repository." };
8829
9083
  }
8830
9084
  const workspaceRoot = rootInfo.mainRoot;
8831
- const hasYaml = existsSync4(path55.join(workspaceRoot, WORKSPACE_FILE));
8832
- const hasManifest = existsSync4(path55.join(workspaceRoot, REPOS_MANIFEST_FILE));
9085
+ const hasYaml = existsSync4(path56.join(workspaceRoot, WORKSPACE_FILE));
9086
+ const hasManifest = existsSync4(path56.join(workspaceRoot, REPOS_MANIFEST_FILE));
8833
9087
  if (!hasYaml && !hasManifest) {
8834
9088
  return {
8835
9089
  ok: false,
@@ -8854,19 +9108,31 @@ function selectMembers(members, only) {
8854
9108
  }
8855
9109
 
8856
9110
  // src/workspace/worktree/state.ts
8857
- import path56 from "node:path";
9111
+ import path57 from "node:path";
8858
9112
  var WORKTREE_STATE_FILE = ".haus-worktree.json";
8859
9113
  function worktreesDir(mainRoot) {
8860
- return path56.join(mainRoot, ".claude", "worktrees");
9114
+ return path57.join(mainRoot, ".claude", "worktrees");
9115
+ }
9116
+ function isValidWorktreeSlug(slug) {
9117
+ if (slug === "" || slug === "." || slug === "..") return false;
9118
+ if (slug.includes("/") || slug.includes("\\")) return false;
9119
+ return /^[A-Za-z0-9._-]+$/.test(slug);
8861
9120
  }
8862
9121
  function worktreePath(mainRoot, slug) {
8863
- return path56.join(worktreesDir(mainRoot), slug);
9122
+ if (!isValidWorktreeSlug(slug)) {
9123
+ throw new Error(
9124
+ `Invalid worktree slug ${JSON.stringify(slug)} \u2014 slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
9125
+ );
9126
+ }
9127
+ return path57.join(worktreesDir(mainRoot), slug);
8864
9128
  }
8865
9129
  async function readWorktreeState(worktreeRoot) {
8866
- return readJson(path56.join(worktreeRoot, WORKTREE_STATE_FILE));
9130
+ const state = await readJson(path57.join(worktreeRoot, WORKTREE_STATE_FILE));
9131
+ if (state == null || typeof state !== "object" || !Array.isArray(state.members)) return void 0;
9132
+ return state;
8867
9133
  }
8868
9134
  async function writeWorktreeState(worktreeRoot, state) {
8869
- await writeJson(path56.join(worktreeRoot, WORKTREE_STATE_FILE), state);
9135
+ await writeJson(path57.join(worktreeRoot, WORKTREE_STATE_FILE), state);
8870
9136
  }
8871
9137
 
8872
9138
  // src/workspace/worktree/add.ts
@@ -8881,7 +9147,7 @@ async function runAdd(opts) {
8881
9147
  }
8882
9148
  const branch = opts.branch ?? opts.slug;
8883
9149
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
8884
- if (await fs40.pathExists(wsWorktreePath)) {
9150
+ if (await fs41.pathExists(wsWorktreePath)) {
8885
9151
  return {
8886
9152
  ok: false,
8887
9153
  error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate --materialize\` from inside it to check out any missing members (e.g. a worktree created by plain \`git worktree add\`), or \`remove\` first to start over.`
@@ -8900,7 +9166,7 @@ async function runAdd(opts) {
8900
9166
  const memberResults = [];
8901
9167
  for (const member of selected) {
8902
9168
  const start = Date.now();
8903
- const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
9169
+ const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
8904
9170
  const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
8905
9171
  preferDefaultBranchFrom: true
8906
9172
  });
@@ -8930,7 +9196,7 @@ async function runAdd(opts) {
8930
9196
  await mapWithConcurrency(okResults, async (result) => {
8931
9197
  const member = selected.find((m) => m.id === result.member);
8932
9198
  if (!member) return;
8933
- const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
9199
+ const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
8934
9200
  const hydrationStart = Date.now();
8935
9201
  if (opts.hydrate !== false) {
8936
9202
  result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
@@ -8997,10 +9263,10 @@ async function runDryRun(args) {
8997
9263
 
8998
9264
  // src/workspace/worktree/doctor.ts
8999
9265
  import { existsSync as existsSync5 } from "node:fs";
9000
- import path58 from "node:path";
9266
+ import path59 from "node:path";
9001
9267
  var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
9002
9268
  function looksHydrated(memberPath) {
9003
- return HYDRATION_MARKERS.some((marker) => existsSync5(path58.join(memberPath, marker)));
9269
+ return HYDRATION_MARKERS.some((marker) => existsSync5(path59.join(memberPath, marker)));
9004
9270
  }
9005
9271
  async function runDoctor2() {
9006
9272
  const rootInfo = await resolveRoots();
@@ -9014,7 +9280,7 @@ async function runDoctor2() {
9014
9280
  };
9015
9281
  }
9016
9282
  const workspaceRoot = rootInfo.mainRoot;
9017
- const isWorkspace = existsSync5(path58.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path58.join(workspaceRoot, REPOS_MANIFEST_FILE));
9283
+ const isWorkspace = existsSync5(path59.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path59.join(workspaceRoot, REPOS_MANIFEST_FILE));
9018
9284
  if (!isWorkspace) {
9019
9285
  return {
9020
9286
  isWorkspace: false,
@@ -9027,7 +9293,7 @@ async function runDoctor2() {
9027
9293
  };
9028
9294
  }
9029
9295
  const wsWorktreesDir = worktreesDir(workspaceRoot);
9030
- const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path58.sep}`);
9296
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path59.sep}`);
9031
9297
  const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
9032
9298
  const members = await readMembers(rootInfo);
9033
9299
  const problems = [];
@@ -9035,10 +9301,10 @@ async function runDoctor2() {
9035
9301
  let slug;
9036
9302
  if (inWorkspaceWorktree) {
9037
9303
  const wtPath = rootInfo.repoRoot;
9038
- slug = rootInfo.worktreeName ?? path58.basename(wtPath);
9304
+ slug = rootInfo.worktreeName ?? path59.basename(wtPath);
9039
9305
  const state = await readWorktreeState(wtPath);
9040
9306
  for (const member of members) {
9041
- const memberPath = path58.join(wtPath, member.folder);
9307
+ const memberPath = path59.join(wtPath, member.folder);
9042
9308
  const materialized = existsSync5(memberPath);
9043
9309
  const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
9044
9310
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
@@ -9069,16 +9335,16 @@ async function runDoctor2() {
9069
9335
  for (const member of members) {
9070
9336
  const entries = await listWorktrees(member.absPath);
9071
9337
  for (const entry of entries) {
9072
- const entryPath = path58.normalize(entry.path);
9073
- const marker = `${path58.sep}.claude${path58.sep}worktrees${path58.sep}`;
9338
+ const entryPath = path59.normalize(entry.path);
9339
+ const marker = `${path59.sep}.claude${path59.sep}worktrees${path59.sep}`;
9074
9340
  const idx = entryPath.indexOf(marker);
9075
9341
  if (idx === -1) continue;
9076
9342
  const rest = entryPath.slice(idx + marker.length);
9077
- const slugName = rest.split(path58.sep)[0];
9343
+ const slugName = rest.split(path59.sep)[0];
9078
9344
  if (!slugName) continue;
9079
- if (!existsSync5(path58.join(wsWorktreesDir, slugName))) {
9345
+ if (!existsSync5(path59.join(wsWorktreesDir, slugName))) {
9080
9346
  orphans.push(
9081
- `${member.id}: worktree registered at ${entry.path} but ${path58.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
9347
+ `${member.id}: worktree registered at ${entry.path} but ${path59.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
9082
9348
  );
9083
9349
  }
9084
9350
  }
@@ -9089,7 +9355,7 @@ async function runDoctor2() {
9089
9355
 
9090
9356
  // src/workspace/worktree/hydrate-command.ts
9091
9357
  import { existsSync as existsSync6 } from "node:fs";
9092
- import path59 from "node:path";
9358
+ import path60 from "node:path";
9093
9359
  async function runHydrateWorktree(opts) {
9094
9360
  const rootInfo = await resolveRoots();
9095
9361
  if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
@@ -9100,7 +9366,7 @@ async function runHydrateWorktree(opts) {
9100
9366
  }
9101
9367
  const workspaceRoot = rootInfo.mainRoot;
9102
9368
  const wtPath = rootInfo.repoRoot;
9103
- if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path59.sep}`)) {
9369
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path60.sep}`)) {
9104
9370
  return {
9105
9371
  ok: false,
9106
9372
  error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
@@ -9112,13 +9378,13 @@ async function runHydrateWorktree(opts) {
9112
9378
  return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
9113
9379
  }
9114
9380
  const state = await readWorktreeState(wtPath);
9115
- const slug = state?.slug ?? path59.basename(wtPath);
9381
+ const slug = state?.slug ?? path60.basename(wtPath);
9116
9382
  const detectedBranch = await currentBranch(wtPath);
9117
9383
  const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
9118
9384
  if (opts.dryRun) {
9119
9385
  const results2 = await Promise.all(
9120
9386
  selected.map(async (member) => {
9121
- const memberPath = path59.join(wtPath, member.folder);
9387
+ const memberPath = path60.join(wtPath, member.folder);
9122
9388
  if (existsSync6(memberPath)) {
9123
9389
  return { member: member.id, folder: member.folder, status: "planned" };
9124
9390
  }
@@ -9139,7 +9405,7 @@ async function runHydrateWorktree(opts) {
9139
9405
  const results = await mapWithConcurrency(
9140
9406
  selected,
9141
9407
  async (member) => {
9142
- const memberPath = path59.join(wtPath, member.folder);
9408
+ const memberPath = path60.join(wtPath, member.folder);
9143
9409
  let branchAction;
9144
9410
  if (!existsSync6(memberPath)) {
9145
9411
  if (!opts.materialize) {
@@ -9202,26 +9468,26 @@ async function runHydrateWorktree(opts) {
9202
9468
  }
9203
9469
 
9204
9470
  // src/workspace/worktree/list.ts
9205
- import path60 from "node:path";
9206
- import fs41 from "fs-extra";
9471
+ import path61 from "node:path";
9472
+ import fs42 from "fs-extra";
9207
9473
  async function runList2() {
9208
9474
  const resolved = await resolveWorkspaceForWorktree();
9209
9475
  if (!resolved.ok) return { ok: false, error: resolved.reason };
9210
9476
  const { rootInfo, workspaceRoot } = resolved;
9211
9477
  const dir = worktreesDir(workspaceRoot);
9212
- if (!await fs41.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
9213
- const entries = await fs41.readdir(dir, { withFileTypes: true });
9478
+ if (!await fs42.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
9479
+ const entries = await fs42.readdir(dir, { withFileTypes: true });
9214
9480
  const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
9215
9481
  const configuredMembers = await readMembers(rootInfo);
9216
9482
  const worktrees = [];
9217
9483
  for (const slug of slugs) {
9218
- const wtPath = path60.join(dir, slug);
9484
+ const wtPath = path61.join(dir, slug);
9219
9485
  const state = await readWorktreeState(wtPath);
9220
9486
  const members = [];
9221
9487
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
9222
9488
  for (const m of source) {
9223
- const memberPath = path60.join(wtPath, m.folder);
9224
- const materialized = await fs41.pathExists(memberPath);
9489
+ const memberPath = path61.join(wtPath, m.folder);
9490
+ const materialized = await fs42.pathExists(memberPath);
9225
9491
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
9226
9492
  members.push({
9227
9493
  id: m.id,
@@ -9242,14 +9508,14 @@ async function runList2() {
9242
9508
  }
9243
9509
 
9244
9510
  // src/workspace/worktree/remove.ts
9245
- import path61 from "node:path";
9246
- import fs42 from "fs-extra";
9511
+ import path62 from "node:path";
9512
+ import fs43 from "fs-extra";
9247
9513
  async function runRemove(opts) {
9248
9514
  const resolved = await resolveWorkspaceForWorktree();
9249
9515
  if (!resolved.ok) return { ok: false, error: resolved.reason };
9250
9516
  const { rootInfo, workspaceRoot } = resolved;
9251
9517
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
9252
- if (!await fs42.pathExists(wsWorktreePath)) {
9518
+ if (!await fs43.pathExists(wsWorktreePath)) {
9253
9519
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
9254
9520
  }
9255
9521
  const state = await readWorktreeState(wsWorktreePath);
@@ -9269,12 +9535,12 @@ async function runRemove(opts) {
9269
9535
  targets.push({
9270
9536
  repo: configMember?.id ?? stateMember.id,
9271
9537
  ownerRepo,
9272
- path: path61.join(wsWorktreePath, stateMember.folder)
9538
+ path: path62.join(wsWorktreePath, stateMember.folder)
9273
9539
  });
9274
9540
  }
9275
9541
  const existingTargets = [];
9276
9542
  for (const target of targets) {
9277
- if (await fs42.pathExists(target.path)) existingTargets.push(target);
9543
+ if (await fs43.pathExists(target.path)) existingTargets.push(target);
9278
9544
  }
9279
9545
  if (!opts.force) {
9280
9546
  const blockers = [];
@@ -9319,14 +9585,19 @@ async function runRemove(opts) {
9319
9585
  await pruneWorktrees(target.ownerRepo);
9320
9586
  }
9321
9587
  const wsTarget = existingTargets.find((t) => t.repo === "(workspace)");
9588
+ let wsRemoveOk = false;
9322
9589
  if (wsTarget) {
9323
9590
  const wsResult = await removeWorktree(workspaceRoot, wsWorktreePath);
9324
- if (wsResult.ok) removed.push("(workspace)");
9325
- else failed.push({ repo: "(workspace)", error: wsResult.stderr ?? "unknown error" });
9591
+ if (wsResult.ok) {
9592
+ removed.push("(workspace)");
9593
+ wsRemoveOk = true;
9594
+ } else {
9595
+ failed.push({ repo: "(workspace)", error: wsResult.stderr ?? "unknown error" });
9596
+ }
9326
9597
  await pruneWorktrees(workspaceRoot);
9327
9598
  }
9328
- if (await fs42.pathExists(wsWorktreePath)) {
9329
- await fs42.remove(wsWorktreePath);
9599
+ if (wsRemoveOk && await fs43.pathExists(wsWorktreePath)) {
9600
+ await fs43.remove(wsWorktreePath);
9330
9601
  }
9331
9602
  return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
9332
9603
  }
@@ -9541,7 +9812,7 @@ async function runWorktree(action, opts = {}) {
9541
9812
 
9542
9813
  // src/commands/workspace.ts
9543
9814
  import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
9544
- import path70 from "node:path";
9815
+ import path71 from "node:path";
9545
9816
 
9546
9817
  // src/commands/workspace/aggregate.ts
9547
9818
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -9596,18 +9867,18 @@ ${summaries.map(
9596
9867
 
9597
9868
  // src/commands/workspace/doctor.ts
9598
9869
  import { existsSync as existsSync7 } from "node:fs";
9599
- import path63 from "node:path";
9870
+ import path64 from "node:path";
9600
9871
 
9601
9872
  // src/commands/workspace/manifest.ts
9602
9873
  import { readFileSync as readFileSync4 } from "node:fs";
9603
- import path62 from "node:path";
9874
+ import path63 from "node:path";
9604
9875
  var MANIFEST_FILE = "workspace.manifest.json";
9605
9876
  function manifestPath(workspaceRoot) {
9606
9877
  return hausPath(workspaceRoot, MANIFEST_FILE);
9607
9878
  }
9608
9879
  function hausVersion() {
9609
9880
  try {
9610
- const pkg = JSON.parse(readFileSync4(path62.join(packageRoot(), "package.json"), "utf8"));
9881
+ const pkg = JSON.parse(readFileSync4(path63.join(packageRoot(), "package.json"), "utf8"));
9611
9882
  return pkg.version ?? "0.0.0";
9612
9883
  } catch {
9613
9884
  return "0.0.0";
@@ -9688,10 +9959,18 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
9688
9959
  });
9689
9960
  return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
9690
9961
  }
9962
+ if (!Array.isArray(manifest.repos)) {
9963
+ flag({
9964
+ repo: "(workspace)",
9965
+ kind: "no-manifest",
9966
+ detail: 'workspace.manifest.json\'s "repos" field is not an array \u2014 run `haus workspace setup --write` to regenerate it.'
9967
+ });
9968
+ return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
9969
+ }
9691
9970
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
9692
9971
  const catalogRefByRepo = [];
9693
9972
  for (const repo of config.repos) {
9694
- const repoRoot = path63.resolve(workspaceRoot, repo.path);
9973
+ const repoRoot = path64.resolve(workspaceRoot, repo.path);
9695
9974
  const entry = manifestByName.get(repo.name);
9696
9975
  if (!entry) {
9697
9976
  flag({
@@ -9764,7 +10043,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
9764
10043
  });
9765
10044
  continue;
9766
10045
  }
9767
- if (!existsSync7(path63.join(workspaceRoot, entry.path))) {
10046
+ if (!existsSync7(path64.join(workspaceRoot, entry.path))) {
9768
10047
  flag({
9769
10048
  repo: entry.repo,
9770
10049
  kind: "missing-linked-context-copy",
@@ -9807,16 +10086,16 @@ function emit(args) {
9807
10086
  }
9808
10087
 
9809
10088
  // src/workspace/link-context/link.ts
10089
+ import path67 from "node:path";
10090
+ import fs46 from "fs-extra";
10091
+
10092
+ // src/workspace/link-context/plan.ts
9810
10093
  import path66 from "node:path";
9811
10094
  import fs45 from "fs-extra";
9812
10095
 
9813
- // src/workspace/link-context/plan.ts
10096
+ // src/workspace/link-context/scan-source.ts
9814
10097
  import path65 from "node:path";
9815
10098
  import fs44 from "fs-extra";
9816
-
9817
- // src/workspace/link-context/scan-source.ts
9818
- import path64 from "node:path";
9819
- import fs43 from "fs-extra";
9820
10099
  var ASSET_SUBDIR = {
9821
10100
  skill: "skills",
9822
10101
  agent: "agents",
@@ -9824,36 +10103,36 @@ var ASSET_SUBDIR = {
9824
10103
  };
9825
10104
  async function hasLinkableContext(member) {
9826
10105
  for (const subdir of Object.values(ASSET_SUBDIR)) {
9827
- if (await fs43.pathExists(path64.join(member.absPath, ".claude", subdir))) return true;
10106
+ if (await fs44.pathExists(path65.join(member.absPath, ".claude", subdir))) return true;
9828
10107
  }
9829
10108
  return false;
9830
10109
  }
9831
10110
  async function collectSkills(member) {
9832
- const dir = path64.join(member.absPath, ".claude", "skills");
9833
- if (!await fs43.pathExists(dir)) return [];
9834
- const names = await fs43.readdir(dir);
10111
+ const dir = path65.join(member.absPath, ".claude", "skills");
10112
+ if (!await fs44.pathExists(dir)) return [];
10113
+ const names = await fs44.readdir(dir);
9835
10114
  const out = [];
9836
10115
  for (const name of [...names].sort()) {
9837
- const abs = path64.join(dir, name);
9838
- const stat = await fs43.lstat(abs);
10116
+ const abs = path65.join(dir, name);
10117
+ const stat = await fs44.lstat(abs);
9839
10118
  if (!stat.isDirectory()) continue;
9840
- if (!await fs43.pathExists(path64.join(abs, "SKILL.md"))) continue;
9841
- out.push({ type: "skill", name, sourceRelPath: path64.posix.join(".claude", "skills", name) });
10119
+ if (!await fs44.pathExists(path65.join(abs, "SKILL.md"))) continue;
10120
+ out.push({ type: "skill", name, sourceRelPath: path65.posix.join(".claude", "skills", name) });
9842
10121
  }
9843
10122
  return out;
9844
10123
  }
9845
10124
  async function collectFlatMarkdown(member, type) {
9846
10125
  const subdir = ASSET_SUBDIR[type];
9847
- const dir = path64.join(member.absPath, ".claude", subdir);
9848
- if (!await fs43.pathExists(dir)) return [];
9849
- const names = await fs43.readdir(dir);
10126
+ const dir = path65.join(member.absPath, ".claude", subdir);
10127
+ if (!await fs44.pathExists(dir)) return [];
10128
+ const names = await fs44.readdir(dir);
9850
10129
  const out = [];
9851
10130
  for (const fileName of [...names].sort()) {
9852
10131
  if (!fileName.endsWith(".md")) continue;
9853
- const abs = path64.join(dir, fileName);
9854
- if (!(await fs43.lstat(abs)).isFile()) continue;
10132
+ const abs = path65.join(dir, fileName);
10133
+ if (!(await fs44.lstat(abs)).isFile()) continue;
9855
10134
  const name = fileName.slice(0, -".md".length);
9856
- out.push({ type, name, sourceRelPath: path64.posix.join(".claude", subdir, fileName) });
10135
+ out.push({ type, name, sourceRelPath: path65.posix.join(".claude", subdir, fileName) });
9857
10136
  }
9858
10137
  return out;
9859
10138
  }
@@ -9868,9 +10147,9 @@ async function collectSourceAssets(member) {
9868
10147
 
9869
10148
  // src/workspace/link-context/plan.ts
9870
10149
  function repoFolderPrefix(member) {
9871
- const base = path65.posix.basename(member.folder);
10150
+ const base = path66.posix.basename(member.folder);
9872
10151
  if (base && base !== "." && base !== "/") return base;
9873
- const fromAbs = path65.basename(member.absPath);
10152
+ const fromAbs = path66.basename(member.absPath);
9874
10153
  return fromAbs || member.id;
9875
10154
  }
9876
10155
  function assetKey(type, destKey) {
@@ -9885,7 +10164,7 @@ async function buildLinkPlan(members) {
9885
10164
  const byKey = /* @__PURE__ */ new Map();
9886
10165
  const membersById = new Map(members.map((m) => [m.id, m]));
9887
10166
  for (const member of members) {
9888
- if (!await fs44.pathExists(member.absPath)) {
10167
+ if (!await fs45.pathExists(member.absPath)) {
9889
10168
  skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
9890
10169
  continue;
9891
10170
  }
@@ -9927,7 +10206,7 @@ async function buildLinkPlan(members) {
9927
10206
  name: asset.name,
9928
10207
  destKey,
9929
10208
  sourceRelPath: asset.sourceRelPath,
9930
- sourceAbsPath: path65.join(member.absPath, asset.sourceRelPath),
10209
+ sourceAbsPath: path66.join(member.absPath, asset.sourceRelPath),
9931
10210
  sourceHash
9932
10211
  });
9933
10212
  }
@@ -9938,7 +10217,7 @@ async function buildLinkPlan(members) {
9938
10217
  function destRelPathFor(type, destKey) {
9939
10218
  const subdir = `${type}s`;
9940
10219
  const leaf = type === "skill" ? destKey : `${destKey}.md`;
9941
- return path66.posix.join(".claude", subdir, leaf);
10220
+ return path67.posix.join(".claude", subdir, leaf);
9942
10221
  }
9943
10222
  async function runLinkContext(workspaceRoot, opts = {}) {
9944
10223
  const write = opts.write ?? false;
@@ -9964,17 +10243,17 @@ async function runLinkContext(workspaceRoot, opts = {}) {
9964
10243
  const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
9965
10244
  for (const entry of plan.entries) {
9966
10245
  const destRel = destRelPathFor(entry.type, entry.destKey);
9967
- const destAbs = path66.join(workspaceRoot, destRel);
10246
+ const destAbs = path67.join(workspaceRoot, destRel);
9968
10247
  if (write) {
9969
- await fs45.ensureDir(path66.dirname(destAbs));
9970
- if (await fs45.pathExists(destAbs)) await fs45.remove(destAbs);
9971
- await fs45.copy(entry.sourceAbsPath, destAbs, {
10248
+ await fs46.ensureDir(path67.dirname(destAbs));
10249
+ if (await fs46.pathExists(destAbs)) await fs46.remove(destAbs);
10250
+ await fs46.copy(entry.sourceAbsPath, destAbs, {
9972
10251
  overwrite: true,
9973
10252
  errorOnExist: false,
9974
10253
  dereference: false,
9975
10254
  filter: (s) => {
9976
10255
  try {
9977
- return !fs45.lstatSync(s).isSymbolicLink();
10256
+ return !fs46.lstatSync(s).isSymbolicLink();
9978
10257
  } catch {
9979
10258
  return false;
9980
10259
  }
@@ -9998,8 +10277,12 @@ async function runLinkContext(workspaceRoot, opts = {}) {
9998
10277
  if (nextPaths.has(old.path)) continue;
9999
10278
  removed.push(old.path);
10000
10279
  if (write) {
10001
- const abs = path66.join(workspaceRoot, old.path);
10002
- if (await fs45.pathExists(abs)) await fs45.remove(abs);
10280
+ const abs = resolveContainedPath(workspaceRoot, old.path);
10281
+ if (abs === null) {
10282
+ warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
10283
+ continue;
10284
+ }
10285
+ if (await fs46.pathExists(abs)) await fs46.remove(abs);
10003
10286
  }
10004
10287
  }
10005
10288
  if (write) {
@@ -10051,11 +10334,11 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
10051
10334
 
10052
10335
  // src/commands/workspace/setup.ts
10053
10336
  import { existsSync as existsSync8, statSync } from "node:fs";
10054
- import path68 from "node:path";
10337
+ import path69 from "node:path";
10055
10338
 
10056
10339
  // src/claude/write-workspace-claude-md.ts
10057
- import path67 from "node:path";
10058
- import fs46 from "fs-extra";
10340
+ import path68 from "node:path";
10341
+ import fs47 from "fs-extra";
10059
10342
  var WORKSPACE_SENTINELS = { begin: WORKSPACE_BLOCK_BEGIN, end: WORKSPACE_BLOCK_END };
10060
10343
  function buildWorkspaceImportBlock(client, members) {
10061
10344
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
@@ -10074,11 +10357,11 @@ ${WORKSPACE_BLOCK_END}`;
10074
10357
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
10075
10358
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
10076
10359
  const dryRun = opts.dryRun ?? false;
10077
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path67.join(workspaceRoot, "CLAUDE.md");
10360
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path68.join(workspaceRoot, "CLAUDE.md");
10078
10361
  const say = opts.quiet ? () => {
10079
10362
  } : log;
10080
- const existed = await fs46.pathExists(filePath);
10081
- const prev = existed ? await fs46.readFile(filePath, "utf8") : "";
10363
+ const existed = await fs47.pathExists(filePath);
10364
+ const prev = existed ? await fs47.readFile(filePath, "utf8") : "";
10082
10365
  const next = opts.collision ? `${block}
10083
10366
  ` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
10084
10367
  const printable = displayPath(workspaceRoot, filePath);
@@ -10105,20 +10388,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
10105
10388
 
10106
10389
  // src/commands/workspace/setup.ts
10107
10390
  function resolveWorkspaceRoot(start = process.cwd()) {
10108
- let dir = path68.resolve(start);
10391
+ let dir = path69.resolve(start);
10109
10392
  for (; ; ) {
10110
- if (existsSync8(path68.join(dir, WORKSPACE_FILE))) return dir;
10111
- const parent = path68.dirname(dir);
10112
- if (parent === dir) return path68.resolve(start);
10393
+ if (existsSync8(path69.join(dir, WORKSPACE_FILE))) return dir;
10394
+ const parent = path69.dirname(dir);
10395
+ if (parent === dir) return path69.resolve(start);
10113
10396
  dir = parent;
10114
10397
  }
10115
10398
  }
10116
10399
  function isRootRepo(workspaceRoot, repoPath) {
10117
- return path68.resolve(workspaceRoot, repoPath) === path68.resolve(workspaceRoot);
10400
+ return path69.resolve(workspaceRoot, repoPath) === path69.resolve(workspaceRoot);
10118
10401
  }
10119
10402
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
10120
10403
  const apply = options.write ?? false;
10121
- const configText = await readText(path68.join(workspaceRoot, WORKSPACE_FILE));
10404
+ const configText = await readText(path69.join(workspaceRoot, WORKSPACE_FILE));
10122
10405
  if (!configText) {
10123
10406
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
10124
10407
  process.exitCode = 1;
@@ -10145,7 +10428,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10145
10428
  const statuses = [];
10146
10429
  const aggregateInputs = [];
10147
10430
  for (const repo of repos) {
10148
- const repoRoot = path68.resolve(workspaceRoot, repo.path);
10431
+ const repoRoot = path69.resolve(workspaceRoot, repo.path);
10149
10432
  say(`
10150
10433
  \u2192 ${repo.name} (${repo.path})`);
10151
10434
  try {
@@ -10178,7 +10461,15 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10178
10461
  status: "failed",
10179
10462
  error: message
10180
10463
  });
10181
- if (!options.continueOnError) throw err;
10464
+ if (!options.continueOnError) {
10465
+ if (options.json) {
10466
+ const result2 = { workspaceRoot, statuses, written: [] };
10467
+ log(JSON.stringify(result2, null, 2));
10468
+ process.exitCode = 1;
10469
+ return result2;
10470
+ }
10471
+ throw err;
10472
+ }
10182
10473
  error(`Setup failed for ${repo.name}: ${message}`);
10183
10474
  }
10184
10475
  }
@@ -10227,7 +10518,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10227
10518
  const status = statusByName.get(repo.name);
10228
10519
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
10229
10520
  if (status?.status === "ok") {
10230
- const lock = await checkLock(path68.resolve(workspaceRoot, repo.path));
10521
+ const lock = await checkLock(path69.resolve(workspaceRoot, repo.path));
10231
10522
  manifestRepos.push({
10232
10523
  name: repo.name,
10233
10524
  path: repo.path,
@@ -10283,7 +10574,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
10283
10574
  try {
10284
10575
  const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
10285
10576
  if (linkResult.ok) {
10286
- written.push(...linkResult.added.map((rel) => path68.join(workspaceRoot, rel)));
10577
+ written.push(...linkResult.added.map((rel) => path69.join(workspaceRoot, rel)));
10287
10578
  if (!options.json) {
10288
10579
  say(
10289
10580
  `Linked ${linkResult.linked.length} cross-repo skill/agent/command copy(ies) into .claude/ (${linkResult.added.length} new, ${linkResult.removed.length} removed)${linkResult.skipped.length > 0 ? ` \u2014 ${linkResult.skipped.length} member(s) skipped, see above` : ""}.`
@@ -10315,8 +10606,8 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
10315
10606
 
10316
10607
  // src/commands/workspace/undo.ts
10317
10608
  import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
10318
- import path69 from "node:path";
10319
- import fs47 from "fs-extra";
10609
+ import path70 from "node:path";
10610
+ import fs48 from "fs-extra";
10320
10611
  var WORKSPACE_AGGREGATE_FILES = [
10321
10612
  "workspace-summary.json",
10322
10613
  "dependency-ownership-map.json",
@@ -10352,7 +10643,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10352
10643
  }
10353
10644
  let anyRepoFailed = false;
10354
10645
  for (const repo of config.repos) {
10355
- const repoRoot = path69.resolve(workspaceRoot, repo.path);
10646
+ const repoRoot = path70.resolve(workspaceRoot, repo.path);
10356
10647
  log(`
10357
10648
  \u2192 ${repo.name} (${repo.path})`);
10358
10649
  try {
@@ -10367,30 +10658,34 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10367
10658
  }
10368
10659
  const linkedContext = await readLinkedContext(workspaceRoot);
10369
10660
  for (const entry of linkedContext) {
10370
- const abs = path69.join(workspaceRoot, entry.path);
10371
- if (await fs47.pathExists(abs)) {
10372
- await fs47.remove(abs);
10661
+ const abs = resolveContainedPath(workspaceRoot, entry.path);
10662
+ if (abs === null) {
10663
+ warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
10664
+ continue;
10665
+ }
10666
+ if (await fs48.pathExists(abs)) {
10667
+ await fs48.remove(abs);
10373
10668
  log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
10374
10669
  }
10375
10670
  }
10376
10671
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
10377
10672
  const abs = hausPath(workspaceRoot, rel);
10378
- if (await fs47.pathExists(abs)) {
10379
- await fs47.remove(abs);
10380
- log(`Removed ${path69.relative(workspaceRoot, abs)}`);
10673
+ if (await fs48.pathExists(abs)) {
10674
+ await fs48.remove(abs);
10675
+ log(`Removed ${path70.relative(workspaceRoot, abs)}`);
10381
10676
  }
10382
10677
  }
10383
10678
  const manifestFile = manifestPath(workspaceRoot);
10384
- if (await fs47.pathExists(manifestFile)) {
10385
- await fs47.remove(manifestFile);
10386
- log(`Removed ${path69.relative(workspaceRoot, manifestFile)}`);
10679
+ if (await fs48.pathExists(manifestFile)) {
10680
+ await fs48.remove(manifestFile);
10681
+ log(`Removed ${path70.relative(workspaceRoot, manifestFile)}`);
10387
10682
  }
10388
10683
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
10389
- if (await fs47.pathExists(workspaceMdPath)) {
10390
- await fs47.remove(workspaceMdPath);
10391
- log(`Removed ${path69.relative(workspaceRoot, workspaceMdPath)}`);
10684
+ if (await fs48.pathExists(workspaceMdPath)) {
10685
+ await fs48.remove(workspaceMdPath);
10686
+ log(`Removed ${path70.relative(workspaceRoot, workspaceMdPath)}`);
10392
10687
  } else {
10393
- const claudeMdPath = path69.join(workspaceRoot, "CLAUDE.md");
10688
+ const claudeMdPath = path70.join(workspaceRoot, "CLAUDE.md");
10394
10689
  if (existsSync9(claudeMdPath)) {
10395
10690
  const prev = await readText(claudeMdPath);
10396
10691
  if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
@@ -10399,19 +10694,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
10399
10694
  end: WORKSPACE_BLOCK_END
10400
10695
  });
10401
10696
  if (next.length === 0) {
10402
- await fs47.remove(claudeMdPath);
10697
+ await fs48.remove(claudeMdPath);
10403
10698
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
10404
10699
  } else {
10405
- await fs47.writeFile(claudeMdPath, next, "utf8");
10700
+ await fs48.writeFile(claudeMdPath, next, "utf8");
10406
10701
  log("Removed haus import block from CLAUDE.md (user content preserved).");
10407
10702
  }
10408
10703
  } else if (prev && hasLegacyWorkspaceBlock(prev)) {
10409
10704
  const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
10410
10705
  if (next.length === 0) {
10411
- await fs47.remove(claudeMdPath);
10706
+ await fs48.remove(claudeMdPath);
10412
10707
  log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
10413
10708
  } else {
10414
- await fs47.writeFile(claudeMdPath, next, "utf8");
10709
+ await fs48.writeFile(claudeMdPath, next, "utf8");
10415
10710
  log(
10416
10711
  "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
10417
10712
  );
@@ -10442,7 +10737,16 @@ function normalizeMaxDepth(maxDepth) {
10442
10737
  warn(`Ignoring invalid --max-depth "${String(maxDepth)}"; using the default depth.`);
10443
10738
  return void 0;
10444
10739
  }
10445
- async function initWorkspace() {
10740
+ async function initWorkspace(options = {}) {
10741
+ if (existsSync10(WORKSPACE_FILE) && !options.yes) {
10742
+ const ok = await confirm(
10743
+ `${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.`
10744
+ );
10745
+ if (!ok) {
10746
+ log("Cancelled.");
10747
+ return;
10748
+ }
10749
+ }
10446
10750
  await writeText(
10447
10751
  WORKSPACE_FILE,
10448
10752
  `client: unknown
@@ -10456,7 +10760,7 @@ relationships: []
10456
10760
  log("Workspace initialized.");
10457
10761
  }
10458
10762
  async function scanWorkspace(workspaceRoot, opts) {
10459
- const configText = await readText(path70.join(workspaceRoot, WORKSPACE_FILE));
10763
+ const configText = await readText(path71.join(workspaceRoot, WORKSPACE_FILE));
10460
10764
  if (!configText) {
10461
10765
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
10462
10766
  process.exitCode = 1;
@@ -10478,7 +10782,7 @@ async function scanWorkspace(workspaceRoot, opts) {
10478
10782
  const inputs = [];
10479
10783
  const failures = [];
10480
10784
  for (const repo of config.repos) {
10481
- const repoRoot = path70.resolve(workspaceRoot, repo.path);
10785
+ const repoRoot = path71.resolve(workspaceRoot, repo.path);
10482
10786
  try {
10483
10787
  if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
10484
10788
  throw new Error("path is not a directory");
@@ -10504,7 +10808,7 @@ async function scanWorkspace(workspaceRoot, opts) {
10504
10808
  }
10505
10809
  async function runWorkspace(action, options = {}) {
10506
10810
  if (action === "init") {
10507
- await initWorkspace();
10811
+ await initWorkspace({ yes: options.yes });
10508
10812
  return;
10509
10813
  }
10510
10814
  const workspaceRoot = resolveWorkspaceRoot();
@@ -10544,7 +10848,7 @@ async function runWorkspace(action, options = {}) {
10544
10848
  // src/cli.ts
10545
10849
  function cliVersion() {
10546
10850
  try {
10547
- const pkgPath = path71.join(packageRoot(), "package.json");
10851
+ const pkgPath = path72.join(packageRoot(), "package.json");
10548
10852
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
10549
10853
  return pkg.version ?? "0.0.0";
10550
10854
  } catch {
@@ -10554,7 +10858,7 @@ function cliVersion() {
10554
10858
  var program = new Command();
10555
10859
  function validateRuntimeNodeVersion() {
10556
10860
  try {
10557
- const pkgPath = path71.join(packageRoot(), "package.json");
10861
+ const pkgPath = path72.join(packageRoot(), "package.json");
10558
10862
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
10559
10863
  const requiredRange = pkg.engines?.node;
10560
10864
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -10615,7 +10919,7 @@ program.command("install").option("--dry-run").option("--force").option("--check
10615
10919
  program.command("scaffold [ids...]").description(
10616
10920
  "Copy catalog config items (ESLint, Prettier) into the project root. Pass item IDs to scaffold specific items, or omit to scaffold all available config items."
10617
10921
  ).option("--force", "Overwrite existing files").option("--dry-run", "Preview what would be written without changing anything").option("--root <path>", "Project root (defaults to cwd)", process.cwd()).action(runScaffold);
10618
- program.command("uninstall").option("--force").action(runUninstallCommand);
10922
+ program.command("uninstall").option("--force").option("-y, --yes", "Skip confirmation").action(runUninstallCommand);
10619
10923
  var guard = program.command("guard");
10620
10924
  guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
10621
10925
  guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
@@ -10632,7 +10936,7 @@ function registerDecisionsCommand(name) {
10632
10936
  registerDecisionsCommand("decisions");
10633
10937
  registerDecisionsCommand("adr");
10634
10938
  var workspace = program.command("workspace");
10635
- workspace.command("init").action(() => runWorkspace("init"));
10939
+ workspace.command("init").option("-y, --yes", "Skip confirmation when haus.workspace.yaml already exists").action((opts) => runWorkspace("init", opts));
10636
10940
  workspace.command("discover").description("Auto-find member repos and write/merge haus.workspace.yaml").option("--write", "Persist haus.workspace.yaml (default previews only)").option("--json", "Output the discovered repos and proposed config as JSON").option("--max-depth <n>", "Max directory depth to traverse (default 3)").option("--client <name>", "Set the workspace client name").action((opts) => runWorkspace("discover", opts));
10637
10941
  workspace.command("scan").description("Aggregate a cross-repo summary from a fast scan of each repo").option("--json", "Output the written artifact paths as JSON").action((opts) => runWorkspace("scan", opts));
10638
10942
  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));