@haus-tech/haus-workflow 0.31.1 → 0.32.1

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);
@@ -542,48 +650,103 @@ function parseManifest(json) {
542
650
  }
543
651
 
544
652
  // src/catalog/remote-catalog.ts
653
+ function isTestMode() {
654
+ return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
655
+ }
545
656
  function getCacheDir() {
546
- return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path2.join(os.homedir(), CATALOG_CACHE_SUBDIR);
657
+ return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path3.join(os2.homedir(), CATALOG_CACHE_SUBDIR);
547
658
  }
548
659
  var cachedCatalogRef;
549
660
  var cachedBlobPaths;
661
+ function getBundledCatalogRef() {
662
+ try {
663
+ const manifestPath2 = path3.join(packageRoot(), "library/catalog/manifest.json");
664
+ const raw = fs2.readFileSync(manifestPath2, "utf8");
665
+ const data = JSON.parse(raw);
666
+ if (typeof data.version === "string" && data.version) {
667
+ return data.version.startsWith("v") ? data.version : `v${data.version}`;
668
+ }
669
+ } catch {
670
+ }
671
+ return void 0;
672
+ }
550
673
  function getResolvedCatalogRef() {
551
- return cachedCatalogRef ?? process.env["HAUS_CATALOG_REF"] ?? "main";
674
+ const resolved = cachedCatalogRef ?? process.env["HAUS_CATALOG_REF"] ?? getBundledCatalogRef();
675
+ if (!resolved) {
676
+ warn(
677
+ "Could not determine catalog ref from cache, env, or bundled snapshot \u2014 falling back to main (moving target)."
678
+ );
679
+ return "main";
680
+ }
681
+ return resolved;
552
682
  }
553
683
  function isCatalogRefResolved() {
554
684
  return cachedCatalogRef !== void 0 || process.env["HAUS_CATALOG_REF"] !== void 0;
555
685
  }
556
686
  async function resolveCatalogRef(opts) {
557
687
  const env = opts?.env ?? process.env;
558
- if (env["HAUS_CATALOG_REF"]) return env["HAUS_CATALOG_REF"];
688
+ if (env["HAUS_CATALOG_REF"]) {
689
+ if (env["HAUS_CATALOG_REF"] === "main") {
690
+ warn(
691
+ "HAUS_CATALOG_REF=main is set \u2014 fetching from the moving main branch. Pin to a release tag for reproducible installs."
692
+ );
693
+ }
694
+ return env["HAUS_CATALOG_REF"];
695
+ }
559
696
  const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
560
697
  const tag = await fetchLatest();
561
- return tag ?? "main";
698
+ if (tag !== null) return tag;
699
+ const fallback = opts?.fallbackRef;
700
+ if (fallback) {
701
+ warn(
702
+ `Tag resolution failed \u2014 using cached ref ${fallback}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
703
+ );
704
+ return fallback;
705
+ }
706
+ const bundled = getBundledCatalogRef();
707
+ if (bundled) {
708
+ warn(
709
+ `Tag resolution failed \u2014 using bundled snapshot ref ${bundled}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
710
+ );
711
+ return bundled;
712
+ }
713
+ warn(
714
+ "Tag resolution failed and no fallback ref is available. Set HAUS_CATALOG_REF explicitly to avoid fetching from main."
715
+ );
716
+ return "main";
562
717
  }
563
718
  async function remoteBase() {
564
- if (process.env["HAUS_CATALOG_REMOTE_BASE"]) {
719
+ if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
565
720
  return process.env["HAUS_CATALOG_REMOTE_BASE"];
566
721
  }
567
722
  if (cachedCatalogRef === void 0) {
568
- cachedCatalogRef = await resolveCatalogRef();
723
+ cachedCatalogRef = await resolveCatalogRef({ fallbackRef: getBundledCatalogRef() });
569
724
  }
570
725
  return `${CATALOG_REPO_URL}/${cachedCatalogRef}`;
571
726
  }
572
727
  async function fetchText(url) {
573
728
  try {
574
729
  const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
575
- if (!res.ok) return null;
730
+ if (!res.ok) {
731
+ warn(`Catalog fetch HTTP ${res.status}: ${url}`);
732
+ return null;
733
+ }
576
734
  return await res.text();
577
- } catch {
735
+ } catch (e) {
736
+ warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
578
737
  return null;
579
738
  }
580
739
  }
581
740
  async function fetchBytes(url) {
582
741
  try {
583
742
  const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
584
- if (!res.ok) return null;
743
+ if (!res.ok) {
744
+ warn(`Catalog fetch HTTP ${res.status}: ${url}`);
745
+ return null;
746
+ }
585
747
  return Buffer.from(await res.arrayBuffer());
586
- } catch {
748
+ } catch (e) {
749
+ warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
587
750
  return null;
588
751
  }
589
752
  }
@@ -606,13 +769,13 @@ async function writeTextIfChanged(dest, text) {
606
769
  await fs2.writeFile(dest, text, "utf8");
607
770
  return "updated";
608
771
  }
609
- await fs2.ensureDir(path2.dirname(dest));
772
+ await fs2.ensureDir(path3.dirname(dest));
610
773
  await fs2.writeFile(dest, text, "utf8");
611
774
  return "created";
612
775
  }
613
776
  var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
614
777
  async function readWorkflowTemplate(opts = {}) {
615
- const dest = path2.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
778
+ const dest = path3.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
616
779
  const base = await remoteBase();
617
780
  const text = await fetchText(`${base}/${WORKFLOW_TEMPLATE_REL}`);
618
781
  if (text === null) {
@@ -627,14 +790,14 @@ async function readWorkflowTemplate(opts = {}) {
627
790
  return text;
628
791
  }
629
792
  function isSafeCatalogPath(itemPath) {
630
- if (!itemPath || path2.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
631
- const normalized = path2.normalize(itemPath);
793
+ if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
794
+ const normalized = path3.normalize(itemPath);
632
795
  return !normalized.startsWith("..") && !normalized.includes("/..");
633
796
  }
634
797
  function isSafeRelativeFilePath(rel) {
635
798
  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, "/"));
799
+ if (path3.isAbsolute(rel)) return false;
800
+ const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
638
801
  return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
639
802
  }
640
803
  function githubApiHeaders() {
@@ -656,8 +819,8 @@ function sanitizeRelativeFilePaths(files, label) {
656
819
  }
657
820
  function safeJoin(base, itemPath) {
658
821
  if (!isSafeCatalogPath(itemPath)) return null;
659
- const resolved = path2.resolve(base, itemPath);
660
- return resolved.startsWith(base + path2.sep) || resolved === base ? resolved : null;
822
+ const resolved = path3.resolve(base, itemPath);
823
+ return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
661
824
  }
662
825
  var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
663
826
  function isMarkdownPath(rel) {
@@ -665,13 +828,19 @@ function isMarkdownPath(rel) {
665
828
  }
666
829
  async function listFilesRecursive(dir, base = dir) {
667
830
  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);
831
+ let entries;
832
+ try {
833
+ if (!await fs2.pathExists(dir)) return out;
834
+ entries = await fs2.readdir(dir, { withFileTypes: true });
835
+ } catch {
836
+ return out;
837
+ }
838
+ for (const entry of entries) {
839
+ const full = path3.join(dir, entry.name);
671
840
  if (entry.isDirectory()) {
672
841
  out.push(...await listFilesRecursive(full, base));
673
842
  } else if (entry.isFile()) {
674
- out.push(path2.relative(base, full).replace(/\\/g, "/"));
843
+ out.push(path3.relative(base, full).replace(/\\/g, "/"));
675
844
  }
676
845
  }
677
846
  return out.sort();
@@ -714,7 +883,7 @@ async function fetchGitHubRecursiveBlobPaths(ref) {
714
883
  }
715
884
  async function fetchCatalogBlobPaths(_base) {
716
885
  if (cachedBlobPaths) return cachedBlobPaths;
717
- if (process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
886
+ if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
718
887
  const ref = getResolvedCatalogRef();
719
888
  const paths = await fetchGitHubRecursiveBlobPaths(ref);
720
889
  if (paths) cachedBlobPaths = paths;
@@ -724,7 +893,7 @@ async function listFilesUnderCatalogPrefix(prefix, base) {
724
893
  const normalized = prefix.replace(/\\/g, "/").replace(/\/+$/, "");
725
894
  const prefixSlash = `${normalized}/`;
726
895
  let relFiles;
727
- if (process.env["HAUS_CATALOG_REMOTE_BASE"]) {
896
+ if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
728
897
  relFiles = await listMockPrefixFiles(base, normalized);
729
898
  } else {
730
899
  const blobs = await fetchCatalogBlobPaths(base);
@@ -776,7 +945,7 @@ async function directoryMatchesFetched(destDir, fetched) {
776
945
  if (!relSet.has(rel)) return false;
777
946
  }
778
947
  for (const file of fetched) {
779
- const dest = path2.join(destDir, file.rel);
948
+ const dest = path3.join(destDir, file.rel);
780
949
  if (!await fs2.pathExists(dest)) return false;
781
950
  if (file.kind === "text") {
782
951
  const local = await fs2.readFile(dest, "utf8");
@@ -794,8 +963,8 @@ async function writeFetchedDirectory(destDir, fetched) {
794
963
  }
795
964
  await fs2.ensureDir(destDir);
796
965
  for (const file of fetched) {
797
- const dest = path2.join(destDir, file.rel);
798
- await fs2.ensureDir(path2.dirname(dest));
966
+ const dest = path3.join(destDir, file.rel);
967
+ await fs2.ensureDir(path3.dirname(dest));
799
968
  if (file.kind === "text") {
800
969
  await fs2.writeFile(dest, file.body, "utf8");
801
970
  } else {
@@ -871,7 +1040,7 @@ async function syncConfigItem(item, base) {
871
1040
  if (relFiles && relFiles.length > 0) {
872
1041
  try {
873
1042
  return await syncDirectoryFromPrefix(item, item.path, dest, base, {
874
- validateMarkdown: false,
1043
+ validateMarkdown: true,
875
1044
  requireSkillMd: false,
876
1045
  relFiles
877
1046
  });
@@ -939,7 +1108,7 @@ async function syncOneItem(item, base) {
939
1108
  return "failed";
940
1109
  }
941
1110
  const text = await fetchText(`${base}/${item.path}`);
942
- if (!text) {
1111
+ if (text === null) {
943
1112
  warn(`Failed to fetch content for ${item.id}`);
944
1113
  return "failed";
945
1114
  }
@@ -967,7 +1136,7 @@ async function syncRemoteCatalog() {
967
1136
  try {
968
1137
  await fs2.ensureDir(cacheDir);
969
1138
  await fs2.writeFile(
970
- path2.join(cacheDir, "manifest.json"),
1139
+ path3.join(cacheDir, "manifest.json"),
971
1140
  `${JSON.stringify({ version, items }, null, 2)}
972
1141
  `,
973
1142
  "utf8"
@@ -1011,7 +1180,7 @@ function compareSemver(a, b) {
1011
1180
  return 0;
1012
1181
  }
1013
1182
  async function fetchLatestCatalogTag() {
1014
- if (process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
1183
+ if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
1015
1184
  try {
1016
1185
  const res = await fetch(CATALOG_TAGS_API_URL, {
1017
1186
  signal: AbortSignal.timeout(5e3),
@@ -1035,66 +1204,13 @@ async function fetchLatestCatalogTag() {
1035
1204
  }
1036
1205
  async function getCacheManifestAge() {
1037
1206
  try {
1038
- const stat = await fs2.stat(path2.join(getCacheDir(), "manifest.json"));
1207
+ const stat = await fs2.stat(path3.join(getCacheDir(), "manifest.json"));
1039
1208
  return Date.now() - stat.mtimeMs;
1040
1209
  } catch {
1041
1210
  return null;
1042
1211
  }
1043
1212
  }
1044
1213
 
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
1214
  // src/catalog/load-catalog.ts
1099
1215
  async function loadCatalogContext(root) {
1100
1216
  const envPath = process.env["HAUS_FIXTURE_CATALOG"];
@@ -1137,6 +1253,10 @@ function catalogItemContentPath(contentRoot, item) {
1137
1253
  return path4.join(contentRoot, item.path);
1138
1254
  }
1139
1255
 
1256
+ // src/claude/write-claude-files.ts
1257
+ import path14 from "node:path";
1258
+ import fs14 from "fs-extra";
1259
+
1140
1260
  // src/update/hash-installed.ts
1141
1261
  import path5 from "node:path";
1142
1262
  import fg2 from "fast-glob";
@@ -1660,8 +1780,8 @@ function buildDenyRules() {
1660
1780
  for (const command of DENY_COMMANDS) {
1661
1781
  rules.push(`Bash(${command}:*)`);
1662
1782
  }
1663
- for (const path38 of DENY_PATHS) {
1664
- const pattern = DENY_DIRS.has(path38) ? `${path38}/**` : path38;
1783
+ for (const path46 of DENY_PATHS) {
1784
+ const pattern = DENY_DIRS.has(path46) ? `${path46}/**` : path46;
1665
1785
  for (const tool of FILE_TOOLS) {
1666
1786
  rules.push(`${tool}(${pattern})`);
1667
1787
  }
@@ -1684,6 +1804,13 @@ var PROJECT_HOOK_FRAGMENTS = [
1684
1804
  event: "PreToolUse",
1685
1805
  matcher: "Bash",
1686
1806
  command: "haus guard bash --from-hook"
1807
+ },
1808
+ {
1809
+ id: "haus.decisions-suggest",
1810
+ gate: "keep",
1811
+ event: "Stop",
1812
+ matcher: "*",
1813
+ command: "haus decisions suggest --from-hook"
1687
1814
  }
1688
1815
  ];
1689
1816
  async function readProjectSettings(root) {
@@ -1777,6 +1904,12 @@ var CANONICAL_HOOKS = {
1777
1904
  matcher: "Bash",
1778
1905
  hooks: [{ type: "command", command: "haus guard bash --from-hook" }]
1779
1906
  }
1907
+ ],
1908
+ Stop: [
1909
+ {
1910
+ matcher: "*",
1911
+ hooks: [{ type: "command", command: "haus decisions suggest --from-hook" }]
1912
+ }
1780
1913
  ]
1781
1914
  }
1782
1915
  };
@@ -1798,9 +1931,11 @@ function collectHookCommands(settings) {
1798
1931
  }
1799
1932
  function hausHookContractSatisfied(project, canonical) {
1800
1933
  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;
1934
+ for (const blocks of Object.values(canonical.hooks)) {
1935
+ for (const block of blocks ?? []) {
1936
+ for (const h of block.hooks) {
1937
+ if (!present.has(h.command)) return false;
1938
+ }
1804
1939
  }
1805
1940
  }
1806
1941
  const denySet = new Set(project.permissions?.deny ?? []);
@@ -1851,9 +1986,62 @@ async function verifyProjectSettingsHooksContract(root) {
1851
1986
  return { ok: true, message: "settings.json carries required haus hook contract." };
1852
1987
  }
1853
1988
 
1854
- // src/claude/write-prettierignore.ts
1989
+ // src/claude/write-decisions-seed.ts
1855
1990
  import path9 from "node:path";
1856
1991
  import fs8 from "fs-extra";
1992
+
1993
+ // library/catalog/decisions-triggers.json
1994
+ var decisions_triggers_default = {
1995
+ decisionsDir: "docs/decisions",
1996
+ legacyDecisionsDir: "docs/adr",
1997
+ pathGlobs: [
1998
+ "**/package.json",
1999
+ "**/pnpm-lock.yaml",
2000
+ "**/yarn.lock",
2001
+ "**/package-lock.json",
2002
+ "**/migrations/**",
2003
+ "**/prisma/schema.prisma",
2004
+ "**/.github/workflows/**",
2005
+ "**/Dockerfile*",
2006
+ "**/docker-compose*.yml",
2007
+ "**/terraform/**",
2008
+ "**/openapi*.yaml",
2009
+ "**/schema.graphql"
2010
+ ],
2011
+ securityPathGlobs: ["**/.env.example", "**/auth/**", "**/middleware/**"],
2012
+ pathRegex: ["(^|/)src/.*/auth/", "(^|/)middleware/", "\\.env\\.example$"],
2013
+ minFilesChanged: 8,
2014
+ minLinesChanged: 200,
2015
+ exemptGlobs: ["docs/**", "tests/**", "**/*.md", ".haus-workflow/**"],
2016
+ requiredSections: ["## Context", "## Decision", "## Consequences"],
2017
+ recommendedSections: ["## Motivation (why)", "## Alternatives considered"],
2018
+ decisionFilePattern: "^\\d{4}-.+\\.md$"
2019
+ };
2020
+
2021
+ // src/decisions/triggers.ts
2022
+ var DECISIONS_TRIGGERS = decisions_triggers_default;
2023
+ var DECISION_FILE_RE = new RegExp(DECISIONS_TRIGGERS.decisionFilePattern);
2024
+
2025
+ // src/claude/write-decisions-seed.ts
2026
+ var SEED_README = `# Architecture Decision Records (ADRs)
2027
+
2028
+ Why we chose X \u2014 short, dated, write-once decision log for humans and agents.
2029
+
2030
+ | ADR | Title | Why (one line) | Status |
2031
+ | --- | ----- | -------------- | ------ |
2032
+ `;
2033
+ async function writeDecisionsSeed(root, dryRun) {
2034
+ const decisionsDir = path9.join(root, DECISIONS_TRIGGERS.decisionsDir);
2035
+ const readmePath = path9.join(decisionsDir, "README.md");
2036
+ if (await fs8.pathExists(readmePath)) return null;
2037
+ if (!dryRun) await fs8.ensureDir(decisionsDir);
2038
+ await writeManagedText(root, readmePath, SEED_README, dryRun);
2039
+ return readmePath;
2040
+ }
2041
+
2042
+ // src/claude/write-prettierignore.ts
2043
+ import path10 from "node:path";
2044
+ import fs9 from "fs-extra";
1857
2045
  var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
1858
2046
  var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
1859
2047
  var IGNORED_PATHS = [".haus-workflow/"];
@@ -1909,20 +2097,21 @@ ${block}
1909
2097
  `;
1910
2098
  }
1911
2099
  async function writePrettierIgnore(root, dryRun) {
1912
- const filePath = path9.join(root, ".prettierignore");
1913
- const prev = await fs8.pathExists(filePath) ? await fs8.readFile(filePath, "utf8") : "";
2100
+ const filePath = path10.join(root, ".prettierignore");
2101
+ const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
1914
2102
  const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
1915
2103
  await writeManagedText(root, filePath, next, dryRun);
1916
2104
  return filePath;
1917
2105
  }
1918
2106
 
1919
2107
  // src/claude/write-root-claude-md.ts
1920
- import path10 from "node:path";
1921
- import fs9 from "fs-extra";
2108
+ import path11 from "node:path";
2109
+ import fs10 from "fs-extra";
1922
2110
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
1923
2111
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
1924
2112
  var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
1925
- @.haus-workflow/workflow-config.md`;
2113
+ @.haus-workflow/workflow-config.md
2114
+ @docs/decisions/README.md`;
1926
2115
  function buildImportBlock() {
1927
2116
  return `${BLOCK_BEGIN}
1928
2117
  ${IMPORT_CONTENT}
@@ -1983,21 +2172,21 @@ ${block}
1983
2172
  `;
1984
2173
  }
1985
2174
  async function writeRootClaudeMd(root, dryRun) {
1986
- const filePath = path10.join(root, "CLAUDE.md");
2175
+ const filePath = path11.join(root, "CLAUDE.md");
1987
2176
  const block = buildImportBlock();
1988
- const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
2177
+ const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
1989
2178
  const next = injectHausBlock(prev, block);
1990
2179
  await writeManagedText(root, filePath, next, dryRun);
1991
2180
  return filePath;
1992
2181
  }
1993
2182
 
1994
2183
  // src/claude/write-workflow-config.ts
1995
- import path12 from "node:path";
1996
- import fs11 from "fs-extra";
2184
+ import path13 from "node:path";
2185
+ import fs12 from "fs-extra";
1997
2186
 
1998
2187
  // src/claude/derive-workflow-config.ts
1999
- import path11 from "node:path";
2000
- import fs10 from "fs-extra";
2188
+ import path12 from "node:path";
2189
+ import fs11 from "fs-extra";
2001
2190
  function binCmd(pm, bin, args) {
2002
2191
  const tail = args ? ` ${args}` : "";
2003
2192
  if (pm === "yarn") return `yarn ${bin}${tail}`;
@@ -2006,7 +2195,7 @@ function binCmd(pm, bin, args) {
2006
2195
  }
2007
2196
  async function deriveWorkflowConfig(root, ctx) {
2008
2197
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
2009
- const pkg = await readJson(path11.join(root, "package.json"));
2198
+ const pkg = await readJson(path12.join(root, "package.json"));
2010
2199
  const scripts = pkg?.scripts ?? {};
2011
2200
  const deps = new Set(ctx.dependencies);
2012
2201
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -2016,7 +2205,7 @@ async function deriveWorkflowConfig(root, ctx) {
2016
2205
  return null;
2017
2206
  };
2018
2207
  const hasDep = (name) => deps.has(name);
2019
- const exists = (rel) => fs10.pathExistsSync(path11.join(root, rel));
2208
+ const exists = (rel) => fs11.pathExistsSync(path12.join(root, rel));
2020
2209
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
2021
2210
  const hasCypress = hasDep("cypress");
2022
2211
  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 +2273,7 @@ var FALLBACK_CONTEXT = {
2084
2273
  async function writeWorkflowConfig(root, dryRun, opts = {}) {
2085
2274
  const destPath = hausPath(root, "workflow-config.md");
2086
2275
  const printable = displayPath(root, destPath);
2087
- const exists = await fs11.pathExists(destPath);
2276
+ const exists = await fs12.pathExists(destPath);
2088
2277
  if (exists && !opts.refill) {
2089
2278
  if (dryRun) log(printable + ": exists (project-owned, skipping)");
2090
2279
  return null;
@@ -2092,11 +2281,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2092
2281
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
2093
2282
  ...FALLBACK_CONTEXT,
2094
2283
  root,
2095
- repoName: path12.basename(root)
2284
+ repoName: path13.basename(root)
2096
2285
  };
2097
2286
  const values = await deriveWorkflowConfig(root, ctx);
2098
2287
  if (exists) {
2099
- const current = await fs11.readFile(destPath, "utf8");
2288
+ const current = await fs12.readFile(destPath, "utf8");
2100
2289
  const refilled = refillContent(current, values);
2101
2290
  if (refilled === current) {
2102
2291
  if (dryRun) log(printable + ": no blank fields to refill");
@@ -2118,7 +2307,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2118
2307
  }
2119
2308
 
2120
2309
  // src/claude/write-workflow.ts
2121
- import fs12 from "fs-extra";
2310
+ import fs13 from "fs-extra";
2122
2311
  var STABLE_ID = "template.workflow";
2123
2312
  function makeWorkflowHeader(pkgVersion, contentHash) {
2124
2313
  return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
@@ -2137,8 +2326,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
2137
2326
  ${templateContent}`;
2138
2327
  const destPath = hausPath(root, "WORKFLOW.md");
2139
2328
  const printable = displayPath(root, destPath);
2140
- if (await fs12.pathExists(destPath)) {
2141
- const existing = await fs12.readFile(destPath, "utf8");
2329
+ if (await fs13.pathExists(destPath)) {
2330
+ const existing = await fs13.readFile(destPath, "utf8");
2142
2331
  const firstLine = existing.split("\n")[0] ?? "";
2143
2332
  const parsed = parseHausManagedHeader(firstLine);
2144
2333
  if (!parsed) {
@@ -2156,15 +2345,32 @@ ${templateContent}`;
2156
2345
  return null;
2157
2346
  }
2158
2347
  const existingContent = existing.slice(firstLine.length + 1);
2159
- if (!parsed.hash && !force) {
2160
- warn(
2161
- `${printable}: managed header missing hash \u2014 cannot verify tamper status, skipping. Use --force to overwrite.`
2162
- );
2163
- return null;
2164
- }
2165
- if (parsed.hash && hashText(normaliseLF(existingContent)) !== parsed.hash && !force) {
2166
- warn(`${printable}: content modified by user \u2014 skipping. Use --force to overwrite.`);
2167
- return null;
2348
+ const bodyMatchesTemplate = hashText(normaliseLF(existingContent)) === contentHash;
2349
+ if (parsed.hash) {
2350
+ const bodyIntact = hashText(normaliseLF(existingContent)) === parsed.hash;
2351
+ if (!bodyIntact && !force) {
2352
+ warn(`${printable}: content modified by user \u2014 skipping. Use --force to overwrite.`);
2353
+ return null;
2354
+ }
2355
+ } else {
2356
+ if (!bodyMatchesTemplate && !force) {
2357
+ const migratedHeader = makeWorkflowHeader(
2358
+ pkgVersion,
2359
+ hashText(normaliseLF(existingContent))
2360
+ );
2361
+ const migrated = `${migratedHeader}
2362
+ ${existingContent}`;
2363
+ if (hasTextChanged(existing, migrated)) {
2364
+ if (dryRun) {
2365
+ log(`${printable}: migrating legacy hash header (body preserved)`);
2366
+ } else {
2367
+ await writeText(destPath, migrated);
2368
+ }
2369
+ } else if (dryRun) {
2370
+ log(`${printable}: unchanged`);
2371
+ }
2372
+ return destPath;
2373
+ }
2168
2374
  }
2169
2375
  if (!hasTextChanged(existing, next)) {
2170
2376
  if (dryRun) log(`${printable}: unchanged`);
@@ -2172,7 +2378,7 @@ ${templateContent}`;
2172
2378
  }
2173
2379
  }
2174
2380
  if (dryRun) {
2175
- const prev = await fs12.pathExists(destPath) ? await fs12.readFile(destPath, "utf8") : "";
2381
+ const prev = await fs13.pathExists(destPath) ? await fs13.readFile(destPath, "utf8") : "";
2176
2382
  if (!prev) {
2177
2383
  log(createUnifiedDiff(printable, "", next));
2178
2384
  } else {
@@ -2195,6 +2401,8 @@ function targetDirForType(type) {
2195
2401
  return null;
2196
2402
  }
2197
2403
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2404
+ const say = opts.quiet ? () => {
2405
+ } : log;
2198
2406
  const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
2199
2407
  recommended: [],
2200
2408
  skipped: [],
@@ -2205,13 +2413,14 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2205
2413
  estimatedTokenReductionPct: 0
2206
2414
  };
2207
2415
  const pkgRoot = packageRoot();
2208
- const hausVersion2 = (await readJson(path13.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2416
+ const hausVersion2 = (await readJson(path14.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2209
2417
  const coreFiles = [
2210
2418
  claudePath(root, "settings.json"),
2211
2419
  claudePath(root, "rules", "haus.md"),
2212
2420
  claudePath(root, "commands", "haus-doctor.md")
2213
2421
  ];
2214
2422
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
2423
+ const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
2215
2424
  const workflowPath = await writeWorkflow(root, hausVersion2, dryRun, opts.force);
2216
2425
  const workflowConfigPath = await writeWorkflowConfig(root, dryRun, {
2217
2426
  refill: opts.refillConfig
@@ -2219,6 +2428,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2219
2428
  const prettierIgnorePath = await writePrettierIgnore(root, dryRun);
2220
2429
  const p6Files = [
2221
2430
  rootClaudeMdPath,
2431
+ ...decisionsSeedPath ? [decisionsSeedPath] : [],
2222
2432
  ...workflowPath ? [workflowPath] : [],
2223
2433
  ...workflowConfigPath ? [workflowConfigPath] : [],
2224
2434
  prettierIgnorePath
@@ -2238,16 +2448,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2238
2448
  dryRun
2239
2449
  );
2240
2450
  const legacyReviewPath = claudePath(root, "commands", "haus-review.md");
2241
- if (await fs13.pathExists(legacyReviewPath)) {
2242
- const content2 = await fs13.readFile(legacyReviewPath, "utf8");
2451
+ if (await fs14.pathExists(legacyReviewPath)) {
2452
+ const content2 = await fs14.readFile(legacyReviewPath, "utf8");
2243
2453
  const stub = 'Run `haus context --task "code review"` then review diff.';
2244
2454
  if (content2 === stub || content2 === `${stub}
2245
2455
  ` || content2 === `${stub}\r
2246
2456
  `) {
2247
2457
  if (dryRun) {
2248
- log(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
2458
+ say(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
2249
2459
  } else {
2250
- await fs13.remove(legacyReviewPath);
2460
+ await fs14.remove(legacyReviewPath);
2251
2461
  }
2252
2462
  }
2253
2463
  }
@@ -2275,16 +2485,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2275
2485
  dryRun
2276
2486
  );
2277
2487
  const legacySecurityPath = claudePath(root, "rules", "security.md");
2278
- if (await fs13.pathExists(legacySecurityPath)) {
2279
- const content2 = await fs13.readFile(legacySecurityPath, "utf8");
2488
+ if (await fs14.pathExists(legacySecurityPath)) {
2489
+ const content2 = await fs14.readFile(legacySecurityPath, "utf8");
2280
2490
  const stub = "- Never read secrets.\n- Block dangerous shell commands.";
2281
2491
  if (content2 === stub || content2 === `${stub}
2282
2492
  ` || content2 === `${stub}\r
2283
2493
  `) {
2284
2494
  if (dryRun) {
2285
- log(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
2495
+ say(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
2286
2496
  } else {
2287
- await fs13.remove(legacySecurityPath);
2497
+ await fs14.remove(legacySecurityPath);
2288
2498
  }
2289
2499
  }
2290
2500
  }
@@ -2299,11 +2509,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2299
2509
  ];
2300
2510
  for (const rel of LEGACY_PRUNED_ARTIFACTS) {
2301
2511
  const legacyPath = hausPath(root, rel);
2302
- if (await fs13.pathExists(legacyPath)) {
2512
+ if (await fs14.pathExists(legacyPath)) {
2303
2513
  if (dryRun) {
2304
- log(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
2514
+ say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
2305
2515
  } else {
2306
- await fs13.remove(legacyPath);
2516
+ await fs14.remove(legacyPath);
2307
2517
  }
2308
2518
  }
2309
2519
  }
@@ -2345,30 +2555,69 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2345
2555
  );
2346
2556
  continue;
2347
2557
  }
2348
- const destination = claudePath(root, target, path13.basename(sourcePath));
2349
- if (await fs13.pathExists(sourcePath)) {
2558
+ const destination = claudePath(root, target, path14.basename(sourcePath));
2559
+ if (await fs14.pathExists(sourcePath)) {
2350
2560
  if (dryRun) {
2351
- const exists = await fs13.pathExists(destination);
2352
- log(
2561
+ const exists = await fs14.pathExists(destination);
2562
+ say(
2353
2563
  `${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
2354
2564
  );
2355
2565
  } else if (item.type === "skill") {
2566
+ const skillFiles = (await fs14.readdir(sourcePath, { recursive: true })).filter(
2567
+ (f) => typeof f === "string" && f.endsWith(".md")
2568
+ );
2569
+ let skillValid = true;
2570
+ for (const relFile of skillFiles) {
2571
+ const mdPath = path14.join(sourcePath, relFile);
2572
+ const mdContent = await fs14.readFile(mdPath, "utf8");
2573
+ const mdValidation = validateCatalogItem(
2574
+ {
2575
+ id: manifestItem.id,
2576
+ type: "skill",
2577
+ path: manifestItem.path,
2578
+ source: manifestItem.source
2579
+ },
2580
+ mdContent
2581
+ );
2582
+ if (!mdValidation.ok) {
2583
+ warn(
2584
+ `Skipping ${item.id}: pre-copy validation failed (${relFile}) \u2014 ${mdValidation.reason}`
2585
+ );
2586
+ skillValid = false;
2587
+ break;
2588
+ }
2589
+ }
2590
+ if (!skillValid) continue;
2356
2591
  await installCatalogSkill(sourcePath, destination, {
2357
2592
  originSourceId: manifestItem.originSourceId,
2358
2593
  dryRun: false
2359
2594
  });
2360
2595
  } else {
2361
- await fs13.ensureDir(path13.dirname(destination));
2362
- await fs13.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2596
+ const fileContent = await fs14.readFile(sourcePath, "utf8");
2597
+ const validation = validateCatalogItem(
2598
+ {
2599
+ id: manifestItem.id,
2600
+ type: manifestItem.type,
2601
+ path: manifestItem.path,
2602
+ source: manifestItem.source
2603
+ },
2604
+ fileContent
2605
+ );
2606
+ if (!validation.ok) {
2607
+ warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
2608
+ continue;
2609
+ }
2610
+ await fs14.ensureDir(path14.dirname(destination));
2611
+ await fs14.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2363
2612
  }
2364
2613
  files.push(destination);
2365
- const relPaths = [path13.relative(root, destination)];
2614
+ const relPaths = [path14.relative(root, destination)];
2366
2615
  if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
2367
2616
  const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
2368
2617
  if (sharedRel) {
2369
2618
  superpowersSharedInstalled = true;
2370
2619
  relPaths.push(sharedRel);
2371
- files.push(path13.join(root, sharedRel));
2620
+ files.push(path14.join(root, sharedRel));
2372
2621
  }
2373
2622
  }
2374
2623
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -2385,7 +2634,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2385
2634
  `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
2386
2635
  );
2387
2636
  }
2388
- await cleanupStaleCatalogItems(root, manifestById, dryRun);
2637
+ await cleanupStaleCatalogItems(root, manifestById, dryRun, opts.quiet);
2389
2638
  if (dryRun) return [...new Set(files)];
2390
2639
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
2391
2640
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
@@ -2422,7 +2671,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2422
2671
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
2423
2672
  return [...new Set(files)];
2424
2673
  }
2425
- async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2674
+ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2675
+ const say = quiet ? () => {
2676
+ } : log;
2426
2677
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
2427
2678
  if (!prevLock?.length) return;
2428
2679
  for (const entry of prevLock) {
@@ -2435,7 +2686,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2435
2686
  if (relPaths.length === 0) continue;
2436
2687
  const existing = [];
2437
2688
  for (const rel of relPaths) {
2438
- if (await fs13.pathExists(path13.join(root, rel))) existing.push(rel);
2689
+ if (await fs14.pathExists(path14.join(root, rel))) existing.push(rel);
2439
2690
  }
2440
2691
  if (existing.length === 0) continue;
2441
2692
  const pruneReason = deprecated ? "deprecated" : "stale";
@@ -2453,18 +2704,115 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2453
2704
  continue;
2454
2705
  }
2455
2706
  for (const rel of existing) {
2456
- const abs = path13.join(root, rel);
2707
+ const abs = path14.join(root, rel);
2457
2708
  if (dryRun) {
2458
- log(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2709
+ say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2459
2710
  continue;
2460
2711
  }
2461
- await fs13.remove(abs);
2462
- await pruneEmptyDir(path13.dirname(abs));
2463
- log(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2712
+ await fs14.remove(abs);
2713
+ await pruneEmptyDir(path14.dirname(abs));
2714
+ say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2464
2715
  }
2465
2716
  }
2466
2717
  }
2467
2718
 
2719
+ // src/refs/fetch-refs.ts
2720
+ import path16 from "node:path";
2721
+ import fs16 from "fs-extra";
2722
+
2723
+ // src/refs/cache-meta.ts
2724
+ import path15 from "node:path";
2725
+ import fs15 from "fs-extra";
2726
+ var META_FILENAME = "cache-meta.json";
2727
+ async function readCacheMeta(cacheDir) {
2728
+ const metaPath = path15.join(cacheDir, META_FILENAME);
2729
+ try {
2730
+ const raw = await fs15.readFile(metaPath, "utf8");
2731
+ return JSON.parse(raw);
2732
+ } catch {
2733
+ return {};
2734
+ }
2735
+ }
2736
+ async function writeCacheMeta(cacheDir, meta) {
2737
+ await fs15.ensureDir(cacheDir);
2738
+ const metaPath = path15.join(cacheDir, META_FILENAME);
2739
+ await fs15.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
2740
+ }
2741
+
2742
+ // src/refs/fetch-refs.ts
2743
+ function urlToSlug(url) {
2744
+ const u = new URL(url);
2745
+ return (u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
2746
+ }
2747
+ function isLlmsTxtUrl(ref) {
2748
+ try {
2749
+ const u = new URL(ref);
2750
+ return (u.protocol === "https:" || u.protocol === "http:") && u.pathname.endsWith("/llms.txt");
2751
+ } catch {
2752
+ return false;
2753
+ }
2754
+ }
2755
+ async function fetchSingleRef(url, cacheDir, meta) {
2756
+ const existing = meta[url];
2757
+ const headers = {};
2758
+ const safeFile = existing?.file && existing.file === path16.basename(existing.file);
2759
+ const cachedFileExists = safeFile && await fs16.pathExists(path16.join(cacheDir, existing.file));
2760
+ if (cachedFileExists) {
2761
+ if (existing?.etag) headers["If-None-Match"] = existing.etag;
2762
+ if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
2763
+ }
2764
+ try {
2765
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
2766
+ if (res.status === 304) return { result: "unchanged" };
2767
+ if (!res.ok) return { result: "failed" };
2768
+ const text = await res.text();
2769
+ const file = `${urlToSlug(url)}.md`;
2770
+ await fs16.ensureDir(cacheDir);
2771
+ await fs16.writeFile(path16.join(cacheDir, file), text, "utf8");
2772
+ meta[url] = {
2773
+ url,
2774
+ etag: res.headers.get("etag") ?? void 0,
2775
+ lastModified: res.headers.get("last-modified") ?? void 0,
2776
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
2777
+ file
2778
+ };
2779
+ return { result: "fetched", file };
2780
+ } catch {
2781
+ return { result: "failed" };
2782
+ }
2783
+ }
2784
+ async function fetchRefsForItems(items, cacheDir) {
2785
+ const urls = [...new Set(items.flatMap((item) => (item.references ?? []).filter(isLlmsTxtUrl)))];
2786
+ const summary = {
2787
+ fetched: 0,
2788
+ unchanged: 0,
2789
+ failed: 0,
2790
+ failedUrls: [],
2791
+ cachedFiles: {}
2792
+ };
2793
+ if (urls.length === 0) return summary;
2794
+ const meta = await readCacheMeta(cacheDir);
2795
+ await Promise.all(
2796
+ urls.map(async (url) => {
2797
+ const outcome = await fetchSingleRef(url, cacheDir, meta);
2798
+ if (outcome.result === "fetched") {
2799
+ summary.fetched++;
2800
+ summary.cachedFiles[url] = path16.join(cacheDir, outcome.file);
2801
+ } else if (outcome.result === "unchanged") {
2802
+ summary.unchanged++;
2803
+ if (meta[url]?.file) summary.cachedFiles[url] = path16.join(cacheDir, meta[url].file);
2804
+ } else {
2805
+ summary.failed++;
2806
+ summary.failedUrls.push(url);
2807
+ }
2808
+ })
2809
+ );
2810
+ if (summary.fetched > 0) {
2811
+ await writeCacheMeta(cacheDir, meta);
2812
+ }
2813
+ return summary;
2814
+ }
2815
+
2468
2816
  // src/utils/args.ts
2469
2817
  function parseIdList(value) {
2470
2818
  if (!value) return [];
@@ -2474,7 +2822,7 @@ function parseIdList(value) {
2474
2822
 
2475
2823
  // src/commands/apply.ts
2476
2824
  async function cacheHasItems() {
2477
- const data = await readJson(path14.join(getCacheDir(), "manifest.json"));
2825
+ const data = await readJson(path17.join(getCacheDir(), "manifest.json"));
2478
2826
  return Array.isArray(data?.items) && data.items.length > 0;
2479
2827
  }
2480
2828
  function installableRecommendedItems(recommended) {
@@ -2566,6 +2914,23 @@ async function runApply(options) {
2566
2914
  refillConfig: options.refillConfig,
2567
2915
  force: options.force
2568
2916
  });
2917
+ if (!isDryRun) {
2918
+ try {
2919
+ const cacheDir = hausPath(root, "llms-cache");
2920
+ const allItems = await loadCatalog(root);
2921
+ const summary = await fetchRefsForItems(allItems, cacheDir);
2922
+ if (summary.fetched > 0)
2923
+ log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
2924
+ if (summary.failed > 0)
2925
+ warn(
2926
+ `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
2927
+ );
2928
+ } catch (err) {
2929
+ warn(
2930
+ `Could not fetch llms.txt references: ${err instanceof Error ? err.message : String(err)}`
2931
+ );
2932
+ }
2933
+ }
2569
2934
  if (isDryRun) {
2570
2935
  log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
2571
2936
  } else {
@@ -2579,7 +2944,7 @@ function auditForbiddenTags(items) {
2579
2944
  const failures = [];
2580
2945
  const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
2581
2946
  for (const item of items) {
2582
- for (const tag of item.tags) {
2947
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
2583
2948
  if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
2584
2949
  }
2585
2950
  for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
@@ -2600,84 +2965,509 @@ async function runCatalogAudit() {
2600
2965
  }
2601
2966
 
2602
2967
  // src/commands/clone.ts
2968
+ init_exec();
2603
2969
  import { existsSync as existsSync2 } from "node:fs";
2604
- import path15 from "node:path";
2970
+ import path18 from "node:path";
2971
+ var GIT_LOCATION_VARS = [
2972
+ "GIT_DIR",
2973
+ "GIT_WORK_TREE",
2974
+ "GIT_INDEX_FILE",
2975
+ "GIT_COMMON_DIR",
2976
+ "GIT_OBJECT_DIRECTORY",
2977
+ "GIT_NAMESPACE",
2978
+ "GIT_PREFIX"
2979
+ ];
2980
+ function cloneEnv() {
2981
+ const env = { ...process.env };
2982
+ for (const key of GIT_LOCATION_VARS) delete env[key];
2983
+ return env;
2984
+ }
2985
+ function repoNameFromUrl(url) {
2986
+ const trimmed = url.trim().replace(/\.git$/, "").replace(/\/+$/, "");
2987
+ const tail = trimmed.split(/[/:]/).pop() ?? "";
2988
+ return tail || "repo";
2989
+ }
2990
+ async function runClone(url, opts = {}) {
2991
+ if (!url || !url.trim()) {
2992
+ error("A git URL is required: `haus clone <url> [dir]`.");
2993
+ process.exitCode = 1;
2994
+ return;
2995
+ }
2996
+ const target = path18.resolve(opts.dir?.trim() || repoNameFromUrl(url));
2997
+ if (existsSync2(target)) {
2998
+ log(`\u2022 ${path18.basename(target)} already present at ${target} \u2014 skipped`);
2999
+ return;
3000
+ }
3001
+ if (opts.dryRun) {
3002
+ log(`would clone ${url} \u2192 ${target}`);
3003
+ return;
3004
+ }
3005
+ const res = await runGit(["clone", "--", url, target], { env: cloneEnv(), extendEnv: false });
3006
+ if (res.exitCode !== 0) {
3007
+ error(`clone failed for ${url}: ${(res.stderr || res.stdout).trim()}`);
3008
+ process.exitCode = 1;
3009
+ return;
3010
+ }
3011
+ log(`\u2713 cloned ${url} \u2192 ${target}`);
3012
+ }
2605
3013
 
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
- });
3014
+ // src/commands/decisions.ts
3015
+ import path22 from "node:path";
3016
+
3017
+ // src/decisions/check.ts
3018
+ import path21 from "node:path";
3019
+ import fs19 from "fs-extra";
3020
+
3021
+ // src/decisions/diff.ts
3022
+ init_exec();
3023
+ async function collectDiffStats(root, opts) {
3024
+ const gitArgs = opts.staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only", opts.range ?? "HEAD"];
3025
+ const names = await runGit(gitArgs, { cwd: root });
3026
+ const files = names.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
3027
+ const numstatArgs = opts.staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat", opts.range ?? "HEAD"];
3028
+ const numstat = await runGit(numstatArgs, { cwd: root });
3029
+ let linesAdded = 0;
3030
+ let linesRemoved = 0;
3031
+ for (const line2 of numstat.stdout.split("\n")) {
3032
+ const parts = line2.trim().split(/\s+/);
3033
+ if (parts.length < 3) continue;
3034
+ const added = parts[0] === "-" ? 0 : Number(parts[0]);
3035
+ const removed = parts[1] === "-" ? 0 : Number(parts[1]);
3036
+ if (!Number.isNaN(added)) linesAdded += added;
3037
+ if (!Number.isNaN(removed)) linesRemoved += removed;
3038
+ }
3039
+ return { files, linesAdded, linesRemoved };
3040
+ }
3041
+
3042
+ // src/decisions/match.ts
3043
+ function segmentToRegex(segment) {
3044
+ if (segment === "**") return ".*";
3045
+ return segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
3046
+ }
3047
+ function globToRegExp(pattern) {
3048
+ const pat = pattern.replace(/\\/g, "/");
3049
+ if (pat.startsWith("**/") && pat.endsWith("/**")) {
3050
+ const middle = pat.slice(3, -3);
3051
+ const midRe = middle.split("/").map(segmentToRegex).join("/");
3052
+ return new RegExp(`(^|/)${midRe}(/|$)`);
3053
+ }
3054
+ if (pat.startsWith("**/")) {
3055
+ const rest = pat.slice(3).split("/").map(segmentToRegex).join("/");
3056
+ return new RegExp(`^(?:.*/)?${rest}$`);
3057
+ }
3058
+ const reSource = pat.split("/").map(segmentToRegex).join("/");
3059
+ return new RegExp(`^${reSource}$`);
3060
+ }
3061
+ function matchesPathGlob(filePath, pattern) {
3062
+ const norm = filePath.replace(/\\/g, "/");
3063
+ return globToRegExp(pattern).test(norm);
3064
+ }
3065
+ function matchesAnyGlob(filePath, patterns) {
3066
+ return patterns.some((p) => matchesPathGlob(filePath, p));
3067
+ }
3068
+ function matchesAnyRegex(filePath, patterns) {
3069
+ const norm = filePath.replace(/\\/g, "/");
3070
+ return patterns.some((p) => new RegExp(p).test(norm));
3071
+ }
3072
+
3073
+ // src/decisions/paths.ts
3074
+ import path19 from "node:path";
3075
+ import fs17 from "fs-extra";
3076
+ async function resolveDecisionsDir(root) {
3077
+ const primary = path19.join(root, DECISIONS_TRIGGERS.decisionsDir);
3078
+ if (await fs17.pathExists(primary)) return primary;
3079
+ const legacy = path19.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3080
+ if (await fs17.pathExists(legacy)) return legacy;
3081
+ return primary;
3082
+ }
3083
+ function relativeDecisionPath(decisionsDir, root) {
3084
+ return path19.relative(root, decisionsDir).replace(/\\/g, "/");
3085
+ }
3086
+
3087
+ // src/decisions/validate.ts
3088
+ import path20 from "node:path";
3089
+ import fs18 from "fs-extra";
3090
+ function validateDecisionContent(content2) {
3091
+ const errors = [];
3092
+ for (const section of DECISIONS_TRIGGERS.requiredSections) {
3093
+ if (!content2.includes(section)) {
3094
+ errors.push(`missing required section: ${section}`);
3095
+ }
3096
+ }
3097
+ if (!/^#\s+ADR-\d{4}:/m.test(content2)) {
3098
+ errors.push('heading must match "# ADR-NNNN: Title"');
3099
+ }
3100
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
3101
+ }
3102
+ function indexReferencesNumber(readme, number) {
3103
+ const padded = number.padStart(4, "0");
3104
+ return readme.includes(`[${padded}]`) || readme.includes(`(${padded}-`) || readme.includes(`ADR-${padded}`);
3105
+ }
3106
+ async function validateDecisionFile(filePath) {
3107
+ const content2 = await readText(filePath) ?? "";
3108
+ return validateDecisionContent(content2);
3109
+ }
3110
+ function isDecisionFileName(name) {
3111
+ return DECISION_FILE_RE.test(name);
3112
+ }
3113
+ function decisionNumberFromFile(name) {
3114
+ const match = /^(\d{4})-/.exec(name);
3115
+ return match?.[1] ?? null;
3116
+ }
3117
+ async function validateIndexSync(decisionsDir, decisionFiles) {
3118
+ const readmePath = path20.join(decisionsDir, "README.md");
3119
+ if (!await fs18.pathExists(readmePath)) {
3120
+ return ["docs/decisions/README.md is missing"];
3121
+ }
3122
+ const readme = await readText(readmePath) ?? "";
3123
+ const errors = [];
3124
+ for (const file of decisionFiles) {
3125
+ const num = decisionNumberFromFile(path20.basename(file));
3126
+ if (num && !indexReferencesNumber(readme, num)) {
3127
+ errors.push(`README.md missing index row for ADR-${num}`);
3128
+ }
3129
+ }
3130
+ return errors;
3131
+ }
3132
+
3133
+ // src/decisions/check.ts
3134
+ function isExempt(file) {
3135
+ return matchesAnyGlob(file, DECISIONS_TRIGGERS.exemptGlobs);
3136
+ }
3137
+ function countTopLevelDirs(files) {
3138
+ const dirs = /* @__PURE__ */ new Set();
3139
+ for (const f of files) {
3140
+ const parts = f.split("/");
3141
+ if (parts.length > 1) dirs.add(parts[0]);
3142
+ }
3143
+ return dirs.size;
3144
+ }
3145
+ function isDecisionTriggered(stats) {
3146
+ const relevant = stats.files.filter((f) => !isExempt(f));
3147
+ if (relevant.length === 0) return false;
3148
+ for (const file of relevant) {
3149
+ if (matchesAnyGlob(file, DECISIONS_TRIGGERS.pathGlobs)) return true;
3150
+ if (matchesAnyRegex(file, DECISIONS_TRIGGERS.pathRegex)) return true;
3151
+ }
3152
+ const totalLines = stats.linesAdded + stats.linesRemoved;
3153
+ if (relevant.length >= DECISIONS_TRIGGERS.minFilesChanged && countTopLevelDirs(relevant) >= 2) {
3154
+ return true;
3155
+ }
3156
+ if (totalLines >= DECISIONS_TRIGGERS.minLinesChanged && relevant.some((f) => !f.startsWith("docs/"))) {
3157
+ return true;
3158
+ }
3159
+ return false;
3160
+ }
3161
+ function isSecurityTriggered(stats) {
3162
+ const relevant = stats.files.filter((f) => !isExempt(f));
3163
+ const security = DECISIONS_TRIGGERS.securityPathGlobs ?? [];
3164
+ return relevant.some(
3165
+ (f) => matchesAnyGlob(f, security) || matchesAnyRegex(f, DECISIONS_TRIGGERS.pathRegex)
3166
+ );
3167
+ }
3168
+ async function readGateMode(root) {
3169
+ const cfg = await readJson(hausPath(root, "adr-gate.json"));
3170
+ return cfg?.mode === "off" ? "off" : "on";
3171
+ }
3172
+ function hasSkipToken(text) {
3173
+ return text.includes("[adr-skip]");
3174
+ }
3175
+ async function checkSatisfaction(input2) {
3176
+ const errors = [];
3177
+ const decisionsPrefix = `${input2.decisionsRel}/`;
3178
+ const newDecisionFiles = input2.changedFiles.filter(
3179
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path21.basename(f))
3180
+ );
3181
+ if (newDecisionFiles.length === 0) {
3182
+ errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
3183
+ return errors;
3184
+ }
3185
+ const decisionsDir = path21.join(input2.root, input2.decisionsRel);
3186
+ for (const rel of newDecisionFiles) {
3187
+ const abs = path21.join(input2.root, rel);
3188
+ const content2 = await readText(abs) ?? "";
3189
+ const valid = validateDecisionContent(content2);
3190
+ if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
3191
+ }
3192
+ const indexErrors = await validateIndexSync(
3193
+ decisionsDir,
3194
+ newDecisionFiles.map((f) => path21.basename(f))
3195
+ );
3196
+ errors.push(...indexErrors);
3197
+ return errors;
3198
+ }
3199
+ async function runDecisionsCheck(root, opts = {}) {
3200
+ const reasons = [];
3201
+ if (await readGateMode(root) === "off") {
2615
3202
  return {
2616
- command,
2617
- args,
2618
- stdout: String(result.stdout ?? ""),
2619
- stderr: String(result.stderr ?? ""),
2620
- exitCode: result.exitCode ?? 0
3203
+ triggered: false,
3204
+ satisfied: true,
3205
+ reasons: ["gate disabled via .haus-workflow/adr-gate.json"],
3206
+ securityTriggered: false
3207
+ };
3208
+ }
3209
+ const stats = await collectDiffStats(root, opts);
3210
+ const triggered = isDecisionTriggered(stats);
3211
+ const securityTriggered = isSecurityTriggered(stats);
3212
+ if (!triggered) {
3213
+ return {
3214
+ triggered: false,
3215
+ satisfied: true,
3216
+ reasons: ["no decision-worthy changes"],
3217
+ securityTriggered
3218
+ };
3219
+ }
3220
+ const skipText = `${opts.prBody ?? ""}
3221
+ ${opts.commitMessages ?? ""}`;
3222
+ if (hasSkipToken(skipText) && !securityTriggered) {
3223
+ reasons.push("[adr-skip] present \u2014 gate waived (non-security change)");
3224
+ return { triggered: true, satisfied: true, reasons, securityTriggered };
3225
+ }
3226
+ if (hasSkipToken(skipText) && securityTriggered) {
3227
+ reasons.push("[adr-skip] ignored for security/auth path changes");
3228
+ }
3229
+ const decisionsDir = await resolveDecisionsDir(root);
3230
+ const decisionsRel = relativeDecisionPath(decisionsDir, root);
3231
+ const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
3232
+ let satisfactionErrors = await checkSatisfaction({
3233
+ root,
3234
+ decisionsRel,
3235
+ changedFiles: stats.files
3236
+ });
3237
+ if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
3238
+ const legacyExists = await fs19.pathExists(path21.join(root, legacyRel));
3239
+ if (legacyExists && !await fs19.pathExists(decisionsDir)) {
3240
+ satisfactionErrors = await checkSatisfaction({
3241
+ root,
3242
+ decisionsRel: legacyRel,
3243
+ changedFiles: stats.files
3244
+ });
3245
+ }
3246
+ }
3247
+ if (satisfactionErrors.length === 0) {
3248
+ return {
3249
+ triggered: true,
3250
+ satisfied: true,
3251
+ reasons: ["decision record present"],
3252
+ securityTriggered
2621
3253
  };
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
3254
  }
3255
+ reasons.push(...satisfactionErrors);
3256
+ return { triggered: true, satisfied: false, reasons, securityTriggered };
2626
3257
  }
2627
- async function runGit(args, options = {}) {
2628
- return runCommand("git", args, options);
3258
+ async function listDecisionNumbers(decisionsDir) {
3259
+ if (!await fs19.pathExists(decisionsDir)) return [];
3260
+ const entries = await fs19.readdir(decisionsDir);
3261
+ return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
2629
3262
  }
2630
3263
 
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;
3264
+ // src/decisions/next-number.ts
3265
+ async function nextDecisionNumber(root) {
3266
+ const decisionsDir = await resolveDecisionsDir(root);
3267
+ const numbers = await listDecisionNumbers(decisionsDir);
3268
+ const max = numbers.length === 0 ? 0 : Math.max(...numbers);
3269
+ return String(max + 1).padStart(4, "0");
2645
3270
  }
2646
- function repoNameFromUrl(url) {
2647
- const trimmed = url.trim().replace(/\.git$/, "").replace(/\/+$/, "");
2648
- const tail = trimmed.split(/[/:]/).pop() ?? "";
2649
- return tail || "repo";
3271
+
3272
+ // src/decisions/suggest.ts
3273
+ function draftTemplate(number, title) {
3274
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3275
+ return `# ADR-${number}: ${title}
3276
+
3277
+ - **Status:** Proposed
3278
+ - **Date:** ${today}
3279
+ - **Decided by:** _(pending approval)_ (draft by haus decisions suggest)
3280
+ - **Affects:** _(paths/components)_
3281
+ - **Related:** _(PR/issue link)_
3282
+
3283
+ ## Context
3284
+
3285
+ _(What problem or situation triggered this decision?)_
3286
+
3287
+ ## Decision
3288
+
3289
+ _(One clear sentence: what we chose.)_
3290
+
3291
+ ## Motivation (why)
3292
+
3293
+ _(Why this over alternatives \u2014 the line agents read for "why".)_
3294
+
3295
+ ## Alternatives considered
3296
+
3297
+ - _(Alternative)_ \u2014 rejected because _(reason)_.
3298
+
3299
+ ## Consequences
3300
+
3301
+ - _(Positive and negative follow-ons.)_
3302
+ `;
2650
3303
  }
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;
3304
+ async function runDecisionsSuggest(root, opts = {}) {
3305
+ const stats = await collectDiffStats(root, { staged: true });
3306
+ const stagedCheck = await runDecisionsCheck(root, { staged: true });
3307
+ const unstagedStats = await collectDiffStats(root, {});
3308
+ const triggered = isDecisionTriggered(stats) || isDecisionTriggered(unstagedStats);
3309
+ if (!triggered) return;
3310
+ if (stagedCheck.triggered && stagedCheck.satisfied) return;
3311
+ const number = await nextDecisionNumber(root);
3312
+ const title = opts.title ?? "Decision from current changes";
3313
+ const draft = draftTemplate(number, title);
3314
+ const decisionsDir = await resolveDecisionsDir(root);
3315
+ const rel = relativeDecisionPath(decisionsDir, root);
3316
+ const file = `${rel}/${number}-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "decision"}.md`;
3317
+ const payload = {
3318
+ action: "propose_decision",
3319
+ message: "Decision-worthy change detected. Draft an ADR from this template and ask the user to approve before writing.",
3320
+ file,
3321
+ draft,
3322
+ indexHint: `Add a row to ${rel}/README.md for ADR-${number}.`
3323
+ };
3324
+ if (opts.fromHook) {
3325
+ process.stdout.write(`${JSON.stringify(payload)}
3326
+ `);
2655
3327
  return;
2656
3328
  }
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`);
3329
+ process.stdout.write(`${draft}
3330
+ `);
3331
+ }
3332
+
3333
+ // src/commands/decisions.ts
3334
+ async function runDecisions(sub, arg, options = {}) {
3335
+ const root = process.cwd();
3336
+ if (sub === "check") {
3337
+ const commitMessages = options.range != null ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
3338
+ (m) => m.runGit(["log", "--format=%B", options.range], { cwd: root })
3339
+ )).stdout : "";
3340
+ const result = await runDecisionsCheck(root, {
3341
+ staged: options.staged,
3342
+ range: options.range,
3343
+ prBody: options.prBody ?? process.env.PR_BODY,
3344
+ commitMessages
3345
+ });
3346
+ if (!result.triggered) {
3347
+ log(result.reasons[0] ?? "no decision-worthy changes");
3348
+ return;
3349
+ }
3350
+ if (result.satisfied) {
3351
+ if (result.reasons.some((r) => r.includes("[adr-skip]"))) {
3352
+ warn(`::warning::${result.reasons.join("; ")}`);
3353
+ }
3354
+ log(`decisions gate: satisfied (${result.reasons.join("; ")})`);
3355
+ return;
3356
+ }
3357
+ for (const reason of result.reasons) warn(reason);
3358
+ error(
3359
+ "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."
3360
+ );
3361
+ process.exitCode = 1;
2660
3362
  return;
2661
3363
  }
2662
- if (opts.dryRun) {
2663
- log(`would clone ${url} \u2192 ${target}`);
3364
+ if (sub === "suggest") {
3365
+ await runDecisionsSuggest(root, { fromHook: options.fromHook, title: options.title });
2664
3366
  return;
2665
3367
  }
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;
3368
+ if (sub === "next-number") {
3369
+ log(await nextDecisionNumber(root));
2670
3370
  return;
2671
3371
  }
2672
- log(`\u2713 cloned ${url} \u2192 ${target}`);
3372
+ if (sub === "validate") {
3373
+ const target = arg ? path22.resolve(root, arg) : void 0;
3374
+ if (!target) {
3375
+ error("usage: haus decisions validate <path-to-decision.md>");
3376
+ process.exitCode = 1;
3377
+ return;
3378
+ }
3379
+ const result = await validateDecisionFile(target);
3380
+ if (!result.ok) {
3381
+ for (const e of result.errors) error(e);
3382
+ process.exitCode = 1;
3383
+ return;
3384
+ }
3385
+ log("valid");
3386
+ }
2673
3387
  }
2674
3388
 
2675
3389
  // src/commands/doctor.ts
2676
- import path20 from "node:path";
2677
- import fs15 from "fs-extra";
3390
+ import path28 from "node:path";
3391
+ import fs22 from "fs-extra";
3392
+
3393
+ // src/decisions/doctor.ts
3394
+ import path23 from "node:path";
3395
+ import fs20 from "fs-extra";
3396
+ async function auditDecisionsLayout(root) {
3397
+ const findings = [];
3398
+ const primary = path23.join(root, DECISIONS_TRIGGERS.decisionsDir);
3399
+ const legacy = path23.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3400
+ const hasPrimary = await fs20.pathExists(primary);
3401
+ const hasLegacy = await fs20.pathExists(legacy);
3402
+ if (!hasPrimary && hasLegacy) {
3403
+ findings.push({
3404
+ level: "advisory",
3405
+ line: `- DECISIONS: legacy ${DECISIONS_TRIGGERS.legacyDecisionsDir}/ present \u2014 migrate to docs/decisions/`,
3406
+ sentence: "Decision records still live under the legacy docs/adr/ path",
3407
+ fix: `git mv ${DECISIONS_TRIGGERS.legacyDecisionsDir} ${DECISIONS_TRIGGERS.decisionsDir}`
3408
+ });
3409
+ return findings;
3410
+ }
3411
+ if (!hasPrimary) return findings;
3412
+ const decisionsDir = await resolveDecisionsDir(root);
3413
+ const rel = relativeDecisionPath(decisionsDir, root);
3414
+ const readmePath = path23.join(decisionsDir, "README.md");
3415
+ if (!await fs20.pathExists(readmePath)) {
3416
+ findings.push({
3417
+ level: "advisory",
3418
+ line: `- DECISIONS: missing ${rel}/README.md index`,
3419
+ sentence: "No decision index for agents to load at session start",
3420
+ fix: `create ${rel}/README.md and @import it from CLAUDE.md`
3421
+ });
3422
+ return findings;
3423
+ }
3424
+ const readme = await readText(readmePath);
3425
+ const entries = (await fs20.readdir(decisionsDir)).filter(
3426
+ (f) => f.endsWith(".md") && f !== "README.md"
3427
+ );
3428
+ for (const file of entries) {
3429
+ const num = decisionNumberFromFile(file);
3430
+ if (num && readme && !readme.includes(`[${num}]`) && !readme.includes(`(${num}-`)) {
3431
+ findings.push({
3432
+ level: "advisory",
3433
+ line: `- DECISIONS: ${file} missing from ${rel}/README.md`,
3434
+ sentence: `ADR-${num} is not listed in the decision index`,
3435
+ fix: `add a row for ADR-${num} to ${rel}/README.md`
3436
+ });
3437
+ }
3438
+ }
3439
+ const claude = await readText(path23.join(root, "CLAUDE.md"));
3440
+ if (claude && !claude.includes(`@${rel}/README.md`)) {
3441
+ findings.push({
3442
+ level: "advisory",
3443
+ line: `- DECISIONS: CLAUDE.md does not @import @${rel}/README.md`,
3444
+ sentence: "Agents may not load the decision index at session start",
3445
+ fix: `add @${rel}/README.md to CLAUDE.md`
3446
+ });
3447
+ }
3448
+ const numbers = await listDecisionNumbers(decisionsDir);
3449
+ const dupes = numbers.filter((n, i) => numbers.indexOf(n) !== i);
3450
+ if (dupes.length > 0) {
3451
+ findings.push({
3452
+ level: "advisory",
3453
+ line: `- DECISIONS: duplicate numbers: ${[...new Set(dupes)].join(", ")}`,
3454
+ sentence: "Multiple decision files share the same number",
3455
+ fix: "renumber one file and update README.md"
3456
+ });
3457
+ }
3458
+ if (findings.length === 0) {
3459
+ findings.push({
3460
+ level: "ok",
3461
+ line: `- DECISIONS OK: ${rel}/ (${entries.length} record(s))`,
3462
+ sentence: "",
3463
+ fix: ""
3464
+ });
3465
+ }
3466
+ return findings;
3467
+ }
2678
3468
 
2679
3469
  // src/scanner/scan-project.ts
2680
- import path19 from "node:path";
3470
+ import path27 from "node:path";
2681
3471
 
2682
3472
  // src/utils/audit-checks.ts
2683
3473
  function isRecord(v) {
@@ -2704,8 +3494,8 @@ function compareVersions(a, b) {
2704
3494
  }
2705
3495
 
2706
3496
  // src/scanner/detect-package-manager.ts
2707
- import path16 from "node:path";
2708
- import fs14 from "fs-extra";
3497
+ import path24 from "node:path";
3498
+ import fs21 from "fs-extra";
2709
3499
  function detectPackageManager(root, packageManagerField) {
2710
3500
  const field = String(packageManagerField ?? "").trim();
2711
3501
  if (field.startsWith("yarn@")) {
@@ -2723,9 +3513,9 @@ function detectPackageManager(root, packageManagerField) {
2723
3513
  if (satisfiesVersion(version, ">=9")) return "npm";
2724
3514
  return "unknown";
2725
3515
  }
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";
3516
+ if (fs21.existsSync(path24.join(root, "yarn.lock"))) return "yarn";
3517
+ if (fs21.existsSync(path24.join(root, "pnpm-lock.yaml"))) return "pnpm";
3518
+ if (fs21.existsSync(path24.join(root, "package-lock.json"))) return "npm";
2729
3519
  return "unknown";
2730
3520
  }
2731
3521
 
@@ -2927,7 +3717,7 @@ function runDetection(ctx, rules = STACK_RULES) {
2927
3717
  }
2928
3718
 
2929
3719
  // src/scanner/detection.ts
2930
- import path17 from "node:path";
3720
+ import path25 from "node:path";
2931
3721
  var UNSUPPORTED_MARKERS = {
2932
3722
  "requirements.txt": "python",
2933
3723
  "pyproject.toml": "python",
@@ -2981,14 +3771,14 @@ function finalizeRoles(registryRoles, deps, files) {
2981
3771
  function collectUnsupportedSignals(files) {
2982
3772
  return [
2983
3773
  ...new Set(
2984
- files.map((f) => UNSUPPORTED_MARKERS[path17.basename(f)]).filter((s) => Boolean(s))
3774
+ files.map((f) => UNSUPPORTED_MARKERS[path25.basename(f)]).filter((s) => Boolean(s))
2985
3775
  )
2986
3776
  ].sort();
2987
3777
  }
2988
3778
 
2989
3779
  // src/scanner/render.ts
2990
3780
  import { readFile } from "node:fs/promises";
2991
- import path18 from "node:path";
3781
+ import path26 from "node:path";
2992
3782
  async function buildContentBlob(root, files) {
2993
3783
  const candidates = files.filter(
2994
3784
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -2996,7 +3786,7 @@ async function buildContentBlob(root, files) {
2996
3786
  const slice = candidates.slice(0, 300);
2997
3787
  const parts = await mapWithConcurrency(slice, async (rel) => {
2998
3788
  try {
2999
- return await readFile(path18.join(root, rel), "utf8");
3789
+ return await readFile(path26.join(root, rel), "utf8");
3000
3790
  } catch {
3001
3791
  return "";
3002
3792
  }
@@ -3026,7 +3816,7 @@ function buildSourcesReport(items) {
3026
3816
  }
3027
3817
  return {
3028
3818
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3029
- items: [...statusBySource.entries()].map(([id, status]) => ({ id, status })).sort((a, b) => a.id.localeCompare(b.id))
3819
+ items: [...statusBySource.entries()].map(([source, status]) => ({ source, status })).sort((a, b) => a.source.localeCompare(b.source))
3030
3820
  };
3031
3821
  }
3032
3822
  async function writeSourcesReport(root) {
@@ -3083,8 +3873,8 @@ var SAFE_FILES = [
3083
3873
  "Gemfile"
3084
3874
  ];
3085
3875
  async function scanProject(root) {
3086
- const pkg = await readJson(path19.join(root, "package.json"));
3087
- const composer = await readJson(path19.join(root, "composer.json"));
3876
+ const pkg = await readJson(path27.join(root, "package.json"));
3877
+ const composer = await readJson(path27.join(root, "composer.json"));
3088
3878
  const files = await listFiles(root, SAFE_FILES);
3089
3879
  const safeFiles = files.filter((f) => !blocked(f));
3090
3880
  const deps = dependencySet(pkg, composer);
@@ -3106,14 +3896,12 @@ async function scanProject(root) {
3106
3896
  crossRepoHints.push("Containerized services detected");
3107
3897
  if (safeFiles.some((f) => f.includes("turbo.json") || f.includes("nx.json")))
3108
3898
  crossRepoHints.push("Monorepo orchestration detected");
3109
- if (safeFiles.some((f) => f.includes("wp-content/uploads")))
3110
- securityRisks.push("Uploads directory present");
3111
3899
  const unsupportedSignals = collectUnsupportedSignals(safeFiles);
3112
3900
  const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
3113
3901
  const context = {
3114
3902
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3115
3903
  root,
3116
- repoName: String(pkg?.name ?? path19.basename(root)),
3904
+ repoName: String(pkg?.name ?? path27.basename(root)),
3117
3905
  packageManager,
3118
3906
  repoRoles: roles,
3119
3907
  detectedStacks: stacks,
@@ -3184,11 +3972,16 @@ async function runDoctor(options) {
3184
3972
  );
3185
3973
  const detail = [];
3186
3974
  const attention = [];
3975
+ const suggestions = [];
3187
3976
  const ok = (text) => detail.push({ stream: "log", text });
3188
3977
  const flag = (text, sentence, fix) => {
3189
3978
  detail.push({ stream: "warn", text });
3190
3979
  attention.push({ sentence, fix });
3191
3980
  };
3981
+ const suggest = (text, sentence, fix) => {
3982
+ detail.push({ stream: "warn", text });
3983
+ suggestions.push({ sentence, fix });
3984
+ };
3192
3985
  ok(`Repo: ${context.repoName}`);
3193
3986
  ok(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
3194
3987
  ok(`Package manager: ${context.packageManager}`);
@@ -3199,7 +3992,11 @@ async function runDoctor(options) {
3199
3992
  }
3200
3993
  const hooks = await verifyProjectSettingsHooksContract(root);
3201
3994
  if (hooks.skipped) {
3202
- ok(`- HOOKS: (skipped) ${hooks.message}`);
3995
+ flag(
3996
+ `- HOOKS: (skipped) ${hooks.message}`,
3997
+ "No Claude Code settings.json \u2014 haus security hooks are not installed",
3998
+ "haus apply --write"
3999
+ );
3203
4000
  } else if (!hooks.ok) {
3204
4001
  flag(
3205
4002
  `- HOOKS FAIL: ${hooks.message}`,
@@ -3210,7 +4007,7 @@ async function runDoctor(options) {
3210
4007
  } else {
3211
4008
  ok(`- HOOKS OK: ${hooks.message}`);
3212
4009
  }
3213
- const rootClaudeMdPath = path20.join(root, "CLAUDE.md");
4010
+ const rootClaudeMdPath = path28.join(root, "CLAUDE.md");
3214
4011
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
3215
4012
  if (!rootClaudeMdContent) {
3216
4013
  flag(
@@ -3238,7 +4035,7 @@ async function runDoctor(options) {
3238
4035
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
3239
4036
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
3240
4037
  for (const target of importTargets) {
3241
- if (!await fs15.pathExists(hausPath(root, target))) {
4038
+ if (!await fs22.pathExists(hausPath(root, target))) {
3242
4039
  flag(
3243
4040
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
3244
4041
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -3249,7 +4046,7 @@ async function runDoctor(options) {
3249
4046
  }
3250
4047
  }
3251
4048
  const workflowPath = hausPath(root, "WORKFLOW.md");
3252
- const workflowExists = await fs15.pathExists(workflowPath);
4049
+ const workflowExists = await fs22.pathExists(workflowPath);
3253
4050
  if (!workflowExists) {
3254
4051
  flag(
3255
4052
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -3272,20 +4069,20 @@ async function runDoctor(options) {
3272
4069
  "haus apply --write --force"
3273
4070
  );
3274
4071
  } else {
3275
- const cachePath = path20.join(getCacheDir(), "templates/agentic-workflow-standard.md");
3276
- const bundledPath = path20.join(
4072
+ const cachePath = path28.join(getCacheDir(), "templates/agentic-workflow-standard.md");
4073
+ const bundledPath = path28.join(
3277
4074
  packageRoot(),
3278
4075
  "library",
3279
4076
  "global",
3280
4077
  "templates",
3281
4078
  "agentic-workflow-standard.md"
3282
4079
  );
3283
- const templatePath = await fs15.pathExists(cachePath) ? cachePath : bundledPath;
4080
+ const templatePath = await fs22.pathExists(cachePath) ? cachePath : bundledPath;
3284
4081
  const templateContent = await readText(templatePath);
3285
4082
  if (storedHashMatch && templateContent) {
3286
4083
  const currentHash = hashText(normaliseLF(templateContent));
3287
4084
  if (storedHashMatch[1] !== currentHash) {
3288
- flag(
4085
+ suggest(
3289
4086
  "- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
3290
4087
  "The workflow standard is out of date",
3291
4088
  "haus apply --write"
@@ -3300,7 +4097,7 @@ async function runDoctor(options) {
3300
4097
  }
3301
4098
  }
3302
4099
  const workflowConfigPath = hausPath(root, "workflow-config.md");
3303
- const workflowConfigExists = await fs15.pathExists(workflowConfigPath);
4100
+ const workflowConfigExists = await fs22.pathExists(workflowConfigPath);
3304
4101
  if (!workflowConfigExists) {
3305
4102
  flag(
3306
4103
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -3308,10 +4105,10 @@ async function runDoctor(options) {
3308
4105
  "haus apply --write"
3309
4106
  );
3310
4107
  } else {
3311
- const cfg = await fs15.readFile(workflowConfigPath, "utf8");
4108
+ const cfg = await fs22.readFile(workflowConfigPath, "utf8");
3312
4109
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
3313
4110
  if (unfilled > 0) {
3314
- flag(
4111
+ suggest(
3315
4112
  `- .haus-workflow/workflow-config.md: ${unfilled} field(s) still unfilled (run \`haus apply --refill-config\` to auto-fill detectable ones)`,
3316
4113
  `${unfilled} workflow-config field(s) are still blank`,
3317
4114
  "haus apply --refill-config"
@@ -3321,7 +4118,7 @@ async function runDoctor(options) {
3321
4118
  }
3322
4119
  }
3323
4120
  if (workflowExists) {
3324
- const prettierIgnore = await readText(path20.join(root, ".prettierignore")) ?? "";
4121
+ const prettierIgnore = await readText(path28.join(root, ".prettierignore")) ?? "";
3325
4122
  if (!prettierIgnore.split("\n").some((l) => l.trim() === ".haus-workflow/")) {
3326
4123
  flag(
3327
4124
  "- .prettierignore: not protecting .haus-workflow/ (run `haus apply --write`)",
@@ -3332,9 +4129,13 @@ async function runDoctor(options) {
3332
4129
  ok("- .prettierignore: protects .haus-workflow/");
3333
4130
  }
3334
4131
  }
4132
+ for (const finding of await auditDecisionsLayout(root)) {
4133
+ if (finding.level === "ok") ok(finding.line);
4134
+ else suggest(finding.line, finding.sentence, finding.fix);
4135
+ }
3335
4136
  const cacheAgeMs = await getCacheManifestAge();
3336
4137
  if (cacheAgeMs === null) {
3337
- flag(
4138
+ suggest(
3338
4139
  "- CATALOG CACHE: absent (run `haus update` to populate)",
3339
4140
  "The catalog cache hasn't been downloaded yet",
3340
4141
  "haus update"
@@ -3342,7 +4143,7 @@ async function runDoctor(options) {
3342
4143
  } else {
3343
4144
  const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
3344
4145
  if (cacheAgeDays >= 7) {
3345
- flag(
4146
+ suggest(
3346
4147
  `- CATALOG CACHE: stale (${cacheAgeDays}d old \u2014 run \`haus update\`)`,
3347
4148
  `The catalog cache is ${cacheAgeDays} days old`,
3348
4149
  "haus update"
@@ -3351,11 +4152,11 @@ async function runDoctor(options) {
3351
4152
  ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
3352
4153
  }
3353
4154
  }
3354
- const pkgJson = await readJson(path20.join(packageRoot(), "package.json"));
4155
+ const pkgJson = await readJson(path28.join(packageRoot(), "package.json"));
3355
4156
  const currentVersion = pkgJson?.version ?? "0.0.0";
3356
4157
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
3357
4158
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
3358
- flag(
4159
+ suggest(
3359
4160
  `- CLI UPDATE: ${currentVersion} \u2192 ${npmStatus.latest} available (run: npm install -g ${NPM_PACKAGE_NAME})`,
3360
4161
  `A newer haus (${npmStatus.latest}) is available`,
3361
4162
  `npm install -g ${NPM_PACKAGE_NAME}`
@@ -3365,12 +4166,17 @@ async function runDoctor(options) {
3365
4166
  } else {
3366
4167
  ok(`- CLI: ${currentVersion} (version check unavailable)`);
3367
4168
  }
4169
+ if (attention.length > 0) process.exitCode = 1;
3368
4170
  if (attention.length === 0) {
3369
4171
  log("\u2705 Your project is set up and healthy.");
3370
4172
  } else {
3371
4173
  log(`\u26A0\uFE0F ${attention.length} thing(s) need attention:`);
3372
4174
  for (const a of attention) log(` \u2022 ${a.sentence} \u2014 fix: ${a.fix}`);
3373
4175
  }
4176
+ if (suggestions.length > 0) {
4177
+ log(`\u{1F4A1} ${suggestions.length} suggestion(s):`);
4178
+ for (const s of suggestions) log(` \u2022 ${s.sentence} \u2014 try: ${s.fix}`);
4179
+ }
3374
4180
  log("Haus Doctor");
3375
4181
  for (const line2 of detail) {
3376
4182
  if (line2.stream === "warn") warn(line2.text);
@@ -3502,6 +4308,61 @@ async function runExplainRecommendation(options) {
3502
4308
  log(formatRecommendationHuman(normalized));
3503
4309
  }
3504
4310
 
4311
+ // src/commands/fetch-refs.ts
4312
+ var REFS_CACHE_SUBDIR = "llms-cache";
4313
+ async function runFetchRefs(options) {
4314
+ const root = process.cwd();
4315
+ const cacheDir = hausPath(root, REFS_CACHE_SUBDIR);
4316
+ let allItems;
4317
+ try {
4318
+ allItems = await loadCatalog(root);
4319
+ } catch (err) {
4320
+ warn(`Could not load catalog: ${err instanceof Error ? err.message : String(err)}`);
4321
+ process.exitCode = 1;
4322
+ return;
4323
+ }
4324
+ let items = allItems;
4325
+ if (options.id) {
4326
+ items = allItems.filter((item) => item.id === options.id);
4327
+ if (items.length === 0) {
4328
+ warn(`No catalog item found with id "${options.id}".`);
4329
+ process.exitCode = 1;
4330
+ return;
4331
+ }
4332
+ }
4333
+ const withRefs = items.filter((item) => (item.references ?? []).some(isLlmsTxtUrl));
4334
+ if (withRefs.length === 0) {
4335
+ if (options.json) {
4336
+ log(
4337
+ JSON.stringify(
4338
+ { fetched: 0, unchanged: 0, failed: 0, failedUrls: [], cachedFiles: {} },
4339
+ null,
4340
+ 2
4341
+ )
4342
+ );
4343
+ } else {
4344
+ log("No catalog items with llms.txt references found.");
4345
+ }
4346
+ return;
4347
+ }
4348
+ const summary = await fetchRefsForItems(withRefs, cacheDir);
4349
+ if (options.json) {
4350
+ log(JSON.stringify(summary, null, 2));
4351
+ return;
4352
+ }
4353
+ for (const [url, filePath] of Object.entries(summary.cachedFiles)) {
4354
+ log(`Cached: ${filePath} (${url})`);
4355
+ }
4356
+ if (summary.fetched > 0) log(`Fetched: ${summary.fetched} URL(s)`);
4357
+ if (summary.unchanged > 0) log(`Unchanged (etag match): ${summary.unchanged} URL(s)`);
4358
+ if (summary.failed > 0) {
4359
+ warn(`Failed to fetch ${summary.failed} URL(s): ${summary.failedUrls.join(", ")}`);
4360
+ }
4361
+ if (summary.fetched === 0 && summary.unchanged > 0 && summary.failed === 0) {
4362
+ log("All references up to date.");
4363
+ }
4364
+ }
4365
+
3505
4366
  // src/commands/guard.ts
3506
4367
  import { readFileSync as readFileSync2 } from "node:fs";
3507
4368
 
@@ -3538,7 +4399,15 @@ function stdin() {
3538
4399
  }
3539
4400
  }
3540
4401
  function deny(reason) {
3541
- log(JSON.stringify({ permissionDecision: "deny", permissionDecisionReason: reason }));
4402
+ log(
4403
+ JSON.stringify({
4404
+ hookSpecificOutput: {
4405
+ hookEventName: "PreToolUse",
4406
+ permissionDecision: "deny",
4407
+ permissionDecisionReason: reason
4408
+ }
4409
+ })
4410
+ );
3542
4411
  }
3543
4412
  async function runGuard(kind, _options) {
3544
4413
  const raw = stdin();
@@ -3549,7 +4418,6 @@ async function runGuard(kind, _options) {
3549
4418
  if (isRecord(parsed)) payload = parsed;
3550
4419
  } catch {
3551
4420
  deny("Malformed hook payload");
3552
- process.exitCode = 1;
3553
4421
  return;
3554
4422
  }
3555
4423
  }
@@ -3559,8 +4427,6 @@ async function runGuard(kind, _options) {
3559
4427
  const reason2 = guardFileAccess(candidate);
3560
4428
  if (reason2) {
3561
4429
  deny(reason2);
3562
- process.exitCode = 1;
3563
- return;
3564
4430
  }
3565
4431
  return;
3566
4432
  }
@@ -3568,13 +4434,12 @@ async function runGuard(kind, _options) {
3568
4434
  const reason = guardBash(command);
3569
4435
  if (reason) {
3570
4436
  deny(reason);
3571
- process.exitCode = 1;
3572
4437
  }
3573
4438
  }
3574
4439
 
3575
4440
  // src/commands/init.ts
3576
- import path21 from "node:path";
3577
- import fs16 from "fs-extra";
4441
+ import path29 from "node:path";
4442
+ import fs23 from "fs-extra";
3578
4443
 
3579
4444
  // src/utils/prompts.ts
3580
4445
  import { stdin as input, stdout as output } from "node:process";
@@ -3595,6 +4460,7 @@ async function confirm(question) {
3595
4460
  }
3596
4461
 
3597
4462
  // src/recommender/git-signal.ts
4463
+ init_exec();
3598
4464
  async function readChangedFiles(root) {
3599
4465
  if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
3600
4466
  try {
@@ -3662,9 +4528,7 @@ function mergeRecommendationWarnings(context) {
3662
4528
  // src/recommender/recommend.ts
3663
4529
  async function recommend(root, context, opts = {}) {
3664
4530
  const items = await loadCatalog(root);
3665
- const sources = await readJson(
3666
- hausPath(root, "sources-report.json")
3667
- ) ?? {};
4531
+ const sources = buildSourcesReport(items);
3668
4532
  const deep = await readJson(hausPath(root, "deep-context.json")) ?? {};
3669
4533
  const scannerStacks = buildStackSet(context);
3670
4534
  const scannerRoles = new Set(context.repoRoles.map((r) => r.toLowerCase()));
@@ -3682,7 +4546,7 @@ async function recommend(root, context, opts = {}) {
3682
4546
  const depSet = scannerDeps;
3683
4547
  const recommended = [];
3684
4548
  const skipped = [];
3685
- const sourceTrust = new Map((sources.items ?? []).map((x) => [x.id, x.status ?? "candidate"]));
4549
+ const sourceTrust = new Map(sources.items.map((x) => [x.source, x.status]));
3686
4550
  const changedFiles = await readChangedFiles(root);
3687
4551
  const skip = (id, code, message, signal) => {
3688
4552
  skipped.push({ id, reason: message, skipReasons: [{ code, message, signal }] });
@@ -3690,8 +4554,13 @@ async function recommend(root, context, opts = {}) {
3690
4554
  const roleSignal = (name) => scannerRoles.has(name.toLowerCase()) ? `role:${name}` : `deep:role:${name}`;
3691
4555
  const stackSignal = (name) => scannerStacks.has(name.toLowerCase()) ? `tag:${name}` : `deep:tag:${name}`;
3692
4556
  for (const item of items) {
3693
- const itemSearchText = `${item.id} ${item.tags.join(" ")}`.toLowerCase();
3694
- if (UNSUPPORTED.some((x) => itemSearchText.includes(x))) {
4557
+ const normSource = typeof item.source === "string" ? item.source.trim() : "";
4558
+ if (!normSource) {
4559
+ skip(item.id, "invalid-source", "Item source is missing or empty");
4560
+ continue;
4561
+ }
4562
+ const itemTags = item.tags.map((t) => t.toLowerCase());
4563
+ if (UNSUPPORTED.some((x) => itemTags.includes(x))) {
3695
4564
  skip(item.id, "unsupported-policy", "Unsupported stack policy");
3696
4565
  continue;
3697
4566
  }
@@ -3699,7 +4568,7 @@ async function recommend(root, context, opts = {}) {
3699
4568
  skip(item.id, "deprecated", "Catalog item is deprecated", "reviewStatus:deprecated");
3700
4569
  continue;
3701
4570
  }
3702
- if (item.source === "curated") {
4571
+ if (normSource === "curated") {
3703
4572
  const rs = item.reviewStatus;
3704
4573
  if (!rs || rs !== "approved") {
3705
4574
  skip(
@@ -3720,17 +4589,19 @@ async function recommend(root, context, opts = {}) {
3720
4589
  continue;
3721
4590
  }
3722
4591
  }
3723
- if (SENSITIVE_ITEM_KEYWORDS.some((x) => itemSearchText.includes(x))) {
4592
+ const sensitiveInId = SENSITIVE_ITEM_KEYWORDS.some((x) => item.id.toLowerCase().includes(x));
4593
+ const sensitiveInTags = SENSITIVE_ITEM_KEYWORDS.some((x) => itemTags.includes(x));
4594
+ if (sensitiveInId || sensitiveInTags) {
3724
4595
  skip(item.id, "sensitive-policy", "Sensitive content policy block");
3725
4596
  continue;
3726
4597
  }
3727
- const trust = sourceTrust.get(item.source);
4598
+ const trust = sourceTrust.get(normSource);
3728
4599
  if (trust === "candidate" || trust === "rejected") {
3729
4600
  skip(item.id, "source-trust", "Source trust policy block", `trust:${trust}`);
3730
4601
  continue;
3731
4602
  }
3732
- if (item.source && item.source !== "haus" && trust !== "approved") {
3733
- skip(item.id, "source-approval", "Source not approved", `source:${item.source}`);
4603
+ if (normSource !== "haus" && trust !== "approved") {
4604
+ skip(item.id, "source-approval", "Source not approved", `source:${normSource}`);
3734
4605
  continue;
3735
4606
  }
3736
4607
  if (item.id === "haus.nx21-monorepo-patterns" && !roleSet.has("nx-monorepo")) {
@@ -3755,15 +4626,6 @@ async function recommend(root, context, opts = {}) {
3755
4626
  skip(item.id, "required-role-missing", "Required role missing: database", "role:database");
3756
4627
  continue;
3757
4628
  }
3758
- if (item.id === "haus.ecc-typescript-reviewer" && (stackSet.has("react") || stackSet.has("react19") || stackSet.has("nextjs") || stackSet.has("next") || roleSet.has("react-app") || roleSet.has("next-app"))) {
3759
- skip(
3760
- item.id,
3761
- "co-install-react-reviewer",
3762
- "Skip typescript-reviewer on React/Next stacks (use react-reviewer)",
3763
- "stack:react|nextjs|role:react-app|next-app"
3764
- );
3765
- continue;
3766
- }
3767
4629
  const isDefaultBaseline = item.default === true;
3768
4630
  const reasons = [];
3769
4631
  const push = (code, message, signal) => reasons.push({ code, message, signal });
@@ -3781,9 +4643,11 @@ async function recommend(root, context, opts = {}) {
3781
4643
  `packageManager:${context.packageManager}`
3782
4644
  );
3783
4645
  }
3784
- const configSignal = item.tags.find(
3785
- (t) => context.warnings.join(" ").toLowerCase().includes(t.toLowerCase())
3786
- );
4646
+ const configSignal = item.tags.find((t) => {
4647
+ const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4648
+ const re = new RegExp(`(?:^|\\W)${escaped}(?:\\W|$)`, "i");
4649
+ return context.warnings.some((w) => re.test(w));
4650
+ });
3787
4651
  if (configSignal) push("config-signal-match", "config signal match", `warning:${configSignal}`);
3788
4652
  const idSegment = item.id.split(".").pop() ?? "";
3789
4653
  const changedMatch = idSegment ? changedFiles.find((f) => f.includes(idSegment)) : void 0;
@@ -3916,6 +4780,13 @@ function applyCoInstallSuppression(recommended, skipped) {
3916
4780
  code: "co-install-redis-official",
3917
4781
  message: "Skip ecc-redis-patterns when official redis-connections skill is selected",
3918
4782
  signal: "co-install:redis-connections"
4783
+ },
4784
+ {
4785
+ suppress: "haus.ecc-typescript-reviewer",
4786
+ whenAnyRecommended: ["haus.ecc-react-reviewer"],
4787
+ code: "co-install-react-reviewer",
4788
+ message: "Skip typescript-reviewer on React/Next stacks (use react-reviewer)",
4789
+ signal: "co-install:react-reviewer"
3919
4790
  }
3920
4791
  ];
3921
4792
  for (const rule of rules) {
@@ -4009,37 +4880,39 @@ function buildOptInEligible(items, skipped) {
4009
4880
 
4010
4881
  // src/commands/setup-core.ts
4011
4882
  async function runSetupCore(root, opts) {
4012
- const { json, apply, dryRun, confirm: confirm2 } = opts;
4883
+ const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
4884
+ const say = quiet ? () => {
4885
+ } : log;
4013
4886
  const scanResult = await scanProject(root);
4014
4887
  if (json) {
4015
- log(JSON.stringify(scanResult, null, 2));
4888
+ say(JSON.stringify(scanResult, null, 2));
4016
4889
  } else {
4017
- log("Haus scan complete");
4018
- log(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
4019
- log(`Package manager: ${scanResult.packageManager}`);
4890
+ say("Haus scan complete");
4891
+ say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
4892
+ say(`Package manager: ${scanResult.packageManager}`);
4020
4893
  }
4021
4894
  const context = await readContextOrScan(root);
4022
4895
  const recommendation = await recommend(root, context);
4023
4896
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
4024
4897
  if (json) {
4025
- log(JSON.stringify(recommendation, null, 2));
4898
+ say(JSON.stringify(recommendation, null, 2));
4026
4899
  } else {
4027
- log("Haus recommendation ready");
4028
- log(`Recommended: ${recommendation.recommended.length}`);
4029
- log(`Skipped: ${recommendation.skipped.length}`);
4900
+ say("Haus recommendation ready");
4901
+ say(`Recommended: ${recommendation.recommended.length}`);
4902
+ say(`Skipped: ${recommendation.skipped.length}`);
4030
4903
  }
4031
4904
  const hooks = await verifyProjectSettingsHooksContract(root);
4032
4905
  const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
4033
- log(`Repo: ${context.repoName}`);
4034
- for (const warning of warningLines) log(`- WARN: ${warning}`);
4906
+ say(`Repo: ${context.repoName}`);
4907
+ for (const warning of warningLines) say(`- WARN: ${warning}`);
4035
4908
  const hooksOk = hooks.ok;
4036
4909
  if (hooks.skipped) {
4037
- log(`- HOOKS: (skipped) ${hooks.message}`);
4910
+ say(`- HOOKS: (skipped) ${hooks.message}`);
4038
4911
  } else if (!hooks.ok) {
4039
- log(`- HOOKS FAIL: ${hooks.message}`);
4040
- process.exitCode = 1;
4912
+ say(`- HOOKS FAIL: ${hooks.message}`);
4913
+ if (!apply) process.exitCode = 1;
4041
4914
  } else {
4042
- log(`- HOOKS OK: ${hooks.message}`);
4915
+ say(`- HOOKS OK: ${hooks.message}`);
4043
4916
  }
4044
4917
  const baseResult = {
4045
4918
  root,
@@ -4054,32 +4927,32 @@ async function runSetupCore(root, opts) {
4054
4927
  if (confirm2) {
4055
4928
  const approved = await confirm2();
4056
4929
  if (!approved) {
4057
- log("Setup reviewed. No files written.");
4058
- log("Next step: run `haus apply --write` when ready.");
4930
+ say("Setup reviewed. No files written.");
4931
+ say("Next step: run `haus apply --write` when ready.");
4059
4932
  return baseResult;
4060
4933
  }
4061
4934
  }
4062
4935
  if (!dryRun && !process.env["HAUS_FIXTURE_CATALOG"]) {
4063
- log("Syncing remote catalog...");
4936
+ say("Syncing remote catalog...");
4064
4937
  const sync = await syncRemoteCatalog();
4065
4938
  if (sync.newItems.length > 0) {
4066
- log(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
4939
+ say(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
4067
4940
  } else if (sync.refreshed.length > 0) {
4068
- log(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
4941
+ say(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
4069
4942
  }
4070
4943
  }
4071
- const files = await writeClaudeFiles(root, dryRun ?? false);
4072
- log("Applied files:");
4073
- files.forEach((f) => log(`- ${displayPath(root, f)}`));
4944
+ const files = await writeClaudeFiles(root, dryRun ?? false, void 0, { quiet: opts.quiet });
4945
+ say("Applied files:");
4946
+ files.forEach((f) => say(`- ${displayPath(root, f)}`));
4074
4947
  const hooksAfter = await verifyProjectSettingsHooksContract(root);
4075
4948
  const hooksOkAfter = hooksAfter.ok;
4076
4949
  if (hooksAfter.skipped) {
4077
- log(`- HOOKS: (skipped) ${hooksAfter.message}`);
4950
+ say(`- HOOKS: (skipped) ${hooksAfter.message}`);
4078
4951
  } else if (!hooksAfter.ok) {
4079
- log(`- HOOKS FAIL: ${hooksAfter.message}`);
4952
+ say(`- HOOKS FAIL: ${hooksAfter.message}`);
4080
4953
  process.exitCode = 1;
4081
4954
  } else {
4082
- log(`- HOOKS OK: ${hooksAfter.message}`);
4955
+ say(`- HOOKS OK: ${hooksAfter.message}`);
4083
4956
  }
4084
4957
  return { ...baseResult, hooksOk: hooksOkAfter, written: files };
4085
4958
  }
@@ -4098,8 +4971,8 @@ async function runSetupProject(options) {
4098
4971
  // src/commands/init.ts
4099
4972
  async function runInit(options) {
4100
4973
  const root = process.cwd();
4101
- const hausDir = path21.join(root, ".haus-workflow");
4102
- const alreadyInit = await fs16.pathExists(hausDir);
4974
+ const hausDir = path29.join(root, ".haus-workflow");
4975
+ const alreadyInit = await fs23.pathExists(hausDir);
4103
4976
  if (alreadyInit) {
4104
4977
  log("Haus AI already initialized in this project.");
4105
4978
  log("Run `haus setup-project` to reconfigure.");
@@ -4111,8 +4984,8 @@ async function runInit(options) {
4111
4984
 
4112
4985
  // src/install/apply.ts
4113
4986
  import crypto2 from "node:crypto";
4114
- import path22 from "node:path";
4115
- import fs17 from "fs-extra";
4987
+ import path30 from "node:path";
4988
+ import fs24 from "fs-extra";
4116
4989
 
4117
4990
  // src/install/header.ts
4118
4991
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -4194,44 +5067,44 @@ ${content2}`;
4194
5067
  // src/install/apply.ts
4195
5068
  var SCHEMA_VERSION2 = "1";
4196
5069
  function hashContent(content2) {
4197
- return `sha256-${crypto2.createHash("sha256").update(content2).digest("hex")}`;
5070
+ return `sha256-${crypto2.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
4198
5071
  }
4199
5072
  function sourceVersion() {
4200
5073
  try {
4201
- const pkgPath = path22.join(packageRoot(), "package.json");
4202
- const pkg = JSON.parse(fs17.readFileSync(pkgPath, "utf8"));
5074
+ const pkgPath = path30.join(packageRoot(), "package.json");
5075
+ const pkg = JSON.parse(fs24.readFileSync(pkgPath, "utf8"));
4203
5076
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
4204
5077
  } catch {
4205
5078
  return "haus@0.0.0";
4206
5079
  }
4207
5080
  }
4208
5081
  function globalSrcDir() {
4209
- return path22.join(packageRoot(), "library", "global");
5082
+ return path30.join(packageRoot(), "library", "global");
4210
5083
  }
4211
5084
  function collectSourceFiles(srcDir, claudeDir) {
4212
5085
  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)) {
5086
+ const skillsDir = path30.join(srcDir, "skills");
5087
+ if (fs24.pathExistsSync(skillsDir)) {
5088
+ for (const skillName of fs24.readdirSync(skillsDir)) {
5089
+ const skillFile = path30.join(skillsDir, skillName, "SKILL.md");
5090
+ if (fs24.pathExistsSync(skillFile)) {
4218
5091
  entries.push({
4219
5092
  stableId: `skill.${skillName}`,
4220
- srcRelPath: path22.join("library", "global", "skills", skillName, "SKILL.md"),
4221
- destPath: path22.join(claudeDir, "skills", skillName, "SKILL.md")
5093
+ srcRelPath: path30.join("library", "global", "skills", skillName, "SKILL.md"),
5094
+ destPath: path30.join(claudeDir, "skills", skillName, "SKILL.md")
4222
5095
  });
4223
5096
  }
4224
5097
  }
4225
5098
  }
4226
- const commandsDir = path22.join(srcDir, "commands");
4227
- if (fs17.pathExistsSync(commandsDir)) {
4228
- for (const fileName of fs17.readdirSync(commandsDir)) {
5099
+ const commandsDir = path30.join(srcDir, "commands");
5100
+ if (fs24.pathExistsSync(commandsDir)) {
5101
+ for (const fileName of fs24.readdirSync(commandsDir)) {
4229
5102
  if (!fileName.endsWith(".md")) continue;
4230
5103
  const commandName = fileName.slice(0, -".md".length);
4231
5104
  entries.push({
4232
5105
  stableId: `command.${commandName}`,
4233
- srcRelPath: path22.join("library", "global", "commands", fileName),
4234
- destPath: path22.join(claudeDir, "commands", fileName)
5106
+ srcRelPath: path30.join("library", "global", "commands", fileName),
5107
+ destPath: path30.join(claudeDir, "commands", fileName)
4235
5108
  });
4236
5109
  }
4237
5110
  }
@@ -4249,13 +5122,15 @@ async function applyInstall(options = {}) {
4249
5122
  created: [],
4250
5123
  updated: [],
4251
5124
  skipped: [],
5125
+ userOwned: [],
5126
+ userEdited: [],
4252
5127
  deleted: [],
4253
5128
  hookIds: [],
4254
5129
  drift: false
4255
5130
  };
4256
5131
  const manifestFiles = [];
4257
5132
  for (const entry of sourceFiles) {
4258
- const srcPath = path22.join(packageRoot(), entry.srcRelPath);
5133
+ const srcPath = path30.join(packageRoot(), entry.srcRelPath);
4259
5134
  const rawContent = await readText(srcPath);
4260
5135
  if (rawContent === void 0) {
4261
5136
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -4275,7 +5150,7 @@ async function applyInstall(options = {}) {
4275
5150
  }
4276
5151
  continue;
4277
5152
  }
4278
- const destExists = fs17.pathExistsSync(entry.destPath);
5153
+ const destExists = fs24.pathExistsSync(entry.destPath);
4279
5154
  if (destExists) {
4280
5155
  const currentContent = await readText(entry.destPath);
4281
5156
  if (currentContent !== void 0) {
@@ -4283,11 +5158,13 @@ async function applyInstall(options = {}) {
4283
5158
  if (!hasHeader) {
4284
5159
  warn(`Refusing to overwrite user-owned file: ${entry.destPath}`);
4285
5160
  result.skipped.push(entry.destPath);
5161
+ result.userOwned.push(entry.destPath);
4286
5162
  continue;
4287
5163
  }
4288
5164
  if (existing && hashContent(currentContent) !== existing.hash && !force) {
4289
5165
  warn(`User edited haus file (skipping): ${entry.destPath} \u2014 use --force to overwrite`);
4290
5166
  result.skipped.push(entry.destPath);
5167
+ result.userEdited.push(entry.destPath);
4291
5168
  manifestFiles.push(existing);
4292
5169
  continue;
4293
5170
  }
@@ -4311,25 +5188,29 @@ async function applyInstall(options = {}) {
4311
5188
  schemaVersion: SCHEMA_VERSION2
4312
5189
  });
4313
5190
  }
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;
5191
+ let mergedSettings;
5192
+ if (!check) {
5193
+ const fragmentPath = path30.join(srcDir, "settings-fragments", "hooks.json");
5194
+ const fragments = await loadHooksFragment(fragmentPath);
5195
+ const settings = await readSettings();
5196
+ const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
5197
+ const { settings: deniedSettings } = mergeDenyRules(hookSettings, buildDenyRules());
5198
+ const { settings: allowedSettings } = mergeAllowRules(deniedSettings, buildAllowRules());
5199
+ const merged = mergeAskRules(allowedSettings, buildAskRules());
5200
+ mergedSettings = merged.settings;
5201
+ result.hookIds = addedIds;
5202
+ }
4322
5203
  if (!check && existingManifest) {
4323
5204
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
4324
5205
  for (const entry of existingManifest.files) {
4325
5206
  if (currentDestPaths.has(entry.destPath)) continue;
4326
- if (!fs17.pathExistsSync(entry.destPath)) continue;
5207
+ if (!fs24.pathExistsSync(entry.destPath)) continue;
4327
5208
  const content2 = await readText(entry.destPath);
4328
5209
  if (!content2) continue;
4329
5210
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
4330
5211
  const currentHash = hashContent(content2);
4331
5212
  if (hasHeader && currentHash === entry.hash) {
4332
- if (!dryRun) await fs17.remove(entry.destPath);
5213
+ if (!dryRun) await fs24.remove(entry.destPath);
4333
5214
  result.deleted.push(entry.destPath);
4334
5215
  } else {
4335
5216
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -4337,10 +5218,10 @@ async function applyInstall(options = {}) {
4337
5218
  }
4338
5219
  }
4339
5220
  }
4340
- if (!dryRun && !check) {
5221
+ if (!dryRun && !check && mergedSettings) {
4341
5222
  await writeSettings(mergedSettings);
4342
5223
  const manifest = buildManifest(source, manifestFiles, [
4343
- .../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...addedIds])
5224
+ .../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...result.hookIds])
4344
5225
  ]);
4345
5226
  await writeManifest(manifest);
4346
5227
  }
@@ -4360,9 +5241,10 @@ function printApplyResult(result, dryRun) {
4360
5241
  log(`${prefix}Deleted (orphaned):`);
4361
5242
  result.deleted.forEach((p) => log(` x ${p}`));
4362
5243
  }
4363
- if (result.skipped.length) {
5244
+ const silentlySkipped = result.skipped.filter((p) => !result.userOwned.includes(p));
5245
+ if (silentlySkipped.length) {
4364
5246
  log(`${prefix}Skipped:`);
4365
- result.skipped.forEach((p) => log(` - ${p}`));
5247
+ silentlySkipped.forEach((p) => log(` - ${p}`));
4366
5248
  }
4367
5249
  if (result.hookIds.length) {
4368
5250
  log(`${prefix}Hooks added: ${result.hookIds.join(", ")}`);
@@ -4410,7 +5292,7 @@ function buildCompanionSuggestions(tools, isInstalled) {
4410
5292
  }
4411
5293
  return lines;
4412
5294
  }
4413
- function commandExists(bin) {
5295
+ function commandExists2(bin) {
4414
5296
  try {
4415
5297
  const r = execaSync("sh", ["-c", 'command -v -- "$1"', "probe", bin], { reject: false });
4416
5298
  return r.exitCode === 0;
@@ -4419,7 +5301,7 @@ function commandExists(bin) {
4419
5301
  }
4420
5302
  }
4421
5303
  function printCompanionToolSuggestions(deps = {}) {
4422
- const isInstalled = deps.isInstalled ?? commandExists;
5304
+ const isInstalled = deps.isInstalled ?? commandExists2;
4423
5305
  const emit2 = deps.log ?? log;
4424
5306
  const lines = buildCompanionSuggestions(COMPANION_TOOLS, isInstalled);
4425
5307
  for (const line2 of lines) emit2(line2);
@@ -4481,7 +5363,7 @@ async function runRecommend(options) {
4481
5363
  `Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`)`
4482
5364
  );
4483
5365
  }
4484
- for (const w of result.warnings.filter((w2) => w2.startsWith("--include:"))) log(w);
5366
+ for (const w of result.warnings) log(`- WARN: ${w}`);
4485
5367
  }
4486
5368
 
4487
5369
  // src/commands/refresh.ts
@@ -4497,8 +5379,8 @@ async function runRefresh() {
4497
5379
  }
4498
5380
 
4499
5381
  // src/install/scaffold.ts
4500
- import path23 from "node:path";
4501
- import fs18 from "fs-extra";
5382
+ import path31 from "node:path";
5383
+ import fs25 from "fs-extra";
4502
5384
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4503
5385
  const scaffolded = [];
4504
5386
  const skipped = [];
@@ -4511,27 +5393,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4511
5393
  skipped.push(name);
4512
5394
  if (outcome === "skipped-exists") skippedExisting.push(name);
4513
5395
  };
4514
- const realRoot = await fs18.realpath(catalogRoot).catch(() => null);
5396
+ const realRoot = await fs25.realpath(catalogRoot).catch(() => null);
4515
5397
  for (const item of items) {
4516
5398
  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 === "..");
5399
+ const sourcePath = path31.resolve(catalogRoot, item.path);
5400
+ const rel = path31.relative(catalogRoot, sourcePath);
5401
+ const escapes = rel === "" || path31.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
4520
5402
  if (escapes) {
4521
5403
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
4522
5404
  continue;
4523
5405
  }
4524
- const realSource = await fs18.realpath(sourcePath).catch(() => null);
5406
+ const realSource = await fs25.realpath(sourcePath).catch(() => null);
4525
5407
  if (!realRoot || !realSource) {
4526
5408
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4527
5409
  continue;
4528
5410
  }
4529
- const realRel = path23.relative(realRoot, realSource);
4530
- if (realRel !== "" && (path23.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
5411
+ const realRel = path31.relative(realRoot, realSource);
5412
+ if (realRel !== "" && (path31.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
4531
5413
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
4532
5414
  continue;
4533
5415
  }
4534
- const stat = await fs18.lstat(sourcePath).catch(() => null);
5416
+ const stat = await fs25.lstat(sourcePath).catch(() => null);
4535
5417
  if (!stat) {
4536
5418
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4537
5419
  continue;
@@ -4541,14 +5423,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4541
5423
  continue;
4542
5424
  }
4543
5425
  if (stat.isFile()) {
4544
- const filename = path23.basename(sourcePath);
4545
- const dest = path23.join(projectRoot, filename);
5426
+ const filename = path31.basename(sourcePath);
5427
+ const dest = path31.join(projectRoot, filename);
4546
5428
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
4547
5429
  } else if (stat.isDirectory()) {
4548
- const entries = await fs18.readdir(sourcePath);
5430
+ const entries = await fs25.readdir(sourcePath);
4549
5431
  for (const entry of entries) {
4550
- const src = path23.join(sourcePath, entry);
4551
- const dest = path23.join(projectRoot, entry);
5432
+ const src = path31.join(sourcePath, entry);
5433
+ const dest = path31.join(projectRoot, entry);
4552
5434
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
4553
5435
  }
4554
5436
  }
@@ -4556,42 +5438,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4556
5438
  return { scaffolded, skipped, skippedExisting };
4557
5439
  }
4558
5440
  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);
5441
+ const destDisplay = path31.relative(projectRoot, dest) || path31.basename(dest);
5442
+ const srcStat = await fs25.lstat(src).catch(() => null);
4561
5443
  if (!srcStat) {
4562
- warn(`Skipping ${path23.basename(src)}: source not found`);
5444
+ warn(`Skipping ${path31.basename(src)}: source not found`);
4563
5445
  return "skipped-refused";
4564
5446
  }
4565
5447
  if (srcStat.isSymbolicLink()) {
4566
- warn(`Skipping ${path23.basename(src)}: source is a symlink`);
5448
+ warn(`Skipping ${path31.basename(src)}: source is a symlink`);
4567
5449
  return "skipped-refused";
4568
5450
  }
4569
- const exists = await fs18.pathExists(dest);
5451
+ const exists = await fs25.pathExists(dest);
4570
5452
  if (exists && !opts.force) {
4571
5453
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
4572
5454
  return "skipped-exists";
4573
5455
  }
4574
5456
  if (opts.dryRun) {
4575
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path23.basename(dest)} (${itemId})`);
5457
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path31.basename(dest)} (${itemId})`);
4576
5458
  return "scaffolded";
4577
5459
  }
4578
5460
  if (exists && opts.force) {
4579
- await fs18.remove(dest);
5461
+ await fs25.remove(dest);
4580
5462
  }
4581
- await fs18.ensureDir(path23.dirname(dest));
4582
- await fs18.copy(src, dest, {
5463
+ await fs25.ensureDir(path31.dirname(dest));
5464
+ await fs25.copy(src, dest, {
4583
5465
  overwrite: true,
4584
5466
  dereference: false,
4585
5467
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
4586
5468
  filter: (s) => {
4587
5469
  try {
4588
- return !fs18.lstatSync(s).isSymbolicLink();
5470
+ return !fs25.lstatSync(s).isSymbolicLink();
4589
5471
  } catch {
4590
5472
  return false;
4591
5473
  }
4592
5474
  }
4593
5475
  });
4594
- log(`${exists ? "Overwrote" : "Created"} ${path23.basename(dest)} (${itemId})`);
5476
+ log(`${exists ? "Overwrote" : "Created"} ${path31.basename(dest)} (${itemId})`);
4595
5477
  return "scaffolded";
4596
5478
  }
4597
5479
 
@@ -4625,13 +5507,12 @@ async function runScaffold(ids, options) {
4625
5507
  log(`\u2713 Scaffolded: ${result.scaffolded.join(", ")}`);
4626
5508
  }
4627
5509
  }
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
- }
5510
+ const refused = result.skipped.filter((name) => !result.skippedExisting.includes(name));
5511
+ if (refused.length > 0) {
5512
+ log(`Skipped (source unavailable): ${refused.join(", ")}`);
5513
+ }
5514
+ if (result.skippedExisting.length > 0) {
5515
+ log(`Already present (re-run with --force to overwrite): ${result.skippedExisting.join(", ")}`);
4635
5516
  }
4636
5517
  }
4637
5518
 
@@ -4645,11 +5526,12 @@ async function runScan(options) {
4645
5526
  log("Haus scan complete");
4646
5527
  log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
4647
5528
  log(`Package manager: ${result.packageManager}`);
5529
+ for (const w of result.warnings) log(`- WARN: ${w}`);
4648
5530
  }
4649
5531
 
4650
5532
  // src/commands/undo.ts
4651
- import path24 from "node:path";
4652
- import fs19 from "fs-extra";
5533
+ import path32 from "node:path";
5534
+ import fs26 from "fs-extra";
4653
5535
 
4654
5536
  // src/claude/managed-paths.ts
4655
5537
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md", "commands/haus-doctor.md"];
@@ -4666,74 +5548,77 @@ async function collectManagedPaths(root) {
4666
5548
  const lock = await readJson(hausPath(root, "haus.lock.json"));
4667
5549
  for (const row of lock ?? []) {
4668
5550
  for (const rel of row.paths ?? []) {
4669
- paths.add(path24.resolve(root, rel));
5551
+ paths.add(path32.resolve(root, rel));
4670
5552
  }
4671
5553
  }
4672
5554
  const existing = [];
4673
5555
  for (const abs of paths) {
4674
- if (await fs19.pathExists(abs)) existing.push(abs);
5556
+ if (await fs26.pathExists(abs)) existing.push(abs);
4675
5557
  }
4676
5558
  return existing;
4677
5559
  }
4678
5560
  async function settingsHasHausContent(root) {
4679
5561
  const settingsPath = claudePath(root, "settings.json");
4680
- if (!await fs19.pathExists(settingsPath)) return false;
5562
+ if (!await fs26.pathExists(settingsPath)) return false;
4681
5563
  const settings = await readProjectSettings(root);
4682
5564
  return settings._haus != null;
4683
5565
  }
4684
5566
  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");
5567
+ const filePath = path32.join(root, "CLAUDE.md");
5568
+ if (!await fs26.pathExists(filePath)) return false;
5569
+ const text = await fs26.readFile(filePath, "utf8");
4688
5570
  return text.includes(BLOCK_BEGIN);
4689
5571
  }
4690
5572
  async function stripProjectSettings(root) {
4691
5573
  const settingsPath = claudePath(root, "settings.json");
4692
- if (!await fs19.pathExists(settingsPath)) return false;
5574
+ if (!await fs26.pathExists(settingsPath)) return false;
4693
5575
  let settings = await readProjectSettings(root);
4694
5576
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4695
5577
  const hasContent = Object.keys(settings).length > 0;
4696
5578
  if (hasContent) {
4697
5579
  await writeProjectSettings(root, settings);
4698
- log(`Stripped haus rules from ${path24.relative(root, settingsPath)} (user settings preserved).`);
5580
+ log(`Stripped haus rules from ${path32.relative(root, settingsPath)} (user settings preserved).`);
4699
5581
  return true;
4700
5582
  }
4701
- await fs19.remove(settingsPath);
4702
- log(`Removed ${path24.relative(root, settingsPath)} (no user-owned settings remained).`);
5583
+ await fs26.remove(settingsPath);
5584
+ log(`Removed ${path32.relative(root, settingsPath)} (no user-owned settings remained).`);
4703
5585
  return true;
4704
5586
  }
4705
5587
  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");
5588
+ const filePath = path32.join(root, "CLAUDE.md");
5589
+ if (!await fs26.pathExists(filePath)) return false;
5590
+ const prev = await fs26.readFile(filePath, "utf8");
4709
5591
  if (!prev.includes(BLOCK_BEGIN)) return false;
4710
5592
  const next = stripHausBlock(prev);
4711
5593
  if (next.length === 0) {
4712
- await fs19.remove(filePath);
5594
+ await fs26.remove(filePath);
4713
5595
  log("Removed CLAUDE.md (only contained haus import block).");
4714
5596
  } else {
4715
- await fs19.writeFile(filePath, next, "utf8");
5597
+ await fs26.writeFile(filePath, next, "utf8");
4716
5598
  log("Removed haus import block from CLAUDE.md (user content preserved).");
4717
5599
  }
4718
5600
  return true;
4719
5601
  }
4720
5602
  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);
5603
+ if (!await fs26.pathExists(dir)) return;
5604
+ try {
5605
+ const entries = await fs26.readdir(dir);
5606
+ if (entries.length === 0) await fs26.remove(dir);
5607
+ } catch {
5608
+ }
4724
5609
  }
4725
5610
  async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
4726
5611
  if (managedAbsPaths.length === 0) return;
4727
5612
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4728
5613
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
4729
5614
  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);
5615
+ if (!await fs26.pathExists(abs)) continue;
5616
+ const rel = path32.relative(root, abs);
5617
+ const backupPath = path32.join(backupRoot, rel);
5618
+ await fs26.ensureDir(path32.dirname(backupPath));
5619
+ await fs26.copy(abs, backupPath);
4735
5620
  }
4736
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path24.relative(root, backupRoot)}.`);
5621
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path32.relative(root, backupRoot)}.`);
4737
5622
  }
4738
5623
  async function runUndo(options) {
4739
5624
  const root = process.cwd();
@@ -4744,7 +5629,7 @@ async function runUndo(options) {
4744
5629
  log("Nothing to remove: no haus-managed files found in this directory.");
4745
5630
  return;
4746
5631
  }
4747
- const relTargets = managed.map((p) => path24.relative(root, p));
5632
+ const relTargets = managed.map((p) => path32.relative(root, p));
4748
5633
  const summaryParts = [...relTargets];
4749
5634
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
4750
5635
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -4761,9 +5646,9 @@ User-owned .claude/ files will be preserved.`
4761
5646
  }
4762
5647
  await backupManagedFilesBeforeUndo(root, managed);
4763
5648
  for (const abs of managed) {
4764
- if (!await fs19.pathExists(abs)) continue;
4765
- await fs19.remove(abs);
4766
- log(`Removed ${path24.relative(root, abs)}`);
5649
+ if (!await fs26.pathExists(abs)) continue;
5650
+ await fs26.remove(abs);
5651
+ log(`Removed ${path32.relative(root, abs)}`);
4767
5652
  }
4768
5653
  if (stripSettings) await stripProjectSettings(root);
4769
5654
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -4774,8 +5659,8 @@ User-owned .claude/ files will be preserved.`
4774
5659
 
4775
5660
  // src/install/uninstall.ts
4776
5661
  import crypto3 from "node:crypto";
4777
- import path25 from "node:path";
4778
- import fs20 from "fs-extra";
5662
+ import path33 from "node:path";
5663
+ import fs27 from "fs-extra";
4779
5664
  async function runUninstall(options = {}) {
4780
5665
  const { force = false } = options;
4781
5666
  const manifest = await readManifest();
@@ -4785,7 +5670,7 @@ async function runUninstall(options = {}) {
4785
5670
  return result;
4786
5671
  }
4787
5672
  for (const entry of manifest.files) {
4788
- const exists = fs20.pathExistsSync(entry.destPath);
5673
+ const exists = fs27.pathExistsSync(entry.destPath);
4789
5674
  if (!exists) continue;
4790
5675
  const content2 = await readText(entry.destPath);
4791
5676
  if (content2 === void 0) continue;
@@ -4803,22 +5688,25 @@ async function runUninstall(options = {}) {
4803
5688
  result.skipped.push(entry.destPath);
4804
5689
  continue;
4805
5690
  }
4806
- await fs20.remove(entry.destPath);
4807
- await pruneEmptyDir(path25.dirname(entry.destPath));
5691
+ await fs27.remove(entry.destPath);
5692
+ await pruneEmptyDir(path33.dirname(entry.destPath));
4808
5693
  result.deleted.push(entry.destPath);
4809
5694
  }
4810
5695
  const settings = await readSettings();
4811
5696
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4812
5697
  await writeSettings(stripped);
4813
5698
  result.hooksStripped = true;
4814
- const hausDir = path25.join(globalClaudeDir(), "haus");
5699
+ const hausDir = path33.join(globalClaudeDir(), "haus");
4815
5700
  const manifestPath2 = hausManifestPath();
4816
- if (fs20.pathExistsSync(manifestPath2)) {
4817
- await fs20.remove(manifestPath2);
5701
+ if (fs27.pathExistsSync(manifestPath2)) {
5702
+ await fs27.remove(manifestPath2);
4818
5703
  }
4819
- if (fs20.pathExistsSync(hausDir)) {
4820
- const remaining = await fs20.readdir(hausDir);
4821
- if (remaining.length === 0) await fs20.remove(hausDir);
5704
+ if (fs27.pathExistsSync(hausDir)) {
5705
+ try {
5706
+ const remaining = await fs27.readdir(hausDir);
5707
+ if (remaining.length === 0) await fs27.remove(hausDir);
5708
+ } catch {
5709
+ }
4822
5710
  }
4823
5711
  return result;
4824
5712
  }
@@ -4849,12 +5737,12 @@ async function runUninstallCommand(options) {
4849
5737
  }
4850
5738
 
4851
5739
  // src/commands/update.ts
4852
- import path27 from "node:path";
5740
+ import path35 from "node:path";
4853
5741
 
4854
5742
  // src/claude/refresh-project.ts
4855
- import fs21 from "fs-extra";
5743
+ import fs28 from "fs-extra";
4856
5744
  async function isHausProject(root) {
4857
- if (await fs21.pathExists(hausPath(root, "recommendation.json"))) return true;
5745
+ if (await fs28.pathExists(hausPath(root, "recommendation.json"))) return true;
4858
5746
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
4859
5747
  if (result.status === "ok") return result.value._haus != null;
4860
5748
  return false;
@@ -4889,7 +5777,7 @@ function summarizeLockDiff(before, after) {
4889
5777
 
4890
5778
  // src/update/lockfile.ts
4891
5779
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
4892
- import path26 from "node:path";
5780
+ import path34 from "node:path";
4893
5781
  async function checkLock(root) {
4894
5782
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
4895
5783
  const hasValidVersions = lock.every(
@@ -4920,7 +5808,7 @@ async function applyLock(root) {
4920
5808
  try {
4921
5809
  const backupDir = hausPath(root, "backups");
4922
5810
  await mkdir(backupDir, { recursive: true });
4923
- await copyFile(lockPath, path26.join(backupDir, `haus.lock.${Date.now()}.json`));
5811
+ await copyFile(lockPath, path34.join(backupDir, `haus.lock.${Date.now()}.json`));
4924
5812
  } catch {
4925
5813
  }
4926
5814
  const enriched = await Promise.all(
@@ -4942,7 +5830,7 @@ function diffLock(before, after) {
4942
5830
  }
4943
5831
  async function hasLocalOverrides(root) {
4944
5832
  try {
4945
- await readFile2(path26.join(root, ".claude", "settings.json"), "utf8");
5833
+ await readFile2(path34.join(root, ".claude", "settings.json"), "utf8");
4946
5834
  return true;
4947
5835
  } catch {
4948
5836
  return false;
@@ -4954,7 +5842,7 @@ var NPM_PACKAGE_NAME2 = "@haus-tech/haus-workflow";
4954
5842
  async function runUpdate(options) {
4955
5843
  const root = process.cwd();
4956
5844
  if (options.check) {
4957
- const pkgJson2 = await readJson(path27.join(packageRoot(), "package.json"));
5845
+ const pkgJson2 = await readJson(path35.join(packageRoot(), "package.json"));
4958
5846
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
4959
5847
  const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
4960
5848
  checkLock(root),
@@ -4980,11 +5868,10 @@ async function runUpdate(options) {
4980
5868
  2
4981
5869
  )
4982
5870
  );
4983
- if (!status.ok) process.exitCode = 1;
4984
- if (status.driftCount > 0) process.exitCode = 1;
5871
+ if (status.count > 0 && !status.ok) process.exitCode = 1;
4985
5872
  return;
4986
5873
  }
4987
- const pkgJson = await readJson(path27.join(packageRoot(), "package.json"));
5874
+ const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
4988
5875
  const currentVersion = pkgJson?.version ?? "0.0.0";
4989
5876
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4990
5877
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -5042,9 +5929,9 @@ async function refreshGlobalInstall() {
5042
5929
  } else {
5043
5930
  log("~/.claude already up to date.");
5044
5931
  }
5045
- if (result.skipped.length > 0) {
5932
+ if (result.userEdited.length > 0) {
5046
5933
  log(
5047
- `Preserved ${result.skipped.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
5934
+ `Preserved ${result.userEdited.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
5048
5935
  );
5049
5936
  }
5050
5937
  } catch (err) {
@@ -5061,16 +5948,15 @@ async function detectGlobalInstallDrift() {
5061
5948
  }
5062
5949
 
5063
5950
  // src/commands/validate-catalog.ts
5064
- import path29 from "node:path";
5951
+ import path37 from "node:path";
5065
5952
 
5066
5953
  // src/catalog/validate-core.ts
5067
- import fs22 from "node:fs";
5068
- import path28 from "node:path";
5954
+ import fs29 from "node:fs";
5955
+ import path36 from "node:path";
5069
5956
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
5070
- var MANIFEST_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
5071
5957
  function isSafeCatalogPath2(itemPath) {
5072
- if (!itemPath || path28.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
5073
- const normalized = path28.normalize(itemPath);
5958
+ if (!itemPath || path36.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
5959
+ const normalized = path36.normalize(itemPath);
5074
5960
  return !normalized.startsWith("..") && !normalized.includes("/..");
5075
5961
  }
5076
5962
  function auditForbiddenStacks(items) {
@@ -5121,10 +6007,8 @@ function auditManifestStructure(manifestVersion, items) {
5121
6007
  const failures = [];
5122
6008
  const seenIds = /* @__PURE__ */ new Map();
5123
6009
  const seenPaths = /* @__PURE__ */ new Map();
5124
- if (typeof manifestVersion !== "string" || !MANIFEST_SEMVER_RE.test(manifestVersion)) {
5125
- failures.push(
5126
- 'manifest.json: top-level "version" is not valid semver (expected X.Y.Z or X.Y.Z-pre)'
5127
- );
6010
+ if (typeof manifestVersion !== "string" || !ITEM_SEMVER_RE.test(manifestVersion)) {
6011
+ failures.push('manifest.json: top-level "version" is not valid semver (expected X.Y.Z)');
5128
6012
  }
5129
6013
  for (let i = 0; i < items.length; i++) {
5130
6014
  const item = items[i];
@@ -5181,10 +6065,19 @@ function checkRequiredFrontmatter(text, label) {
5181
6065
  }
5182
6066
  return failures;
5183
6067
  }
6068
+ function readForAudit(absPath, label, failures) {
6069
+ try {
6070
+ return fs29.readFileSync(absPath, "utf8");
6071
+ } catch (err) {
6072
+ failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
6073
+ return null;
6074
+ }
6075
+ }
5184
6076
  function auditTemplateContent(manifestDir, absPath, itemId) {
5185
- const rel = path28.relative(manifestDir, absPath);
5186
- const text = fs22.readFileSync(absPath, "utf8");
6077
+ const rel = path36.relative(manifestDir, absPath);
5187
6078
  const failures = [];
6079
+ const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
6080
+ if (text === null) return failures;
5188
6081
  const lines = text.split(/\r?\n/);
5189
6082
  for (let i = 0; i < lines.length; i++) {
5190
6083
  const line2 = lines[i] ?? "";
@@ -5203,44 +6096,47 @@ function auditShippedFiles(manifestDir, items) {
5203
6096
  const failures = [];
5204
6097
  for (const item of items) {
5205
6098
  if (!item.path || !isSafeCatalogPath2(item.path)) continue;
5206
- const absPath = path28.join(manifestDir, item.path);
6099
+ const absPath = path36.join(manifestDir, item.path);
5207
6100
  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)}`);
6101
+ const skillMd = path36.join(absPath, "SKILL.md");
6102
+ if (!fs29.existsSync(skillMd)) {
6103
+ failures.push(`${item.id}: missing ${path36.relative(manifestDir, skillMd)}`);
5211
6104
  continue;
5212
6105
  }
5213
- const text = fs22.readFileSync(skillMd, "utf8");
6106
+ const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
6107
+ if (text === null) continue;
5214
6108
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
5215
6109
  failures.push(
5216
- ...auditForbiddenTagsInText(text, `${item.id}: ${path28.relative(manifestDir, skillMd)}`)
6110
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path36.relative(manifestDir, skillMd)}`)
5217
6111
  );
5218
6112
  } else if (item.type === "agent") {
5219
- if (!fs22.existsSync(absPath)) {
6113
+ if (!fs29.existsSync(absPath)) {
5220
6114
  failures.push(`${item.id}: missing agent file ${item.path}`);
5221
6115
  continue;
5222
6116
  }
5223
- const text = fs22.readFileSync(absPath, "utf8");
5224
- const rel = path28.relative(manifestDir, absPath);
6117
+ const rel = path36.relative(manifestDir, absPath);
6118
+ const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6119
+ if (text === null) continue;
5225
6120
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
5226
6121
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
5227
6122
  } else if (item.type === "template") {
5228
- if (!fs22.existsSync(absPath)) {
6123
+ if (!fs29.existsSync(absPath)) {
5229
6124
  failures.push(`${item.id}: missing template file ${item.path}`);
5230
6125
  continue;
5231
6126
  }
5232
6127
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
5233
6128
  } else if (item.type === "command") {
5234
- if (!fs22.existsSync(absPath)) {
6129
+ if (!fs29.existsSync(absPath)) {
5235
6130
  failures.push(`${item.id}: missing command file ${item.path}`);
5236
6131
  continue;
5237
6132
  }
5238
- const text = fs22.readFileSync(absPath, "utf8");
5239
- const rel = path28.relative(manifestDir, absPath);
6133
+ const rel = path36.relative(manifestDir, absPath);
6134
+ const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6135
+ if (text === null) continue;
5240
6136
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
5241
6137
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
5242
6138
  } else if (item.type === "config") {
5243
- if (!fs22.existsSync(absPath)) {
6139
+ if (!fs29.existsSync(absPath)) {
5244
6140
  failures.push(`${item.id}: missing config path ${item.path}`);
5245
6141
  }
5246
6142
  }
@@ -5253,10 +6149,19 @@ var DIR_ITEM_TYPE = {
5253
6149
  templates: "template",
5254
6150
  commands: "command"
5255
6151
  };
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);
6152
+ function walkMd(dir, fn, failures) {
6153
+ let entries;
6154
+ try {
6155
+ entries = fs29.readdirSync(dir, { withFileTypes: true });
6156
+ } catch (err) {
6157
+ failures.push(
6158
+ `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
6159
+ );
6160
+ return;
6161
+ }
6162
+ for (const entry of entries) {
6163
+ const full = path36.join(dir, entry.name);
6164
+ if (entry.isDirectory()) walkMd(full, fn, failures);
5260
6165
  else if (entry.name.endsWith(".md")) fn(full);
5261
6166
  }
5262
6167
  }
@@ -5264,26 +6169,31 @@ function auditMarkdownContent(manifestDir, items) {
5264
6169
  const failures = [];
5265
6170
  const pathSourceMap = buildItemPathSourceMap(items);
5266
6171
  for (const dir of ["skills", "agents", "templates", "commands"]) {
5267
- const abs = path28.join(manifestDir, dir);
5268
- if (!fs22.existsSync(abs)) continue;
6172
+ const abs = path36.join(manifestDir, dir);
6173
+ if (!fs29.existsSync(abs)) continue;
5269
6174
  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)`);
6175
+ walkMd(
6176
+ abs,
6177
+ (file) => {
6178
+ const rel = path36.relative(manifestDir, file);
6179
+ const text = readForAudit(file, rel, failures);
6180
+ if (text === null) return;
6181
+ const norm = rel.replace(/\\/g, "/");
6182
+ const source = resolveMarkdownItemSource(norm, pathSourceMap);
6183
+ const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
6184
+ const lines = text.split(/\r?\n/);
6185
+ for (let i = 0; i < lines.length; i++) {
6186
+ const line2 = lines[i] ?? "";
6187
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
6188
+ failures.push(`${rel}:${i + 1}: risky install pattern`);
6189
+ if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
6190
+ failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
6191
+ }
5283
6192
  }
5284
- }
5285
- if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
5286
- });
6193
+ if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
6194
+ },
6195
+ failures
6196
+ );
5287
6197
  }
5288
6198
  return failures;
5289
6199
  }
@@ -5306,8 +6216,8 @@ async function runValidateCatalog(manifestPath2) {
5306
6216
  process.exitCode = 1;
5307
6217
  return;
5308
6218
  }
5309
- const abs = path29.resolve(process.cwd(), manifestPath2);
5310
- const manifestDir = path29.dirname(abs);
6219
+ const abs = path37.resolve(process.cwd(), manifestPath2);
6220
+ const manifestDir = path37.dirname(abs);
5311
6221
  const data = await readJson(abs);
5312
6222
  if (!data?.items) {
5313
6223
  error(`Could not read catalog manifest at ${abs}`);
@@ -5325,7 +6235,7 @@ async function runValidateCatalog(manifestPath2) {
5325
6235
 
5326
6236
  // src/commands/workspace.ts
5327
6237
  import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
5328
- import path36 from "node:path";
6238
+ import path44 from "node:path";
5329
6239
 
5330
6240
  // src/commands/workspace/aggregate.ts
5331
6241
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -5379,7 +6289,7 @@ ${summaries.map(
5379
6289
  }
5380
6290
 
5381
6291
  // src/commands/workspace/config.ts
5382
- import path30 from "node:path";
6292
+ import path38 from "node:path";
5383
6293
  import YAML from "yaml";
5384
6294
  var WORKSPACE_FILE = "haus.workspace.yaml";
5385
6295
  function parseWorkspaceConfig(text) {
@@ -5402,11 +6312,11 @@ function parseWorkspaceConfig(text) {
5402
6312
  };
5403
6313
  }
5404
6314
  async function readWorkspaceConfig(workspaceRoot) {
5405
- return parseWorkspaceConfig(await readText(path30.join(workspaceRoot, WORKSPACE_FILE)));
6315
+ return parseWorkspaceConfig(await readText(path38.join(workspaceRoot, WORKSPACE_FILE)));
5406
6316
  }
5407
6317
 
5408
6318
  // src/commands/workspace/discover.ts
5409
- import path31 from "node:path";
6319
+ import path39 from "node:path";
5410
6320
  import fg4 from "fast-glob";
5411
6321
  import YAML2 from "yaml";
5412
6322
  var DEFAULT_MAX_DEPTH = 3;
@@ -5429,13 +6339,16 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
5429
6339
  onlyFiles: false,
5430
6340
  deep: maxDepth,
5431
6341
  followSymbolicLinks: false,
6342
+ // Discovery walks arbitrary directories under the workspace root; an
6343
+ // unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
6344
+ suppressErrors: true,
5432
6345
  ignore: IGNORE
5433
6346
  });
5434
6347
  const gitDirs = /* @__PURE__ */ new Set();
5435
6348
  const manifestDirs = /* @__PURE__ */ new Set();
5436
6349
  for (const match of matches) {
5437
- const base = path31.posix.basename(match);
5438
- const dir = path31.posix.dirname(match);
6350
+ const base = path39.posix.basename(match);
6351
+ const dir = path39.posix.dirname(match);
5439
6352
  const owner = dir === "." ? "." : dir;
5440
6353
  if (base === ".git") gitDirs.add(owner);
5441
6354
  else manifestDirs.add(owner);
@@ -5451,9 +6364,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
5451
6364
  }
5452
6365
  repoRoots.sort((a, b) => a.localeCompare(b));
5453
6366
  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);
6367
+ const absDir = path39.resolve(workspaceRoot, relDir);
6368
+ const pkg = await readJson(path39.join(absDir, "package.json"));
6369
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path39.basename(relDir === "." ? workspaceRoot : absDir);
5457
6370
  let role = "auto";
5458
6371
  try {
5459
6372
  const scan = await scanProject(absDir);
@@ -5496,7 +6409,7 @@ function renderWorkspaceYaml(config) {
5496
6409
  });
5497
6410
  }
5498
6411
  async function runDiscover(workspaceRoot, opts = {}) {
5499
- const yamlPath = path31.join(workspaceRoot, "haus.workspace.yaml");
6412
+ const yamlPath = path39.join(workspaceRoot, "haus.workspace.yaml");
5500
6413
  const existingText = await readText(yamlPath);
5501
6414
  const existing = parseWorkspaceConfig(existingText);
5502
6415
  if (existingText && !existing) {
@@ -5514,34 +6427,37 @@ async function runDiscover(workspaceRoot, opts = {}) {
5514
6427
  }
5515
6428
  const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
5516
6429
  const yamlText = renderWorkspaceYaml(merged);
5517
- if (opts.json) {
5518
- log(JSON.stringify({ discovered, config: merged }, null, 2));
5519
- }
5520
6430
  if (opts.write) {
5521
6431
  await writeText(yamlPath, yamlText);
5522
- log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
6432
+ if (opts.json) {
6433
+ log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
6434
+ } else {
6435
+ log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
6436
+ }
5523
6437
  return;
5524
6438
  }
5525
- if (!opts.json) {
5526
- log("Proposed haus.workspace.yaml (run with --write to persist):\n");
5527
- log(yamlText);
6439
+ if (opts.json) {
6440
+ log(JSON.stringify({ discovered, config: merged }, null, 2));
6441
+ return;
5528
6442
  }
6443
+ log("Proposed haus.workspace.yaml (run with --write to persist):\n");
6444
+ log(yamlText);
5529
6445
  }
5530
6446
 
5531
6447
  // src/commands/workspace/doctor.ts
5532
6448
  import { existsSync as existsSync3 } from "node:fs";
5533
- import path33 from "node:path";
6449
+ import path41 from "node:path";
5534
6450
 
5535
6451
  // src/commands/workspace/manifest.ts
5536
6452
  import { readFileSync as readFileSync3 } from "node:fs";
5537
- import path32 from "node:path";
6453
+ import path40 from "node:path";
5538
6454
  var MANIFEST_FILE = "workspace.manifest.json";
5539
6455
  function manifestPath(workspaceRoot) {
5540
6456
  return hausPath(workspaceRoot, MANIFEST_FILE);
5541
6457
  }
5542
6458
  function hausVersion() {
5543
6459
  try {
5544
- const pkg = JSON.parse(readFileSync3(path32.join(packageRoot(), "package.json"), "utf8"));
6460
+ const pkg = JSON.parse(readFileSync3(path40.join(packageRoot(), "package.json"), "utf8"));
5545
6461
  return pkg.version ?? "0.0.0";
5546
6462
  } catch {
5547
6463
  return "0.0.0";
@@ -5607,7 +6523,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
5607
6523
  }
5608
6524
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
5609
6525
  for (const repo of config.repos) {
5610
- const repoRoot = path33.resolve(workspaceRoot, repo.path);
6526
+ const repoRoot = path41.resolve(workspaceRoot, repo.path);
5611
6527
  const entry = manifestByName.get(repo.name);
5612
6528
  if (!entry) {
5613
6529
  flag({
@@ -5681,11 +6597,11 @@ function emit(args) {
5681
6597
 
5682
6598
  // src/commands/workspace/setup.ts
5683
6599
  import { existsSync as existsSync4, statSync } from "node:fs";
5684
- import path35 from "node:path";
6600
+ import path43 from "node:path";
5685
6601
 
5686
6602
  // src/claude/write-workspace-claude-md.ts
5687
- import path34 from "node:path";
5688
- import fs23 from "fs-extra";
6603
+ import path42 from "node:path";
6604
+ import fs30 from "fs-extra";
5689
6605
  function buildWorkspaceImportBlock(client, members) {
5690
6606
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
5691
6607
  const body = [
@@ -5703,25 +6619,27 @@ ${BLOCK_END}`;
5703
6619
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5704
6620
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
5705
6621
  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") : "";
6622
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path42.join(workspaceRoot, "CLAUDE.md");
6623
+ const say = opts.quiet ? () => {
6624
+ } : log;
6625
+ const prev = await fs30.pathExists(filePath) ? await fs30.readFile(filePath, "utf8") : "";
5708
6626
  const next = opts.collision ? `${block}
5709
6627
  ` : injectHausBlock(prev, block);
5710
6628
  const printable = displayPath(workspaceRoot, filePath);
5711
6629
  if (dryRun) {
5712
6630
  if (!prev) {
5713
- log(createUnifiedDiff(printable, "", next));
6631
+ say(createUnifiedDiff(printable, "", next));
5714
6632
  } else if (hasTextChanged(prev, next)) {
5715
- log(createUnifiedDiff(printable, prev, next));
6633
+ say(createUnifiedDiff(printable, prev, next));
5716
6634
  } else {
5717
- log(`${printable}: unchanged`);
6635
+ say(`${printable}: unchanged`);
5718
6636
  }
5719
6637
  return filePath;
5720
6638
  }
5721
6639
  if (hasTextChanged(prev, next) && prev.length > 0) {
5722
6640
  const diffText = createUnifiedDiff(printable, prev, next);
5723
6641
  const summary = summarizeDiff(diffText);
5724
- log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
6642
+ say(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
5725
6643
  }
5726
6644
  await writeText(filePath, next);
5727
6645
  return filePath;
@@ -5729,20 +6647,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5729
6647
 
5730
6648
  // src/commands/workspace/setup.ts
5731
6649
  function resolveWorkspaceRoot(start = process.cwd()) {
5732
- let dir = path35.resolve(start);
6650
+ let dir = path43.resolve(start);
5733
6651
  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);
6652
+ if (existsSync4(path43.join(dir, WORKSPACE_FILE))) return dir;
6653
+ const parent = path43.dirname(dir);
6654
+ if (parent === dir) return path43.resolve(start);
5737
6655
  dir = parent;
5738
6656
  }
5739
6657
  }
5740
6658
  function isRootRepo(workspaceRoot, repoPath) {
5741
- return path35.resolve(workspaceRoot, repoPath) === path35.resolve(workspaceRoot);
6659
+ return path43.resolve(workspaceRoot, repoPath) === path43.resolve(workspaceRoot);
5742
6660
  }
5743
6661
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
5744
6662
  const apply = options.write ?? false;
5745
- const configText = await readText(path35.join(workspaceRoot, WORKSPACE_FILE));
6663
+ const configText = await readText(path43.join(workspaceRoot, WORKSPACE_FILE));
5746
6664
  if (!configText) {
5747
6665
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5748
6666
  process.exitCode = 1;
@@ -5754,22 +6672,34 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5754
6672
  process.exitCode = 1;
5755
6673
  return { workspaceRoot, statuses: [], written: [] };
5756
6674
  }
6675
+ const say = options.json ? () => {
6676
+ } : log;
5757
6677
  const onlySet = options.only && options.only.length > 0 ? new Set(options.only) : void 0;
6678
+ if (onlySet) {
6679
+ const unknown = [...onlySet].filter((name) => !config.repos.some((r) => r.name === name));
6680
+ if (unknown.length > 0) {
6681
+ error(`--only matched no configured repo(s): ${unknown.join(", ")}`);
6682
+ process.exitCode = 1;
6683
+ return { workspaceRoot, statuses: [], written: [] };
6684
+ }
6685
+ }
5758
6686
  const repos = onlySet ? config.repos.filter((r) => onlySet.has(r.name)) : config.repos;
5759
6687
  const statuses = [];
5760
6688
  const aggregateInputs = [];
5761
6689
  for (const repo of repos) {
5762
- const repoRoot = path35.resolve(workspaceRoot, repo.path);
5763
- log(`
6690
+ const repoRoot = path43.resolve(workspaceRoot, repo.path);
6691
+ say(`
5764
6692
  \u2192 ${repo.name} (${repo.path})`);
5765
6693
  try {
5766
6694
  if (!existsSync4(repoRoot) || !statSync(repoRoot).isDirectory()) {
5767
6695
  throw new Error(`Repo path is not a directory: ${repo.path}`);
5768
6696
  }
5769
6697
  const res = await runSetupCore(repoRoot, {
5770
- json: options.json,
5771
6698
  apply,
5772
- dryRun: options.dryRun
6699
+ dryRun: options.dryRun,
6700
+ // Under --json, mute the inner pipeline entirely; we serialize the
6701
+ // aggregated result below. Otherwise let it print its normal human output.
6702
+ quiet: options.json
5773
6703
  });
5774
6704
  statuses.push({
5775
6705
  name: repo.name,
@@ -5809,9 +6739,10 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5809
6739
  client: config.client,
5810
6740
  members: config.repos.map((r) => ({ name: r.name, path: r.path })),
5811
6741
  collision,
5812
- dryRun: options.dryRun
6742
+ dryRun: options.dryRun,
6743
+ quiet: options.json
5813
6744
  });
5814
- written.push(docPath);
6745
+ if (!options.dryRun) written.push(docPath);
5815
6746
  }
5816
6747
  if (apply && !options.dryRun) {
5817
6748
  const statusByName = new Map(statuses.map((s) => [s.name, s]));
@@ -5827,7 +6758,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5827
6758
  const status = statusByName.get(repo.name);
5828
6759
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
5829
6760
  if (status?.status === "ok") {
5830
- const lock = await checkLock(path35.resolve(workspaceRoot, repo.path));
6761
+ const lock = await checkLock(path43.resolve(workspaceRoot, repo.path));
5831
6762
  manifestRepos.push({
5832
6763
  name: repo.name,
5833
6764
  path: repo.path,
@@ -5876,9 +6807,17 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5876
6807
  }
5877
6808
  const ok = statuses.filter((s) => s.status === "ok").length;
5878
6809
  const failed = statuses.length - ok;
5879
- log(`
5880
- Workspace setup complete: ${ok} ok, ${failed} failed.`);
5881
- return { workspaceRoot, statuses, written };
6810
+ const result = { workspaceRoot, statuses, written };
6811
+ if (options.json) {
6812
+ log(JSON.stringify(result, null, 2));
6813
+ } else if (!apply) {
6814
+ say(`
6815
+ Workspace preview complete: ${ok} ok, ${failed} failed. Re-run with --write to apply.`);
6816
+ } else {
6817
+ say(`
6818
+ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} file(s).`);
6819
+ }
6820
+ return result;
5882
6821
  }
5883
6822
 
5884
6823
  // src/commands/workspace.ts
@@ -5891,7 +6830,9 @@ function normalizeOnly(only) {
5891
6830
  function normalizeMaxDepth(maxDepth) {
5892
6831
  if (maxDepth === void 0) return void 0;
5893
6832
  const n = typeof maxDepth === "number" ? maxDepth : Number.parseInt(maxDepth, 10);
5894
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
6833
+ if (Number.isFinite(n) && n > 0) return Math.floor(n);
6834
+ warn(`Ignoring invalid --max-depth "${String(maxDepth)}"; using the default depth.`);
6835
+ return void 0;
5895
6836
  }
5896
6837
  async function initWorkspace() {
5897
6838
  await writeText(
@@ -5907,7 +6848,7 @@ relationships: []
5907
6848
  log("Workspace initialized.");
5908
6849
  }
5909
6850
  async function scanWorkspace(workspaceRoot, opts) {
5910
- const configText = await readText(path36.join(workspaceRoot, WORKSPACE_FILE));
6851
+ const configText = await readText(path44.join(workspaceRoot, WORKSPACE_FILE));
5911
6852
  if (!configText) {
5912
6853
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5913
6854
  process.exitCode = 1;
@@ -5927,20 +6868,31 @@ async function scanWorkspace(workspaceRoot, opts) {
5927
6868
  return;
5928
6869
  }
5929
6870
  const inputs = [];
6871
+ const failures = [];
5930
6872
  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}`);
6873
+ const repoRoot = path44.resolve(workspaceRoot, repo.path);
6874
+ try {
6875
+ if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
6876
+ throw new Error("path is not a directory");
6877
+ }
6878
+ const result = await scanProject(repoRoot);
6879
+ inputs.push({ name: repo.name, path: repo.path, context: result });
6880
+ } catch (err) {
6881
+ failures.push(
6882
+ `${repo.name} (${repo.path}): ${err instanceof Error ? err.message : String(err)}`
6883
+ );
5934
6884
  }
5935
- const result = await scanProject(repoRoot);
5936
- inputs.push({ name: repo.name, path: repo.path, context: result });
5937
6885
  }
5938
6886
  const written = await writeWorkspaceArtifacts(workspaceRoot, inputs, config.relationships);
5939
6887
  if (opts.json) {
5940
- log(JSON.stringify({ written }, null, 2));
6888
+ log(JSON.stringify({ written, scanned: inputs.length, failed: failures }, null, 2));
5941
6889
  } else {
5942
- log(`Workspace scan complete. Wrote ${written.length} artifact(s) under .haus-workflow/.`);
6890
+ log(
6891
+ `Workspace scan complete. Scanned ${inputs.length} repo(s); wrote ${written.length} artifact(s) under .haus-workflow/.`
6892
+ );
6893
+ for (const f of failures) warn(`- skipped ${f}`);
5943
6894
  }
6895
+ if (failures.length > 0) process.exitCode = 1;
5944
6896
  }
5945
6897
  async function runWorkspace(action, options = {}) {
5946
6898
  if (action === "init") {
@@ -5978,7 +6930,7 @@ async function runWorkspace(action, options = {}) {
5978
6930
  // src/cli.ts
5979
6931
  function cliVersion() {
5980
6932
  try {
5981
- const pkgPath = path37.join(packageRoot(), "package.json");
6933
+ const pkgPath = path45.join(packageRoot(), "package.json");
5982
6934
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5983
6935
  return pkg.version ?? "0.0.0";
5984
6936
  } catch {
@@ -5988,7 +6940,7 @@ function cliVersion() {
5988
6940
  var program = new Command();
5989
6941
  function validateRuntimeNodeVersion() {
5990
6942
  try {
5991
- const pkgPath = path37.join(packageRoot(), "package.json");
6943
+ const pkgPath = path45.join(packageRoot(), "package.json");
5992
6944
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5993
6945
  const requiredRange = pkg.engines?.node;
5994
6946
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -6009,6 +6961,7 @@ program.command("recommend").option("--json").option(
6009
6961
  ).action(runRecommend);
6010
6962
  program.command("setup-project").option("--json").action(runSetupProject);
6011
6963
  program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
6964
+ 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
6965
  program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
6013
6966
  "--ids <ids...>",
6014
6967
  "Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
@@ -6037,6 +6990,17 @@ program.command("uninstall").option("--force").action(runUninstallCommand);
6037
6990
  var guard = program.command("guard");
6038
6991
  guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
6039
6992
  guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
6993
+ function registerDecisionsCommand(name) {
6994
+ const cmd = program.command(name).description("Architecture decision records (ADR) gate and drafts");
6995
+ 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));
6996
+ cmd.command("suggest").option("--from-hook", "Emit JSON for Claude Code Stop hook").option("--title <title>", "Draft title").action(
6997
+ (opts) => runDecisions("suggest", void 0, opts)
6998
+ );
6999
+ cmd.command("next-number").action(() => runDecisions("next-number"));
7000
+ cmd.command("validate").argument("[path]", "Decision markdown file").action((filePath) => runDecisions("validate", filePath));
7001
+ }
7002
+ registerDecisionsCommand("decisions");
7003
+ registerDecisionsCommand("adr");
6040
7004
  var workspace = program.command("workspace");
6041
7005
  workspace.command("init").action(() => runWorkspace("init"));
6042
7006
  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));