@haus-tech/haus-workflow 0.31.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,18 +1,74 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res, err) => function __init() {
5
+ if (err) throw err[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err = [e], e;
10
+ }
11
+ };
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
16
+
17
+ // src/utils/exec.ts
18
+ var exec_exports = {};
19
+ __export(exec_exports, {
20
+ commandExists: () => commandExists,
21
+ runCommand: () => runCommand,
22
+ runGit: () => runGit
23
+ });
24
+ import { execa } from "execa";
25
+ async function runCommand(command, args = [], options = {}) {
26
+ try {
27
+ const result = await execa(command, args, {
28
+ reject: false,
29
+ // non-zero exits are returned, not thrown
30
+ ...options
31
+ });
32
+ return {
33
+ command,
34
+ args,
35
+ stdout: String(result.stdout ?? ""),
36
+ stderr: String(result.stderr ?? ""),
37
+ exitCode: result.exitCode ?? 0
38
+ };
39
+ } catch (error2) {
40
+ const message = error2 instanceof Error ? error2.message : String(error2);
41
+ throw new Error(`Failed to run command: ${command} ${args.join(" ")} (${message})`);
42
+ }
43
+ }
44
+ async function runGit(args, options = {}) {
45
+ return runCommand("git", args, options);
46
+ }
47
+ async function commandExists(command) {
48
+ try {
49
+ const result = await runCommand(command, ["--version"]);
50
+ return result.exitCode === 0;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+ var init_exec = __esm({
56
+ "src/utils/exec.ts"() {
57
+ "use strict";
58
+ }
59
+ });
2
60
 
3
61
  // src/cli.ts
4
62
  import { readFileSync as readFileSync4 } from "node:fs";
5
- import path37 from "node:path";
63
+ import path45 from "node:path";
6
64
  import { Command } from "commander";
7
65
 
8
66
  // src/commands/apply.ts
9
- import path14 from "node:path";
67
+ import path17 from "node:path";
10
68
  import checkbox from "@inquirer/checkbox";
11
69
 
12
- // src/catalog/remote-catalog.ts
13
- import os from "node:os";
14
- import path2 from "node:path";
15
- import fs2 from "fs-extra";
70
+ // src/catalog/load-catalog.ts
71
+ import path4 from "node:path";
16
72
 
17
73
  // src/utils/fs.ts
18
74
  import crypto from "node:crypto";
@@ -90,6 +146,7 @@ async function listFiles(root, patterns) {
90
146
  cwd: root,
91
147
  dot: true,
92
148
  onlyFiles: true,
149
+ suppressErrors: true,
93
150
  ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"]
94
151
  });
95
152
  return files.sort((a, b) => a.localeCompare(b));
@@ -108,6 +165,57 @@ async function mapWithConcurrency(items, fn, concurrency = 24) {
108
165
  return results;
109
166
  }
110
167
 
168
+ // src/utils/paths.ts
169
+ import { existsSync, readFileSync } from "node:fs";
170
+ import os from "node:os";
171
+ import path2 from "node:path";
172
+ import { fileURLToPath } from "node:url";
173
+ var HAUS_DIR = ".haus-workflow";
174
+ function hausPath(root, ...parts) {
175
+ return path2.join(root, HAUS_DIR, ...parts);
176
+ }
177
+ function claudePath(root, ...parts) {
178
+ return path2.join(root, ".claude", ...parts);
179
+ }
180
+ function displayPath(root, targetPath) {
181
+ const rel = path2.relative(root, targetPath).replace(/\\/g, "/");
182
+ if (rel && !rel.startsWith("../") && rel !== "..") {
183
+ return rel.startsWith("./") ? rel : `./${rel}`;
184
+ }
185
+ const home = os.homedir();
186
+ const normalized = targetPath.replace(/\\/g, "/");
187
+ if (home && home.trim().length > 0) {
188
+ const homeRel = path2.relative(home, targetPath).replace(/\\/g, "/");
189
+ if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
190
+ return `~/${homeRel}`;
191
+ }
192
+ }
193
+ return normalized;
194
+ }
195
+ function packageRoot() {
196
+ let dir = path2.dirname(fileURLToPath(import.meta.url));
197
+ for (let i = 0; i < 12; i++) {
198
+ const pkgPath = path2.join(dir, "package.json");
199
+ if (existsSync(pkgPath)) {
200
+ try {
201
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
202
+ if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
203
+ } catch {
204
+ }
205
+ }
206
+ const parent = path2.dirname(dir);
207
+ if (parent === dir) break;
208
+ dir = parent;
209
+ }
210
+ const file = fileURLToPath(import.meta.url);
211
+ return path2.resolve(path2.dirname(file), "../..");
212
+ }
213
+
214
+ // src/catalog/remote-catalog.ts
215
+ import os2 from "node:os";
216
+ import path3 from "node:path";
217
+ import fs2 from "fs-extra";
218
+
111
219
  // src/utils/logger.ts
112
220
  var log = (msg, ...args) => {
113
221
  console.log(msg, ...args);
@@ -543,7 +651,7 @@ function parseManifest(json) {
543
651
 
544
652
  // src/catalog/remote-catalog.ts
545
653
  function getCacheDir() {
546
- return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path2.join(os.homedir(), CATALOG_CACHE_SUBDIR);
654
+ return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path3.join(os2.homedir(), CATALOG_CACHE_SUBDIR);
547
655
  }
548
656
  var cachedCatalogRef;
549
657
  var cachedBlobPaths;
@@ -606,13 +714,13 @@ async function writeTextIfChanged(dest, text) {
606
714
  await fs2.writeFile(dest, text, "utf8");
607
715
  return "updated";
608
716
  }
609
- await fs2.ensureDir(path2.dirname(dest));
717
+ await fs2.ensureDir(path3.dirname(dest));
610
718
  await fs2.writeFile(dest, text, "utf8");
611
719
  return "created";
612
720
  }
613
721
  var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
614
722
  async function readWorkflowTemplate(opts = {}) {
615
- const dest = path2.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
723
+ const dest = path3.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
616
724
  const base = await remoteBase();
617
725
  const text = await fetchText(`${base}/${WORKFLOW_TEMPLATE_REL}`);
618
726
  if (text === null) {
@@ -627,14 +735,14 @@ async function readWorkflowTemplate(opts = {}) {
627
735
  return text;
628
736
  }
629
737
  function isSafeCatalogPath(itemPath) {
630
- if (!itemPath || path2.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
631
- const normalized = path2.normalize(itemPath);
738
+ if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
739
+ const normalized = path3.normalize(itemPath);
632
740
  return !normalized.startsWith("..") && !normalized.includes("/..");
633
741
  }
634
742
  function isSafeRelativeFilePath(rel) {
635
743
  if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
636
- if (path2.isAbsolute(rel)) return false;
637
- const normalized = path2.posix.normalize(rel.replace(/\\/g, "/"));
744
+ if (path3.isAbsolute(rel)) return false;
745
+ const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
638
746
  return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
639
747
  }
640
748
  function githubApiHeaders() {
@@ -656,8 +764,8 @@ function sanitizeRelativeFilePaths(files, label) {
656
764
  }
657
765
  function safeJoin(base, itemPath) {
658
766
  if (!isSafeCatalogPath(itemPath)) return null;
659
- const resolved = path2.resolve(base, itemPath);
660
- return resolved.startsWith(base + path2.sep) || resolved === base ? resolved : null;
767
+ const resolved = path3.resolve(base, itemPath);
768
+ return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
661
769
  }
662
770
  var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
663
771
  function isMarkdownPath(rel) {
@@ -665,13 +773,19 @@ function isMarkdownPath(rel) {
665
773
  }
666
774
  async function listFilesRecursive(dir, base = dir) {
667
775
  const out = [];
668
- if (!await fs2.pathExists(dir)) return out;
669
- for (const entry of await fs2.readdir(dir, { withFileTypes: true })) {
670
- const full = path2.join(dir, entry.name);
776
+ let entries;
777
+ try {
778
+ if (!await fs2.pathExists(dir)) return out;
779
+ entries = await fs2.readdir(dir, { withFileTypes: true });
780
+ } catch {
781
+ return out;
782
+ }
783
+ for (const entry of entries) {
784
+ const full = path3.join(dir, entry.name);
671
785
  if (entry.isDirectory()) {
672
786
  out.push(...await listFilesRecursive(full, base));
673
787
  } else if (entry.isFile()) {
674
- out.push(path2.relative(base, full).replace(/\\/g, "/"));
788
+ out.push(path3.relative(base, full).replace(/\\/g, "/"));
675
789
  }
676
790
  }
677
791
  return out.sort();
@@ -776,7 +890,7 @@ async function directoryMatchesFetched(destDir, fetched) {
776
890
  if (!relSet.has(rel)) return false;
777
891
  }
778
892
  for (const file of fetched) {
779
- const dest = path2.join(destDir, file.rel);
893
+ const dest = path3.join(destDir, file.rel);
780
894
  if (!await fs2.pathExists(dest)) return false;
781
895
  if (file.kind === "text") {
782
896
  const local = await fs2.readFile(dest, "utf8");
@@ -794,8 +908,8 @@ async function writeFetchedDirectory(destDir, fetched) {
794
908
  }
795
909
  await fs2.ensureDir(destDir);
796
910
  for (const file of fetched) {
797
- const dest = path2.join(destDir, file.rel);
798
- await fs2.ensureDir(path2.dirname(dest));
911
+ const dest = path3.join(destDir, file.rel);
912
+ await fs2.ensureDir(path3.dirname(dest));
799
913
  if (file.kind === "text") {
800
914
  await fs2.writeFile(dest, file.body, "utf8");
801
915
  } else {
@@ -939,7 +1053,7 @@ async function syncOneItem(item, base) {
939
1053
  return "failed";
940
1054
  }
941
1055
  const text = await fetchText(`${base}/${item.path}`);
942
- if (!text) {
1056
+ if (text === null) {
943
1057
  warn(`Failed to fetch content for ${item.id}`);
944
1058
  return "failed";
945
1059
  }
@@ -967,7 +1081,7 @@ async function syncRemoteCatalog() {
967
1081
  try {
968
1082
  await fs2.ensureDir(cacheDir);
969
1083
  await fs2.writeFile(
970
- path2.join(cacheDir, "manifest.json"),
1084
+ path3.join(cacheDir, "manifest.json"),
971
1085
  `${JSON.stringify({ version, items }, null, 2)}
972
1086
  `,
973
1087
  "utf8"
@@ -1035,66 +1149,13 @@ async function fetchLatestCatalogTag() {
1035
1149
  }
1036
1150
  async function getCacheManifestAge() {
1037
1151
  try {
1038
- const stat = await fs2.stat(path2.join(getCacheDir(), "manifest.json"));
1152
+ const stat = await fs2.stat(path3.join(getCacheDir(), "manifest.json"));
1039
1153
  return Date.now() - stat.mtimeMs;
1040
1154
  } catch {
1041
1155
  return null;
1042
1156
  }
1043
1157
  }
1044
1158
 
1045
- // src/claude/write-claude-files.ts
1046
- import path13 from "node:path";
1047
- import fs13 from "fs-extra";
1048
-
1049
- // src/catalog/load-catalog.ts
1050
- import path4 from "node:path";
1051
-
1052
- // src/utils/paths.ts
1053
- import { existsSync, readFileSync } from "node:fs";
1054
- import os2 from "node:os";
1055
- import path3 from "node:path";
1056
- import { fileURLToPath } from "node:url";
1057
- var HAUS_DIR = ".haus-workflow";
1058
- function hausPath(root, ...parts) {
1059
- return path3.join(root, HAUS_DIR, ...parts);
1060
- }
1061
- function claudePath(root, ...parts) {
1062
- return path3.join(root, ".claude", ...parts);
1063
- }
1064
- function displayPath(root, targetPath) {
1065
- const rel = path3.relative(root, targetPath).replace(/\\/g, "/");
1066
- if (rel && !rel.startsWith("../") && rel !== "..") {
1067
- return rel.startsWith("./") ? rel : `./${rel}`;
1068
- }
1069
- const home = os2.homedir();
1070
- const normalized = targetPath.replace(/\\/g, "/");
1071
- if (home && home.trim().length > 0) {
1072
- const homeRel = path3.relative(home, targetPath).replace(/\\/g, "/");
1073
- if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
1074
- return `~/${homeRel}`;
1075
- }
1076
- }
1077
- return normalized;
1078
- }
1079
- function packageRoot() {
1080
- let dir = path3.dirname(fileURLToPath(import.meta.url));
1081
- for (let i = 0; i < 12; i++) {
1082
- const pkgPath = path3.join(dir, "package.json");
1083
- if (existsSync(pkgPath)) {
1084
- try {
1085
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1086
- if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
1087
- } catch {
1088
- }
1089
- }
1090
- const parent = path3.dirname(dir);
1091
- if (parent === dir) break;
1092
- dir = parent;
1093
- }
1094
- const file = fileURLToPath(import.meta.url);
1095
- return path3.resolve(path3.dirname(file), "../..");
1096
- }
1097
-
1098
1159
  // src/catalog/load-catalog.ts
1099
1160
  async function loadCatalogContext(root) {
1100
1161
  const envPath = process.env["HAUS_FIXTURE_CATALOG"];
@@ -1137,6 +1198,10 @@ function catalogItemContentPath(contentRoot, item) {
1137
1198
  return path4.join(contentRoot, item.path);
1138
1199
  }
1139
1200
 
1201
+ // src/claude/write-claude-files.ts
1202
+ import path14 from "node:path";
1203
+ import fs14 from "fs-extra";
1204
+
1140
1205
  // src/update/hash-installed.ts
1141
1206
  import path5 from "node:path";
1142
1207
  import fg2 from "fast-glob";
@@ -1660,8 +1725,8 @@ function buildDenyRules() {
1660
1725
  for (const command of DENY_COMMANDS) {
1661
1726
  rules.push(`Bash(${command}:*)`);
1662
1727
  }
1663
- for (const path38 of DENY_PATHS) {
1664
- const pattern = DENY_DIRS.has(path38) ? `${path38}/**` : path38;
1728
+ for (const path46 of DENY_PATHS) {
1729
+ const pattern = DENY_DIRS.has(path46) ? `${path46}/**` : path46;
1665
1730
  for (const tool of FILE_TOOLS) {
1666
1731
  rules.push(`${tool}(${pattern})`);
1667
1732
  }
@@ -1684,6 +1749,13 @@ var PROJECT_HOOK_FRAGMENTS = [
1684
1749
  event: "PreToolUse",
1685
1750
  matcher: "Bash",
1686
1751
  command: "haus guard bash --from-hook"
1752
+ },
1753
+ {
1754
+ id: "haus.decisions-suggest",
1755
+ gate: "keep",
1756
+ event: "Stop",
1757
+ matcher: "*",
1758
+ command: "haus decisions suggest --from-hook"
1687
1759
  }
1688
1760
  ];
1689
1761
  async function readProjectSettings(root) {
@@ -1777,6 +1849,12 @@ var CANONICAL_HOOKS = {
1777
1849
  matcher: "Bash",
1778
1850
  hooks: [{ type: "command", command: "haus guard bash --from-hook" }]
1779
1851
  }
1852
+ ],
1853
+ Stop: [
1854
+ {
1855
+ matcher: "*",
1856
+ hooks: [{ type: "command", command: "haus decisions suggest --from-hook" }]
1857
+ }
1780
1858
  ]
1781
1859
  }
1782
1860
  };
@@ -1798,9 +1876,11 @@ function collectHookCommands(settings) {
1798
1876
  }
1799
1877
  function hausHookContractSatisfied(project, canonical) {
1800
1878
  const present = new Set(collectHookCommands(project));
1801
- for (const block of canonical.hooks.PreToolUse) {
1802
- for (const h of block.hooks) {
1803
- if (!present.has(h.command)) return false;
1879
+ for (const blocks of Object.values(canonical.hooks)) {
1880
+ for (const block of blocks ?? []) {
1881
+ for (const h of block.hooks) {
1882
+ if (!present.has(h.command)) return false;
1883
+ }
1804
1884
  }
1805
1885
  }
1806
1886
  const denySet = new Set(project.permissions?.deny ?? []);
@@ -1851,9 +1931,62 @@ async function verifyProjectSettingsHooksContract(root) {
1851
1931
  return { ok: true, message: "settings.json carries required haus hook contract." };
1852
1932
  }
1853
1933
 
1854
- // src/claude/write-prettierignore.ts
1934
+ // src/claude/write-decisions-seed.ts
1855
1935
  import path9 from "node:path";
1856
1936
  import fs8 from "fs-extra";
1937
+
1938
+ // library/catalog/decisions-triggers.json
1939
+ var decisions_triggers_default = {
1940
+ decisionsDir: "docs/decisions",
1941
+ legacyDecisionsDir: "docs/adr",
1942
+ pathGlobs: [
1943
+ "**/package.json",
1944
+ "**/pnpm-lock.yaml",
1945
+ "**/yarn.lock",
1946
+ "**/package-lock.json",
1947
+ "**/migrations/**",
1948
+ "**/prisma/schema.prisma",
1949
+ "**/.github/workflows/**",
1950
+ "**/Dockerfile*",
1951
+ "**/docker-compose*.yml",
1952
+ "**/terraform/**",
1953
+ "**/openapi*.yaml",
1954
+ "**/schema.graphql"
1955
+ ],
1956
+ securityPathGlobs: ["**/.env.example", "**/auth/**", "**/middleware/**"],
1957
+ pathRegex: ["(^|/)src/.*/auth/", "(^|/)middleware/", "\\.env\\.example$"],
1958
+ minFilesChanged: 8,
1959
+ minLinesChanged: 200,
1960
+ exemptGlobs: ["docs/**", "tests/**", "**/*.md", ".haus-workflow/**"],
1961
+ requiredSections: ["## Context", "## Decision", "## Consequences"],
1962
+ recommendedSections: ["## Motivation (why)", "## Alternatives considered"],
1963
+ decisionFilePattern: "^\\d{4}-.+\\.md$"
1964
+ };
1965
+
1966
+ // src/decisions/triggers.ts
1967
+ var DECISIONS_TRIGGERS = decisions_triggers_default;
1968
+ var DECISION_FILE_RE = new RegExp(DECISIONS_TRIGGERS.decisionFilePattern);
1969
+
1970
+ // src/claude/write-decisions-seed.ts
1971
+ var SEED_README = `# Architecture Decision Records (ADRs)
1972
+
1973
+ Why we chose X \u2014 short, dated, write-once decision log for humans and agents.
1974
+
1975
+ | ADR | Title | Why (one line) | Status |
1976
+ | --- | ----- | -------------- | ------ |
1977
+ `;
1978
+ async function writeDecisionsSeed(root, dryRun) {
1979
+ const decisionsDir = path9.join(root, DECISIONS_TRIGGERS.decisionsDir);
1980
+ const readmePath = path9.join(decisionsDir, "README.md");
1981
+ if (await fs8.pathExists(readmePath)) return null;
1982
+ if (!dryRun) await fs8.ensureDir(decisionsDir);
1983
+ await writeManagedText(root, readmePath, SEED_README, dryRun);
1984
+ return readmePath;
1985
+ }
1986
+
1987
+ // src/claude/write-prettierignore.ts
1988
+ import path10 from "node:path";
1989
+ import fs9 from "fs-extra";
1857
1990
  var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
1858
1991
  var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
1859
1992
  var IGNORED_PATHS = [".haus-workflow/"];
@@ -1909,20 +2042,21 @@ ${block}
1909
2042
  `;
1910
2043
  }
1911
2044
  async function writePrettierIgnore(root, dryRun) {
1912
- const filePath = path9.join(root, ".prettierignore");
1913
- const prev = await fs8.pathExists(filePath) ? await fs8.readFile(filePath, "utf8") : "";
2045
+ const filePath = path10.join(root, ".prettierignore");
2046
+ const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
1914
2047
  const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
1915
2048
  await writeManagedText(root, filePath, next, dryRun);
1916
2049
  return filePath;
1917
2050
  }
1918
2051
 
1919
2052
  // src/claude/write-root-claude-md.ts
1920
- import path10 from "node:path";
1921
- import fs9 from "fs-extra";
2053
+ import path11 from "node:path";
2054
+ import fs10 from "fs-extra";
1922
2055
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
1923
2056
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
1924
2057
  var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
1925
- @.haus-workflow/workflow-config.md`;
2058
+ @.haus-workflow/workflow-config.md
2059
+ @docs/decisions/README.md`;
1926
2060
  function buildImportBlock() {
1927
2061
  return `${BLOCK_BEGIN}
1928
2062
  ${IMPORT_CONTENT}
@@ -1983,21 +2117,21 @@ ${block}
1983
2117
  `;
1984
2118
  }
1985
2119
  async function writeRootClaudeMd(root, dryRun) {
1986
- const filePath = path10.join(root, "CLAUDE.md");
2120
+ const filePath = path11.join(root, "CLAUDE.md");
1987
2121
  const block = buildImportBlock();
1988
- const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
2122
+ const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
1989
2123
  const next = injectHausBlock(prev, block);
1990
2124
  await writeManagedText(root, filePath, next, dryRun);
1991
2125
  return filePath;
1992
2126
  }
1993
2127
 
1994
2128
  // src/claude/write-workflow-config.ts
1995
- import path12 from "node:path";
1996
- import fs11 from "fs-extra";
2129
+ import path13 from "node:path";
2130
+ import fs12 from "fs-extra";
1997
2131
 
1998
2132
  // src/claude/derive-workflow-config.ts
1999
- import path11 from "node:path";
2000
- import fs10 from "fs-extra";
2133
+ import path12 from "node:path";
2134
+ import fs11 from "fs-extra";
2001
2135
  function binCmd(pm, bin, args) {
2002
2136
  const tail = args ? ` ${args}` : "";
2003
2137
  if (pm === "yarn") return `yarn ${bin}${tail}`;
@@ -2006,7 +2140,7 @@ function binCmd(pm, bin, args) {
2006
2140
  }
2007
2141
  async function deriveWorkflowConfig(root, ctx) {
2008
2142
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
2009
- const pkg = await readJson(path11.join(root, "package.json"));
2143
+ const pkg = await readJson(path12.join(root, "package.json"));
2010
2144
  const scripts = pkg?.scripts ?? {};
2011
2145
  const deps = new Set(ctx.dependencies);
2012
2146
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -2016,7 +2150,7 @@ async function deriveWorkflowConfig(root, ctx) {
2016
2150
  return null;
2017
2151
  };
2018
2152
  const hasDep = (name) => deps.has(name);
2019
- const exists = (rel) => fs10.pathExistsSync(path11.join(root, rel));
2153
+ const exists = (rel) => fs11.pathExistsSync(path12.join(root, rel));
2020
2154
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
2021
2155
  const hasCypress = hasDep("cypress");
2022
2156
  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;
@@ -2084,7 +2218,7 @@ var FALLBACK_CONTEXT = {
2084
2218
  async function writeWorkflowConfig(root, dryRun, opts = {}) {
2085
2219
  const destPath = hausPath(root, "workflow-config.md");
2086
2220
  const printable = displayPath(root, destPath);
2087
- const exists = await fs11.pathExists(destPath);
2221
+ const exists = await fs12.pathExists(destPath);
2088
2222
  if (exists && !opts.refill) {
2089
2223
  if (dryRun) log(printable + ": exists (project-owned, skipping)");
2090
2224
  return null;
@@ -2092,11 +2226,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2092
2226
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
2093
2227
  ...FALLBACK_CONTEXT,
2094
2228
  root,
2095
- repoName: path12.basename(root)
2229
+ repoName: path13.basename(root)
2096
2230
  };
2097
2231
  const values = await deriveWorkflowConfig(root, ctx);
2098
2232
  if (exists) {
2099
- const current = await fs11.readFile(destPath, "utf8");
2233
+ const current = await fs12.readFile(destPath, "utf8");
2100
2234
  const refilled = refillContent(current, values);
2101
2235
  if (refilled === current) {
2102
2236
  if (dryRun) log(printable + ": no blank fields to refill");
@@ -2118,7 +2252,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2118
2252
  }
2119
2253
 
2120
2254
  // src/claude/write-workflow.ts
2121
- import fs12 from "fs-extra";
2255
+ import fs13 from "fs-extra";
2122
2256
  var STABLE_ID = "template.workflow";
2123
2257
  function makeWorkflowHeader(pkgVersion, contentHash) {
2124
2258
  return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
@@ -2137,8 +2271,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
2137
2271
  ${templateContent}`;
2138
2272
  const destPath = hausPath(root, "WORKFLOW.md");
2139
2273
  const printable = displayPath(root, destPath);
2140
- if (await fs12.pathExists(destPath)) {
2141
- const existing = await fs12.readFile(destPath, "utf8");
2274
+ if (await fs13.pathExists(destPath)) {
2275
+ const existing = await fs13.readFile(destPath, "utf8");
2142
2276
  const firstLine = existing.split("\n")[0] ?? "";
2143
2277
  const parsed = parseHausManagedHeader(firstLine);
2144
2278
  if (!parsed) {
@@ -2172,7 +2306,7 @@ ${templateContent}`;
2172
2306
  }
2173
2307
  }
2174
2308
  if (dryRun) {
2175
- const prev = await fs12.pathExists(destPath) ? await fs12.readFile(destPath, "utf8") : "";
2309
+ const prev = await fs13.pathExists(destPath) ? await fs13.readFile(destPath, "utf8") : "";
2176
2310
  if (!prev) {
2177
2311
  log(createUnifiedDiff(printable, "", next));
2178
2312
  } else {
@@ -2195,6 +2329,8 @@ function targetDirForType(type) {
2195
2329
  return null;
2196
2330
  }
2197
2331
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2332
+ const say = opts.quiet ? () => {
2333
+ } : log;
2198
2334
  const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
2199
2335
  recommended: [],
2200
2336
  skipped: [],
@@ -2205,13 +2341,14 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2205
2341
  estimatedTokenReductionPct: 0
2206
2342
  };
2207
2343
  const pkgRoot = packageRoot();
2208
- const hausVersion2 = (await readJson(path13.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2344
+ const hausVersion2 = (await readJson(path14.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2209
2345
  const coreFiles = [
2210
2346
  claudePath(root, "settings.json"),
2211
2347
  claudePath(root, "rules", "haus.md"),
2212
2348
  claudePath(root, "commands", "haus-doctor.md")
2213
2349
  ];
2214
2350
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
2351
+ const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
2215
2352
  const workflowPath = await writeWorkflow(root, hausVersion2, dryRun, opts.force);
2216
2353
  const workflowConfigPath = await writeWorkflowConfig(root, dryRun, {
2217
2354
  refill: opts.refillConfig
@@ -2219,6 +2356,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2219
2356
  const prettierIgnorePath = await writePrettierIgnore(root, dryRun);
2220
2357
  const p6Files = [
2221
2358
  rootClaudeMdPath,
2359
+ ...decisionsSeedPath ? [decisionsSeedPath] : [],
2222
2360
  ...workflowPath ? [workflowPath] : [],
2223
2361
  ...workflowConfigPath ? [workflowConfigPath] : [],
2224
2362
  prettierIgnorePath
@@ -2238,16 +2376,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2238
2376
  dryRun
2239
2377
  );
2240
2378
  const legacyReviewPath = claudePath(root, "commands", "haus-review.md");
2241
- if (await fs13.pathExists(legacyReviewPath)) {
2242
- const content2 = await fs13.readFile(legacyReviewPath, "utf8");
2379
+ if (await fs14.pathExists(legacyReviewPath)) {
2380
+ const content2 = await fs14.readFile(legacyReviewPath, "utf8");
2243
2381
  const stub = 'Run `haus context --task "code review"` then review diff.';
2244
2382
  if (content2 === stub || content2 === `${stub}
2245
2383
  ` || content2 === `${stub}\r
2246
2384
  `) {
2247
2385
  if (dryRun) {
2248
- log(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
2386
+ say(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
2249
2387
  } else {
2250
- await fs13.remove(legacyReviewPath);
2388
+ await fs14.remove(legacyReviewPath);
2251
2389
  }
2252
2390
  }
2253
2391
  }
@@ -2275,16 +2413,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2275
2413
  dryRun
2276
2414
  );
2277
2415
  const legacySecurityPath = claudePath(root, "rules", "security.md");
2278
- if (await fs13.pathExists(legacySecurityPath)) {
2279
- const content2 = await fs13.readFile(legacySecurityPath, "utf8");
2416
+ if (await fs14.pathExists(legacySecurityPath)) {
2417
+ const content2 = await fs14.readFile(legacySecurityPath, "utf8");
2280
2418
  const stub = "- Never read secrets.\n- Block dangerous shell commands.";
2281
2419
  if (content2 === stub || content2 === `${stub}
2282
2420
  ` || content2 === `${stub}\r
2283
2421
  `) {
2284
2422
  if (dryRun) {
2285
- log(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
2423
+ say(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
2286
2424
  } else {
2287
- await fs13.remove(legacySecurityPath);
2425
+ await fs14.remove(legacySecurityPath);
2288
2426
  }
2289
2427
  }
2290
2428
  }
@@ -2299,11 +2437,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2299
2437
  ];
2300
2438
  for (const rel of LEGACY_PRUNED_ARTIFACTS) {
2301
2439
  const legacyPath = hausPath(root, rel);
2302
- if (await fs13.pathExists(legacyPath)) {
2440
+ if (await fs14.pathExists(legacyPath)) {
2303
2441
  if (dryRun) {
2304
- log(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
2442
+ say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
2305
2443
  } else {
2306
- await fs13.remove(legacyPath);
2444
+ await fs14.remove(legacyPath);
2307
2445
  }
2308
2446
  }
2309
2447
  }
@@ -2345,11 +2483,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2345
2483
  );
2346
2484
  continue;
2347
2485
  }
2348
- const destination = claudePath(root, target, path13.basename(sourcePath));
2349
- if (await fs13.pathExists(sourcePath)) {
2486
+ const destination = claudePath(root, target, path14.basename(sourcePath));
2487
+ if (await fs14.pathExists(sourcePath)) {
2350
2488
  if (dryRun) {
2351
- const exists = await fs13.pathExists(destination);
2352
- log(
2489
+ const exists = await fs14.pathExists(destination);
2490
+ say(
2353
2491
  `${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
2354
2492
  );
2355
2493
  } else if (item.type === "skill") {
@@ -2358,17 +2496,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2358
2496
  dryRun: false
2359
2497
  });
2360
2498
  } else {
2361
- await fs13.ensureDir(path13.dirname(destination));
2362
- await fs13.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2499
+ await fs14.ensureDir(path14.dirname(destination));
2500
+ await fs14.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2363
2501
  }
2364
2502
  files.push(destination);
2365
- const relPaths = [path13.relative(root, destination)];
2503
+ const relPaths = [path14.relative(root, destination)];
2366
2504
  if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
2367
2505
  const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
2368
2506
  if (sharedRel) {
2369
2507
  superpowersSharedInstalled = true;
2370
2508
  relPaths.push(sharedRel);
2371
- files.push(path13.join(root, sharedRel));
2509
+ files.push(path14.join(root, sharedRel));
2372
2510
  }
2373
2511
  }
2374
2512
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -2385,7 +2523,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2385
2523
  `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
2386
2524
  );
2387
2525
  }
2388
- await cleanupStaleCatalogItems(root, manifestById, dryRun);
2526
+ await cleanupStaleCatalogItems(root, manifestById, dryRun, opts.quiet);
2389
2527
  if (dryRun) return [...new Set(files)];
2390
2528
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
2391
2529
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
@@ -2422,7 +2560,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2422
2560
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
2423
2561
  return [...new Set(files)];
2424
2562
  }
2425
- async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2563
+ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2564
+ const say = quiet ? () => {
2565
+ } : log;
2426
2566
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
2427
2567
  if (!prevLock?.length) return;
2428
2568
  for (const entry of prevLock) {
@@ -2435,7 +2575,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2435
2575
  if (relPaths.length === 0) continue;
2436
2576
  const existing = [];
2437
2577
  for (const rel of relPaths) {
2438
- if (await fs13.pathExists(path13.join(root, rel))) existing.push(rel);
2578
+ if (await fs14.pathExists(path14.join(root, rel))) existing.push(rel);
2439
2579
  }
2440
2580
  if (existing.length === 0) continue;
2441
2581
  const pruneReason = deprecated ? "deprecated" : "stale";
@@ -2453,18 +2593,115 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2453
2593
  continue;
2454
2594
  }
2455
2595
  for (const rel of existing) {
2456
- const abs = path13.join(root, rel);
2596
+ const abs = path14.join(root, rel);
2457
2597
  if (dryRun) {
2458
- log(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2598
+ say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2459
2599
  continue;
2460
2600
  }
2461
- await fs13.remove(abs);
2462
- await pruneEmptyDir(path13.dirname(abs));
2463
- log(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2601
+ await fs14.remove(abs);
2602
+ await pruneEmptyDir(path14.dirname(abs));
2603
+ say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2464
2604
  }
2465
2605
  }
2466
2606
  }
2467
2607
 
2608
+ // src/refs/fetch-refs.ts
2609
+ import path16 from "node:path";
2610
+ import fs16 from "fs-extra";
2611
+
2612
+ // src/refs/cache-meta.ts
2613
+ import path15 from "node:path";
2614
+ import fs15 from "fs-extra";
2615
+ var META_FILENAME = "cache-meta.json";
2616
+ async function readCacheMeta(cacheDir) {
2617
+ const metaPath = path15.join(cacheDir, META_FILENAME);
2618
+ try {
2619
+ const raw = await fs15.readFile(metaPath, "utf8");
2620
+ return JSON.parse(raw);
2621
+ } catch {
2622
+ return {};
2623
+ }
2624
+ }
2625
+ async function writeCacheMeta(cacheDir, meta) {
2626
+ await fs15.ensureDir(cacheDir);
2627
+ const metaPath = path15.join(cacheDir, META_FILENAME);
2628
+ await fs15.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
2629
+ }
2630
+
2631
+ // src/refs/fetch-refs.ts
2632
+ function urlToSlug(url) {
2633
+ const u = new URL(url);
2634
+ return (u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
2635
+ }
2636
+ function isLlmsTxtUrl(ref) {
2637
+ try {
2638
+ const u = new URL(ref);
2639
+ return (u.protocol === "https:" || u.protocol === "http:") && u.pathname.endsWith("/llms.txt");
2640
+ } catch {
2641
+ return false;
2642
+ }
2643
+ }
2644
+ async function fetchSingleRef(url, cacheDir, meta) {
2645
+ const existing = meta[url];
2646
+ const headers = {};
2647
+ const safeFile = existing?.file && existing.file === path16.basename(existing.file);
2648
+ const cachedFileExists = safeFile && await fs16.pathExists(path16.join(cacheDir, existing.file));
2649
+ if (cachedFileExists) {
2650
+ if (existing?.etag) headers["If-None-Match"] = existing.etag;
2651
+ if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
2652
+ }
2653
+ try {
2654
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
2655
+ if (res.status === 304) return { result: "unchanged" };
2656
+ if (!res.ok) return { result: "failed" };
2657
+ const text = await res.text();
2658
+ const file = `${urlToSlug(url)}.md`;
2659
+ await fs16.ensureDir(cacheDir);
2660
+ await fs16.writeFile(path16.join(cacheDir, file), text, "utf8");
2661
+ meta[url] = {
2662
+ url,
2663
+ etag: res.headers.get("etag") ?? void 0,
2664
+ lastModified: res.headers.get("last-modified") ?? void 0,
2665
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
2666
+ file
2667
+ };
2668
+ return { result: "fetched", file };
2669
+ } catch {
2670
+ return { result: "failed" };
2671
+ }
2672
+ }
2673
+ async function fetchRefsForItems(items, cacheDir) {
2674
+ const urls = [...new Set(items.flatMap((item) => (item.references ?? []).filter(isLlmsTxtUrl)))];
2675
+ const summary = {
2676
+ fetched: 0,
2677
+ unchanged: 0,
2678
+ failed: 0,
2679
+ failedUrls: [],
2680
+ cachedFiles: {}
2681
+ };
2682
+ if (urls.length === 0) return summary;
2683
+ const meta = await readCacheMeta(cacheDir);
2684
+ await Promise.all(
2685
+ urls.map(async (url) => {
2686
+ const outcome = await fetchSingleRef(url, cacheDir, meta);
2687
+ if (outcome.result === "fetched") {
2688
+ summary.fetched++;
2689
+ summary.cachedFiles[url] = path16.join(cacheDir, outcome.file);
2690
+ } else if (outcome.result === "unchanged") {
2691
+ summary.unchanged++;
2692
+ if (meta[url]?.file) summary.cachedFiles[url] = path16.join(cacheDir, meta[url].file);
2693
+ } else {
2694
+ summary.failed++;
2695
+ summary.failedUrls.push(url);
2696
+ }
2697
+ })
2698
+ );
2699
+ if (summary.fetched > 0) {
2700
+ await writeCacheMeta(cacheDir, meta);
2701
+ }
2702
+ return summary;
2703
+ }
2704
+
2468
2705
  // src/utils/args.ts
2469
2706
  function parseIdList(value) {
2470
2707
  if (!value) return [];
@@ -2474,7 +2711,7 @@ function parseIdList(value) {
2474
2711
 
2475
2712
  // src/commands/apply.ts
2476
2713
  async function cacheHasItems() {
2477
- const data = await readJson(path14.join(getCacheDir(), "manifest.json"));
2714
+ const data = await readJson(path17.join(getCacheDir(), "manifest.json"));
2478
2715
  return Array.isArray(data?.items) && data.items.length > 0;
2479
2716
  }
2480
2717
  function installableRecommendedItems(recommended) {
@@ -2566,6 +2803,23 @@ async function runApply(options) {
2566
2803
  refillConfig: options.refillConfig,
2567
2804
  force: options.force
2568
2805
  });
2806
+ if (!isDryRun) {
2807
+ try {
2808
+ const cacheDir = hausPath(root, "llms-cache");
2809
+ const allItems = await loadCatalog(root);
2810
+ const summary = await fetchRefsForItems(allItems, cacheDir);
2811
+ if (summary.fetched > 0)
2812
+ log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
2813
+ if (summary.failed > 0)
2814
+ warn(
2815
+ `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
2816
+ );
2817
+ } catch (err) {
2818
+ warn(
2819
+ `Could not fetch llms.txt references: ${err instanceof Error ? err.message : String(err)}`
2820
+ );
2821
+ }
2822
+ }
2569
2823
  if (isDryRun) {
2570
2824
  log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
2571
2825
  } else {
@@ -2579,7 +2833,7 @@ function auditForbiddenTags(items) {
2579
2833
  const failures = [];
2580
2834
  const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
2581
2835
  for (const item of items) {
2582
- for (const tag of item.tags) {
2836
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
2583
2837
  if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
2584
2838
  }
2585
2839
  for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
@@ -2600,84 +2854,509 @@ async function runCatalogAudit() {
2600
2854
  }
2601
2855
 
2602
2856
  // src/commands/clone.ts
2857
+ init_exec();
2603
2858
  import { existsSync as existsSync2 } from "node:fs";
2604
- import path15 from "node:path";
2859
+ import path18 from "node:path";
2860
+ var GIT_LOCATION_VARS = [
2861
+ "GIT_DIR",
2862
+ "GIT_WORK_TREE",
2863
+ "GIT_INDEX_FILE",
2864
+ "GIT_COMMON_DIR",
2865
+ "GIT_OBJECT_DIRECTORY",
2866
+ "GIT_NAMESPACE",
2867
+ "GIT_PREFIX"
2868
+ ];
2869
+ function cloneEnv() {
2870
+ const env = { ...process.env };
2871
+ for (const key of GIT_LOCATION_VARS) delete env[key];
2872
+ return env;
2873
+ }
2874
+ function repoNameFromUrl(url) {
2875
+ const trimmed = url.trim().replace(/\.git$/, "").replace(/\/+$/, "");
2876
+ const tail = trimmed.split(/[/:]/).pop() ?? "";
2877
+ return tail || "repo";
2878
+ }
2879
+ async function runClone(url, opts = {}) {
2880
+ if (!url || !url.trim()) {
2881
+ error("A git URL is required: `haus clone <url> [dir]`.");
2882
+ process.exitCode = 1;
2883
+ return;
2884
+ }
2885
+ const target = path18.resolve(opts.dir?.trim() || repoNameFromUrl(url));
2886
+ if (existsSync2(target)) {
2887
+ log(`\u2022 ${path18.basename(target)} already present at ${target} \u2014 skipped`);
2888
+ return;
2889
+ }
2890
+ if (opts.dryRun) {
2891
+ log(`would clone ${url} \u2192 ${target}`);
2892
+ return;
2893
+ }
2894
+ const res = await runGit(["clone", "--", url, target], { env: cloneEnv(), extendEnv: false });
2895
+ if (res.exitCode !== 0) {
2896
+ error(`clone failed for ${url}: ${(res.stderr || res.stdout).trim()}`);
2897
+ process.exitCode = 1;
2898
+ return;
2899
+ }
2900
+ log(`\u2713 cloned ${url} \u2192 ${target}`);
2901
+ }
2605
2902
 
2606
- // src/utils/exec.ts
2607
- import { execa } from "execa";
2608
- async function runCommand(command, args = [], options = {}) {
2609
- try {
2610
- const result = await execa(command, args, {
2611
- reject: false,
2612
- // non-zero exits are returned, not thrown
2613
- ...options
2614
- });
2903
+ // src/commands/decisions.ts
2904
+ import path22 from "node:path";
2905
+
2906
+ // src/decisions/check.ts
2907
+ import path21 from "node:path";
2908
+ import fs19 from "fs-extra";
2909
+
2910
+ // src/decisions/diff.ts
2911
+ init_exec();
2912
+ async function collectDiffStats(root, opts) {
2913
+ const gitArgs = opts.staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only", opts.range ?? "HEAD"];
2914
+ const names = await runGit(gitArgs, { cwd: root });
2915
+ const files = names.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
2916
+ const numstatArgs = opts.staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat", opts.range ?? "HEAD"];
2917
+ const numstat = await runGit(numstatArgs, { cwd: root });
2918
+ let linesAdded = 0;
2919
+ let linesRemoved = 0;
2920
+ for (const line2 of numstat.stdout.split("\n")) {
2921
+ const parts = line2.trim().split(/\s+/);
2922
+ if (parts.length < 3) continue;
2923
+ const added = parts[0] === "-" ? 0 : Number(parts[0]);
2924
+ const removed = parts[1] === "-" ? 0 : Number(parts[1]);
2925
+ if (!Number.isNaN(added)) linesAdded += added;
2926
+ if (!Number.isNaN(removed)) linesRemoved += removed;
2927
+ }
2928
+ return { files, linesAdded, linesRemoved };
2929
+ }
2930
+
2931
+ // src/decisions/match.ts
2932
+ function segmentToRegex(segment) {
2933
+ if (segment === "**") return ".*";
2934
+ return segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
2935
+ }
2936
+ function globToRegExp(pattern) {
2937
+ const pat = pattern.replace(/\\/g, "/");
2938
+ if (pat.startsWith("**/") && pat.endsWith("/**")) {
2939
+ const middle = pat.slice(3, -3);
2940
+ const midRe = middle.split("/").map(segmentToRegex).join("/");
2941
+ return new RegExp(`(^|/)${midRe}(/|$)`);
2942
+ }
2943
+ if (pat.startsWith("**/")) {
2944
+ const rest = pat.slice(3).split("/").map(segmentToRegex).join("/");
2945
+ return new RegExp(`^(?:.*/)?${rest}$`);
2946
+ }
2947
+ const reSource = pat.split("/").map(segmentToRegex).join("/");
2948
+ return new RegExp(`^${reSource}$`);
2949
+ }
2950
+ function matchesPathGlob(filePath, pattern) {
2951
+ const norm = filePath.replace(/\\/g, "/");
2952
+ return globToRegExp(pattern).test(norm);
2953
+ }
2954
+ function matchesAnyGlob(filePath, patterns) {
2955
+ return patterns.some((p) => matchesPathGlob(filePath, p));
2956
+ }
2957
+ function matchesAnyRegex(filePath, patterns) {
2958
+ const norm = filePath.replace(/\\/g, "/");
2959
+ return patterns.some((p) => new RegExp(p).test(norm));
2960
+ }
2961
+
2962
+ // src/decisions/paths.ts
2963
+ import path19 from "node:path";
2964
+ import fs17 from "fs-extra";
2965
+ async function resolveDecisionsDir(root) {
2966
+ const primary = path19.join(root, DECISIONS_TRIGGERS.decisionsDir);
2967
+ if (await fs17.pathExists(primary)) return primary;
2968
+ const legacy = path19.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
2969
+ if (await fs17.pathExists(legacy)) return legacy;
2970
+ return primary;
2971
+ }
2972
+ function relativeDecisionPath(decisionsDir, root) {
2973
+ return path19.relative(root, decisionsDir).replace(/\\/g, "/");
2974
+ }
2975
+
2976
+ // src/decisions/validate.ts
2977
+ import path20 from "node:path";
2978
+ import fs18 from "fs-extra";
2979
+ function validateDecisionContent(content2) {
2980
+ const errors = [];
2981
+ for (const section of DECISIONS_TRIGGERS.requiredSections) {
2982
+ if (!content2.includes(section)) {
2983
+ errors.push(`missing required section: ${section}`);
2984
+ }
2985
+ }
2986
+ if (!/^#\s+ADR-\d{4}:/m.test(content2)) {
2987
+ errors.push('heading must match "# ADR-NNNN: Title"');
2988
+ }
2989
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
2990
+ }
2991
+ function indexReferencesNumber(readme, number) {
2992
+ const padded = number.padStart(4, "0");
2993
+ return readme.includes(`[${padded}]`) || readme.includes(`(${padded}-`) || readme.includes(`ADR-${padded}`);
2994
+ }
2995
+ async function validateDecisionFile(filePath) {
2996
+ const content2 = await readText(filePath) ?? "";
2997
+ return validateDecisionContent(content2);
2998
+ }
2999
+ function isDecisionFileName(name) {
3000
+ return DECISION_FILE_RE.test(name);
3001
+ }
3002
+ function decisionNumberFromFile(name) {
3003
+ const match = /^(\d{4})-/.exec(name);
3004
+ return match?.[1] ?? null;
3005
+ }
3006
+ async function validateIndexSync(decisionsDir, decisionFiles) {
3007
+ const readmePath = path20.join(decisionsDir, "README.md");
3008
+ if (!await fs18.pathExists(readmePath)) {
3009
+ return ["docs/decisions/README.md is missing"];
3010
+ }
3011
+ const readme = await readText(readmePath) ?? "";
3012
+ const errors = [];
3013
+ for (const file of decisionFiles) {
3014
+ const num = decisionNumberFromFile(path20.basename(file));
3015
+ if (num && !indexReferencesNumber(readme, num)) {
3016
+ errors.push(`README.md missing index row for ADR-${num}`);
3017
+ }
3018
+ }
3019
+ return errors;
3020
+ }
3021
+
3022
+ // src/decisions/check.ts
3023
+ function isExempt(file) {
3024
+ return matchesAnyGlob(file, DECISIONS_TRIGGERS.exemptGlobs);
3025
+ }
3026
+ function countTopLevelDirs(files) {
3027
+ const dirs = /* @__PURE__ */ new Set();
3028
+ for (const f of files) {
3029
+ const parts = f.split("/");
3030
+ if (parts.length > 1) dirs.add(parts[0]);
3031
+ }
3032
+ return dirs.size;
3033
+ }
3034
+ function isDecisionTriggered(stats) {
3035
+ const relevant = stats.files.filter((f) => !isExempt(f));
3036
+ if (relevant.length === 0) return false;
3037
+ for (const file of relevant) {
3038
+ if (matchesAnyGlob(file, DECISIONS_TRIGGERS.pathGlobs)) return true;
3039
+ if (matchesAnyRegex(file, DECISIONS_TRIGGERS.pathRegex)) return true;
3040
+ }
3041
+ const totalLines = stats.linesAdded + stats.linesRemoved;
3042
+ if (relevant.length >= DECISIONS_TRIGGERS.minFilesChanged && countTopLevelDirs(relevant) >= 2) {
3043
+ return true;
3044
+ }
3045
+ if (totalLines >= DECISIONS_TRIGGERS.minLinesChanged && relevant.some((f) => !f.startsWith("docs/"))) {
3046
+ return true;
3047
+ }
3048
+ return false;
3049
+ }
3050
+ function isSecurityTriggered(stats) {
3051
+ const relevant = stats.files.filter((f) => !isExempt(f));
3052
+ const security = DECISIONS_TRIGGERS.securityPathGlobs ?? [];
3053
+ return relevant.some(
3054
+ (f) => matchesAnyGlob(f, security) || matchesAnyRegex(f, DECISIONS_TRIGGERS.pathRegex)
3055
+ );
3056
+ }
3057
+ async function readGateMode(root) {
3058
+ const cfg = await readJson(hausPath(root, "adr-gate.json"));
3059
+ return cfg?.mode === "off" ? "off" : "on";
3060
+ }
3061
+ function hasSkipToken(text) {
3062
+ return text.includes("[adr-skip]");
3063
+ }
3064
+ async function checkSatisfaction(input2) {
3065
+ const errors = [];
3066
+ const decisionsPrefix = `${input2.decisionsRel}/`;
3067
+ const newDecisionFiles = input2.changedFiles.filter(
3068
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path21.basename(f))
3069
+ );
3070
+ if (newDecisionFiles.length === 0) {
3071
+ errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
3072
+ return errors;
3073
+ }
3074
+ const decisionsDir = path21.join(input2.root, input2.decisionsRel);
3075
+ for (const rel of newDecisionFiles) {
3076
+ const abs = path21.join(input2.root, rel);
3077
+ const content2 = await readText(abs) ?? "";
3078
+ const valid = validateDecisionContent(content2);
3079
+ if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
3080
+ }
3081
+ const indexErrors = await validateIndexSync(
3082
+ decisionsDir,
3083
+ newDecisionFiles.map((f) => path21.basename(f))
3084
+ );
3085
+ errors.push(...indexErrors);
3086
+ return errors;
3087
+ }
3088
+ async function runDecisionsCheck(root, opts = {}) {
3089
+ const reasons = [];
3090
+ if (await readGateMode(root) === "off") {
3091
+ return {
3092
+ triggered: false,
3093
+ satisfied: true,
3094
+ reasons: ["gate disabled via .haus-workflow/adr-gate.json"],
3095
+ securityTriggered: false
3096
+ };
3097
+ }
3098
+ const stats = await collectDiffStats(root, opts);
3099
+ const triggered = isDecisionTriggered(stats);
3100
+ const securityTriggered = isSecurityTriggered(stats);
3101
+ if (!triggered) {
2615
3102
  return {
2616
- command,
2617
- args,
2618
- stdout: String(result.stdout ?? ""),
2619
- stderr: String(result.stderr ?? ""),
2620
- exitCode: result.exitCode ?? 0
3103
+ triggered: false,
3104
+ satisfied: true,
3105
+ reasons: ["no decision-worthy changes"],
3106
+ securityTriggered
3107
+ };
3108
+ }
3109
+ const skipText = `${opts.prBody ?? ""}
3110
+ ${opts.commitMessages ?? ""}`;
3111
+ if (hasSkipToken(skipText) && !securityTriggered) {
3112
+ reasons.push("[adr-skip] present \u2014 gate waived (non-security change)");
3113
+ return { triggered: true, satisfied: true, reasons, securityTriggered };
3114
+ }
3115
+ if (hasSkipToken(skipText) && securityTriggered) {
3116
+ reasons.push("[adr-skip] ignored for security/auth path changes");
3117
+ }
3118
+ const decisionsDir = await resolveDecisionsDir(root);
3119
+ const decisionsRel = relativeDecisionPath(decisionsDir, root);
3120
+ const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
3121
+ let satisfactionErrors = await checkSatisfaction({
3122
+ root,
3123
+ decisionsRel,
3124
+ changedFiles: stats.files
3125
+ });
3126
+ if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
3127
+ const legacyExists = await fs19.pathExists(path21.join(root, legacyRel));
3128
+ if (legacyExists && !await fs19.pathExists(decisionsDir)) {
3129
+ satisfactionErrors = await checkSatisfaction({
3130
+ root,
3131
+ decisionsRel: legacyRel,
3132
+ changedFiles: stats.files
3133
+ });
3134
+ }
3135
+ }
3136
+ if (satisfactionErrors.length === 0) {
3137
+ return {
3138
+ triggered: true,
3139
+ satisfied: true,
3140
+ reasons: ["decision record present"],
3141
+ securityTriggered
2621
3142
  };
2622
- } catch (error2) {
2623
- const message = error2 instanceof Error ? error2.message : String(error2);
2624
- throw new Error(`Failed to run command: ${command} ${args.join(" ")} (${message})`);
2625
3143
  }
3144
+ reasons.push(...satisfactionErrors);
3145
+ return { triggered: true, satisfied: false, reasons, securityTriggered };
2626
3146
  }
2627
- async function runGit(args, options = {}) {
2628
- return runCommand("git", args, options);
3147
+ async function listDecisionNumbers(decisionsDir) {
3148
+ if (!await fs19.pathExists(decisionsDir)) return [];
3149
+ const entries = await fs19.readdir(decisionsDir);
3150
+ return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
2629
3151
  }
2630
3152
 
2631
- // src/commands/clone.ts
2632
- var GIT_LOCATION_VARS = [
2633
- "GIT_DIR",
2634
- "GIT_WORK_TREE",
2635
- "GIT_INDEX_FILE",
2636
- "GIT_COMMON_DIR",
2637
- "GIT_OBJECT_DIRECTORY",
2638
- "GIT_NAMESPACE",
2639
- "GIT_PREFIX"
2640
- ];
2641
- function cloneEnv() {
2642
- const env = { ...process.env };
2643
- for (const key of GIT_LOCATION_VARS) delete env[key];
2644
- return env;
3153
+ // src/decisions/next-number.ts
3154
+ async function nextDecisionNumber(root) {
3155
+ const decisionsDir = await resolveDecisionsDir(root);
3156
+ const numbers = await listDecisionNumbers(decisionsDir);
3157
+ const max = numbers.length === 0 ? 0 : Math.max(...numbers);
3158
+ return String(max + 1).padStart(4, "0");
2645
3159
  }
2646
- function repoNameFromUrl(url) {
2647
- const trimmed = url.trim().replace(/\.git$/, "").replace(/\/+$/, "");
2648
- const tail = trimmed.split(/[/:]/).pop() ?? "";
2649
- return tail || "repo";
3160
+
3161
+ // src/decisions/suggest.ts
3162
+ function draftTemplate(number, title) {
3163
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3164
+ return `# ADR-${number}: ${title}
3165
+
3166
+ - **Status:** Proposed
3167
+ - **Date:** ${today}
3168
+ - **Decided by:** _(pending approval)_ (draft by haus decisions suggest)
3169
+ - **Affects:** _(paths/components)_
3170
+ - **Related:** _(PR/issue link)_
3171
+
3172
+ ## Context
3173
+
3174
+ _(What problem or situation triggered this decision?)_
3175
+
3176
+ ## Decision
3177
+
3178
+ _(One clear sentence: what we chose.)_
3179
+
3180
+ ## Motivation (why)
3181
+
3182
+ _(Why this over alternatives \u2014 the line agents read for "why".)_
3183
+
3184
+ ## Alternatives considered
3185
+
3186
+ - _(Alternative)_ \u2014 rejected because _(reason)_.
3187
+
3188
+ ## Consequences
3189
+
3190
+ - _(Positive and negative follow-ons.)_
3191
+ `;
2650
3192
  }
2651
- async function runClone(url, opts = {}) {
2652
- if (!url || !url.trim()) {
2653
- error("A git URL is required: `haus clone <url> [dir]`.");
2654
- process.exitCode = 1;
3193
+ async function runDecisionsSuggest(root, opts = {}) {
3194
+ const stats = await collectDiffStats(root, { staged: true });
3195
+ const stagedCheck = await runDecisionsCheck(root, { staged: true });
3196
+ const unstagedStats = await collectDiffStats(root, {});
3197
+ const triggered = isDecisionTriggered(stats) || isDecisionTriggered(unstagedStats);
3198
+ if (!triggered) return;
3199
+ if (stagedCheck.triggered && stagedCheck.satisfied) return;
3200
+ const number = await nextDecisionNumber(root);
3201
+ const title = opts.title ?? "Decision from current changes";
3202
+ const draft = draftTemplate(number, title);
3203
+ const decisionsDir = await resolveDecisionsDir(root);
3204
+ const rel = relativeDecisionPath(decisionsDir, root);
3205
+ const file = `${rel}/${number}-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "decision"}.md`;
3206
+ const payload = {
3207
+ action: "propose_decision",
3208
+ message: "Decision-worthy change detected. Draft an ADR from this template and ask the user to approve before writing.",
3209
+ file,
3210
+ draft,
3211
+ indexHint: `Add a row to ${rel}/README.md for ADR-${number}.`
3212
+ };
3213
+ if (opts.fromHook) {
3214
+ process.stdout.write(`${JSON.stringify(payload)}
3215
+ `);
2655
3216
  return;
2656
3217
  }
2657
- const target = path15.resolve(opts.dir?.trim() || repoNameFromUrl(url));
2658
- if (existsSync2(target)) {
2659
- log(`\u2022 ${path15.basename(target)} already present at ${target} \u2014 skipped`);
3218
+ process.stdout.write(`${draft}
3219
+ `);
3220
+ }
3221
+
3222
+ // src/commands/decisions.ts
3223
+ async function runDecisions(sub, arg, options = {}) {
3224
+ const root = process.cwd();
3225
+ if (sub === "check") {
3226
+ const commitMessages = options.range != null ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
3227
+ (m) => m.runGit(["log", "--format=%B", options.range], { cwd: root })
3228
+ )).stdout : "";
3229
+ const result = await runDecisionsCheck(root, {
3230
+ staged: options.staged,
3231
+ range: options.range,
3232
+ prBody: options.prBody ?? process.env.PR_BODY,
3233
+ commitMessages
3234
+ });
3235
+ if (!result.triggered) {
3236
+ log(result.reasons[0] ?? "no decision-worthy changes");
3237
+ return;
3238
+ }
3239
+ if (result.satisfied) {
3240
+ if (result.reasons.some((r) => r.includes("[adr-skip]"))) {
3241
+ warn(`::warning::${result.reasons.join("; ")}`);
3242
+ }
3243
+ log(`decisions gate: satisfied (${result.reasons.join("; ")})`);
3244
+ return;
3245
+ }
3246
+ for (const reason of result.reasons) warn(reason);
3247
+ error(
3248
+ "decisions gate: decision-worthy change without a valid ADR under docs/decisions/. Run `haus decisions suggest`, add docs/decisions/NNNN-*.md + README row, or use [adr-skip] with justification."
3249
+ );
3250
+ process.exitCode = 1;
2660
3251
  return;
2661
3252
  }
2662
- if (opts.dryRun) {
2663
- log(`would clone ${url} \u2192 ${target}`);
3253
+ if (sub === "suggest") {
3254
+ await runDecisionsSuggest(root, { fromHook: options.fromHook, title: options.title });
2664
3255
  return;
2665
3256
  }
2666
- const res = await runGit(["clone", url, target], { env: cloneEnv(), extendEnv: false });
2667
- if (res.exitCode !== 0) {
2668
- error(`clone failed for ${url}: ${(res.stderr || res.stdout).trim()}`);
2669
- process.exitCode = 1;
3257
+ if (sub === "next-number") {
3258
+ log(await nextDecisionNumber(root));
2670
3259
  return;
2671
3260
  }
2672
- log(`\u2713 cloned ${url} \u2192 ${target}`);
3261
+ if (sub === "validate") {
3262
+ const target = arg ? path22.resolve(root, arg) : void 0;
3263
+ if (!target) {
3264
+ error("usage: haus decisions validate <path-to-decision.md>");
3265
+ process.exitCode = 1;
3266
+ return;
3267
+ }
3268
+ const result = await validateDecisionFile(target);
3269
+ if (!result.ok) {
3270
+ for (const e of result.errors) error(e);
3271
+ process.exitCode = 1;
3272
+ return;
3273
+ }
3274
+ log("valid");
3275
+ }
2673
3276
  }
2674
3277
 
2675
3278
  // src/commands/doctor.ts
2676
- import path20 from "node:path";
2677
- import fs15 from "fs-extra";
3279
+ import path28 from "node:path";
3280
+ import fs22 from "fs-extra";
3281
+
3282
+ // src/decisions/doctor.ts
3283
+ import path23 from "node:path";
3284
+ import fs20 from "fs-extra";
3285
+ async function auditDecisionsLayout(root) {
3286
+ const findings = [];
3287
+ const primary = path23.join(root, DECISIONS_TRIGGERS.decisionsDir);
3288
+ const legacy = path23.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3289
+ const hasPrimary = await fs20.pathExists(primary);
3290
+ const hasLegacy = await fs20.pathExists(legacy);
3291
+ if (!hasPrimary && hasLegacy) {
3292
+ findings.push({
3293
+ level: "advisory",
3294
+ line: `- DECISIONS: legacy ${DECISIONS_TRIGGERS.legacyDecisionsDir}/ present \u2014 migrate to docs/decisions/`,
3295
+ sentence: "Decision records still live under the legacy docs/adr/ path",
3296
+ fix: `git mv ${DECISIONS_TRIGGERS.legacyDecisionsDir} ${DECISIONS_TRIGGERS.decisionsDir}`
3297
+ });
3298
+ return findings;
3299
+ }
3300
+ if (!hasPrimary) return findings;
3301
+ const decisionsDir = await resolveDecisionsDir(root);
3302
+ const rel = relativeDecisionPath(decisionsDir, root);
3303
+ const readmePath = path23.join(decisionsDir, "README.md");
3304
+ if (!await fs20.pathExists(readmePath)) {
3305
+ findings.push({
3306
+ level: "advisory",
3307
+ line: `- DECISIONS: missing ${rel}/README.md index`,
3308
+ sentence: "No decision index for agents to load at session start",
3309
+ fix: `create ${rel}/README.md and @import it from CLAUDE.md`
3310
+ });
3311
+ return findings;
3312
+ }
3313
+ const readme = await readText(readmePath);
3314
+ const entries = (await fs20.readdir(decisionsDir)).filter(
3315
+ (f) => f.endsWith(".md") && f !== "README.md"
3316
+ );
3317
+ for (const file of entries) {
3318
+ const num = decisionNumberFromFile(file);
3319
+ if (num && readme && !readme.includes(`[${num}]`) && !readme.includes(`(${num}-`)) {
3320
+ findings.push({
3321
+ level: "advisory",
3322
+ line: `- DECISIONS: ${file} missing from ${rel}/README.md`,
3323
+ sentence: `ADR-${num} is not listed in the decision index`,
3324
+ fix: `add a row for ADR-${num} to ${rel}/README.md`
3325
+ });
3326
+ }
3327
+ }
3328
+ const claude = await readText(path23.join(root, "CLAUDE.md"));
3329
+ if (claude && !claude.includes(`@${rel}/README.md`)) {
3330
+ findings.push({
3331
+ level: "advisory",
3332
+ line: `- DECISIONS: CLAUDE.md does not @import @${rel}/README.md`,
3333
+ sentence: "Agents may not load the decision index at session start",
3334
+ fix: `add @${rel}/README.md to CLAUDE.md`
3335
+ });
3336
+ }
3337
+ const numbers = await listDecisionNumbers(decisionsDir);
3338
+ const dupes = numbers.filter((n, i) => numbers.indexOf(n) !== i);
3339
+ if (dupes.length > 0) {
3340
+ findings.push({
3341
+ level: "advisory",
3342
+ line: `- DECISIONS: duplicate numbers: ${[...new Set(dupes)].join(", ")}`,
3343
+ sentence: "Multiple decision files share the same number",
3344
+ fix: "renumber one file and update README.md"
3345
+ });
3346
+ }
3347
+ if (findings.length === 0) {
3348
+ findings.push({
3349
+ level: "ok",
3350
+ line: `- DECISIONS OK: ${rel}/ (${entries.length} record(s))`,
3351
+ sentence: "",
3352
+ fix: ""
3353
+ });
3354
+ }
3355
+ return findings;
3356
+ }
2678
3357
 
2679
3358
  // src/scanner/scan-project.ts
2680
- import path19 from "node:path";
3359
+ import path27 from "node:path";
2681
3360
 
2682
3361
  // src/utils/audit-checks.ts
2683
3362
  function isRecord(v) {
@@ -2704,8 +3383,8 @@ function compareVersions(a, b) {
2704
3383
  }
2705
3384
 
2706
3385
  // src/scanner/detect-package-manager.ts
2707
- import path16 from "node:path";
2708
- import fs14 from "fs-extra";
3386
+ import path24 from "node:path";
3387
+ import fs21 from "fs-extra";
2709
3388
  function detectPackageManager(root, packageManagerField) {
2710
3389
  const field = String(packageManagerField ?? "").trim();
2711
3390
  if (field.startsWith("yarn@")) {
@@ -2723,9 +3402,9 @@ function detectPackageManager(root, packageManagerField) {
2723
3402
  if (satisfiesVersion(version, ">=9")) return "npm";
2724
3403
  return "unknown";
2725
3404
  }
2726
- if (fs14.existsSync(path16.join(root, "yarn.lock"))) return "yarn";
2727
- if (fs14.existsSync(path16.join(root, "pnpm-lock.yaml"))) return "pnpm";
2728
- if (fs14.existsSync(path16.join(root, "package-lock.json"))) return "npm";
3405
+ if (fs21.existsSync(path24.join(root, "yarn.lock"))) return "yarn";
3406
+ if (fs21.existsSync(path24.join(root, "pnpm-lock.yaml"))) return "pnpm";
3407
+ if (fs21.existsSync(path24.join(root, "package-lock.json"))) return "npm";
2729
3408
  return "unknown";
2730
3409
  }
2731
3410
 
@@ -2927,7 +3606,7 @@ function runDetection(ctx, rules = STACK_RULES) {
2927
3606
  }
2928
3607
 
2929
3608
  // src/scanner/detection.ts
2930
- import path17 from "node:path";
3609
+ import path25 from "node:path";
2931
3610
  var UNSUPPORTED_MARKERS = {
2932
3611
  "requirements.txt": "python",
2933
3612
  "pyproject.toml": "python",
@@ -2981,14 +3660,14 @@ function finalizeRoles(registryRoles, deps, files) {
2981
3660
  function collectUnsupportedSignals(files) {
2982
3661
  return [
2983
3662
  ...new Set(
2984
- files.map((f) => UNSUPPORTED_MARKERS[path17.basename(f)]).filter((s) => Boolean(s))
3663
+ files.map((f) => UNSUPPORTED_MARKERS[path25.basename(f)]).filter((s) => Boolean(s))
2985
3664
  )
2986
3665
  ].sort();
2987
3666
  }
2988
3667
 
2989
3668
  // src/scanner/render.ts
2990
3669
  import { readFile } from "node:fs/promises";
2991
- import path18 from "node:path";
3670
+ import path26 from "node:path";
2992
3671
  async function buildContentBlob(root, files) {
2993
3672
  const candidates = files.filter(
2994
3673
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -2996,7 +3675,7 @@ async function buildContentBlob(root, files) {
2996
3675
  const slice = candidates.slice(0, 300);
2997
3676
  const parts = await mapWithConcurrency(slice, async (rel) => {
2998
3677
  try {
2999
- return await readFile(path18.join(root, rel), "utf8");
3678
+ return await readFile(path26.join(root, rel), "utf8");
3000
3679
  } catch {
3001
3680
  return "";
3002
3681
  }
@@ -3083,8 +3762,8 @@ var SAFE_FILES = [
3083
3762
  "Gemfile"
3084
3763
  ];
3085
3764
  async function scanProject(root) {
3086
- const pkg = await readJson(path19.join(root, "package.json"));
3087
- const composer = await readJson(path19.join(root, "composer.json"));
3765
+ const pkg = await readJson(path27.join(root, "package.json"));
3766
+ const composer = await readJson(path27.join(root, "composer.json"));
3088
3767
  const files = await listFiles(root, SAFE_FILES);
3089
3768
  const safeFiles = files.filter((f) => !blocked(f));
3090
3769
  const deps = dependencySet(pkg, composer);
@@ -3106,14 +3785,12 @@ async function scanProject(root) {
3106
3785
  crossRepoHints.push("Containerized services detected");
3107
3786
  if (safeFiles.some((f) => f.includes("turbo.json") || f.includes("nx.json")))
3108
3787
  crossRepoHints.push("Monorepo orchestration detected");
3109
- if (safeFiles.some((f) => f.includes("wp-content/uploads")))
3110
- securityRisks.push("Uploads directory present");
3111
3788
  const unsupportedSignals = collectUnsupportedSignals(safeFiles);
3112
3789
  const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
3113
3790
  const context = {
3114
3791
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3115
3792
  root,
3116
- repoName: String(pkg?.name ?? path19.basename(root)),
3793
+ repoName: String(pkg?.name ?? path27.basename(root)),
3117
3794
  packageManager,
3118
3795
  repoRoles: roles,
3119
3796
  detectedStacks: stacks,
@@ -3184,11 +3861,16 @@ async function runDoctor(options) {
3184
3861
  );
3185
3862
  const detail = [];
3186
3863
  const attention = [];
3864
+ const suggestions = [];
3187
3865
  const ok = (text) => detail.push({ stream: "log", text });
3188
3866
  const flag = (text, sentence, fix) => {
3189
3867
  detail.push({ stream: "warn", text });
3190
3868
  attention.push({ sentence, fix });
3191
3869
  };
3870
+ const suggest = (text, sentence, fix) => {
3871
+ detail.push({ stream: "warn", text });
3872
+ suggestions.push({ sentence, fix });
3873
+ };
3192
3874
  ok(`Repo: ${context.repoName}`);
3193
3875
  ok(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
3194
3876
  ok(`Package manager: ${context.packageManager}`);
@@ -3199,7 +3881,11 @@ async function runDoctor(options) {
3199
3881
  }
3200
3882
  const hooks = await verifyProjectSettingsHooksContract(root);
3201
3883
  if (hooks.skipped) {
3202
- ok(`- HOOKS: (skipped) ${hooks.message}`);
3884
+ flag(
3885
+ `- HOOKS: (skipped) ${hooks.message}`,
3886
+ "No Claude Code settings.json \u2014 haus security hooks are not installed",
3887
+ "haus apply --write"
3888
+ );
3203
3889
  } else if (!hooks.ok) {
3204
3890
  flag(
3205
3891
  `- HOOKS FAIL: ${hooks.message}`,
@@ -3210,7 +3896,7 @@ async function runDoctor(options) {
3210
3896
  } else {
3211
3897
  ok(`- HOOKS OK: ${hooks.message}`);
3212
3898
  }
3213
- const rootClaudeMdPath = path20.join(root, "CLAUDE.md");
3899
+ const rootClaudeMdPath = path28.join(root, "CLAUDE.md");
3214
3900
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
3215
3901
  if (!rootClaudeMdContent) {
3216
3902
  flag(
@@ -3238,7 +3924,7 @@ async function runDoctor(options) {
3238
3924
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
3239
3925
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
3240
3926
  for (const target of importTargets) {
3241
- if (!await fs15.pathExists(hausPath(root, target))) {
3927
+ if (!await fs22.pathExists(hausPath(root, target))) {
3242
3928
  flag(
3243
3929
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
3244
3930
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -3249,7 +3935,7 @@ async function runDoctor(options) {
3249
3935
  }
3250
3936
  }
3251
3937
  const workflowPath = hausPath(root, "WORKFLOW.md");
3252
- const workflowExists = await fs15.pathExists(workflowPath);
3938
+ const workflowExists = await fs22.pathExists(workflowPath);
3253
3939
  if (!workflowExists) {
3254
3940
  flag(
3255
3941
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -3272,20 +3958,20 @@ async function runDoctor(options) {
3272
3958
  "haus apply --write --force"
3273
3959
  );
3274
3960
  } else {
3275
- const cachePath = path20.join(getCacheDir(), "templates/agentic-workflow-standard.md");
3276
- const bundledPath = path20.join(
3961
+ const cachePath = path28.join(getCacheDir(), "templates/agentic-workflow-standard.md");
3962
+ const bundledPath = path28.join(
3277
3963
  packageRoot(),
3278
3964
  "library",
3279
3965
  "global",
3280
3966
  "templates",
3281
3967
  "agentic-workflow-standard.md"
3282
3968
  );
3283
- const templatePath = await fs15.pathExists(cachePath) ? cachePath : bundledPath;
3969
+ const templatePath = await fs22.pathExists(cachePath) ? cachePath : bundledPath;
3284
3970
  const templateContent = await readText(templatePath);
3285
3971
  if (storedHashMatch && templateContent) {
3286
3972
  const currentHash = hashText(normaliseLF(templateContent));
3287
3973
  if (storedHashMatch[1] !== currentHash) {
3288
- flag(
3974
+ suggest(
3289
3975
  "- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
3290
3976
  "The workflow standard is out of date",
3291
3977
  "haus apply --write"
@@ -3300,7 +3986,7 @@ async function runDoctor(options) {
3300
3986
  }
3301
3987
  }
3302
3988
  const workflowConfigPath = hausPath(root, "workflow-config.md");
3303
- const workflowConfigExists = await fs15.pathExists(workflowConfigPath);
3989
+ const workflowConfigExists = await fs22.pathExists(workflowConfigPath);
3304
3990
  if (!workflowConfigExists) {
3305
3991
  flag(
3306
3992
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -3308,10 +3994,10 @@ async function runDoctor(options) {
3308
3994
  "haus apply --write"
3309
3995
  );
3310
3996
  } else {
3311
- const cfg = await fs15.readFile(workflowConfigPath, "utf8");
3997
+ const cfg = await fs22.readFile(workflowConfigPath, "utf8");
3312
3998
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
3313
3999
  if (unfilled > 0) {
3314
- flag(
4000
+ suggest(
3315
4001
  `- .haus-workflow/workflow-config.md: ${unfilled} field(s) still unfilled (run \`haus apply --refill-config\` to auto-fill detectable ones)`,
3316
4002
  `${unfilled} workflow-config field(s) are still blank`,
3317
4003
  "haus apply --refill-config"
@@ -3321,7 +4007,7 @@ async function runDoctor(options) {
3321
4007
  }
3322
4008
  }
3323
4009
  if (workflowExists) {
3324
- const prettierIgnore = await readText(path20.join(root, ".prettierignore")) ?? "";
4010
+ const prettierIgnore = await readText(path28.join(root, ".prettierignore")) ?? "";
3325
4011
  if (!prettierIgnore.split("\n").some((l) => l.trim() === ".haus-workflow/")) {
3326
4012
  flag(
3327
4013
  "- .prettierignore: not protecting .haus-workflow/ (run `haus apply --write`)",
@@ -3332,9 +4018,13 @@ async function runDoctor(options) {
3332
4018
  ok("- .prettierignore: protects .haus-workflow/");
3333
4019
  }
3334
4020
  }
4021
+ for (const finding of await auditDecisionsLayout(root)) {
4022
+ if (finding.level === "ok") ok(finding.line);
4023
+ else suggest(finding.line, finding.sentence, finding.fix);
4024
+ }
3335
4025
  const cacheAgeMs = await getCacheManifestAge();
3336
4026
  if (cacheAgeMs === null) {
3337
- flag(
4027
+ suggest(
3338
4028
  "- CATALOG CACHE: absent (run `haus update` to populate)",
3339
4029
  "The catalog cache hasn't been downloaded yet",
3340
4030
  "haus update"
@@ -3342,7 +4032,7 @@ async function runDoctor(options) {
3342
4032
  } else {
3343
4033
  const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
3344
4034
  if (cacheAgeDays >= 7) {
3345
- flag(
4035
+ suggest(
3346
4036
  `- CATALOG CACHE: stale (${cacheAgeDays}d old \u2014 run \`haus update\`)`,
3347
4037
  `The catalog cache is ${cacheAgeDays} days old`,
3348
4038
  "haus update"
@@ -3351,11 +4041,11 @@ async function runDoctor(options) {
3351
4041
  ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
3352
4042
  }
3353
4043
  }
3354
- const pkgJson = await readJson(path20.join(packageRoot(), "package.json"));
4044
+ const pkgJson = await readJson(path28.join(packageRoot(), "package.json"));
3355
4045
  const currentVersion = pkgJson?.version ?? "0.0.0";
3356
4046
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
3357
4047
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
3358
- flag(
4048
+ suggest(
3359
4049
  `- CLI UPDATE: ${currentVersion} \u2192 ${npmStatus.latest} available (run: npm install -g ${NPM_PACKAGE_NAME})`,
3360
4050
  `A newer haus (${npmStatus.latest}) is available`,
3361
4051
  `npm install -g ${NPM_PACKAGE_NAME}`
@@ -3365,12 +4055,17 @@ async function runDoctor(options) {
3365
4055
  } else {
3366
4056
  ok(`- CLI: ${currentVersion} (version check unavailable)`);
3367
4057
  }
4058
+ if (attention.length > 0) process.exitCode = 1;
3368
4059
  if (attention.length === 0) {
3369
4060
  log("\u2705 Your project is set up and healthy.");
3370
4061
  } else {
3371
4062
  log(`\u26A0\uFE0F ${attention.length} thing(s) need attention:`);
3372
4063
  for (const a of attention) log(` \u2022 ${a.sentence} \u2014 fix: ${a.fix}`);
3373
4064
  }
4065
+ if (suggestions.length > 0) {
4066
+ log(`\u{1F4A1} ${suggestions.length} suggestion(s):`);
4067
+ for (const s of suggestions) log(` \u2022 ${s.sentence} \u2014 try: ${s.fix}`);
4068
+ }
3374
4069
  log("Haus Doctor");
3375
4070
  for (const line2 of detail) {
3376
4071
  if (line2.stream === "warn") warn(line2.text);
@@ -3502,6 +4197,61 @@ async function runExplainRecommendation(options) {
3502
4197
  log(formatRecommendationHuman(normalized));
3503
4198
  }
3504
4199
 
4200
+ // src/commands/fetch-refs.ts
4201
+ var REFS_CACHE_SUBDIR = "llms-cache";
4202
+ async function runFetchRefs(options) {
4203
+ const root = process.cwd();
4204
+ const cacheDir = hausPath(root, REFS_CACHE_SUBDIR);
4205
+ let allItems;
4206
+ try {
4207
+ allItems = await loadCatalog(root);
4208
+ } catch (err) {
4209
+ warn(`Could not load catalog: ${err instanceof Error ? err.message : String(err)}`);
4210
+ process.exitCode = 1;
4211
+ return;
4212
+ }
4213
+ let items = allItems;
4214
+ if (options.id) {
4215
+ items = allItems.filter((item) => item.id === options.id);
4216
+ if (items.length === 0) {
4217
+ warn(`No catalog item found with id "${options.id}".`);
4218
+ process.exitCode = 1;
4219
+ return;
4220
+ }
4221
+ }
4222
+ const withRefs = items.filter((item) => (item.references ?? []).some(isLlmsTxtUrl));
4223
+ if (withRefs.length === 0) {
4224
+ if (options.json) {
4225
+ log(
4226
+ JSON.stringify(
4227
+ { fetched: 0, unchanged: 0, failed: 0, failedUrls: [], cachedFiles: {} },
4228
+ null,
4229
+ 2
4230
+ )
4231
+ );
4232
+ } else {
4233
+ log("No catalog items with llms.txt references found.");
4234
+ }
4235
+ return;
4236
+ }
4237
+ const summary = await fetchRefsForItems(withRefs, cacheDir);
4238
+ if (options.json) {
4239
+ log(JSON.stringify(summary, null, 2));
4240
+ return;
4241
+ }
4242
+ for (const [url, filePath] of Object.entries(summary.cachedFiles)) {
4243
+ log(`Cached: ${filePath} (${url})`);
4244
+ }
4245
+ if (summary.fetched > 0) log(`Fetched: ${summary.fetched} URL(s)`);
4246
+ if (summary.unchanged > 0) log(`Unchanged (etag match): ${summary.unchanged} URL(s)`);
4247
+ if (summary.failed > 0) {
4248
+ warn(`Failed to fetch ${summary.failed} URL(s): ${summary.failedUrls.join(", ")}`);
4249
+ }
4250
+ if (summary.fetched === 0 && summary.unchanged > 0 && summary.failed === 0) {
4251
+ log("All references up to date.");
4252
+ }
4253
+ }
4254
+
3505
4255
  // src/commands/guard.ts
3506
4256
  import { readFileSync as readFileSync2 } from "node:fs";
3507
4257
 
@@ -3538,7 +4288,15 @@ function stdin() {
3538
4288
  }
3539
4289
  }
3540
4290
  function deny(reason) {
3541
- log(JSON.stringify({ permissionDecision: "deny", permissionDecisionReason: reason }));
4291
+ log(
4292
+ JSON.stringify({
4293
+ hookSpecificOutput: {
4294
+ hookEventName: "PreToolUse",
4295
+ permissionDecision: "deny",
4296
+ permissionDecisionReason: reason
4297
+ }
4298
+ })
4299
+ );
3542
4300
  }
3543
4301
  async function runGuard(kind, _options) {
3544
4302
  const raw = stdin();
@@ -3549,7 +4307,6 @@ async function runGuard(kind, _options) {
3549
4307
  if (isRecord(parsed)) payload = parsed;
3550
4308
  } catch {
3551
4309
  deny("Malformed hook payload");
3552
- process.exitCode = 1;
3553
4310
  return;
3554
4311
  }
3555
4312
  }
@@ -3559,8 +4316,6 @@ async function runGuard(kind, _options) {
3559
4316
  const reason2 = guardFileAccess(candidate);
3560
4317
  if (reason2) {
3561
4318
  deny(reason2);
3562
- process.exitCode = 1;
3563
- return;
3564
4319
  }
3565
4320
  return;
3566
4321
  }
@@ -3568,13 +4323,12 @@ async function runGuard(kind, _options) {
3568
4323
  const reason = guardBash(command);
3569
4324
  if (reason) {
3570
4325
  deny(reason);
3571
- process.exitCode = 1;
3572
4326
  }
3573
4327
  }
3574
4328
 
3575
4329
  // src/commands/init.ts
3576
- import path21 from "node:path";
3577
- import fs16 from "fs-extra";
4330
+ import path29 from "node:path";
4331
+ import fs23 from "fs-extra";
3578
4332
 
3579
4333
  // src/utils/prompts.ts
3580
4334
  import { stdin as input, stdout as output } from "node:process";
@@ -3595,6 +4349,7 @@ async function confirm(question) {
3595
4349
  }
3596
4350
 
3597
4351
  // src/recommender/git-signal.ts
4352
+ init_exec();
3598
4353
  async function readChangedFiles(root) {
3599
4354
  if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
3600
4355
  try {
@@ -4009,37 +4764,39 @@ function buildOptInEligible(items, skipped) {
4009
4764
 
4010
4765
  // src/commands/setup-core.ts
4011
4766
  async function runSetupCore(root, opts) {
4012
- const { json, apply, dryRun, confirm: confirm2 } = opts;
4767
+ const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
4768
+ const say = quiet ? () => {
4769
+ } : log;
4013
4770
  const scanResult = await scanProject(root);
4014
4771
  if (json) {
4015
- log(JSON.stringify(scanResult, null, 2));
4772
+ say(JSON.stringify(scanResult, null, 2));
4016
4773
  } else {
4017
- log("Haus scan complete");
4018
- log(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
4019
- log(`Package manager: ${scanResult.packageManager}`);
4774
+ say("Haus scan complete");
4775
+ say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
4776
+ say(`Package manager: ${scanResult.packageManager}`);
4020
4777
  }
4021
4778
  const context = await readContextOrScan(root);
4022
4779
  const recommendation = await recommend(root, context);
4023
4780
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
4024
4781
  if (json) {
4025
- log(JSON.stringify(recommendation, null, 2));
4782
+ say(JSON.stringify(recommendation, null, 2));
4026
4783
  } else {
4027
- log("Haus recommendation ready");
4028
- log(`Recommended: ${recommendation.recommended.length}`);
4029
- log(`Skipped: ${recommendation.skipped.length}`);
4784
+ say("Haus recommendation ready");
4785
+ say(`Recommended: ${recommendation.recommended.length}`);
4786
+ say(`Skipped: ${recommendation.skipped.length}`);
4030
4787
  }
4031
4788
  const hooks = await verifyProjectSettingsHooksContract(root);
4032
4789
  const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
4033
- log(`Repo: ${context.repoName}`);
4034
- for (const warning of warningLines) log(`- WARN: ${warning}`);
4790
+ say(`Repo: ${context.repoName}`);
4791
+ for (const warning of warningLines) say(`- WARN: ${warning}`);
4035
4792
  const hooksOk = hooks.ok;
4036
4793
  if (hooks.skipped) {
4037
- log(`- HOOKS: (skipped) ${hooks.message}`);
4794
+ say(`- HOOKS: (skipped) ${hooks.message}`);
4038
4795
  } else if (!hooks.ok) {
4039
- log(`- HOOKS FAIL: ${hooks.message}`);
4040
- process.exitCode = 1;
4796
+ say(`- HOOKS FAIL: ${hooks.message}`);
4797
+ if (!apply) process.exitCode = 1;
4041
4798
  } else {
4042
- log(`- HOOKS OK: ${hooks.message}`);
4799
+ say(`- HOOKS OK: ${hooks.message}`);
4043
4800
  }
4044
4801
  const baseResult = {
4045
4802
  root,
@@ -4054,32 +4811,32 @@ async function runSetupCore(root, opts) {
4054
4811
  if (confirm2) {
4055
4812
  const approved = await confirm2();
4056
4813
  if (!approved) {
4057
- log("Setup reviewed. No files written.");
4058
- log("Next step: run `haus apply --write` when ready.");
4814
+ say("Setup reviewed. No files written.");
4815
+ say("Next step: run `haus apply --write` when ready.");
4059
4816
  return baseResult;
4060
4817
  }
4061
4818
  }
4062
4819
  if (!dryRun && !process.env["HAUS_FIXTURE_CATALOG"]) {
4063
- log("Syncing remote catalog...");
4820
+ say("Syncing remote catalog...");
4064
4821
  const sync = await syncRemoteCatalog();
4065
4822
  if (sync.newItems.length > 0) {
4066
- log(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
4823
+ say(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
4067
4824
  } else if (sync.refreshed.length > 0) {
4068
- log(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
4825
+ say(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
4069
4826
  }
4070
4827
  }
4071
- const files = await writeClaudeFiles(root, dryRun ?? false);
4072
- log("Applied files:");
4073
- files.forEach((f) => log(`- ${displayPath(root, f)}`));
4828
+ const files = await writeClaudeFiles(root, dryRun ?? false, void 0, { quiet: opts.quiet });
4829
+ say("Applied files:");
4830
+ files.forEach((f) => say(`- ${displayPath(root, f)}`));
4074
4831
  const hooksAfter = await verifyProjectSettingsHooksContract(root);
4075
4832
  const hooksOkAfter = hooksAfter.ok;
4076
4833
  if (hooksAfter.skipped) {
4077
- log(`- HOOKS: (skipped) ${hooksAfter.message}`);
4834
+ say(`- HOOKS: (skipped) ${hooksAfter.message}`);
4078
4835
  } else if (!hooksAfter.ok) {
4079
- log(`- HOOKS FAIL: ${hooksAfter.message}`);
4836
+ say(`- HOOKS FAIL: ${hooksAfter.message}`);
4080
4837
  process.exitCode = 1;
4081
4838
  } else {
4082
- log(`- HOOKS OK: ${hooksAfter.message}`);
4839
+ say(`- HOOKS OK: ${hooksAfter.message}`);
4083
4840
  }
4084
4841
  return { ...baseResult, hooksOk: hooksOkAfter, written: files };
4085
4842
  }
@@ -4098,8 +4855,8 @@ async function runSetupProject(options) {
4098
4855
  // src/commands/init.ts
4099
4856
  async function runInit(options) {
4100
4857
  const root = process.cwd();
4101
- const hausDir = path21.join(root, ".haus-workflow");
4102
- const alreadyInit = await fs16.pathExists(hausDir);
4858
+ const hausDir = path29.join(root, ".haus-workflow");
4859
+ const alreadyInit = await fs23.pathExists(hausDir);
4103
4860
  if (alreadyInit) {
4104
4861
  log("Haus AI already initialized in this project.");
4105
4862
  log("Run `haus setup-project` to reconfigure.");
@@ -4111,8 +4868,8 @@ async function runInit(options) {
4111
4868
 
4112
4869
  // src/install/apply.ts
4113
4870
  import crypto2 from "node:crypto";
4114
- import path22 from "node:path";
4115
- import fs17 from "fs-extra";
4871
+ import path30 from "node:path";
4872
+ import fs24 from "fs-extra";
4116
4873
 
4117
4874
  // src/install/header.ts
4118
4875
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -4198,40 +4955,40 @@ function hashContent(content2) {
4198
4955
  }
4199
4956
  function sourceVersion() {
4200
4957
  try {
4201
- const pkgPath = path22.join(packageRoot(), "package.json");
4202
- const pkg = JSON.parse(fs17.readFileSync(pkgPath, "utf8"));
4958
+ const pkgPath = path30.join(packageRoot(), "package.json");
4959
+ const pkg = JSON.parse(fs24.readFileSync(pkgPath, "utf8"));
4203
4960
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
4204
4961
  } catch {
4205
4962
  return "haus@0.0.0";
4206
4963
  }
4207
4964
  }
4208
4965
  function globalSrcDir() {
4209
- return path22.join(packageRoot(), "library", "global");
4966
+ return path30.join(packageRoot(), "library", "global");
4210
4967
  }
4211
4968
  function collectSourceFiles(srcDir, claudeDir) {
4212
4969
  const entries = [];
4213
- const skillsDir = path22.join(srcDir, "skills");
4214
- if (fs17.pathExistsSync(skillsDir)) {
4215
- for (const skillName of fs17.readdirSync(skillsDir)) {
4216
- const skillFile = path22.join(skillsDir, skillName, "SKILL.md");
4217
- if (fs17.pathExistsSync(skillFile)) {
4970
+ const skillsDir = path30.join(srcDir, "skills");
4971
+ if (fs24.pathExistsSync(skillsDir)) {
4972
+ for (const skillName of fs24.readdirSync(skillsDir)) {
4973
+ const skillFile = path30.join(skillsDir, skillName, "SKILL.md");
4974
+ if (fs24.pathExistsSync(skillFile)) {
4218
4975
  entries.push({
4219
4976
  stableId: `skill.${skillName}`,
4220
- srcRelPath: path22.join("library", "global", "skills", skillName, "SKILL.md"),
4221
- destPath: path22.join(claudeDir, "skills", skillName, "SKILL.md")
4977
+ srcRelPath: path30.join("library", "global", "skills", skillName, "SKILL.md"),
4978
+ destPath: path30.join(claudeDir, "skills", skillName, "SKILL.md")
4222
4979
  });
4223
4980
  }
4224
4981
  }
4225
4982
  }
4226
- const commandsDir = path22.join(srcDir, "commands");
4227
- if (fs17.pathExistsSync(commandsDir)) {
4228
- for (const fileName of fs17.readdirSync(commandsDir)) {
4983
+ const commandsDir = path30.join(srcDir, "commands");
4984
+ if (fs24.pathExistsSync(commandsDir)) {
4985
+ for (const fileName of fs24.readdirSync(commandsDir)) {
4229
4986
  if (!fileName.endsWith(".md")) continue;
4230
4987
  const commandName = fileName.slice(0, -".md".length);
4231
4988
  entries.push({
4232
4989
  stableId: `command.${commandName}`,
4233
- srcRelPath: path22.join("library", "global", "commands", fileName),
4234
- destPath: path22.join(claudeDir, "commands", fileName)
4990
+ srcRelPath: path30.join("library", "global", "commands", fileName),
4991
+ destPath: path30.join(claudeDir, "commands", fileName)
4235
4992
  });
4236
4993
  }
4237
4994
  }
@@ -4249,13 +5006,15 @@ async function applyInstall(options = {}) {
4249
5006
  created: [],
4250
5007
  updated: [],
4251
5008
  skipped: [],
5009
+ userOwned: [],
5010
+ userEdited: [],
4252
5011
  deleted: [],
4253
5012
  hookIds: [],
4254
5013
  drift: false
4255
5014
  };
4256
5015
  const manifestFiles = [];
4257
5016
  for (const entry of sourceFiles) {
4258
- const srcPath = path22.join(packageRoot(), entry.srcRelPath);
5017
+ const srcPath = path30.join(packageRoot(), entry.srcRelPath);
4259
5018
  const rawContent = await readText(srcPath);
4260
5019
  if (rawContent === void 0) {
4261
5020
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -4275,7 +5034,7 @@ async function applyInstall(options = {}) {
4275
5034
  }
4276
5035
  continue;
4277
5036
  }
4278
- const destExists = fs17.pathExistsSync(entry.destPath);
5037
+ const destExists = fs24.pathExistsSync(entry.destPath);
4279
5038
  if (destExists) {
4280
5039
  const currentContent = await readText(entry.destPath);
4281
5040
  if (currentContent !== void 0) {
@@ -4283,11 +5042,13 @@ async function applyInstall(options = {}) {
4283
5042
  if (!hasHeader) {
4284
5043
  warn(`Refusing to overwrite user-owned file: ${entry.destPath}`);
4285
5044
  result.skipped.push(entry.destPath);
5045
+ result.userOwned.push(entry.destPath);
4286
5046
  continue;
4287
5047
  }
4288
5048
  if (existing && hashContent(currentContent) !== existing.hash && !force) {
4289
5049
  warn(`User edited haus file (skipping): ${entry.destPath} \u2014 use --force to overwrite`);
4290
5050
  result.skipped.push(entry.destPath);
5051
+ result.userEdited.push(entry.destPath);
4291
5052
  manifestFiles.push(existing);
4292
5053
  continue;
4293
5054
  }
@@ -4311,25 +5072,29 @@ async function applyInstall(options = {}) {
4311
5072
  schemaVersion: SCHEMA_VERSION2
4312
5073
  });
4313
5074
  }
4314
- const fragmentPath = path22.join(srcDir, "settings-fragments", "hooks.json");
4315
- const fragments = await loadHooksFragment(fragmentPath);
4316
- const settings = await readSettings();
4317
- const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
4318
- const { settings: deniedSettings } = mergeDenyRules(hookSettings, buildDenyRules());
4319
- const { settings: allowedSettings } = mergeAllowRules(deniedSettings, buildAllowRules());
4320
- const { settings: mergedSettings } = mergeAskRules(allowedSettings, buildAskRules());
4321
- result.hookIds = addedIds;
5075
+ let mergedSettings;
5076
+ if (!check) {
5077
+ const fragmentPath = path30.join(srcDir, "settings-fragments", "hooks.json");
5078
+ const fragments = await loadHooksFragment(fragmentPath);
5079
+ const settings = await readSettings();
5080
+ const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
5081
+ const { settings: deniedSettings } = mergeDenyRules(hookSettings, buildDenyRules());
5082
+ const { settings: allowedSettings } = mergeAllowRules(deniedSettings, buildAllowRules());
5083
+ const merged = mergeAskRules(allowedSettings, buildAskRules());
5084
+ mergedSettings = merged.settings;
5085
+ result.hookIds = addedIds;
5086
+ }
4322
5087
  if (!check && existingManifest) {
4323
5088
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
4324
5089
  for (const entry of existingManifest.files) {
4325
5090
  if (currentDestPaths.has(entry.destPath)) continue;
4326
- if (!fs17.pathExistsSync(entry.destPath)) continue;
5091
+ if (!fs24.pathExistsSync(entry.destPath)) continue;
4327
5092
  const content2 = await readText(entry.destPath);
4328
5093
  if (!content2) continue;
4329
5094
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
4330
5095
  const currentHash = hashContent(content2);
4331
5096
  if (hasHeader && currentHash === entry.hash) {
4332
- if (!dryRun) await fs17.remove(entry.destPath);
5097
+ if (!dryRun) await fs24.remove(entry.destPath);
4333
5098
  result.deleted.push(entry.destPath);
4334
5099
  } else {
4335
5100
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -4337,10 +5102,10 @@ async function applyInstall(options = {}) {
4337
5102
  }
4338
5103
  }
4339
5104
  }
4340
- if (!dryRun && !check) {
5105
+ if (!dryRun && !check && mergedSettings) {
4341
5106
  await writeSettings(mergedSettings);
4342
5107
  const manifest = buildManifest(source, manifestFiles, [
4343
- .../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...addedIds])
5108
+ .../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...result.hookIds])
4344
5109
  ]);
4345
5110
  await writeManifest(manifest);
4346
5111
  }
@@ -4360,9 +5125,10 @@ function printApplyResult(result, dryRun) {
4360
5125
  log(`${prefix}Deleted (orphaned):`);
4361
5126
  result.deleted.forEach((p) => log(` x ${p}`));
4362
5127
  }
4363
- if (result.skipped.length) {
5128
+ const silentlySkipped = result.skipped.filter((p) => !result.userOwned.includes(p));
5129
+ if (silentlySkipped.length) {
4364
5130
  log(`${prefix}Skipped:`);
4365
- result.skipped.forEach((p) => log(` - ${p}`));
5131
+ silentlySkipped.forEach((p) => log(` - ${p}`));
4366
5132
  }
4367
5133
  if (result.hookIds.length) {
4368
5134
  log(`${prefix}Hooks added: ${result.hookIds.join(", ")}`);
@@ -4410,7 +5176,7 @@ function buildCompanionSuggestions(tools, isInstalled) {
4410
5176
  }
4411
5177
  return lines;
4412
5178
  }
4413
- function commandExists(bin) {
5179
+ function commandExists2(bin) {
4414
5180
  try {
4415
5181
  const r = execaSync("sh", ["-c", 'command -v -- "$1"', "probe", bin], { reject: false });
4416
5182
  return r.exitCode === 0;
@@ -4419,7 +5185,7 @@ function commandExists(bin) {
4419
5185
  }
4420
5186
  }
4421
5187
  function printCompanionToolSuggestions(deps = {}) {
4422
- const isInstalled = deps.isInstalled ?? commandExists;
5188
+ const isInstalled = deps.isInstalled ?? commandExists2;
4423
5189
  const emit2 = deps.log ?? log;
4424
5190
  const lines = buildCompanionSuggestions(COMPANION_TOOLS, isInstalled);
4425
5191
  for (const line2 of lines) emit2(line2);
@@ -4481,7 +5247,7 @@ async function runRecommend(options) {
4481
5247
  `Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`)`
4482
5248
  );
4483
5249
  }
4484
- for (const w of result.warnings.filter((w2) => w2.startsWith("--include:"))) log(w);
5250
+ for (const w of result.warnings) log(`- WARN: ${w}`);
4485
5251
  }
4486
5252
 
4487
5253
  // src/commands/refresh.ts
@@ -4497,8 +5263,8 @@ async function runRefresh() {
4497
5263
  }
4498
5264
 
4499
5265
  // src/install/scaffold.ts
4500
- import path23 from "node:path";
4501
- import fs18 from "fs-extra";
5266
+ import path31 from "node:path";
5267
+ import fs25 from "fs-extra";
4502
5268
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4503
5269
  const scaffolded = [];
4504
5270
  const skipped = [];
@@ -4511,27 +5277,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4511
5277
  skipped.push(name);
4512
5278
  if (outcome === "skipped-exists") skippedExisting.push(name);
4513
5279
  };
4514
- const realRoot = await fs18.realpath(catalogRoot).catch(() => null);
5280
+ const realRoot = await fs25.realpath(catalogRoot).catch(() => null);
4515
5281
  for (const item of items) {
4516
5282
  if (item.type !== "config") continue;
4517
- const sourcePath = path23.resolve(catalogRoot, item.path);
4518
- const rel = path23.relative(catalogRoot, sourcePath);
4519
- const escapes = rel === "" || path23.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
5283
+ const sourcePath = path31.resolve(catalogRoot, item.path);
5284
+ const rel = path31.relative(catalogRoot, sourcePath);
5285
+ const escapes = rel === "" || path31.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
4520
5286
  if (escapes) {
4521
5287
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
4522
5288
  continue;
4523
5289
  }
4524
- const realSource = await fs18.realpath(sourcePath).catch(() => null);
5290
+ const realSource = await fs25.realpath(sourcePath).catch(() => null);
4525
5291
  if (!realRoot || !realSource) {
4526
5292
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4527
5293
  continue;
4528
5294
  }
4529
- const realRel = path23.relative(realRoot, realSource);
4530
- if (realRel !== "" && (path23.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
5295
+ const realRel = path31.relative(realRoot, realSource);
5296
+ if (realRel !== "" && (path31.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
4531
5297
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
4532
5298
  continue;
4533
5299
  }
4534
- const stat = await fs18.lstat(sourcePath).catch(() => null);
5300
+ const stat = await fs25.lstat(sourcePath).catch(() => null);
4535
5301
  if (!stat) {
4536
5302
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4537
5303
  continue;
@@ -4541,14 +5307,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4541
5307
  continue;
4542
5308
  }
4543
5309
  if (stat.isFile()) {
4544
- const filename = path23.basename(sourcePath);
4545
- const dest = path23.join(projectRoot, filename);
5310
+ const filename = path31.basename(sourcePath);
5311
+ const dest = path31.join(projectRoot, filename);
4546
5312
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
4547
5313
  } else if (stat.isDirectory()) {
4548
- const entries = await fs18.readdir(sourcePath);
5314
+ const entries = await fs25.readdir(sourcePath);
4549
5315
  for (const entry of entries) {
4550
- const src = path23.join(sourcePath, entry);
4551
- const dest = path23.join(projectRoot, entry);
5316
+ const src = path31.join(sourcePath, entry);
5317
+ const dest = path31.join(projectRoot, entry);
4552
5318
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
4553
5319
  }
4554
5320
  }
@@ -4556,42 +5322,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4556
5322
  return { scaffolded, skipped, skippedExisting };
4557
5323
  }
4558
5324
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
4559
- const destDisplay = path23.relative(projectRoot, dest) || path23.basename(dest);
4560
- const srcStat = await fs18.lstat(src).catch(() => null);
5325
+ const destDisplay = path31.relative(projectRoot, dest) || path31.basename(dest);
5326
+ const srcStat = await fs25.lstat(src).catch(() => null);
4561
5327
  if (!srcStat) {
4562
- warn(`Skipping ${path23.basename(src)}: source not found`);
5328
+ warn(`Skipping ${path31.basename(src)}: source not found`);
4563
5329
  return "skipped-refused";
4564
5330
  }
4565
5331
  if (srcStat.isSymbolicLink()) {
4566
- warn(`Skipping ${path23.basename(src)}: source is a symlink`);
5332
+ warn(`Skipping ${path31.basename(src)}: source is a symlink`);
4567
5333
  return "skipped-refused";
4568
5334
  }
4569
- const exists = await fs18.pathExists(dest);
5335
+ const exists = await fs25.pathExists(dest);
4570
5336
  if (exists && !opts.force) {
4571
5337
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
4572
5338
  return "skipped-exists";
4573
5339
  }
4574
5340
  if (opts.dryRun) {
4575
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path23.basename(dest)} (${itemId})`);
5341
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path31.basename(dest)} (${itemId})`);
4576
5342
  return "scaffolded";
4577
5343
  }
4578
5344
  if (exists && opts.force) {
4579
- await fs18.remove(dest);
5345
+ await fs25.remove(dest);
4580
5346
  }
4581
- await fs18.ensureDir(path23.dirname(dest));
4582
- await fs18.copy(src, dest, {
5347
+ await fs25.ensureDir(path31.dirname(dest));
5348
+ await fs25.copy(src, dest, {
4583
5349
  overwrite: true,
4584
5350
  dereference: false,
4585
5351
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
4586
5352
  filter: (s) => {
4587
5353
  try {
4588
- return !fs18.lstatSync(s).isSymbolicLink();
5354
+ return !fs25.lstatSync(s).isSymbolicLink();
4589
5355
  } catch {
4590
5356
  return false;
4591
5357
  }
4592
5358
  }
4593
5359
  });
4594
- log(`${exists ? "Overwrote" : "Created"} ${path23.basename(dest)} (${itemId})`);
5360
+ log(`${exists ? "Overwrote" : "Created"} ${path31.basename(dest)} (${itemId})`);
4595
5361
  return "scaffolded";
4596
5362
  }
4597
5363
 
@@ -4625,13 +5391,12 @@ async function runScaffold(ids, options) {
4625
5391
  log(`\u2713 Scaffolded: ${result.scaffolded.join(", ")}`);
4626
5392
  }
4627
5393
  }
4628
- if (result.skipped.length > 0) {
4629
- log(`Skipped: ${result.skipped.join(", ")}`);
4630
- if (result.skippedExisting.length > 0) {
4631
- log(
4632
- `Already present (re-run with --force to overwrite): ${result.skippedExisting.join(", ")}`
4633
- );
4634
- }
5394
+ const refused = result.skipped.filter((name) => !result.skippedExisting.includes(name));
5395
+ if (refused.length > 0) {
5396
+ log(`Skipped (source unavailable): ${refused.join(", ")}`);
5397
+ }
5398
+ if (result.skippedExisting.length > 0) {
5399
+ log(`Already present (re-run with --force to overwrite): ${result.skippedExisting.join(", ")}`);
4635
5400
  }
4636
5401
  }
4637
5402
 
@@ -4645,11 +5410,12 @@ async function runScan(options) {
4645
5410
  log("Haus scan complete");
4646
5411
  log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
4647
5412
  log(`Package manager: ${result.packageManager}`);
5413
+ for (const w of result.warnings) log(`- WARN: ${w}`);
4648
5414
  }
4649
5415
 
4650
5416
  // src/commands/undo.ts
4651
- import path24 from "node:path";
4652
- import fs19 from "fs-extra";
5417
+ import path32 from "node:path";
5418
+ import fs26 from "fs-extra";
4653
5419
 
4654
5420
  // src/claude/managed-paths.ts
4655
5421
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md", "commands/haus-doctor.md"];
@@ -4666,74 +5432,77 @@ async function collectManagedPaths(root) {
4666
5432
  const lock = await readJson(hausPath(root, "haus.lock.json"));
4667
5433
  for (const row of lock ?? []) {
4668
5434
  for (const rel of row.paths ?? []) {
4669
- paths.add(path24.resolve(root, rel));
5435
+ paths.add(path32.resolve(root, rel));
4670
5436
  }
4671
5437
  }
4672
5438
  const existing = [];
4673
5439
  for (const abs of paths) {
4674
- if (await fs19.pathExists(abs)) existing.push(abs);
5440
+ if (await fs26.pathExists(abs)) existing.push(abs);
4675
5441
  }
4676
5442
  return existing;
4677
5443
  }
4678
5444
  async function settingsHasHausContent(root) {
4679
5445
  const settingsPath = claudePath(root, "settings.json");
4680
- if (!await fs19.pathExists(settingsPath)) return false;
5446
+ if (!await fs26.pathExists(settingsPath)) return false;
4681
5447
  const settings = await readProjectSettings(root);
4682
5448
  return settings._haus != null;
4683
5449
  }
4684
5450
  async function claudeMdHasHausBlock(root) {
4685
- const filePath = path24.join(root, "CLAUDE.md");
4686
- if (!await fs19.pathExists(filePath)) return false;
4687
- const text = await fs19.readFile(filePath, "utf8");
5451
+ const filePath = path32.join(root, "CLAUDE.md");
5452
+ if (!await fs26.pathExists(filePath)) return false;
5453
+ const text = await fs26.readFile(filePath, "utf8");
4688
5454
  return text.includes(BLOCK_BEGIN);
4689
5455
  }
4690
5456
  async function stripProjectSettings(root) {
4691
5457
  const settingsPath = claudePath(root, "settings.json");
4692
- if (!await fs19.pathExists(settingsPath)) return false;
5458
+ if (!await fs26.pathExists(settingsPath)) return false;
4693
5459
  let settings = await readProjectSettings(root);
4694
5460
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4695
5461
  const hasContent = Object.keys(settings).length > 0;
4696
5462
  if (hasContent) {
4697
5463
  await writeProjectSettings(root, settings);
4698
- log(`Stripped haus rules from ${path24.relative(root, settingsPath)} (user settings preserved).`);
5464
+ log(`Stripped haus rules from ${path32.relative(root, settingsPath)} (user settings preserved).`);
4699
5465
  return true;
4700
5466
  }
4701
- await fs19.remove(settingsPath);
4702
- log(`Removed ${path24.relative(root, settingsPath)} (no user-owned settings remained).`);
5467
+ await fs26.remove(settingsPath);
5468
+ log(`Removed ${path32.relative(root, settingsPath)} (no user-owned settings remained).`);
4703
5469
  return true;
4704
5470
  }
4705
5471
  async function stripRootClaudeMd(root) {
4706
- const filePath = path24.join(root, "CLAUDE.md");
4707
- if (!await fs19.pathExists(filePath)) return false;
4708
- const prev = await fs19.readFile(filePath, "utf8");
5472
+ const filePath = path32.join(root, "CLAUDE.md");
5473
+ if (!await fs26.pathExists(filePath)) return false;
5474
+ const prev = await fs26.readFile(filePath, "utf8");
4709
5475
  if (!prev.includes(BLOCK_BEGIN)) return false;
4710
5476
  const next = stripHausBlock(prev);
4711
5477
  if (next.length === 0) {
4712
- await fs19.remove(filePath);
5478
+ await fs26.remove(filePath);
4713
5479
  log("Removed CLAUDE.md (only contained haus import block).");
4714
5480
  } else {
4715
- await fs19.writeFile(filePath, next, "utf8");
5481
+ await fs26.writeFile(filePath, next, "utf8");
4716
5482
  log("Removed haus import block from CLAUDE.md (user content preserved).");
4717
5483
  }
4718
5484
  return true;
4719
5485
  }
4720
5486
  async function pruneDirIfEmpty(dir) {
4721
- if (!await fs19.pathExists(dir)) return;
4722
- const entries = await fs19.readdir(dir);
4723
- if (entries.length === 0) await fs19.remove(dir);
5487
+ if (!await fs26.pathExists(dir)) return;
5488
+ try {
5489
+ const entries = await fs26.readdir(dir);
5490
+ if (entries.length === 0) await fs26.remove(dir);
5491
+ } catch {
5492
+ }
4724
5493
  }
4725
5494
  async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
4726
5495
  if (managedAbsPaths.length === 0) return;
4727
5496
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4728
5497
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
4729
5498
  for (const abs of managedAbsPaths) {
4730
- if (!await fs19.pathExists(abs)) continue;
4731
- const rel = path24.relative(root, abs);
4732
- const backupPath = path24.join(backupRoot, rel);
4733
- await fs19.ensureDir(path24.dirname(backupPath));
4734
- await fs19.copy(abs, backupPath);
5499
+ if (!await fs26.pathExists(abs)) continue;
5500
+ const rel = path32.relative(root, abs);
5501
+ const backupPath = path32.join(backupRoot, rel);
5502
+ await fs26.ensureDir(path32.dirname(backupPath));
5503
+ await fs26.copy(abs, backupPath);
4735
5504
  }
4736
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path24.relative(root, backupRoot)}.`);
5505
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path32.relative(root, backupRoot)}.`);
4737
5506
  }
4738
5507
  async function runUndo(options) {
4739
5508
  const root = process.cwd();
@@ -4744,7 +5513,7 @@ async function runUndo(options) {
4744
5513
  log("Nothing to remove: no haus-managed files found in this directory.");
4745
5514
  return;
4746
5515
  }
4747
- const relTargets = managed.map((p) => path24.relative(root, p));
5516
+ const relTargets = managed.map((p) => path32.relative(root, p));
4748
5517
  const summaryParts = [...relTargets];
4749
5518
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
4750
5519
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -4761,9 +5530,9 @@ User-owned .claude/ files will be preserved.`
4761
5530
  }
4762
5531
  await backupManagedFilesBeforeUndo(root, managed);
4763
5532
  for (const abs of managed) {
4764
- if (!await fs19.pathExists(abs)) continue;
4765
- await fs19.remove(abs);
4766
- log(`Removed ${path24.relative(root, abs)}`);
5533
+ if (!await fs26.pathExists(abs)) continue;
5534
+ await fs26.remove(abs);
5535
+ log(`Removed ${path32.relative(root, abs)}`);
4767
5536
  }
4768
5537
  if (stripSettings) await stripProjectSettings(root);
4769
5538
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -4774,8 +5543,8 @@ User-owned .claude/ files will be preserved.`
4774
5543
 
4775
5544
  // src/install/uninstall.ts
4776
5545
  import crypto3 from "node:crypto";
4777
- import path25 from "node:path";
4778
- import fs20 from "fs-extra";
5546
+ import path33 from "node:path";
5547
+ import fs27 from "fs-extra";
4779
5548
  async function runUninstall(options = {}) {
4780
5549
  const { force = false } = options;
4781
5550
  const manifest = await readManifest();
@@ -4785,7 +5554,7 @@ async function runUninstall(options = {}) {
4785
5554
  return result;
4786
5555
  }
4787
5556
  for (const entry of manifest.files) {
4788
- const exists = fs20.pathExistsSync(entry.destPath);
5557
+ const exists = fs27.pathExistsSync(entry.destPath);
4789
5558
  if (!exists) continue;
4790
5559
  const content2 = await readText(entry.destPath);
4791
5560
  if (content2 === void 0) continue;
@@ -4803,22 +5572,25 @@ async function runUninstall(options = {}) {
4803
5572
  result.skipped.push(entry.destPath);
4804
5573
  continue;
4805
5574
  }
4806
- await fs20.remove(entry.destPath);
4807
- await pruneEmptyDir(path25.dirname(entry.destPath));
5575
+ await fs27.remove(entry.destPath);
5576
+ await pruneEmptyDir(path33.dirname(entry.destPath));
4808
5577
  result.deleted.push(entry.destPath);
4809
5578
  }
4810
5579
  const settings = await readSettings();
4811
5580
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4812
5581
  await writeSettings(stripped);
4813
5582
  result.hooksStripped = true;
4814
- const hausDir = path25.join(globalClaudeDir(), "haus");
5583
+ const hausDir = path33.join(globalClaudeDir(), "haus");
4815
5584
  const manifestPath2 = hausManifestPath();
4816
- if (fs20.pathExistsSync(manifestPath2)) {
4817
- await fs20.remove(manifestPath2);
5585
+ if (fs27.pathExistsSync(manifestPath2)) {
5586
+ await fs27.remove(manifestPath2);
4818
5587
  }
4819
- if (fs20.pathExistsSync(hausDir)) {
4820
- const remaining = await fs20.readdir(hausDir);
4821
- if (remaining.length === 0) await fs20.remove(hausDir);
5588
+ if (fs27.pathExistsSync(hausDir)) {
5589
+ try {
5590
+ const remaining = await fs27.readdir(hausDir);
5591
+ if (remaining.length === 0) await fs27.remove(hausDir);
5592
+ } catch {
5593
+ }
4822
5594
  }
4823
5595
  return result;
4824
5596
  }
@@ -4849,12 +5621,12 @@ async function runUninstallCommand(options) {
4849
5621
  }
4850
5622
 
4851
5623
  // src/commands/update.ts
4852
- import path27 from "node:path";
5624
+ import path35 from "node:path";
4853
5625
 
4854
5626
  // src/claude/refresh-project.ts
4855
- import fs21 from "fs-extra";
5627
+ import fs28 from "fs-extra";
4856
5628
  async function isHausProject(root) {
4857
- if (await fs21.pathExists(hausPath(root, "recommendation.json"))) return true;
5629
+ if (await fs28.pathExists(hausPath(root, "recommendation.json"))) return true;
4858
5630
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
4859
5631
  if (result.status === "ok") return result.value._haus != null;
4860
5632
  return false;
@@ -4889,7 +5661,7 @@ function summarizeLockDiff(before, after) {
4889
5661
 
4890
5662
  // src/update/lockfile.ts
4891
5663
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
4892
- import path26 from "node:path";
5664
+ import path34 from "node:path";
4893
5665
  async function checkLock(root) {
4894
5666
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
4895
5667
  const hasValidVersions = lock.every(
@@ -4920,7 +5692,7 @@ async function applyLock(root) {
4920
5692
  try {
4921
5693
  const backupDir = hausPath(root, "backups");
4922
5694
  await mkdir(backupDir, { recursive: true });
4923
- await copyFile(lockPath, path26.join(backupDir, `haus.lock.${Date.now()}.json`));
5695
+ await copyFile(lockPath, path34.join(backupDir, `haus.lock.${Date.now()}.json`));
4924
5696
  } catch {
4925
5697
  }
4926
5698
  const enriched = await Promise.all(
@@ -4942,7 +5714,7 @@ function diffLock(before, after) {
4942
5714
  }
4943
5715
  async function hasLocalOverrides(root) {
4944
5716
  try {
4945
- await readFile2(path26.join(root, ".claude", "settings.json"), "utf8");
5717
+ await readFile2(path34.join(root, ".claude", "settings.json"), "utf8");
4946
5718
  return true;
4947
5719
  } catch {
4948
5720
  return false;
@@ -4954,7 +5726,7 @@ var NPM_PACKAGE_NAME2 = "@haus-tech/haus-workflow";
4954
5726
  async function runUpdate(options) {
4955
5727
  const root = process.cwd();
4956
5728
  if (options.check) {
4957
- const pkgJson2 = await readJson(path27.join(packageRoot(), "package.json"));
5729
+ const pkgJson2 = await readJson(path35.join(packageRoot(), "package.json"));
4958
5730
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
4959
5731
  const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
4960
5732
  checkLock(root),
@@ -4980,11 +5752,10 @@ async function runUpdate(options) {
4980
5752
  2
4981
5753
  )
4982
5754
  );
4983
- if (!status.ok) process.exitCode = 1;
4984
- if (status.driftCount > 0) process.exitCode = 1;
5755
+ if (status.count > 0 && !status.ok) process.exitCode = 1;
4985
5756
  return;
4986
5757
  }
4987
- const pkgJson = await readJson(path27.join(packageRoot(), "package.json"));
5758
+ const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
4988
5759
  const currentVersion = pkgJson?.version ?? "0.0.0";
4989
5760
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4990
5761
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -5042,9 +5813,9 @@ async function refreshGlobalInstall() {
5042
5813
  } else {
5043
5814
  log("~/.claude already up to date.");
5044
5815
  }
5045
- if (result.skipped.length > 0) {
5816
+ if (result.userEdited.length > 0) {
5046
5817
  log(
5047
- `Preserved ${result.skipped.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
5818
+ `Preserved ${result.userEdited.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
5048
5819
  );
5049
5820
  }
5050
5821
  } catch (err) {
@@ -5061,16 +5832,16 @@ async function detectGlobalInstallDrift() {
5061
5832
  }
5062
5833
 
5063
5834
  // src/commands/validate-catalog.ts
5064
- import path29 from "node:path";
5835
+ import path37 from "node:path";
5065
5836
 
5066
5837
  // src/catalog/validate-core.ts
5067
- import fs22 from "node:fs";
5068
- import path28 from "node:path";
5838
+ import fs29 from "node:fs";
5839
+ import path36 from "node:path";
5069
5840
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
5070
5841
  var MANIFEST_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
5071
5842
  function isSafeCatalogPath2(itemPath) {
5072
- if (!itemPath || path28.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
5073
- const normalized = path28.normalize(itemPath);
5843
+ if (!itemPath || path36.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
5844
+ const normalized = path36.normalize(itemPath);
5074
5845
  return !normalized.startsWith("..") && !normalized.includes("/..");
5075
5846
  }
5076
5847
  function auditForbiddenStacks(items) {
@@ -5181,10 +5952,19 @@ function checkRequiredFrontmatter(text, label) {
5181
5952
  }
5182
5953
  return failures;
5183
5954
  }
5955
+ function readForAudit(absPath, label, failures) {
5956
+ try {
5957
+ return fs29.readFileSync(absPath, "utf8");
5958
+ } catch (err) {
5959
+ failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
5960
+ return null;
5961
+ }
5962
+ }
5184
5963
  function auditTemplateContent(manifestDir, absPath, itemId) {
5185
- const rel = path28.relative(manifestDir, absPath);
5186
- const text = fs22.readFileSync(absPath, "utf8");
5964
+ const rel = path36.relative(manifestDir, absPath);
5187
5965
  const failures = [];
5966
+ const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
5967
+ if (text === null) return failures;
5188
5968
  const lines = text.split(/\r?\n/);
5189
5969
  for (let i = 0; i < lines.length; i++) {
5190
5970
  const line2 = lines[i] ?? "";
@@ -5203,44 +5983,47 @@ function auditShippedFiles(manifestDir, items) {
5203
5983
  const failures = [];
5204
5984
  for (const item of items) {
5205
5985
  if (!item.path || !isSafeCatalogPath2(item.path)) continue;
5206
- const absPath = path28.join(manifestDir, item.path);
5986
+ const absPath = path36.join(manifestDir, item.path);
5207
5987
  if (item.type === "skill") {
5208
- const skillMd = path28.join(absPath, "SKILL.md");
5209
- if (!fs22.existsSync(skillMd)) {
5210
- failures.push(`${item.id}: missing ${path28.relative(manifestDir, skillMd)}`);
5988
+ const skillMd = path36.join(absPath, "SKILL.md");
5989
+ if (!fs29.existsSync(skillMd)) {
5990
+ failures.push(`${item.id}: missing ${path36.relative(manifestDir, skillMd)}`);
5211
5991
  continue;
5212
5992
  }
5213
- const text = fs22.readFileSync(skillMd, "utf8");
5993
+ const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
5994
+ if (text === null) continue;
5214
5995
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
5215
5996
  failures.push(
5216
- ...auditForbiddenTagsInText(text, `${item.id}: ${path28.relative(manifestDir, skillMd)}`)
5997
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path36.relative(manifestDir, skillMd)}`)
5217
5998
  );
5218
5999
  } else if (item.type === "agent") {
5219
- if (!fs22.existsSync(absPath)) {
6000
+ if (!fs29.existsSync(absPath)) {
5220
6001
  failures.push(`${item.id}: missing agent file ${item.path}`);
5221
6002
  continue;
5222
6003
  }
5223
- const text = fs22.readFileSync(absPath, "utf8");
5224
- const rel = path28.relative(manifestDir, absPath);
6004
+ const rel = path36.relative(manifestDir, absPath);
6005
+ const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6006
+ if (text === null) continue;
5225
6007
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
5226
6008
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
5227
6009
  } else if (item.type === "template") {
5228
- if (!fs22.existsSync(absPath)) {
6010
+ if (!fs29.existsSync(absPath)) {
5229
6011
  failures.push(`${item.id}: missing template file ${item.path}`);
5230
6012
  continue;
5231
6013
  }
5232
6014
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
5233
6015
  } else if (item.type === "command") {
5234
- if (!fs22.existsSync(absPath)) {
6016
+ if (!fs29.existsSync(absPath)) {
5235
6017
  failures.push(`${item.id}: missing command file ${item.path}`);
5236
6018
  continue;
5237
6019
  }
5238
- const text = fs22.readFileSync(absPath, "utf8");
5239
- const rel = path28.relative(manifestDir, absPath);
6020
+ const rel = path36.relative(manifestDir, absPath);
6021
+ const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6022
+ if (text === null) continue;
5240
6023
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
5241
6024
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
5242
6025
  } else if (item.type === "config") {
5243
- if (!fs22.existsSync(absPath)) {
6026
+ if (!fs29.existsSync(absPath)) {
5244
6027
  failures.push(`${item.id}: missing config path ${item.path}`);
5245
6028
  }
5246
6029
  }
@@ -5253,10 +6036,19 @@ var DIR_ITEM_TYPE = {
5253
6036
  templates: "template",
5254
6037
  commands: "command"
5255
6038
  };
5256
- function walkMd(dir, fn) {
5257
- for (const entry of fs22.readdirSync(dir, { withFileTypes: true })) {
5258
- const full = path28.join(dir, entry.name);
5259
- if (entry.isDirectory()) walkMd(full, fn);
6039
+ function walkMd(dir, fn, failures) {
6040
+ let entries;
6041
+ try {
6042
+ entries = fs29.readdirSync(dir, { withFileTypes: true });
6043
+ } catch (err) {
6044
+ failures.push(
6045
+ `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
6046
+ );
6047
+ return;
6048
+ }
6049
+ for (const entry of entries) {
6050
+ const full = path36.join(dir, entry.name);
6051
+ if (entry.isDirectory()) walkMd(full, fn, failures);
5260
6052
  else if (entry.name.endsWith(".md")) fn(full);
5261
6053
  }
5262
6054
  }
@@ -5264,26 +6056,31 @@ function auditMarkdownContent(manifestDir, items) {
5264
6056
  const failures = [];
5265
6057
  const pathSourceMap = buildItemPathSourceMap(items);
5266
6058
  for (const dir of ["skills", "agents", "templates", "commands"]) {
5267
- const abs = path28.join(manifestDir, dir);
5268
- if (!fs22.existsSync(abs)) continue;
6059
+ const abs = path36.join(manifestDir, dir);
6060
+ if (!fs29.existsSync(abs)) continue;
5269
6061
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
5270
- walkMd(abs, (file) => {
5271
- const text = fs22.readFileSync(file, "utf8");
5272
- const rel = path28.relative(manifestDir, file);
5273
- const norm = rel.replace(/\\/g, "/");
5274
- const source = resolveMarkdownItemSource(norm, pathSourceMap);
5275
- const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
5276
- const lines = text.split(/\r?\n/);
5277
- for (let i = 0; i < lines.length; i++) {
5278
- const line2 = lines[i] ?? "";
5279
- if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
5280
- failures.push(`${rel}:${i + 1}: risky install pattern`);
5281
- if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
5282
- failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
6062
+ walkMd(
6063
+ abs,
6064
+ (file) => {
6065
+ const rel = path36.relative(manifestDir, file);
6066
+ const text = readForAudit(file, rel, failures);
6067
+ if (text === null) return;
6068
+ const norm = rel.replace(/\\/g, "/");
6069
+ const source = resolveMarkdownItemSource(norm, pathSourceMap);
6070
+ const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
6071
+ const lines = text.split(/\r?\n/);
6072
+ for (let i = 0; i < lines.length; i++) {
6073
+ const line2 = lines[i] ?? "";
6074
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
6075
+ failures.push(`${rel}:${i + 1}: risky install pattern`);
6076
+ if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
6077
+ failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
6078
+ }
5283
6079
  }
5284
- }
5285
- if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
5286
- });
6080
+ if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
6081
+ },
6082
+ failures
6083
+ );
5287
6084
  }
5288
6085
  return failures;
5289
6086
  }
@@ -5306,8 +6103,8 @@ async function runValidateCatalog(manifestPath2) {
5306
6103
  process.exitCode = 1;
5307
6104
  return;
5308
6105
  }
5309
- const abs = path29.resolve(process.cwd(), manifestPath2);
5310
- const manifestDir = path29.dirname(abs);
6106
+ const abs = path37.resolve(process.cwd(), manifestPath2);
6107
+ const manifestDir = path37.dirname(abs);
5311
6108
  const data = await readJson(abs);
5312
6109
  if (!data?.items) {
5313
6110
  error(`Could not read catalog manifest at ${abs}`);
@@ -5325,7 +6122,7 @@ async function runValidateCatalog(manifestPath2) {
5325
6122
 
5326
6123
  // src/commands/workspace.ts
5327
6124
  import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
5328
- import path36 from "node:path";
6125
+ import path44 from "node:path";
5329
6126
 
5330
6127
  // src/commands/workspace/aggregate.ts
5331
6128
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -5379,7 +6176,7 @@ ${summaries.map(
5379
6176
  }
5380
6177
 
5381
6178
  // src/commands/workspace/config.ts
5382
- import path30 from "node:path";
6179
+ import path38 from "node:path";
5383
6180
  import YAML from "yaml";
5384
6181
  var WORKSPACE_FILE = "haus.workspace.yaml";
5385
6182
  function parseWorkspaceConfig(text) {
@@ -5402,11 +6199,11 @@ function parseWorkspaceConfig(text) {
5402
6199
  };
5403
6200
  }
5404
6201
  async function readWorkspaceConfig(workspaceRoot) {
5405
- return parseWorkspaceConfig(await readText(path30.join(workspaceRoot, WORKSPACE_FILE)));
6202
+ return parseWorkspaceConfig(await readText(path38.join(workspaceRoot, WORKSPACE_FILE)));
5406
6203
  }
5407
6204
 
5408
6205
  // src/commands/workspace/discover.ts
5409
- import path31 from "node:path";
6206
+ import path39 from "node:path";
5410
6207
  import fg4 from "fast-glob";
5411
6208
  import YAML2 from "yaml";
5412
6209
  var DEFAULT_MAX_DEPTH = 3;
@@ -5429,13 +6226,16 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
5429
6226
  onlyFiles: false,
5430
6227
  deep: maxDepth,
5431
6228
  followSymbolicLinks: false,
6229
+ // Discovery walks arbitrary directories under the workspace root; an
6230
+ // unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
6231
+ suppressErrors: true,
5432
6232
  ignore: IGNORE
5433
6233
  });
5434
6234
  const gitDirs = /* @__PURE__ */ new Set();
5435
6235
  const manifestDirs = /* @__PURE__ */ new Set();
5436
6236
  for (const match of matches) {
5437
- const base = path31.posix.basename(match);
5438
- const dir = path31.posix.dirname(match);
6237
+ const base = path39.posix.basename(match);
6238
+ const dir = path39.posix.dirname(match);
5439
6239
  const owner = dir === "." ? "." : dir;
5440
6240
  if (base === ".git") gitDirs.add(owner);
5441
6241
  else manifestDirs.add(owner);
@@ -5451,9 +6251,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
5451
6251
  }
5452
6252
  repoRoots.sort((a, b) => a.localeCompare(b));
5453
6253
  return mapWithConcurrency(repoRoots, async (relDir) => {
5454
- const absDir = path31.resolve(workspaceRoot, relDir);
5455
- const pkg = await readJson(path31.join(absDir, "package.json"));
5456
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path31.basename(relDir === "." ? workspaceRoot : absDir);
6254
+ const absDir = path39.resolve(workspaceRoot, relDir);
6255
+ const pkg = await readJson(path39.join(absDir, "package.json"));
6256
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path39.basename(relDir === "." ? workspaceRoot : absDir);
5457
6257
  let role = "auto";
5458
6258
  try {
5459
6259
  const scan = await scanProject(absDir);
@@ -5496,7 +6296,7 @@ function renderWorkspaceYaml(config) {
5496
6296
  });
5497
6297
  }
5498
6298
  async function runDiscover(workspaceRoot, opts = {}) {
5499
- const yamlPath = path31.join(workspaceRoot, "haus.workspace.yaml");
6299
+ const yamlPath = path39.join(workspaceRoot, "haus.workspace.yaml");
5500
6300
  const existingText = await readText(yamlPath);
5501
6301
  const existing = parseWorkspaceConfig(existingText);
5502
6302
  if (existingText && !existing) {
@@ -5514,34 +6314,37 @@ async function runDiscover(workspaceRoot, opts = {}) {
5514
6314
  }
5515
6315
  const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
5516
6316
  const yamlText = renderWorkspaceYaml(merged);
5517
- if (opts.json) {
5518
- log(JSON.stringify({ discovered, config: merged }, null, 2));
5519
- }
5520
6317
  if (opts.write) {
5521
6318
  await writeText(yamlPath, yamlText);
5522
- log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
6319
+ if (opts.json) {
6320
+ log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
6321
+ } else {
6322
+ log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
6323
+ }
5523
6324
  return;
5524
6325
  }
5525
- if (!opts.json) {
5526
- log("Proposed haus.workspace.yaml (run with --write to persist):\n");
5527
- log(yamlText);
6326
+ if (opts.json) {
6327
+ log(JSON.stringify({ discovered, config: merged }, null, 2));
6328
+ return;
5528
6329
  }
6330
+ log("Proposed haus.workspace.yaml (run with --write to persist):\n");
6331
+ log(yamlText);
5529
6332
  }
5530
6333
 
5531
6334
  // src/commands/workspace/doctor.ts
5532
6335
  import { existsSync as existsSync3 } from "node:fs";
5533
- import path33 from "node:path";
6336
+ import path41 from "node:path";
5534
6337
 
5535
6338
  // src/commands/workspace/manifest.ts
5536
6339
  import { readFileSync as readFileSync3 } from "node:fs";
5537
- import path32 from "node:path";
6340
+ import path40 from "node:path";
5538
6341
  var MANIFEST_FILE = "workspace.manifest.json";
5539
6342
  function manifestPath(workspaceRoot) {
5540
6343
  return hausPath(workspaceRoot, MANIFEST_FILE);
5541
6344
  }
5542
6345
  function hausVersion() {
5543
6346
  try {
5544
- const pkg = JSON.parse(readFileSync3(path32.join(packageRoot(), "package.json"), "utf8"));
6347
+ const pkg = JSON.parse(readFileSync3(path40.join(packageRoot(), "package.json"), "utf8"));
5545
6348
  return pkg.version ?? "0.0.0";
5546
6349
  } catch {
5547
6350
  return "0.0.0";
@@ -5607,7 +6410,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
5607
6410
  }
5608
6411
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
5609
6412
  for (const repo of config.repos) {
5610
- const repoRoot = path33.resolve(workspaceRoot, repo.path);
6413
+ const repoRoot = path41.resolve(workspaceRoot, repo.path);
5611
6414
  const entry = manifestByName.get(repo.name);
5612
6415
  if (!entry) {
5613
6416
  flag({
@@ -5681,11 +6484,11 @@ function emit(args) {
5681
6484
 
5682
6485
  // src/commands/workspace/setup.ts
5683
6486
  import { existsSync as existsSync4, statSync } from "node:fs";
5684
- import path35 from "node:path";
6487
+ import path43 from "node:path";
5685
6488
 
5686
6489
  // src/claude/write-workspace-claude-md.ts
5687
- import path34 from "node:path";
5688
- import fs23 from "fs-extra";
6490
+ import path42 from "node:path";
6491
+ import fs30 from "fs-extra";
5689
6492
  function buildWorkspaceImportBlock(client, members) {
5690
6493
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
5691
6494
  const body = [
@@ -5703,25 +6506,27 @@ ${BLOCK_END}`;
5703
6506
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5704
6507
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
5705
6508
  const dryRun = opts.dryRun ?? false;
5706
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path34.join(workspaceRoot, "CLAUDE.md");
5707
- const prev = await fs23.pathExists(filePath) ? await fs23.readFile(filePath, "utf8") : "";
6509
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path42.join(workspaceRoot, "CLAUDE.md");
6510
+ const say = opts.quiet ? () => {
6511
+ } : log;
6512
+ const prev = await fs30.pathExists(filePath) ? await fs30.readFile(filePath, "utf8") : "";
5708
6513
  const next = opts.collision ? `${block}
5709
6514
  ` : injectHausBlock(prev, block);
5710
6515
  const printable = displayPath(workspaceRoot, filePath);
5711
6516
  if (dryRun) {
5712
6517
  if (!prev) {
5713
- log(createUnifiedDiff(printable, "", next));
6518
+ say(createUnifiedDiff(printable, "", next));
5714
6519
  } else if (hasTextChanged(prev, next)) {
5715
- log(createUnifiedDiff(printable, prev, next));
6520
+ say(createUnifiedDiff(printable, prev, next));
5716
6521
  } else {
5717
- log(`${printable}: unchanged`);
6522
+ say(`${printable}: unchanged`);
5718
6523
  }
5719
6524
  return filePath;
5720
6525
  }
5721
6526
  if (hasTextChanged(prev, next) && prev.length > 0) {
5722
6527
  const diffText = createUnifiedDiff(printable, prev, next);
5723
6528
  const summary = summarizeDiff(diffText);
5724
- log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
6529
+ say(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
5725
6530
  }
5726
6531
  await writeText(filePath, next);
5727
6532
  return filePath;
@@ -5729,20 +6534,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5729
6534
 
5730
6535
  // src/commands/workspace/setup.ts
5731
6536
  function resolveWorkspaceRoot(start = process.cwd()) {
5732
- let dir = path35.resolve(start);
6537
+ let dir = path43.resolve(start);
5733
6538
  for (; ; ) {
5734
- if (existsSync4(path35.join(dir, WORKSPACE_FILE))) return dir;
5735
- const parent = path35.dirname(dir);
5736
- if (parent === dir) return path35.resolve(start);
6539
+ if (existsSync4(path43.join(dir, WORKSPACE_FILE))) return dir;
6540
+ const parent = path43.dirname(dir);
6541
+ if (parent === dir) return path43.resolve(start);
5737
6542
  dir = parent;
5738
6543
  }
5739
6544
  }
5740
6545
  function isRootRepo(workspaceRoot, repoPath) {
5741
- return path35.resolve(workspaceRoot, repoPath) === path35.resolve(workspaceRoot);
6546
+ return path43.resolve(workspaceRoot, repoPath) === path43.resolve(workspaceRoot);
5742
6547
  }
5743
6548
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
5744
6549
  const apply = options.write ?? false;
5745
- const configText = await readText(path35.join(workspaceRoot, WORKSPACE_FILE));
6550
+ const configText = await readText(path43.join(workspaceRoot, WORKSPACE_FILE));
5746
6551
  if (!configText) {
5747
6552
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5748
6553
  process.exitCode = 1;
@@ -5754,22 +6559,34 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5754
6559
  process.exitCode = 1;
5755
6560
  return { workspaceRoot, statuses: [], written: [] };
5756
6561
  }
6562
+ const say = options.json ? () => {
6563
+ } : log;
5757
6564
  const onlySet = options.only && options.only.length > 0 ? new Set(options.only) : void 0;
6565
+ if (onlySet) {
6566
+ const unknown = [...onlySet].filter((name) => !config.repos.some((r) => r.name === name));
6567
+ if (unknown.length > 0) {
6568
+ error(`--only matched no configured repo(s): ${unknown.join(", ")}`);
6569
+ process.exitCode = 1;
6570
+ return { workspaceRoot, statuses: [], written: [] };
6571
+ }
6572
+ }
5758
6573
  const repos = onlySet ? config.repos.filter((r) => onlySet.has(r.name)) : config.repos;
5759
6574
  const statuses = [];
5760
6575
  const aggregateInputs = [];
5761
6576
  for (const repo of repos) {
5762
- const repoRoot = path35.resolve(workspaceRoot, repo.path);
5763
- log(`
6577
+ const repoRoot = path43.resolve(workspaceRoot, repo.path);
6578
+ say(`
5764
6579
  \u2192 ${repo.name} (${repo.path})`);
5765
6580
  try {
5766
6581
  if (!existsSync4(repoRoot) || !statSync(repoRoot).isDirectory()) {
5767
6582
  throw new Error(`Repo path is not a directory: ${repo.path}`);
5768
6583
  }
5769
6584
  const res = await runSetupCore(repoRoot, {
5770
- json: options.json,
5771
6585
  apply,
5772
- dryRun: options.dryRun
6586
+ dryRun: options.dryRun,
6587
+ // Under --json, mute the inner pipeline entirely; we serialize the
6588
+ // aggregated result below. Otherwise let it print its normal human output.
6589
+ quiet: options.json
5773
6590
  });
5774
6591
  statuses.push({
5775
6592
  name: repo.name,
@@ -5809,9 +6626,10 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5809
6626
  client: config.client,
5810
6627
  members: config.repos.map((r) => ({ name: r.name, path: r.path })),
5811
6628
  collision,
5812
- dryRun: options.dryRun
6629
+ dryRun: options.dryRun,
6630
+ quiet: options.json
5813
6631
  });
5814
- written.push(docPath);
6632
+ if (!options.dryRun) written.push(docPath);
5815
6633
  }
5816
6634
  if (apply && !options.dryRun) {
5817
6635
  const statusByName = new Map(statuses.map((s) => [s.name, s]));
@@ -5827,7 +6645,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5827
6645
  const status = statusByName.get(repo.name);
5828
6646
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
5829
6647
  if (status?.status === "ok") {
5830
- const lock = await checkLock(path35.resolve(workspaceRoot, repo.path));
6648
+ const lock = await checkLock(path43.resolve(workspaceRoot, repo.path));
5831
6649
  manifestRepos.push({
5832
6650
  name: repo.name,
5833
6651
  path: repo.path,
@@ -5876,9 +6694,17 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5876
6694
  }
5877
6695
  const ok = statuses.filter((s) => s.status === "ok").length;
5878
6696
  const failed = statuses.length - ok;
5879
- log(`
5880
- Workspace setup complete: ${ok} ok, ${failed} failed.`);
5881
- return { workspaceRoot, statuses, written };
6697
+ const result = { workspaceRoot, statuses, written };
6698
+ if (options.json) {
6699
+ log(JSON.stringify(result, null, 2));
6700
+ } else if (!apply) {
6701
+ say(`
6702
+ Workspace preview complete: ${ok} ok, ${failed} failed. Re-run with --write to apply.`);
6703
+ } else {
6704
+ say(`
6705
+ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} file(s).`);
6706
+ }
6707
+ return result;
5882
6708
  }
5883
6709
 
5884
6710
  // src/commands/workspace.ts
@@ -5891,7 +6717,9 @@ function normalizeOnly(only) {
5891
6717
  function normalizeMaxDepth(maxDepth) {
5892
6718
  if (maxDepth === void 0) return void 0;
5893
6719
  const n = typeof maxDepth === "number" ? maxDepth : Number.parseInt(maxDepth, 10);
5894
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
6720
+ if (Number.isFinite(n) && n > 0) return Math.floor(n);
6721
+ warn(`Ignoring invalid --max-depth "${String(maxDepth)}"; using the default depth.`);
6722
+ return void 0;
5895
6723
  }
5896
6724
  async function initWorkspace() {
5897
6725
  await writeText(
@@ -5907,7 +6735,7 @@ relationships: []
5907
6735
  log("Workspace initialized.");
5908
6736
  }
5909
6737
  async function scanWorkspace(workspaceRoot, opts) {
5910
- const configText = await readText(path36.join(workspaceRoot, WORKSPACE_FILE));
6738
+ const configText = await readText(path44.join(workspaceRoot, WORKSPACE_FILE));
5911
6739
  if (!configText) {
5912
6740
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5913
6741
  process.exitCode = 1;
@@ -5927,20 +6755,31 @@ async function scanWorkspace(workspaceRoot, opts) {
5927
6755
  return;
5928
6756
  }
5929
6757
  const inputs = [];
6758
+ const failures = [];
5930
6759
  for (const repo of config.repos) {
5931
- const repoRoot = path36.resolve(workspaceRoot, repo.path);
5932
- if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
5933
- throw new Error(`Repo path is not a directory: ${repo.path}`);
6760
+ const repoRoot = path44.resolve(workspaceRoot, repo.path);
6761
+ try {
6762
+ if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
6763
+ throw new Error("path is not a directory");
6764
+ }
6765
+ const result = await scanProject(repoRoot);
6766
+ inputs.push({ name: repo.name, path: repo.path, context: result });
6767
+ } catch (err) {
6768
+ failures.push(
6769
+ `${repo.name} (${repo.path}): ${err instanceof Error ? err.message : String(err)}`
6770
+ );
5934
6771
  }
5935
- const result = await scanProject(repoRoot);
5936
- inputs.push({ name: repo.name, path: repo.path, context: result });
5937
6772
  }
5938
6773
  const written = await writeWorkspaceArtifacts(workspaceRoot, inputs, config.relationships);
5939
6774
  if (opts.json) {
5940
- log(JSON.stringify({ written }, null, 2));
6775
+ log(JSON.stringify({ written, scanned: inputs.length, failed: failures }, null, 2));
5941
6776
  } else {
5942
- log(`Workspace scan complete. Wrote ${written.length} artifact(s) under .haus-workflow/.`);
6777
+ log(
6778
+ `Workspace scan complete. Scanned ${inputs.length} repo(s); wrote ${written.length} artifact(s) under .haus-workflow/.`
6779
+ );
6780
+ for (const f of failures) warn(`- skipped ${f}`);
5943
6781
  }
6782
+ if (failures.length > 0) process.exitCode = 1;
5944
6783
  }
5945
6784
  async function runWorkspace(action, options = {}) {
5946
6785
  if (action === "init") {
@@ -5978,7 +6817,7 @@ async function runWorkspace(action, options = {}) {
5978
6817
  // src/cli.ts
5979
6818
  function cliVersion() {
5980
6819
  try {
5981
- const pkgPath = path37.join(packageRoot(), "package.json");
6820
+ const pkgPath = path45.join(packageRoot(), "package.json");
5982
6821
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5983
6822
  return pkg.version ?? "0.0.0";
5984
6823
  } catch {
@@ -5988,7 +6827,7 @@ function cliVersion() {
5988
6827
  var program = new Command();
5989
6828
  function validateRuntimeNodeVersion() {
5990
6829
  try {
5991
- const pkgPath = path37.join(packageRoot(), "package.json");
6830
+ const pkgPath = path45.join(packageRoot(), "package.json");
5992
6831
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5993
6832
  const requiredRange = pkg.engines?.node;
5994
6833
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -6009,6 +6848,7 @@ program.command("recommend").option("--json").option(
6009
6848
  ).action(runRecommend);
6010
6849
  program.command("setup-project").option("--json").action(runSetupProject);
6011
6850
  program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
6851
+ program.command("fetch-refs").description("Fetch and cache llms.txt content from catalog item references").option("--all", "Fetch for all catalog items (default)").option("--id <id>", "Fetch for a single catalog item by id").option("--json", "Emit machine-readable JSON summary to stdout").action(runFetchRefs);
6012
6852
  program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
6013
6853
  "--ids <ids...>",
6014
6854
  "Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
@@ -6037,6 +6877,17 @@ program.command("uninstall").option("--force").action(runUninstallCommand);
6037
6877
  var guard = program.command("guard");
6038
6878
  guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
6039
6879
  guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
6880
+ function registerDecisionsCommand(name) {
6881
+ const cmd = program.command(name).description("Architecture decision records (ADR) gate and drafts");
6882
+ cmd.command("check").option("--staged", "Check staged changes only").option("--range <range>", "Git revision range (e.g. origin/main..HEAD)").action((opts) => runDecisions("check", void 0, opts));
6883
+ cmd.command("suggest").option("--from-hook", "Emit JSON for Claude Code Stop hook").option("--title <title>", "Draft title").action(
6884
+ (opts) => runDecisions("suggest", void 0, opts)
6885
+ );
6886
+ cmd.command("next-number").action(() => runDecisions("next-number"));
6887
+ cmd.command("validate").argument("[path]", "Decision markdown file").action((filePath) => runDecisions("validate", filePath));
6888
+ }
6889
+ registerDecisionsCommand("decisions");
6890
+ registerDecisionsCommand("adr");
6040
6891
  var workspace = program.command("workspace");
6041
6892
  workspace.command("init").action(() => runWorkspace("init"));
6042
6893
  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));