@haus-tech/haus-workflow 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,18 +1,74 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res, err) => function __init() {
5
+ if (err) throw err[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err = [e], e;
10
+ }
11
+ };
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
16
+
17
+ // src/utils/exec.ts
18
+ var exec_exports = {};
19
+ __export(exec_exports, {
20
+ commandExists: () => commandExists,
21
+ runCommand: () => runCommand,
22
+ runGit: () => runGit
23
+ });
24
+ import { execa } from "execa";
25
+ async function runCommand(command, args = [], options = {}) {
26
+ try {
27
+ const result = await execa(command, args, {
28
+ reject: false,
29
+ // non-zero exits are returned, not thrown
30
+ ...options
31
+ });
32
+ return {
33
+ command,
34
+ args,
35
+ stdout: String(result.stdout ?? ""),
36
+ stderr: String(result.stderr ?? ""),
37
+ exitCode: result.exitCode ?? 0
38
+ };
39
+ } catch (error2) {
40
+ const message = error2 instanceof Error ? error2.message : String(error2);
41
+ throw new Error(`Failed to run command: ${command} ${args.join(" ")} (${message})`);
42
+ }
43
+ }
44
+ async function runGit(args, options = {}) {
45
+ return runCommand("git", args, options);
46
+ }
47
+ async function commandExists(command) {
48
+ try {
49
+ const result = await runCommand(command, ["--version"]);
50
+ return result.exitCode === 0;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+ var init_exec = __esm({
56
+ "src/utils/exec.ts"() {
57
+ "use strict";
58
+ }
59
+ });
2
60
 
3
61
  // src/cli.ts
4
62
  import { readFileSync as readFileSync4 } from "node:fs";
5
- import path36 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 path13 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);
@@ -182,7 +290,7 @@ var validation_rules_default = {
182
290
  "vendure3",
183
291
  "nestjs",
184
292
  "graphql",
185
- "nx21",
293
+ "nx",
186
294
  "turbo",
187
295
  "nextjs",
188
296
  "react19",
@@ -543,7 +651,7 @@ function parseManifest(json) {
543
651
 
544
652
  // src/catalog/remote-catalog.ts
545
653
  function getCacheDir() {
546
- return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path2.join(os.homedir(), CATALOG_CACHE_SUBDIR);
654
+ return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path3.join(os2.homedir(), CATALOG_CACHE_SUBDIR);
547
655
  }
548
656
  var cachedCatalogRef;
549
657
  var cachedBlobPaths;
@@ -606,13 +714,13 @@ async function writeTextIfChanged(dest, text) {
606
714
  await fs2.writeFile(dest, text, "utf8");
607
715
  return "updated";
608
716
  }
609
- await fs2.ensureDir(path2.dirname(dest));
717
+ await fs2.ensureDir(path3.dirname(dest));
610
718
  await fs2.writeFile(dest, text, "utf8");
611
719
  return "created";
612
720
  }
613
721
  var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
614
722
  async function readWorkflowTemplate(opts = {}) {
615
- const dest = path2.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
723
+ const dest = path3.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
616
724
  const base = await remoteBase();
617
725
  const text = await fetchText(`${base}/${WORKFLOW_TEMPLATE_REL}`);
618
726
  if (text === null) {
@@ -627,14 +735,14 @@ async function readWorkflowTemplate(opts = {}) {
627
735
  return text;
628
736
  }
629
737
  function isSafeCatalogPath(itemPath) {
630
- if (!itemPath || path2.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
631
- const normalized = path2.normalize(itemPath);
738
+ if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
739
+ const normalized = path3.normalize(itemPath);
632
740
  return !normalized.startsWith("..") && !normalized.includes("/..");
633
741
  }
634
742
  function isSafeRelativeFilePath(rel) {
635
743
  if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
636
- if (path2.isAbsolute(rel)) return false;
637
- const normalized = path2.posix.normalize(rel.replace(/\\/g, "/"));
744
+ if (path3.isAbsolute(rel)) return false;
745
+ const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
638
746
  return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
639
747
  }
640
748
  function githubApiHeaders() {
@@ -656,8 +764,8 @@ function sanitizeRelativeFilePaths(files, label) {
656
764
  }
657
765
  function safeJoin(base, itemPath) {
658
766
  if (!isSafeCatalogPath(itemPath)) return null;
659
- const resolved = path2.resolve(base, itemPath);
660
- return resolved.startsWith(base + path2.sep) || resolved === base ? resolved : null;
767
+ const resolved = path3.resolve(base, itemPath);
768
+ return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
661
769
  }
662
770
  var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
663
771
  function isMarkdownPath(rel) {
@@ -665,13 +773,19 @@ function isMarkdownPath(rel) {
665
773
  }
666
774
  async function listFilesRecursive(dir, base = dir) {
667
775
  const out = [];
668
- if (!await fs2.pathExists(dir)) return out;
669
- for (const entry of await fs2.readdir(dir, { withFileTypes: true })) {
670
- const full = path2.join(dir, entry.name);
776
+ let entries;
777
+ try {
778
+ if (!await fs2.pathExists(dir)) return out;
779
+ entries = await fs2.readdir(dir, { withFileTypes: true });
780
+ } catch {
781
+ return out;
782
+ }
783
+ for (const entry of entries) {
784
+ const full = path3.join(dir, entry.name);
671
785
  if (entry.isDirectory()) {
672
786
  out.push(...await listFilesRecursive(full, base));
673
787
  } else if (entry.isFile()) {
674
- out.push(path2.relative(base, full).replace(/\\/g, "/"));
788
+ out.push(path3.relative(base, full).replace(/\\/g, "/"));
675
789
  }
676
790
  }
677
791
  return out.sort();
@@ -776,7 +890,7 @@ async function directoryMatchesFetched(destDir, fetched) {
776
890
  if (!relSet.has(rel)) return false;
777
891
  }
778
892
  for (const file of fetched) {
779
- const dest = path2.join(destDir, file.rel);
893
+ const dest = path3.join(destDir, file.rel);
780
894
  if (!await fs2.pathExists(dest)) return false;
781
895
  if (file.kind === "text") {
782
896
  const local = await fs2.readFile(dest, "utf8");
@@ -794,8 +908,8 @@ async function writeFetchedDirectory(destDir, fetched) {
794
908
  }
795
909
  await fs2.ensureDir(destDir);
796
910
  for (const file of fetched) {
797
- const dest = path2.join(destDir, file.rel);
798
- await fs2.ensureDir(path2.dirname(dest));
911
+ const dest = path3.join(destDir, file.rel);
912
+ await fs2.ensureDir(path3.dirname(dest));
799
913
  if (file.kind === "text") {
800
914
  await fs2.writeFile(dest, file.body, "utf8");
801
915
  } else {
@@ -939,7 +1053,7 @@ async function syncOneItem(item, base) {
939
1053
  return "failed";
940
1054
  }
941
1055
  const text = await fetchText(`${base}/${item.path}`);
942
- if (!text) {
1056
+ if (text === null) {
943
1057
  warn(`Failed to fetch content for ${item.id}`);
944
1058
  return "failed";
945
1059
  }
@@ -967,7 +1081,7 @@ async function syncRemoteCatalog() {
967
1081
  try {
968
1082
  await fs2.ensureDir(cacheDir);
969
1083
  await fs2.writeFile(
970
- path2.join(cacheDir, "manifest.json"),
1084
+ path3.join(cacheDir, "manifest.json"),
971
1085
  `${JSON.stringify({ version, items }, null, 2)}
972
1086
  `,
973
1087
  "utf8"
@@ -1035,66 +1149,13 @@ async function fetchLatestCatalogTag() {
1035
1149
  }
1036
1150
  async function getCacheManifestAge() {
1037
1151
  try {
1038
- const stat = await fs2.stat(path2.join(getCacheDir(), "manifest.json"));
1152
+ const stat = await fs2.stat(path3.join(getCacheDir(), "manifest.json"));
1039
1153
  return Date.now() - stat.mtimeMs;
1040
1154
  } catch {
1041
1155
  return null;
1042
1156
  }
1043
1157
  }
1044
1158
 
1045
- // src/claude/write-claude-files.ts
1046
- import path12 from "node:path";
1047
- import fs12 from "fs-extra";
1048
-
1049
- // src/catalog/load-catalog.ts
1050
- import path4 from "node:path";
1051
-
1052
- // src/utils/paths.ts
1053
- import { existsSync, readFileSync } from "node:fs";
1054
- import os2 from "node:os";
1055
- import path3 from "node:path";
1056
- import { fileURLToPath } from "node:url";
1057
- var HAUS_DIR = ".haus-workflow";
1058
- function hausPath(root, ...parts) {
1059
- return path3.join(root, HAUS_DIR, ...parts);
1060
- }
1061
- function claudePath(root, ...parts) {
1062
- return path3.join(root, ".claude", ...parts);
1063
- }
1064
- function displayPath(root, targetPath) {
1065
- const rel = path3.relative(root, targetPath).replace(/\\/g, "/");
1066
- if (rel && !rel.startsWith("../") && rel !== "..") {
1067
- return rel.startsWith("./") ? rel : `./${rel}`;
1068
- }
1069
- const home = os2.homedir();
1070
- const normalized = targetPath.replace(/\\/g, "/");
1071
- if (home && home.trim().length > 0) {
1072
- const homeRel = path3.relative(home, targetPath).replace(/\\/g, "/");
1073
- if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
1074
- return `~/${homeRel}`;
1075
- }
1076
- }
1077
- return normalized;
1078
- }
1079
- function packageRoot() {
1080
- let dir = path3.dirname(fileURLToPath(import.meta.url));
1081
- for (let i = 0; i < 12; i++) {
1082
- const pkgPath = path3.join(dir, "package.json");
1083
- if (existsSync(pkgPath)) {
1084
- try {
1085
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1086
- if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
1087
- } catch {
1088
- }
1089
- }
1090
- const parent = path3.dirname(dir);
1091
- if (parent === dir) break;
1092
- dir = parent;
1093
- }
1094
- const file = fileURLToPath(import.meta.url);
1095
- return path3.resolve(path3.dirname(file), "../..");
1096
- }
1097
-
1098
1159
  // src/catalog/load-catalog.ts
1099
1160
  async function loadCatalogContext(root) {
1100
1161
  const envPath = process.env["HAUS_FIXTURE_CATALOG"];
@@ -1137,6 +1198,10 @@ function catalogItemContentPath(contentRoot, item) {
1137
1198
  return path4.join(contentRoot, item.path);
1138
1199
  }
1139
1200
 
1201
+ // src/claude/write-claude-files.ts
1202
+ import path14 from "node:path";
1203
+ import fs14 from "fs-extra";
1204
+
1140
1205
  // src/update/hash-installed.ts
1141
1206
  import path5 from "node:path";
1142
1207
  import fg2 from "fast-glob";
@@ -1660,8 +1725,8 @@ function buildDenyRules() {
1660
1725
  for (const command of DENY_COMMANDS) {
1661
1726
  rules.push(`Bash(${command}:*)`);
1662
1727
  }
1663
- for (const path37 of DENY_PATHS) {
1664
- const pattern = DENY_DIRS.has(path37) ? `${path37}/**` : path37;
1728
+ for (const path46 of DENY_PATHS) {
1729
+ const pattern = DENY_DIRS.has(path46) ? `${path46}/**` : path46;
1665
1730
  for (const tool of FILE_TOOLS) {
1666
1731
  rules.push(`${tool}(${pattern})`);
1667
1732
  }
@@ -1684,6 +1749,13 @@ var PROJECT_HOOK_FRAGMENTS = [
1684
1749
  event: "PreToolUse",
1685
1750
  matcher: "Bash",
1686
1751
  command: "haus guard bash --from-hook"
1752
+ },
1753
+ {
1754
+ id: "haus.decisions-suggest",
1755
+ gate: "keep",
1756
+ event: "Stop",
1757
+ matcher: "*",
1758
+ command: "haus decisions suggest --from-hook"
1687
1759
  }
1688
1760
  ];
1689
1761
  async function readProjectSettings(root) {
@@ -1777,6 +1849,12 @@ var CANONICAL_HOOKS = {
1777
1849
  matcher: "Bash",
1778
1850
  hooks: [{ type: "command", command: "haus guard bash --from-hook" }]
1779
1851
  }
1852
+ ],
1853
+ Stop: [
1854
+ {
1855
+ matcher: "*",
1856
+ hooks: [{ type: "command", command: "haus decisions suggest --from-hook" }]
1857
+ }
1780
1858
  ]
1781
1859
  }
1782
1860
  };
@@ -1798,9 +1876,11 @@ function collectHookCommands(settings) {
1798
1876
  }
1799
1877
  function hausHookContractSatisfied(project, canonical) {
1800
1878
  const present = new Set(collectHookCommands(project));
1801
- for (const block of canonical.hooks.PreToolUse) {
1802
- for (const h of block.hooks) {
1803
- if (!present.has(h.command)) return false;
1879
+ for (const blocks of Object.values(canonical.hooks)) {
1880
+ for (const block of blocks ?? []) {
1881
+ for (const h of block.hooks) {
1882
+ if (!present.has(h.command)) return false;
1883
+ }
1804
1884
  }
1805
1885
  }
1806
1886
  const denySet = new Set(project.permissions?.deny ?? []);
@@ -1851,19 +1931,138 @@ async function verifyProjectSettingsHooksContract(root) {
1851
1931
  return { ok: true, message: "settings.json carries required haus hook contract." };
1852
1932
  }
1853
1933
 
1854
- // src/claude/write-root-claude-md.ts
1934
+ // src/claude/write-decisions-seed.ts
1855
1935
  import path9 from "node:path";
1856
1936
  import fs8 from "fs-extra";
1937
+
1938
+ // library/catalog/decisions-triggers.json
1939
+ var decisions_triggers_default = {
1940
+ decisionsDir: "docs/decisions",
1941
+ legacyDecisionsDir: "docs/adr",
1942
+ pathGlobs: [
1943
+ "**/package.json",
1944
+ "**/pnpm-lock.yaml",
1945
+ "**/yarn.lock",
1946
+ "**/package-lock.json",
1947
+ "**/migrations/**",
1948
+ "**/prisma/schema.prisma",
1949
+ "**/.github/workflows/**",
1950
+ "**/Dockerfile*",
1951
+ "**/docker-compose*.yml",
1952
+ "**/terraform/**",
1953
+ "**/openapi*.yaml",
1954
+ "**/schema.graphql"
1955
+ ],
1956
+ securityPathGlobs: ["**/.env.example", "**/auth/**", "**/middleware/**"],
1957
+ pathRegex: ["(^|/)src/.*/auth/", "(^|/)middleware/", "\\.env\\.example$"],
1958
+ minFilesChanged: 8,
1959
+ minLinesChanged: 200,
1960
+ exemptGlobs: ["docs/**", "tests/**", "**/*.md", ".haus-workflow/**"],
1961
+ requiredSections: ["## Context", "## Decision", "## Consequences"],
1962
+ recommendedSections: ["## Motivation (why)", "## Alternatives considered"],
1963
+ decisionFilePattern: "^\\d{4}-.+\\.md$"
1964
+ };
1965
+
1966
+ // src/decisions/triggers.ts
1967
+ var DECISIONS_TRIGGERS = decisions_triggers_default;
1968
+ var DECISION_FILE_RE = new RegExp(DECISIONS_TRIGGERS.decisionFilePattern);
1969
+
1970
+ // src/claude/write-decisions-seed.ts
1971
+ var SEED_README = `# Architecture Decision Records (ADRs)
1972
+
1973
+ Why we chose X \u2014 short, dated, write-once decision log for humans and agents.
1974
+
1975
+ | ADR | Title | Why (one line) | Status |
1976
+ | --- | ----- | -------------- | ------ |
1977
+ `;
1978
+ async function writeDecisionsSeed(root, dryRun) {
1979
+ const decisionsDir = path9.join(root, DECISIONS_TRIGGERS.decisionsDir);
1980
+ const readmePath = path9.join(decisionsDir, "README.md");
1981
+ if (await fs8.pathExists(readmePath)) return null;
1982
+ if (!dryRun) await fs8.ensureDir(decisionsDir);
1983
+ await writeManagedText(root, readmePath, SEED_README, dryRun);
1984
+ return readmePath;
1985
+ }
1986
+
1987
+ // src/claude/write-prettierignore.ts
1988
+ import path10 from "node:path";
1989
+ import fs9 from "fs-extra";
1990
+ var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
1991
+ var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
1992
+ var IGNORED_PATHS = [".haus-workflow/"];
1993
+ function buildPrettierIgnoreBlock() {
1994
+ return [
1995
+ PRETTIERIGNORE_BEGIN,
1996
+ "# haus-owned files \u2014 do not reformat (keeps tamper-detection hashes stable)",
1997
+ ...IGNORED_PATHS,
1998
+ PRETTIERIGNORE_END
1999
+ ].join("\n");
2000
+ }
2001
+ function findLineMarker(content2, marker, from = 0) {
2002
+ let idx = content2.indexOf(marker, from);
2003
+ while (idx !== -1) {
2004
+ const lineStart = idx === 0 || content2[idx - 1] === "\n";
2005
+ const after = idx + marker.length;
2006
+ const lineEnd = after === content2.length || content2[after] === "\n" || content2[after] === "\r";
2007
+ if (lineStart && lineEnd) return { start: idx, end: after };
2008
+ idx = content2.indexOf(marker, idx + marker.length);
2009
+ }
2010
+ return null;
2011
+ }
2012
+ function findBlockRange(content2) {
2013
+ const begin = findLineMarker(content2, PRETTIERIGNORE_BEGIN);
2014
+ if (!begin) return null;
2015
+ const end = findLineMarker(content2, PRETTIERIGNORE_END, begin.end);
2016
+ if (!end) return null;
2017
+ return { start: begin.start, end: end.end };
2018
+ }
2019
+ function injectPrettierIgnoreBlock(existing, block) {
2020
+ const range = findBlockRange(existing);
2021
+ if (range) {
2022
+ const before = existing.slice(0, range.start);
2023
+ const after = existing.slice(range.end);
2024
+ return `${before}${block}${after}`.replace(/\n*$/, "\n");
2025
+ }
2026
+ const loneBegin = findLineMarker(existing, PRETTIERIGNORE_BEGIN);
2027
+ if (loneBegin) {
2028
+ const before = existing.slice(0, loneBegin.start).trimEnd();
2029
+ if (before.length === 0) return `${block}
2030
+ `;
2031
+ return `${before}
2032
+
2033
+ ${block}
2034
+ `;
2035
+ }
2036
+ const trimmed = existing.trimEnd();
2037
+ if (trimmed.length === 0) return `${block}
2038
+ `;
2039
+ return `${trimmed}
2040
+
2041
+ ${block}
2042
+ `;
2043
+ }
2044
+ async function writePrettierIgnore(root, dryRun) {
2045
+ const filePath = path10.join(root, ".prettierignore");
2046
+ const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
2047
+ const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
2048
+ await writeManagedText(root, filePath, next, dryRun);
2049
+ return filePath;
2050
+ }
2051
+
2052
+ // src/claude/write-root-claude-md.ts
2053
+ import path11 from "node:path";
2054
+ import fs10 from "fs-extra";
1857
2055
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
1858
2056
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
1859
2057
  var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
1860
- @.haus-workflow/workflow-config.md`;
2058
+ @.haus-workflow/workflow-config.md
2059
+ @docs/decisions/README.md`;
1861
2060
  function buildImportBlock() {
1862
2061
  return `${BLOCK_BEGIN}
1863
2062
  ${IMPORT_CONTENT}
1864
2063
  ${BLOCK_END}`;
1865
2064
  }
1866
- function findLineMarker(content2, marker, from = 0) {
2065
+ function findLineMarker2(content2, marker, from = 0) {
1867
2066
  let idx = content2.indexOf(marker, from);
1868
2067
  while (idx !== -1) {
1869
2068
  const lineStart = idx === 0 || content2[idx - 1] === "\n";
@@ -1875,9 +2074,9 @@ function findLineMarker(content2, marker, from = 0) {
1875
2074
  return null;
1876
2075
  }
1877
2076
  function findImportBlockRange(content2) {
1878
- const begin = findLineMarker(content2, BLOCK_BEGIN);
2077
+ const begin = findLineMarker2(content2, BLOCK_BEGIN);
1879
2078
  if (!begin) return null;
1880
- const end = findLineMarker(content2, BLOCK_END, begin.end);
2079
+ const end = findLineMarker2(content2, BLOCK_END, begin.end);
1881
2080
  if (!end) return null;
1882
2081
  return { beginStart: begin.start, beginEnd: begin.end, endStart: end.start, endEnd: end.end };
1883
2082
  }
@@ -1897,7 +2096,7 @@ function injectHausBlock(existing, block) {
1897
2096
  const after = existing.slice(range.endEnd);
1898
2097
  return `${before}${block}${after}`;
1899
2098
  }
1900
- const loneBegin = findLineMarker(existing, BLOCK_BEGIN);
2099
+ const loneBegin = findLineMarker2(existing, BLOCK_BEGIN);
1901
2100
  if (loneBegin) {
1902
2101
  const before = existing.slice(0, loneBegin.start).trimEnd();
1903
2102
  if (before.length === 0) return `${block}
@@ -1918,21 +2117,21 @@ ${block}
1918
2117
  `;
1919
2118
  }
1920
2119
  async function writeRootClaudeMd(root, dryRun) {
1921
- const filePath = path9.join(root, "CLAUDE.md");
2120
+ const filePath = path11.join(root, "CLAUDE.md");
1922
2121
  const block = buildImportBlock();
1923
- const prev = await fs8.pathExists(filePath) ? await fs8.readFile(filePath, "utf8") : "";
2122
+ const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
1924
2123
  const next = injectHausBlock(prev, block);
1925
2124
  await writeManagedText(root, filePath, next, dryRun);
1926
2125
  return filePath;
1927
2126
  }
1928
2127
 
1929
2128
  // src/claude/write-workflow-config.ts
1930
- import path11 from "node:path";
1931
- import fs10 from "fs-extra";
2129
+ import path13 from "node:path";
2130
+ import fs12 from "fs-extra";
1932
2131
 
1933
2132
  // src/claude/derive-workflow-config.ts
1934
- import path10 from "node:path";
1935
- import fs9 from "fs-extra";
2133
+ import path12 from "node:path";
2134
+ import fs11 from "fs-extra";
1936
2135
  function binCmd(pm, bin, args) {
1937
2136
  const tail = args ? ` ${args}` : "";
1938
2137
  if (pm === "yarn") return `yarn ${bin}${tail}`;
@@ -1941,7 +2140,7 @@ function binCmd(pm, bin, args) {
1941
2140
  }
1942
2141
  async function deriveWorkflowConfig(root, ctx) {
1943
2142
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
1944
- const pkg = await readJson(path10.join(root, "package.json"));
2143
+ const pkg = await readJson(path12.join(root, "package.json"));
1945
2144
  const scripts = pkg?.scripts ?? {};
1946
2145
  const deps = new Set(ctx.dependencies);
1947
2146
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -1951,7 +2150,7 @@ async function deriveWorkflowConfig(root, ctx) {
1951
2150
  return null;
1952
2151
  };
1953
2152
  const hasDep = (name) => deps.has(name);
1954
- const exists = (rel) => fs9.pathExistsSync(path10.join(root, rel));
2153
+ const exists = (rel) => fs11.pathExistsSync(path12.join(root, rel));
1955
2154
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
1956
2155
  const hasCypress = hasDep("cypress");
1957
2156
  const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
@@ -2019,7 +2218,7 @@ var FALLBACK_CONTEXT = {
2019
2218
  async function writeWorkflowConfig(root, dryRun, opts = {}) {
2020
2219
  const destPath = hausPath(root, "workflow-config.md");
2021
2220
  const printable = displayPath(root, destPath);
2022
- const exists = await fs10.pathExists(destPath);
2221
+ const exists = await fs12.pathExists(destPath);
2023
2222
  if (exists && !opts.refill) {
2024
2223
  if (dryRun) log(printable + ": exists (project-owned, skipping)");
2025
2224
  return null;
@@ -2027,11 +2226,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2027
2226
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
2028
2227
  ...FALLBACK_CONTEXT,
2029
2228
  root,
2030
- repoName: path11.basename(root)
2229
+ repoName: path13.basename(root)
2031
2230
  };
2032
2231
  const values = await deriveWorkflowConfig(root, ctx);
2033
2232
  if (exists) {
2034
- const current = await fs10.readFile(destPath, "utf8");
2233
+ const current = await fs12.readFile(destPath, "utf8");
2035
2234
  const refilled = refillContent(current, values);
2036
2235
  if (refilled === current) {
2037
2236
  if (dryRun) log(printable + ": no blank fields to refill");
@@ -2053,7 +2252,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2053
2252
  }
2054
2253
 
2055
2254
  // src/claude/write-workflow.ts
2056
- import fs11 from "fs-extra";
2255
+ import fs13 from "fs-extra";
2057
2256
  var STABLE_ID = "template.workflow";
2058
2257
  function makeWorkflowHeader(pkgVersion, contentHash) {
2059
2258
  return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
@@ -2072,8 +2271,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
2072
2271
  ${templateContent}`;
2073
2272
  const destPath = hausPath(root, "WORKFLOW.md");
2074
2273
  const printable = displayPath(root, destPath);
2075
- if (await fs11.pathExists(destPath)) {
2076
- const existing = await fs11.readFile(destPath, "utf8");
2274
+ if (await fs13.pathExists(destPath)) {
2275
+ const existing = await fs13.readFile(destPath, "utf8");
2077
2276
  const firstLine = existing.split("\n")[0] ?? "";
2078
2277
  const parsed = parseHausManagedHeader(firstLine);
2079
2278
  if (!parsed) {
@@ -2107,7 +2306,7 @@ ${templateContent}`;
2107
2306
  }
2108
2307
  }
2109
2308
  if (dryRun) {
2110
- const prev = await fs11.pathExists(destPath) ? await fs11.readFile(destPath, "utf8") : "";
2309
+ const prev = await fs13.pathExists(destPath) ? await fs13.readFile(destPath, "utf8") : "";
2111
2310
  if (!prev) {
2112
2311
  log(createUnifiedDiff(printable, "", next));
2113
2312
  } else {
@@ -2130,6 +2329,8 @@ function targetDirForType(type) {
2130
2329
  return null;
2131
2330
  }
2132
2331
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2332
+ const say = opts.quiet ? () => {
2333
+ } : log;
2133
2334
  const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
2134
2335
  recommended: [],
2135
2336
  skipped: [],
@@ -2140,21 +2341,25 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2140
2341
  estimatedTokenReductionPct: 0
2141
2342
  };
2142
2343
  const pkgRoot = packageRoot();
2143
- const hausVersion2 = (await readJson(path12.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2344
+ const hausVersion2 = (await readJson(path14.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2144
2345
  const coreFiles = [
2145
2346
  claudePath(root, "settings.json"),
2146
2347
  claudePath(root, "rules", "haus.md"),
2147
2348
  claudePath(root, "commands", "haus-doctor.md")
2148
2349
  ];
2149
2350
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
2351
+ const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
2150
2352
  const workflowPath = await writeWorkflow(root, hausVersion2, dryRun, opts.force);
2151
2353
  const workflowConfigPath = await writeWorkflowConfig(root, dryRun, {
2152
2354
  refill: opts.refillConfig
2153
2355
  });
2356
+ const prettierIgnorePath = await writePrettierIgnore(root, dryRun);
2154
2357
  const p6Files = [
2155
2358
  rootClaudeMdPath,
2359
+ ...decisionsSeedPath ? [decisionsSeedPath] : [],
2156
2360
  ...workflowPath ? [workflowPath] : [],
2157
- ...workflowConfigPath ? [workflowConfigPath] : []
2361
+ ...workflowConfigPath ? [workflowConfigPath] : [],
2362
+ prettierIgnorePath
2158
2363
  ];
2159
2364
  const files = dryRun ? [...coreFiles, ...p6Files] : [...coreFiles, ...p6Files, hausPath(root, "haus.lock.json")];
2160
2365
  if (dryRun) {
@@ -2171,16 +2376,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2171
2376
  dryRun
2172
2377
  );
2173
2378
  const legacyReviewPath = claudePath(root, "commands", "haus-review.md");
2174
- if (await fs12.pathExists(legacyReviewPath)) {
2175
- const content2 = await fs12.readFile(legacyReviewPath, "utf8");
2379
+ if (await fs14.pathExists(legacyReviewPath)) {
2380
+ const content2 = await fs14.readFile(legacyReviewPath, "utf8");
2176
2381
  const stub = 'Run `haus context --task "code review"` then review diff.';
2177
2382
  if (content2 === stub || content2 === `${stub}
2178
2383
  ` || content2 === `${stub}\r
2179
2384
  `) {
2180
2385
  if (dryRun) {
2181
- log(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
2386
+ say(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
2182
2387
  } else {
2183
- await fs12.remove(legacyReviewPath);
2388
+ await fs14.remove(legacyReviewPath);
2184
2389
  }
2185
2390
  }
2186
2391
  }
@@ -2208,16 +2413,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2208
2413
  dryRun
2209
2414
  );
2210
2415
  const legacySecurityPath = claudePath(root, "rules", "security.md");
2211
- if (await fs12.pathExists(legacySecurityPath)) {
2212
- const content2 = await fs12.readFile(legacySecurityPath, "utf8");
2416
+ if (await fs14.pathExists(legacySecurityPath)) {
2417
+ const content2 = await fs14.readFile(legacySecurityPath, "utf8");
2213
2418
  const stub = "- Never read secrets.\n- Block dangerous shell commands.";
2214
2419
  if (content2 === stub || content2 === `${stub}
2215
2420
  ` || content2 === `${stub}\r
2216
2421
  `) {
2217
2422
  if (dryRun) {
2218
- log(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
2423
+ say(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
2219
2424
  } else {
2220
- await fs12.remove(legacySecurityPath);
2425
+ await fs14.remove(legacySecurityPath);
2221
2426
  }
2222
2427
  }
2223
2428
  }
@@ -2232,11 +2437,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2232
2437
  ];
2233
2438
  for (const rel of LEGACY_PRUNED_ARTIFACTS) {
2234
2439
  const legacyPath = hausPath(root, rel);
2235
- if (await fs12.pathExists(legacyPath)) {
2440
+ if (await fs14.pathExists(legacyPath)) {
2236
2441
  if (dryRun) {
2237
- log(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
2442
+ say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
2238
2443
  } else {
2239
- await fs12.remove(legacyPath);
2444
+ await fs14.remove(legacyPath);
2240
2445
  }
2241
2446
  }
2242
2447
  }
@@ -2278,11 +2483,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2278
2483
  );
2279
2484
  continue;
2280
2485
  }
2281
- const destination = claudePath(root, target, path12.basename(sourcePath));
2282
- if (await fs12.pathExists(sourcePath)) {
2486
+ const destination = claudePath(root, target, path14.basename(sourcePath));
2487
+ if (await fs14.pathExists(sourcePath)) {
2283
2488
  if (dryRun) {
2284
- const exists = await fs12.pathExists(destination);
2285
- log(
2489
+ const exists = await fs14.pathExists(destination);
2490
+ say(
2286
2491
  `${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
2287
2492
  );
2288
2493
  } else if (item.type === "skill") {
@@ -2291,17 +2496,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2291
2496
  dryRun: false
2292
2497
  });
2293
2498
  } else {
2294
- await fs12.ensureDir(path12.dirname(destination));
2295
- await fs12.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2499
+ await fs14.ensureDir(path14.dirname(destination));
2500
+ await fs14.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2296
2501
  }
2297
2502
  files.push(destination);
2298
- const relPaths = [path12.relative(root, destination)];
2503
+ const relPaths = [path14.relative(root, destination)];
2299
2504
  if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
2300
2505
  const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
2301
2506
  if (sharedRel) {
2302
2507
  superpowersSharedInstalled = true;
2303
2508
  relPaths.push(sharedRel);
2304
- files.push(path12.join(root, sharedRel));
2509
+ files.push(path14.join(root, sharedRel));
2305
2510
  }
2306
2511
  }
2307
2512
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -2318,7 +2523,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2318
2523
  `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
2319
2524
  );
2320
2525
  }
2321
- await cleanupStaleCatalogItems(root, manifestById, dryRun);
2526
+ await cleanupStaleCatalogItems(root, manifestById, dryRun, opts.quiet);
2322
2527
  if (dryRun) return [...new Set(files)];
2323
2528
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
2324
2529
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
@@ -2355,7 +2560,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2355
2560
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
2356
2561
  return [...new Set(files)];
2357
2562
  }
2358
- async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2563
+ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2564
+ const say = quiet ? () => {
2565
+ } : log;
2359
2566
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
2360
2567
  if (!prevLock?.length) return;
2361
2568
  for (const entry of prevLock) {
@@ -2368,7 +2575,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2368
2575
  if (relPaths.length === 0) continue;
2369
2576
  const existing = [];
2370
2577
  for (const rel of relPaths) {
2371
- if (await fs12.pathExists(path12.join(root, rel))) existing.push(rel);
2578
+ if (await fs14.pathExists(path14.join(root, rel))) existing.push(rel);
2372
2579
  }
2373
2580
  if (existing.length === 0) continue;
2374
2581
  const pruneReason = deprecated ? "deprecated" : "stale";
@@ -2386,18 +2593,115 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2386
2593
  continue;
2387
2594
  }
2388
2595
  for (const rel of existing) {
2389
- const abs = path12.join(root, rel);
2596
+ const abs = path14.join(root, rel);
2390
2597
  if (dryRun) {
2391
- log(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2598
+ say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2392
2599
  continue;
2393
2600
  }
2394
- await fs12.remove(abs);
2395
- await pruneEmptyDir(path12.dirname(abs));
2396
- log(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2601
+ await fs14.remove(abs);
2602
+ await pruneEmptyDir(path14.dirname(abs));
2603
+ say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2397
2604
  }
2398
2605
  }
2399
2606
  }
2400
2607
 
2608
+ // src/refs/fetch-refs.ts
2609
+ import path16 from "node:path";
2610
+ import fs16 from "fs-extra";
2611
+
2612
+ // src/refs/cache-meta.ts
2613
+ import path15 from "node:path";
2614
+ import fs15 from "fs-extra";
2615
+ var META_FILENAME = "cache-meta.json";
2616
+ async function readCacheMeta(cacheDir) {
2617
+ const metaPath = path15.join(cacheDir, META_FILENAME);
2618
+ try {
2619
+ const raw = await fs15.readFile(metaPath, "utf8");
2620
+ return JSON.parse(raw);
2621
+ } catch {
2622
+ return {};
2623
+ }
2624
+ }
2625
+ async function writeCacheMeta(cacheDir, meta) {
2626
+ await fs15.ensureDir(cacheDir);
2627
+ const metaPath = path15.join(cacheDir, META_FILENAME);
2628
+ await fs15.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
2629
+ }
2630
+
2631
+ // src/refs/fetch-refs.ts
2632
+ function urlToSlug(url) {
2633
+ const u = new URL(url);
2634
+ return (u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
2635
+ }
2636
+ function isLlmsTxtUrl(ref) {
2637
+ try {
2638
+ const u = new URL(ref);
2639
+ return (u.protocol === "https:" || u.protocol === "http:") && u.pathname.endsWith("/llms.txt");
2640
+ } catch {
2641
+ return false;
2642
+ }
2643
+ }
2644
+ async function fetchSingleRef(url, cacheDir, meta) {
2645
+ const existing = meta[url];
2646
+ const headers = {};
2647
+ const safeFile = existing?.file && existing.file === path16.basename(existing.file);
2648
+ const cachedFileExists = safeFile && await fs16.pathExists(path16.join(cacheDir, existing.file));
2649
+ if (cachedFileExists) {
2650
+ if (existing?.etag) headers["If-None-Match"] = existing.etag;
2651
+ if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
2652
+ }
2653
+ try {
2654
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
2655
+ if (res.status === 304) return { result: "unchanged" };
2656
+ if (!res.ok) return { result: "failed" };
2657
+ const text = await res.text();
2658
+ const file = `${urlToSlug(url)}.md`;
2659
+ await fs16.ensureDir(cacheDir);
2660
+ await fs16.writeFile(path16.join(cacheDir, file), text, "utf8");
2661
+ meta[url] = {
2662
+ url,
2663
+ etag: res.headers.get("etag") ?? void 0,
2664
+ lastModified: res.headers.get("last-modified") ?? void 0,
2665
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
2666
+ file
2667
+ };
2668
+ return { result: "fetched", file };
2669
+ } catch {
2670
+ return { result: "failed" };
2671
+ }
2672
+ }
2673
+ async function fetchRefsForItems(items, cacheDir) {
2674
+ const urls = [...new Set(items.flatMap((item) => (item.references ?? []).filter(isLlmsTxtUrl)))];
2675
+ const summary = {
2676
+ fetched: 0,
2677
+ unchanged: 0,
2678
+ failed: 0,
2679
+ failedUrls: [],
2680
+ cachedFiles: {}
2681
+ };
2682
+ if (urls.length === 0) return summary;
2683
+ const meta = await readCacheMeta(cacheDir);
2684
+ await Promise.all(
2685
+ urls.map(async (url) => {
2686
+ const outcome = await fetchSingleRef(url, cacheDir, meta);
2687
+ if (outcome.result === "fetched") {
2688
+ summary.fetched++;
2689
+ summary.cachedFiles[url] = path16.join(cacheDir, outcome.file);
2690
+ } else if (outcome.result === "unchanged") {
2691
+ summary.unchanged++;
2692
+ if (meta[url]?.file) summary.cachedFiles[url] = path16.join(cacheDir, meta[url].file);
2693
+ } else {
2694
+ summary.failed++;
2695
+ summary.failedUrls.push(url);
2696
+ }
2697
+ })
2698
+ );
2699
+ if (summary.fetched > 0) {
2700
+ await writeCacheMeta(cacheDir, meta);
2701
+ }
2702
+ return summary;
2703
+ }
2704
+
2401
2705
  // src/utils/args.ts
2402
2706
  function parseIdList(value) {
2403
2707
  if (!value) return [];
@@ -2407,7 +2711,7 @@ function parseIdList(value) {
2407
2711
 
2408
2712
  // src/commands/apply.ts
2409
2713
  async function cacheHasItems() {
2410
- const data = await readJson(path13.join(getCacheDir(), "manifest.json"));
2714
+ const data = await readJson(path17.join(getCacheDir(), "manifest.json"));
2411
2715
  return Array.isArray(data?.items) && data.items.length > 0;
2412
2716
  }
2413
2717
  function installableRecommendedItems(recommended) {
@@ -2499,6 +2803,23 @@ async function runApply(options) {
2499
2803
  refillConfig: options.refillConfig,
2500
2804
  force: options.force
2501
2805
  });
2806
+ if (!isDryRun) {
2807
+ try {
2808
+ const cacheDir = hausPath(root, "llms-cache");
2809
+ const allItems = await loadCatalog(root);
2810
+ const summary = await fetchRefsForItems(allItems, cacheDir);
2811
+ if (summary.fetched > 0)
2812
+ log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
2813
+ if (summary.failed > 0)
2814
+ warn(
2815
+ `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
2816
+ );
2817
+ } catch (err) {
2818
+ warn(
2819
+ `Could not fetch llms.txt references: ${err instanceof Error ? err.message : String(err)}`
2820
+ );
2821
+ }
2822
+ }
2502
2823
  if (isDryRun) {
2503
2824
  log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
2504
2825
  } else {
@@ -2512,7 +2833,7 @@ function auditForbiddenTags(items) {
2512
2833
  const failures = [];
2513
2834
  const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
2514
2835
  for (const item of items) {
2515
- for (const tag of item.tags) {
2836
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
2516
2837
  if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
2517
2838
  }
2518
2839
  for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
@@ -2533,84 +2854,509 @@ async function runCatalogAudit() {
2533
2854
  }
2534
2855
 
2535
2856
  // src/commands/clone.ts
2857
+ init_exec();
2536
2858
  import { existsSync as existsSync2 } from "node:fs";
2537
- import path14 from "node:path";
2859
+ import path18 from "node:path";
2860
+ var GIT_LOCATION_VARS = [
2861
+ "GIT_DIR",
2862
+ "GIT_WORK_TREE",
2863
+ "GIT_INDEX_FILE",
2864
+ "GIT_COMMON_DIR",
2865
+ "GIT_OBJECT_DIRECTORY",
2866
+ "GIT_NAMESPACE",
2867
+ "GIT_PREFIX"
2868
+ ];
2869
+ function cloneEnv() {
2870
+ const env = { ...process.env };
2871
+ for (const key of GIT_LOCATION_VARS) delete env[key];
2872
+ return env;
2873
+ }
2874
+ function repoNameFromUrl(url) {
2875
+ const trimmed = url.trim().replace(/\.git$/, "").replace(/\/+$/, "");
2876
+ const tail = trimmed.split(/[/:]/).pop() ?? "";
2877
+ return tail || "repo";
2878
+ }
2879
+ async function runClone(url, opts = {}) {
2880
+ if (!url || !url.trim()) {
2881
+ error("A git URL is required: `haus clone <url> [dir]`.");
2882
+ process.exitCode = 1;
2883
+ return;
2884
+ }
2885
+ const target = path18.resolve(opts.dir?.trim() || repoNameFromUrl(url));
2886
+ if (existsSync2(target)) {
2887
+ log(`\u2022 ${path18.basename(target)} already present at ${target} \u2014 skipped`);
2888
+ return;
2889
+ }
2890
+ if (opts.dryRun) {
2891
+ log(`would clone ${url} \u2192 ${target}`);
2892
+ return;
2893
+ }
2894
+ const res = await runGit(["clone", "--", url, target], { env: cloneEnv(), extendEnv: false });
2895
+ if (res.exitCode !== 0) {
2896
+ error(`clone failed for ${url}: ${(res.stderr || res.stdout).trim()}`);
2897
+ process.exitCode = 1;
2898
+ return;
2899
+ }
2900
+ log(`\u2713 cloned ${url} \u2192 ${target}`);
2901
+ }
2538
2902
 
2539
- // src/utils/exec.ts
2540
- import { execa } from "execa";
2541
- async function runCommand(command, args = [], options = {}) {
2542
- try {
2543
- const result = await execa(command, args, {
2544
- reject: false,
2545
- // non-zero exits are returned, not thrown
2546
- ...options
2547
- });
2903
+ // src/commands/decisions.ts
2904
+ import path22 from "node:path";
2905
+
2906
+ // src/decisions/check.ts
2907
+ import path21 from "node:path";
2908
+ import fs19 from "fs-extra";
2909
+
2910
+ // src/decisions/diff.ts
2911
+ init_exec();
2912
+ async function collectDiffStats(root, opts) {
2913
+ const gitArgs = opts.staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only", opts.range ?? "HEAD"];
2914
+ const names = await runGit(gitArgs, { cwd: root });
2915
+ const files = names.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
2916
+ const numstatArgs = opts.staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat", opts.range ?? "HEAD"];
2917
+ const numstat = await runGit(numstatArgs, { cwd: root });
2918
+ let linesAdded = 0;
2919
+ let linesRemoved = 0;
2920
+ for (const line2 of numstat.stdout.split("\n")) {
2921
+ const parts = line2.trim().split(/\s+/);
2922
+ if (parts.length < 3) continue;
2923
+ const added = parts[0] === "-" ? 0 : Number(parts[0]);
2924
+ const removed = parts[1] === "-" ? 0 : Number(parts[1]);
2925
+ if (!Number.isNaN(added)) linesAdded += added;
2926
+ if (!Number.isNaN(removed)) linesRemoved += removed;
2927
+ }
2928
+ return { files, linesAdded, linesRemoved };
2929
+ }
2930
+
2931
+ // src/decisions/match.ts
2932
+ function segmentToRegex(segment) {
2933
+ if (segment === "**") return ".*";
2934
+ return segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
2935
+ }
2936
+ function globToRegExp(pattern) {
2937
+ const pat = pattern.replace(/\\/g, "/");
2938
+ if (pat.startsWith("**/") && pat.endsWith("/**")) {
2939
+ const middle = pat.slice(3, -3);
2940
+ const midRe = middle.split("/").map(segmentToRegex).join("/");
2941
+ return new RegExp(`(^|/)${midRe}(/|$)`);
2942
+ }
2943
+ if (pat.startsWith("**/")) {
2944
+ const rest = pat.slice(3).split("/").map(segmentToRegex).join("/");
2945
+ return new RegExp(`^(?:.*/)?${rest}$`);
2946
+ }
2947
+ const reSource = pat.split("/").map(segmentToRegex).join("/");
2948
+ return new RegExp(`^${reSource}$`);
2949
+ }
2950
+ function matchesPathGlob(filePath, pattern) {
2951
+ const norm = filePath.replace(/\\/g, "/");
2952
+ return globToRegExp(pattern).test(norm);
2953
+ }
2954
+ function matchesAnyGlob(filePath, patterns) {
2955
+ return patterns.some((p) => matchesPathGlob(filePath, p));
2956
+ }
2957
+ function matchesAnyRegex(filePath, patterns) {
2958
+ const norm = filePath.replace(/\\/g, "/");
2959
+ return patterns.some((p) => new RegExp(p).test(norm));
2960
+ }
2961
+
2962
+ // src/decisions/paths.ts
2963
+ import path19 from "node:path";
2964
+ import fs17 from "fs-extra";
2965
+ async function resolveDecisionsDir(root) {
2966
+ const primary = path19.join(root, DECISIONS_TRIGGERS.decisionsDir);
2967
+ if (await fs17.pathExists(primary)) return primary;
2968
+ const legacy = path19.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
2969
+ if (await fs17.pathExists(legacy)) return legacy;
2970
+ return primary;
2971
+ }
2972
+ function relativeDecisionPath(decisionsDir, root) {
2973
+ return path19.relative(root, decisionsDir).replace(/\\/g, "/");
2974
+ }
2975
+
2976
+ // src/decisions/validate.ts
2977
+ import path20 from "node:path";
2978
+ import fs18 from "fs-extra";
2979
+ function validateDecisionContent(content2) {
2980
+ const errors = [];
2981
+ for (const section of DECISIONS_TRIGGERS.requiredSections) {
2982
+ if (!content2.includes(section)) {
2983
+ errors.push(`missing required section: ${section}`);
2984
+ }
2985
+ }
2986
+ if (!/^#\s+ADR-\d{4}:/m.test(content2)) {
2987
+ errors.push('heading must match "# ADR-NNNN: Title"');
2988
+ }
2989
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
2990
+ }
2991
+ function indexReferencesNumber(readme, number) {
2992
+ const padded = number.padStart(4, "0");
2993
+ return readme.includes(`[${padded}]`) || readme.includes(`(${padded}-`) || readme.includes(`ADR-${padded}`);
2994
+ }
2995
+ async function validateDecisionFile(filePath) {
2996
+ const content2 = await readText(filePath) ?? "";
2997
+ return validateDecisionContent(content2);
2998
+ }
2999
+ function isDecisionFileName(name) {
3000
+ return DECISION_FILE_RE.test(name);
3001
+ }
3002
+ function decisionNumberFromFile(name) {
3003
+ const match = /^(\d{4})-/.exec(name);
3004
+ return match?.[1] ?? null;
3005
+ }
3006
+ async function validateIndexSync(decisionsDir, decisionFiles) {
3007
+ const readmePath = path20.join(decisionsDir, "README.md");
3008
+ if (!await fs18.pathExists(readmePath)) {
3009
+ return ["docs/decisions/README.md is missing"];
3010
+ }
3011
+ const readme = await readText(readmePath) ?? "";
3012
+ const errors = [];
3013
+ for (const file of decisionFiles) {
3014
+ const num = decisionNumberFromFile(path20.basename(file));
3015
+ if (num && !indexReferencesNumber(readme, num)) {
3016
+ errors.push(`README.md missing index row for ADR-${num}`);
3017
+ }
3018
+ }
3019
+ return errors;
3020
+ }
3021
+
3022
+ // src/decisions/check.ts
3023
+ function isExempt(file) {
3024
+ return matchesAnyGlob(file, DECISIONS_TRIGGERS.exemptGlobs);
3025
+ }
3026
+ function countTopLevelDirs(files) {
3027
+ const dirs = /* @__PURE__ */ new Set();
3028
+ for (const f of files) {
3029
+ const parts = f.split("/");
3030
+ if (parts.length > 1) dirs.add(parts[0]);
3031
+ }
3032
+ return dirs.size;
3033
+ }
3034
+ function isDecisionTriggered(stats) {
3035
+ const relevant = stats.files.filter((f) => !isExempt(f));
3036
+ if (relevant.length === 0) return false;
3037
+ for (const file of relevant) {
3038
+ if (matchesAnyGlob(file, DECISIONS_TRIGGERS.pathGlobs)) return true;
3039
+ if (matchesAnyRegex(file, DECISIONS_TRIGGERS.pathRegex)) return true;
3040
+ }
3041
+ const totalLines = stats.linesAdded + stats.linesRemoved;
3042
+ if (relevant.length >= DECISIONS_TRIGGERS.minFilesChanged && countTopLevelDirs(relevant) >= 2) {
3043
+ return true;
3044
+ }
3045
+ if (totalLines >= DECISIONS_TRIGGERS.minLinesChanged && relevant.some((f) => !f.startsWith("docs/"))) {
3046
+ return true;
3047
+ }
3048
+ return false;
3049
+ }
3050
+ function isSecurityTriggered(stats) {
3051
+ const relevant = stats.files.filter((f) => !isExempt(f));
3052
+ const security = DECISIONS_TRIGGERS.securityPathGlobs ?? [];
3053
+ return relevant.some(
3054
+ (f) => matchesAnyGlob(f, security) || matchesAnyRegex(f, DECISIONS_TRIGGERS.pathRegex)
3055
+ );
3056
+ }
3057
+ async function readGateMode(root) {
3058
+ const cfg = await readJson(hausPath(root, "adr-gate.json"));
3059
+ return cfg?.mode === "off" ? "off" : "on";
3060
+ }
3061
+ function hasSkipToken(text) {
3062
+ return text.includes("[adr-skip]");
3063
+ }
3064
+ async function checkSatisfaction(input2) {
3065
+ const errors = [];
3066
+ const decisionsPrefix = `${input2.decisionsRel}/`;
3067
+ const newDecisionFiles = input2.changedFiles.filter(
3068
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path21.basename(f))
3069
+ );
3070
+ if (newDecisionFiles.length === 0) {
3071
+ errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
3072
+ return errors;
3073
+ }
3074
+ const decisionsDir = path21.join(input2.root, input2.decisionsRel);
3075
+ for (const rel of newDecisionFiles) {
3076
+ const abs = path21.join(input2.root, rel);
3077
+ const content2 = await readText(abs) ?? "";
3078
+ const valid = validateDecisionContent(content2);
3079
+ if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
3080
+ }
3081
+ const indexErrors = await validateIndexSync(
3082
+ decisionsDir,
3083
+ newDecisionFiles.map((f) => path21.basename(f))
3084
+ );
3085
+ errors.push(...indexErrors);
3086
+ return errors;
3087
+ }
3088
+ async function runDecisionsCheck(root, opts = {}) {
3089
+ const reasons = [];
3090
+ if (await readGateMode(root) === "off") {
3091
+ return {
3092
+ triggered: false,
3093
+ satisfied: true,
3094
+ reasons: ["gate disabled via .haus-workflow/adr-gate.json"],
3095
+ securityTriggered: false
3096
+ };
3097
+ }
3098
+ const stats = await collectDiffStats(root, opts);
3099
+ const triggered = isDecisionTriggered(stats);
3100
+ const securityTriggered = isSecurityTriggered(stats);
3101
+ if (!triggered) {
3102
+ return {
3103
+ triggered: false,
3104
+ satisfied: true,
3105
+ reasons: ["no decision-worthy changes"],
3106
+ securityTriggered
3107
+ };
3108
+ }
3109
+ const skipText = `${opts.prBody ?? ""}
3110
+ ${opts.commitMessages ?? ""}`;
3111
+ if (hasSkipToken(skipText) && !securityTriggered) {
3112
+ reasons.push("[adr-skip] present \u2014 gate waived (non-security change)");
3113
+ return { triggered: true, satisfied: true, reasons, securityTriggered };
3114
+ }
3115
+ if (hasSkipToken(skipText) && securityTriggered) {
3116
+ reasons.push("[adr-skip] ignored for security/auth path changes");
3117
+ }
3118
+ const decisionsDir = await resolveDecisionsDir(root);
3119
+ const decisionsRel = relativeDecisionPath(decisionsDir, root);
3120
+ const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
3121
+ let satisfactionErrors = await checkSatisfaction({
3122
+ root,
3123
+ decisionsRel,
3124
+ changedFiles: stats.files
3125
+ });
3126
+ if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
3127
+ const legacyExists = await fs19.pathExists(path21.join(root, legacyRel));
3128
+ if (legacyExists && !await fs19.pathExists(decisionsDir)) {
3129
+ satisfactionErrors = await checkSatisfaction({
3130
+ root,
3131
+ decisionsRel: legacyRel,
3132
+ changedFiles: stats.files
3133
+ });
3134
+ }
3135
+ }
3136
+ if (satisfactionErrors.length === 0) {
2548
3137
  return {
2549
- command,
2550
- args,
2551
- stdout: String(result.stdout ?? ""),
2552
- stderr: String(result.stderr ?? ""),
2553
- exitCode: result.exitCode ?? 0
3138
+ triggered: true,
3139
+ satisfied: true,
3140
+ reasons: ["decision record present"],
3141
+ securityTriggered
2554
3142
  };
2555
- } catch (error2) {
2556
- const message = error2 instanceof Error ? error2.message : String(error2);
2557
- throw new Error(`Failed to run command: ${command} ${args.join(" ")} (${message})`);
2558
3143
  }
3144
+ reasons.push(...satisfactionErrors);
3145
+ return { triggered: true, satisfied: false, reasons, securityTriggered };
2559
3146
  }
2560
- async function runGit(args, options = {}) {
2561
- return runCommand("git", args, options);
3147
+ async function listDecisionNumbers(decisionsDir) {
3148
+ if (!await fs19.pathExists(decisionsDir)) return [];
3149
+ const entries = await fs19.readdir(decisionsDir);
3150
+ return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
2562
3151
  }
2563
3152
 
2564
- // src/commands/clone.ts
2565
- var GIT_LOCATION_VARS = [
2566
- "GIT_DIR",
2567
- "GIT_WORK_TREE",
2568
- "GIT_INDEX_FILE",
2569
- "GIT_COMMON_DIR",
2570
- "GIT_OBJECT_DIRECTORY",
2571
- "GIT_NAMESPACE",
2572
- "GIT_PREFIX"
2573
- ];
2574
- function cloneEnv() {
2575
- const env = { ...process.env };
2576
- for (const key of GIT_LOCATION_VARS) delete env[key];
2577
- return env;
3153
+ // src/decisions/next-number.ts
3154
+ async function nextDecisionNumber(root) {
3155
+ const decisionsDir = await resolveDecisionsDir(root);
3156
+ const numbers = await listDecisionNumbers(decisionsDir);
3157
+ const max = numbers.length === 0 ? 0 : Math.max(...numbers);
3158
+ return String(max + 1).padStart(4, "0");
2578
3159
  }
2579
- function repoNameFromUrl(url) {
2580
- const trimmed = url.trim().replace(/\.git$/, "").replace(/\/+$/, "");
2581
- const tail = trimmed.split(/[/:]/).pop() ?? "";
2582
- return tail || "repo";
3160
+
3161
+ // src/decisions/suggest.ts
3162
+ function draftTemplate(number, title) {
3163
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3164
+ return `# ADR-${number}: ${title}
3165
+
3166
+ - **Status:** Proposed
3167
+ - **Date:** ${today}
3168
+ - **Decided by:** _(pending approval)_ (draft by haus decisions suggest)
3169
+ - **Affects:** _(paths/components)_
3170
+ - **Related:** _(PR/issue link)_
3171
+
3172
+ ## Context
3173
+
3174
+ _(What problem or situation triggered this decision?)_
3175
+
3176
+ ## Decision
3177
+
3178
+ _(One clear sentence: what we chose.)_
3179
+
3180
+ ## Motivation (why)
3181
+
3182
+ _(Why this over alternatives \u2014 the line agents read for "why".)_
3183
+
3184
+ ## Alternatives considered
3185
+
3186
+ - _(Alternative)_ \u2014 rejected because _(reason)_.
3187
+
3188
+ ## Consequences
3189
+
3190
+ - _(Positive and negative follow-ons.)_
3191
+ `;
2583
3192
  }
2584
- async function runClone(url, opts = {}) {
2585
- if (!url || !url.trim()) {
2586
- error("A git URL is required: `haus clone <url> [dir]`.");
2587
- process.exitCode = 1;
3193
+ async function runDecisionsSuggest(root, opts = {}) {
3194
+ const stats = await collectDiffStats(root, { staged: true });
3195
+ const stagedCheck = await runDecisionsCheck(root, { staged: true });
3196
+ const unstagedStats = await collectDiffStats(root, {});
3197
+ const triggered = isDecisionTriggered(stats) || isDecisionTriggered(unstagedStats);
3198
+ if (!triggered) return;
3199
+ if (stagedCheck.triggered && stagedCheck.satisfied) return;
3200
+ const number = await nextDecisionNumber(root);
3201
+ const title = opts.title ?? "Decision from current changes";
3202
+ const draft = draftTemplate(number, title);
3203
+ const decisionsDir = await resolveDecisionsDir(root);
3204
+ const rel = relativeDecisionPath(decisionsDir, root);
3205
+ const file = `${rel}/${number}-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "decision"}.md`;
3206
+ const payload = {
3207
+ action: "propose_decision",
3208
+ message: "Decision-worthy change detected. Draft an ADR from this template and ask the user to approve before writing.",
3209
+ file,
3210
+ draft,
3211
+ indexHint: `Add a row to ${rel}/README.md for ADR-${number}.`
3212
+ };
3213
+ if (opts.fromHook) {
3214
+ process.stdout.write(`${JSON.stringify(payload)}
3215
+ `);
2588
3216
  return;
2589
3217
  }
2590
- const target = path14.resolve(opts.dir?.trim() || repoNameFromUrl(url));
2591
- if (existsSync2(target)) {
2592
- log(`\u2022 ${path14.basename(target)} already present at ${target} \u2014 skipped`);
3218
+ process.stdout.write(`${draft}
3219
+ `);
3220
+ }
3221
+
3222
+ // src/commands/decisions.ts
3223
+ async function runDecisions(sub, arg, options = {}) {
3224
+ const root = process.cwd();
3225
+ if (sub === "check") {
3226
+ const commitMessages = options.range != null ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
3227
+ (m) => m.runGit(["log", "--format=%B", options.range], { cwd: root })
3228
+ )).stdout : "";
3229
+ const result = await runDecisionsCheck(root, {
3230
+ staged: options.staged,
3231
+ range: options.range,
3232
+ prBody: options.prBody ?? process.env.PR_BODY,
3233
+ commitMessages
3234
+ });
3235
+ if (!result.triggered) {
3236
+ log(result.reasons[0] ?? "no decision-worthy changes");
3237
+ return;
3238
+ }
3239
+ if (result.satisfied) {
3240
+ if (result.reasons.some((r) => r.includes("[adr-skip]"))) {
3241
+ warn(`::warning::${result.reasons.join("; ")}`);
3242
+ }
3243
+ log(`decisions gate: satisfied (${result.reasons.join("; ")})`);
3244
+ return;
3245
+ }
3246
+ for (const reason of result.reasons) warn(reason);
3247
+ error(
3248
+ "decisions gate: decision-worthy change without a valid ADR under docs/decisions/. Run `haus decisions suggest`, add docs/decisions/NNNN-*.md + README row, or use [adr-skip] with justification."
3249
+ );
3250
+ process.exitCode = 1;
2593
3251
  return;
2594
3252
  }
2595
- if (opts.dryRun) {
2596
- log(`would clone ${url} \u2192 ${target}`);
3253
+ if (sub === "suggest") {
3254
+ await runDecisionsSuggest(root, { fromHook: options.fromHook, title: options.title });
2597
3255
  return;
2598
3256
  }
2599
- const res = await runGit(["clone", url, target], { env: cloneEnv(), extendEnv: false });
2600
- if (res.exitCode !== 0) {
2601
- error(`clone failed for ${url}: ${(res.stderr || res.stdout).trim()}`);
2602
- process.exitCode = 1;
3257
+ if (sub === "next-number") {
3258
+ log(await nextDecisionNumber(root));
2603
3259
  return;
2604
3260
  }
2605
- log(`\u2713 cloned ${url} \u2192 ${target}`);
3261
+ if (sub === "validate") {
3262
+ const target = arg ? path22.resolve(root, arg) : void 0;
3263
+ if (!target) {
3264
+ error("usage: haus decisions validate <path-to-decision.md>");
3265
+ process.exitCode = 1;
3266
+ return;
3267
+ }
3268
+ const result = await validateDecisionFile(target);
3269
+ if (!result.ok) {
3270
+ for (const e of result.errors) error(e);
3271
+ process.exitCode = 1;
3272
+ return;
3273
+ }
3274
+ log("valid");
3275
+ }
2606
3276
  }
2607
3277
 
2608
3278
  // src/commands/doctor.ts
2609
- import path19 from "node:path";
2610
- import fs14 from "fs-extra";
3279
+ import path28 from "node:path";
3280
+ import fs22 from "fs-extra";
3281
+
3282
+ // src/decisions/doctor.ts
3283
+ import path23 from "node:path";
3284
+ import fs20 from "fs-extra";
3285
+ async function auditDecisionsLayout(root) {
3286
+ const findings = [];
3287
+ const primary = path23.join(root, DECISIONS_TRIGGERS.decisionsDir);
3288
+ const legacy = path23.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3289
+ const hasPrimary = await fs20.pathExists(primary);
3290
+ const hasLegacy = await fs20.pathExists(legacy);
3291
+ if (!hasPrimary && hasLegacy) {
3292
+ findings.push({
3293
+ level: "advisory",
3294
+ line: `- DECISIONS: legacy ${DECISIONS_TRIGGERS.legacyDecisionsDir}/ present \u2014 migrate to docs/decisions/`,
3295
+ sentence: "Decision records still live under the legacy docs/adr/ path",
3296
+ fix: `git mv ${DECISIONS_TRIGGERS.legacyDecisionsDir} ${DECISIONS_TRIGGERS.decisionsDir}`
3297
+ });
3298
+ return findings;
3299
+ }
3300
+ if (!hasPrimary) return findings;
3301
+ const decisionsDir = await resolveDecisionsDir(root);
3302
+ const rel = relativeDecisionPath(decisionsDir, root);
3303
+ const readmePath = path23.join(decisionsDir, "README.md");
3304
+ if (!await fs20.pathExists(readmePath)) {
3305
+ findings.push({
3306
+ level: "advisory",
3307
+ line: `- DECISIONS: missing ${rel}/README.md index`,
3308
+ sentence: "No decision index for agents to load at session start",
3309
+ fix: `create ${rel}/README.md and @import it from CLAUDE.md`
3310
+ });
3311
+ return findings;
3312
+ }
3313
+ const readme = await readText(readmePath);
3314
+ const entries = (await fs20.readdir(decisionsDir)).filter(
3315
+ (f) => f.endsWith(".md") && f !== "README.md"
3316
+ );
3317
+ for (const file of entries) {
3318
+ const num = decisionNumberFromFile(file);
3319
+ if (num && readme && !readme.includes(`[${num}]`) && !readme.includes(`(${num}-`)) {
3320
+ findings.push({
3321
+ level: "advisory",
3322
+ line: `- DECISIONS: ${file} missing from ${rel}/README.md`,
3323
+ sentence: `ADR-${num} is not listed in the decision index`,
3324
+ fix: `add a row for ADR-${num} to ${rel}/README.md`
3325
+ });
3326
+ }
3327
+ }
3328
+ const claude = await readText(path23.join(root, "CLAUDE.md"));
3329
+ if (claude && !claude.includes(`@${rel}/README.md`)) {
3330
+ findings.push({
3331
+ level: "advisory",
3332
+ line: `- DECISIONS: CLAUDE.md does not @import @${rel}/README.md`,
3333
+ sentence: "Agents may not load the decision index at session start",
3334
+ fix: `add @${rel}/README.md to CLAUDE.md`
3335
+ });
3336
+ }
3337
+ const numbers = await listDecisionNumbers(decisionsDir);
3338
+ const dupes = numbers.filter((n, i) => numbers.indexOf(n) !== i);
3339
+ if (dupes.length > 0) {
3340
+ findings.push({
3341
+ level: "advisory",
3342
+ line: `- DECISIONS: duplicate numbers: ${[...new Set(dupes)].join(", ")}`,
3343
+ sentence: "Multiple decision files share the same number",
3344
+ fix: "renumber one file and update README.md"
3345
+ });
3346
+ }
3347
+ if (findings.length === 0) {
3348
+ findings.push({
3349
+ level: "ok",
3350
+ line: `- DECISIONS OK: ${rel}/ (${entries.length} record(s))`,
3351
+ sentence: "",
3352
+ fix: ""
3353
+ });
3354
+ }
3355
+ return findings;
3356
+ }
2611
3357
 
2612
3358
  // src/scanner/scan-project.ts
2613
- import path18 from "node:path";
3359
+ import path27 from "node:path";
2614
3360
 
2615
3361
  // src/utils/audit-checks.ts
2616
3362
  function isRecord(v) {
@@ -2637,8 +3383,8 @@ function compareVersions(a, b) {
2637
3383
  }
2638
3384
 
2639
3385
  // src/scanner/detect-package-manager.ts
2640
- import path15 from "node:path";
2641
- import fs13 from "fs-extra";
3386
+ import path24 from "node:path";
3387
+ import fs21 from "fs-extra";
2642
3388
  function detectPackageManager(root, packageManagerField) {
2643
3389
  const field = String(packageManagerField ?? "").trim();
2644
3390
  if (field.startsWith("yarn@")) {
@@ -2656,9 +3402,9 @@ function detectPackageManager(root, packageManagerField) {
2656
3402
  if (satisfiesVersion(version, ">=9")) return "npm";
2657
3403
  return "unknown";
2658
3404
  }
2659
- if (fs13.existsSync(path15.join(root, "yarn.lock"))) return "yarn";
2660
- if (fs13.existsSync(path15.join(root, "pnpm-lock.yaml"))) return "pnpm";
2661
- if (fs13.existsSync(path15.join(root, "package-lock.json"))) return "npm";
3405
+ if (fs21.existsSync(path24.join(root, "yarn.lock"))) return "yarn";
3406
+ if (fs21.existsSync(path24.join(root, "pnpm-lock.yaml"))) return "pnpm";
3407
+ if (fs21.existsSync(path24.join(root, "package-lock.json"))) return "npm";
2662
3408
  return "unknown";
2663
3409
  }
2664
3410
 
@@ -2860,7 +3606,7 @@ function runDetection(ctx, rules = STACK_RULES) {
2860
3606
  }
2861
3607
 
2862
3608
  // src/scanner/detection.ts
2863
- import path16 from "node:path";
3609
+ import path25 from "node:path";
2864
3610
  var UNSUPPORTED_MARKERS = {
2865
3611
  "requirements.txt": "python",
2866
3612
  "pyproject.toml": "python",
@@ -2914,14 +3660,14 @@ function finalizeRoles(registryRoles, deps, files) {
2914
3660
  function collectUnsupportedSignals(files) {
2915
3661
  return [
2916
3662
  ...new Set(
2917
- files.map((f) => UNSUPPORTED_MARKERS[path16.basename(f)]).filter((s) => Boolean(s))
3663
+ files.map((f) => UNSUPPORTED_MARKERS[path25.basename(f)]).filter((s) => Boolean(s))
2918
3664
  )
2919
3665
  ].sort();
2920
3666
  }
2921
3667
 
2922
3668
  // src/scanner/render.ts
2923
3669
  import { readFile } from "node:fs/promises";
2924
- import path17 from "node:path";
3670
+ import path26 from "node:path";
2925
3671
  async function buildContentBlob(root, files) {
2926
3672
  const candidates = files.filter(
2927
3673
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -2929,7 +3675,7 @@ async function buildContentBlob(root, files) {
2929
3675
  const slice = candidates.slice(0, 300);
2930
3676
  const parts = await mapWithConcurrency(slice, async (rel) => {
2931
3677
  try {
2932
- return await readFile(path17.join(root, rel), "utf8");
3678
+ return await readFile(path26.join(root, rel), "utf8");
2933
3679
  } catch {
2934
3680
  return "";
2935
3681
  }
@@ -3016,8 +3762,8 @@ var SAFE_FILES = [
3016
3762
  "Gemfile"
3017
3763
  ];
3018
3764
  async function scanProject(root) {
3019
- const pkg = await readJson(path18.join(root, "package.json"));
3020
- const composer = await readJson(path18.join(root, "composer.json"));
3765
+ const pkg = await readJson(path27.join(root, "package.json"));
3766
+ const composer = await readJson(path27.join(root, "composer.json"));
3021
3767
  const files = await listFiles(root, SAFE_FILES);
3022
3768
  const safeFiles = files.filter((f) => !blocked(f));
3023
3769
  const deps = dependencySet(pkg, composer);
@@ -3039,14 +3785,12 @@ async function scanProject(root) {
3039
3785
  crossRepoHints.push("Containerized services detected");
3040
3786
  if (safeFiles.some((f) => f.includes("turbo.json") || f.includes("nx.json")))
3041
3787
  crossRepoHints.push("Monorepo orchestration detected");
3042
- if (safeFiles.some((f) => f.includes("wp-content/uploads")))
3043
- securityRisks.push("Uploads directory present");
3044
3788
  const unsupportedSignals = collectUnsupportedSignals(safeFiles);
3045
3789
  const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
3046
3790
  const context = {
3047
3791
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3048
3792
  root,
3049
- repoName: String(pkg?.name ?? path18.basename(root)),
3793
+ repoName: String(pkg?.name ?? path27.basename(root)),
3050
3794
  packageManager,
3051
3795
  repoRoles: roles,
3052
3796
  detectedStacks: stacks,
@@ -3117,11 +3861,16 @@ async function runDoctor(options) {
3117
3861
  );
3118
3862
  const detail = [];
3119
3863
  const attention = [];
3864
+ const suggestions = [];
3120
3865
  const ok = (text) => detail.push({ stream: "log", text });
3121
3866
  const flag = (text, sentence, fix) => {
3122
3867
  detail.push({ stream: "warn", text });
3123
3868
  attention.push({ sentence, fix });
3124
3869
  };
3870
+ const suggest = (text, sentence, fix) => {
3871
+ detail.push({ stream: "warn", text });
3872
+ suggestions.push({ sentence, fix });
3873
+ };
3125
3874
  ok(`Repo: ${context.repoName}`);
3126
3875
  ok(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
3127
3876
  ok(`Package manager: ${context.packageManager}`);
@@ -3132,7 +3881,11 @@ async function runDoctor(options) {
3132
3881
  }
3133
3882
  const hooks = await verifyProjectSettingsHooksContract(root);
3134
3883
  if (hooks.skipped) {
3135
- ok(`- HOOKS: (skipped) ${hooks.message}`);
3884
+ flag(
3885
+ `- HOOKS: (skipped) ${hooks.message}`,
3886
+ "No Claude Code settings.json \u2014 haus security hooks are not installed",
3887
+ "haus apply --write"
3888
+ );
3136
3889
  } else if (!hooks.ok) {
3137
3890
  flag(
3138
3891
  `- HOOKS FAIL: ${hooks.message}`,
@@ -3143,7 +3896,7 @@ async function runDoctor(options) {
3143
3896
  } else {
3144
3897
  ok(`- HOOKS OK: ${hooks.message}`);
3145
3898
  }
3146
- const rootClaudeMdPath = path19.join(root, "CLAUDE.md");
3899
+ const rootClaudeMdPath = path28.join(root, "CLAUDE.md");
3147
3900
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
3148
3901
  if (!rootClaudeMdContent) {
3149
3902
  flag(
@@ -3171,7 +3924,7 @@ async function runDoctor(options) {
3171
3924
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
3172
3925
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
3173
3926
  for (const target of importTargets) {
3174
- if (!await fs14.pathExists(hausPath(root, target))) {
3927
+ if (!await fs22.pathExists(hausPath(root, target))) {
3175
3928
  flag(
3176
3929
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
3177
3930
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -3182,7 +3935,7 @@ async function runDoctor(options) {
3182
3935
  }
3183
3936
  }
3184
3937
  const workflowPath = hausPath(root, "WORKFLOW.md");
3185
- const workflowExists = await fs14.pathExists(workflowPath);
3938
+ const workflowExists = await fs22.pathExists(workflowPath);
3186
3939
  if (!workflowExists) {
3187
3940
  flag(
3188
3941
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -3205,20 +3958,20 @@ async function runDoctor(options) {
3205
3958
  "haus apply --write --force"
3206
3959
  );
3207
3960
  } else {
3208
- const cachePath = path19.join(getCacheDir(), "templates/agentic-workflow-standard.md");
3209
- const bundledPath = path19.join(
3961
+ const cachePath = path28.join(getCacheDir(), "templates/agentic-workflow-standard.md");
3962
+ const bundledPath = path28.join(
3210
3963
  packageRoot(),
3211
3964
  "library",
3212
3965
  "global",
3213
3966
  "templates",
3214
3967
  "agentic-workflow-standard.md"
3215
3968
  );
3216
- const templatePath = await fs14.pathExists(cachePath) ? cachePath : bundledPath;
3969
+ const templatePath = await fs22.pathExists(cachePath) ? cachePath : bundledPath;
3217
3970
  const templateContent = await readText(templatePath);
3218
3971
  if (storedHashMatch && templateContent) {
3219
3972
  const currentHash = hashText(normaliseLF(templateContent));
3220
3973
  if (storedHashMatch[1] !== currentHash) {
3221
- flag(
3974
+ suggest(
3222
3975
  "- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
3223
3976
  "The workflow standard is out of date",
3224
3977
  "haus apply --write"
@@ -3233,7 +3986,7 @@ async function runDoctor(options) {
3233
3986
  }
3234
3987
  }
3235
3988
  const workflowConfigPath = hausPath(root, "workflow-config.md");
3236
- const workflowConfigExists = await fs14.pathExists(workflowConfigPath);
3989
+ const workflowConfigExists = await fs22.pathExists(workflowConfigPath);
3237
3990
  if (!workflowConfigExists) {
3238
3991
  flag(
3239
3992
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -3241,10 +3994,10 @@ async function runDoctor(options) {
3241
3994
  "haus apply --write"
3242
3995
  );
3243
3996
  } else {
3244
- const cfg = await fs14.readFile(workflowConfigPath, "utf8");
3997
+ const cfg = await fs22.readFile(workflowConfigPath, "utf8");
3245
3998
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
3246
3999
  if (unfilled > 0) {
3247
- flag(
4000
+ suggest(
3248
4001
  `- .haus-workflow/workflow-config.md: ${unfilled} field(s) still unfilled (run \`haus apply --refill-config\` to auto-fill detectable ones)`,
3249
4002
  `${unfilled} workflow-config field(s) are still blank`,
3250
4003
  "haus apply --refill-config"
@@ -3253,9 +4006,25 @@ async function runDoctor(options) {
3253
4006
  ok("- .haus-workflow/workflow-config.md: OK (project-owned)");
3254
4007
  }
3255
4008
  }
4009
+ if (workflowExists) {
4010
+ const prettierIgnore = await readText(path28.join(root, ".prettierignore")) ?? "";
4011
+ if (!prettierIgnore.split("\n").some((l) => l.trim() === ".haus-workflow/")) {
4012
+ flag(
4013
+ "- .prettierignore: not protecting .haus-workflow/ (run `haus apply --write`)",
4014
+ 'The formatter may reformat managed files and trigger false "modified locally" reports',
4015
+ "haus apply --write"
4016
+ );
4017
+ } else {
4018
+ ok("- .prettierignore: protects .haus-workflow/");
4019
+ }
4020
+ }
4021
+ for (const finding of await auditDecisionsLayout(root)) {
4022
+ if (finding.level === "ok") ok(finding.line);
4023
+ else suggest(finding.line, finding.sentence, finding.fix);
4024
+ }
3256
4025
  const cacheAgeMs = await getCacheManifestAge();
3257
4026
  if (cacheAgeMs === null) {
3258
- flag(
4027
+ suggest(
3259
4028
  "- CATALOG CACHE: absent (run `haus update` to populate)",
3260
4029
  "The catalog cache hasn't been downloaded yet",
3261
4030
  "haus update"
@@ -3263,7 +4032,7 @@ async function runDoctor(options) {
3263
4032
  } else {
3264
4033
  const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
3265
4034
  if (cacheAgeDays >= 7) {
3266
- flag(
4035
+ suggest(
3267
4036
  `- CATALOG CACHE: stale (${cacheAgeDays}d old \u2014 run \`haus update\`)`,
3268
4037
  `The catalog cache is ${cacheAgeDays} days old`,
3269
4038
  "haus update"
@@ -3272,11 +4041,11 @@ async function runDoctor(options) {
3272
4041
  ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
3273
4042
  }
3274
4043
  }
3275
- const pkgJson = await readJson(path19.join(packageRoot(), "package.json"));
4044
+ const pkgJson = await readJson(path28.join(packageRoot(), "package.json"));
3276
4045
  const currentVersion = pkgJson?.version ?? "0.0.0";
3277
4046
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
3278
4047
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
3279
- flag(
4048
+ suggest(
3280
4049
  `- CLI UPDATE: ${currentVersion} \u2192 ${npmStatus.latest} available (run: npm install -g ${NPM_PACKAGE_NAME})`,
3281
4050
  `A newer haus (${npmStatus.latest}) is available`,
3282
4051
  `npm install -g ${NPM_PACKAGE_NAME}`
@@ -3286,12 +4055,17 @@ async function runDoctor(options) {
3286
4055
  } else {
3287
4056
  ok(`- CLI: ${currentVersion} (version check unavailable)`);
3288
4057
  }
4058
+ if (attention.length > 0) process.exitCode = 1;
3289
4059
  if (attention.length === 0) {
3290
4060
  log("\u2705 Your project is set up and healthy.");
3291
4061
  } else {
3292
4062
  log(`\u26A0\uFE0F ${attention.length} thing(s) need attention:`);
3293
4063
  for (const a of attention) log(` \u2022 ${a.sentence} \u2014 fix: ${a.fix}`);
3294
4064
  }
4065
+ if (suggestions.length > 0) {
4066
+ log(`\u{1F4A1} ${suggestions.length} suggestion(s):`);
4067
+ for (const s of suggestions) log(` \u2022 ${s.sentence} \u2014 try: ${s.fix}`);
4068
+ }
3295
4069
  log("Haus Doctor");
3296
4070
  for (const line2 of detail) {
3297
4071
  if (line2.stream === "warn") warn(line2.text);
@@ -3423,6 +4197,61 @@ async function runExplainRecommendation(options) {
3423
4197
  log(formatRecommendationHuman(normalized));
3424
4198
  }
3425
4199
 
4200
+ // src/commands/fetch-refs.ts
4201
+ var REFS_CACHE_SUBDIR = "llms-cache";
4202
+ async function runFetchRefs(options) {
4203
+ const root = process.cwd();
4204
+ const cacheDir = hausPath(root, REFS_CACHE_SUBDIR);
4205
+ let allItems;
4206
+ try {
4207
+ allItems = await loadCatalog(root);
4208
+ } catch (err) {
4209
+ warn(`Could not load catalog: ${err instanceof Error ? err.message : String(err)}`);
4210
+ process.exitCode = 1;
4211
+ return;
4212
+ }
4213
+ let items = allItems;
4214
+ if (options.id) {
4215
+ items = allItems.filter((item) => item.id === options.id);
4216
+ if (items.length === 0) {
4217
+ warn(`No catalog item found with id "${options.id}".`);
4218
+ process.exitCode = 1;
4219
+ return;
4220
+ }
4221
+ }
4222
+ const withRefs = items.filter((item) => (item.references ?? []).some(isLlmsTxtUrl));
4223
+ if (withRefs.length === 0) {
4224
+ if (options.json) {
4225
+ log(
4226
+ JSON.stringify(
4227
+ { fetched: 0, unchanged: 0, failed: 0, failedUrls: [], cachedFiles: {} },
4228
+ null,
4229
+ 2
4230
+ )
4231
+ );
4232
+ } else {
4233
+ log("No catalog items with llms.txt references found.");
4234
+ }
4235
+ return;
4236
+ }
4237
+ const summary = await fetchRefsForItems(withRefs, cacheDir);
4238
+ if (options.json) {
4239
+ log(JSON.stringify(summary, null, 2));
4240
+ return;
4241
+ }
4242
+ for (const [url, filePath] of Object.entries(summary.cachedFiles)) {
4243
+ log(`Cached: ${filePath} (${url})`);
4244
+ }
4245
+ if (summary.fetched > 0) log(`Fetched: ${summary.fetched} URL(s)`);
4246
+ if (summary.unchanged > 0) log(`Unchanged (etag match): ${summary.unchanged} URL(s)`);
4247
+ if (summary.failed > 0) {
4248
+ warn(`Failed to fetch ${summary.failed} URL(s): ${summary.failedUrls.join(", ")}`);
4249
+ }
4250
+ if (summary.fetched === 0 && summary.unchanged > 0 && summary.failed === 0) {
4251
+ log("All references up to date.");
4252
+ }
4253
+ }
4254
+
3426
4255
  // src/commands/guard.ts
3427
4256
  import { readFileSync as readFileSync2 } from "node:fs";
3428
4257
 
@@ -3459,7 +4288,15 @@ function stdin() {
3459
4288
  }
3460
4289
  }
3461
4290
  function deny(reason) {
3462
- log(JSON.stringify({ permissionDecision: "deny", permissionDecisionReason: reason }));
4291
+ log(
4292
+ JSON.stringify({
4293
+ hookSpecificOutput: {
4294
+ hookEventName: "PreToolUse",
4295
+ permissionDecision: "deny",
4296
+ permissionDecisionReason: reason
4297
+ }
4298
+ })
4299
+ );
3463
4300
  }
3464
4301
  async function runGuard(kind, _options) {
3465
4302
  const raw = stdin();
@@ -3470,7 +4307,6 @@ async function runGuard(kind, _options) {
3470
4307
  if (isRecord(parsed)) payload = parsed;
3471
4308
  } catch {
3472
4309
  deny("Malformed hook payload");
3473
- process.exitCode = 1;
3474
4310
  return;
3475
4311
  }
3476
4312
  }
@@ -3480,8 +4316,6 @@ async function runGuard(kind, _options) {
3480
4316
  const reason2 = guardFileAccess(candidate);
3481
4317
  if (reason2) {
3482
4318
  deny(reason2);
3483
- process.exitCode = 1;
3484
- return;
3485
4319
  }
3486
4320
  return;
3487
4321
  }
@@ -3489,13 +4323,12 @@ async function runGuard(kind, _options) {
3489
4323
  const reason = guardBash(command);
3490
4324
  if (reason) {
3491
4325
  deny(reason);
3492
- process.exitCode = 1;
3493
4326
  }
3494
4327
  }
3495
4328
 
3496
4329
  // src/commands/init.ts
3497
- import path20 from "node:path";
3498
- import fs15 from "fs-extra";
4330
+ import path29 from "node:path";
4331
+ import fs23 from "fs-extra";
3499
4332
 
3500
4333
  // src/utils/prompts.ts
3501
4334
  import { stdin as input, stdout as output } from "node:process";
@@ -3516,6 +4349,7 @@ async function confirm(question) {
3516
4349
  }
3517
4350
 
3518
4351
  // src/recommender/git-signal.ts
4352
+ init_exec();
3519
4353
  async function readChangedFiles(root) {
3520
4354
  if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
3521
4355
  try {
@@ -3930,37 +4764,39 @@ function buildOptInEligible(items, skipped) {
3930
4764
 
3931
4765
  // src/commands/setup-core.ts
3932
4766
  async function runSetupCore(root, opts) {
3933
- const { json, apply, dryRun, confirm: confirm2 } = opts;
4767
+ const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
4768
+ const say = quiet ? () => {
4769
+ } : log;
3934
4770
  const scanResult = await scanProject(root);
3935
4771
  if (json) {
3936
- log(JSON.stringify(scanResult, null, 2));
4772
+ say(JSON.stringify(scanResult, null, 2));
3937
4773
  } else {
3938
- log("Haus scan complete");
3939
- log(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
3940
- log(`Package manager: ${scanResult.packageManager}`);
4774
+ say("Haus scan complete");
4775
+ say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
4776
+ say(`Package manager: ${scanResult.packageManager}`);
3941
4777
  }
3942
4778
  const context = await readContextOrScan(root);
3943
4779
  const recommendation = await recommend(root, context);
3944
4780
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
3945
4781
  if (json) {
3946
- log(JSON.stringify(recommendation, null, 2));
4782
+ say(JSON.stringify(recommendation, null, 2));
3947
4783
  } else {
3948
- log("Haus recommendation ready");
3949
- log(`Recommended: ${recommendation.recommended.length}`);
3950
- log(`Skipped: ${recommendation.skipped.length}`);
4784
+ say("Haus recommendation ready");
4785
+ say(`Recommended: ${recommendation.recommended.length}`);
4786
+ say(`Skipped: ${recommendation.skipped.length}`);
3951
4787
  }
3952
4788
  const hooks = await verifyProjectSettingsHooksContract(root);
3953
4789
  const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
3954
- log(`Repo: ${context.repoName}`);
3955
- for (const warning of warningLines) log(`- WARN: ${warning}`);
4790
+ say(`Repo: ${context.repoName}`);
4791
+ for (const warning of warningLines) say(`- WARN: ${warning}`);
3956
4792
  const hooksOk = hooks.ok;
3957
4793
  if (hooks.skipped) {
3958
- log(`- HOOKS: (skipped) ${hooks.message}`);
4794
+ say(`- HOOKS: (skipped) ${hooks.message}`);
3959
4795
  } else if (!hooks.ok) {
3960
- log(`- HOOKS FAIL: ${hooks.message}`);
3961
- process.exitCode = 1;
4796
+ say(`- HOOKS FAIL: ${hooks.message}`);
4797
+ if (!apply) process.exitCode = 1;
3962
4798
  } else {
3963
- log(`- HOOKS OK: ${hooks.message}`);
4799
+ say(`- HOOKS OK: ${hooks.message}`);
3964
4800
  }
3965
4801
  const baseResult = {
3966
4802
  root,
@@ -3975,32 +4811,32 @@ async function runSetupCore(root, opts) {
3975
4811
  if (confirm2) {
3976
4812
  const approved = await confirm2();
3977
4813
  if (!approved) {
3978
- log("Setup reviewed. No files written.");
3979
- log("Next step: run `haus apply --write` when ready.");
4814
+ say("Setup reviewed. No files written.");
4815
+ say("Next step: run `haus apply --write` when ready.");
3980
4816
  return baseResult;
3981
4817
  }
3982
4818
  }
3983
4819
  if (!dryRun && !process.env["HAUS_FIXTURE_CATALOG"]) {
3984
- log("Syncing remote catalog...");
4820
+ say("Syncing remote catalog...");
3985
4821
  const sync = await syncRemoteCatalog();
3986
4822
  if (sync.newItems.length > 0) {
3987
- log(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
4823
+ say(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
3988
4824
  } else if (sync.refreshed.length > 0) {
3989
- log(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
4825
+ say(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
3990
4826
  }
3991
4827
  }
3992
- const files = await writeClaudeFiles(root, dryRun ?? false);
3993
- log("Applied files:");
3994
- files.forEach((f) => log(`- ${displayPath(root, f)}`));
4828
+ const files = await writeClaudeFiles(root, dryRun ?? false, void 0, { quiet: opts.quiet });
4829
+ say("Applied files:");
4830
+ files.forEach((f) => say(`- ${displayPath(root, f)}`));
3995
4831
  const hooksAfter = await verifyProjectSettingsHooksContract(root);
3996
4832
  const hooksOkAfter = hooksAfter.ok;
3997
4833
  if (hooksAfter.skipped) {
3998
- log(`- HOOKS: (skipped) ${hooksAfter.message}`);
4834
+ say(`- HOOKS: (skipped) ${hooksAfter.message}`);
3999
4835
  } else if (!hooksAfter.ok) {
4000
- log(`- HOOKS FAIL: ${hooksAfter.message}`);
4836
+ say(`- HOOKS FAIL: ${hooksAfter.message}`);
4001
4837
  process.exitCode = 1;
4002
4838
  } else {
4003
- log(`- HOOKS OK: ${hooksAfter.message}`);
4839
+ say(`- HOOKS OK: ${hooksAfter.message}`);
4004
4840
  }
4005
4841
  return { ...baseResult, hooksOk: hooksOkAfter, written: files };
4006
4842
  }
@@ -4019,8 +4855,8 @@ async function runSetupProject(options) {
4019
4855
  // src/commands/init.ts
4020
4856
  async function runInit(options) {
4021
4857
  const root = process.cwd();
4022
- const hausDir = path20.join(root, ".haus-workflow");
4023
- const alreadyInit = await fs15.pathExists(hausDir);
4858
+ const hausDir = path29.join(root, ".haus-workflow");
4859
+ const alreadyInit = await fs23.pathExists(hausDir);
4024
4860
  if (alreadyInit) {
4025
4861
  log("Haus AI already initialized in this project.");
4026
4862
  log("Run `haus setup-project` to reconfigure.");
@@ -4032,8 +4868,8 @@ async function runInit(options) {
4032
4868
 
4033
4869
  // src/install/apply.ts
4034
4870
  import crypto2 from "node:crypto";
4035
- import path21 from "node:path";
4036
- import fs16 from "fs-extra";
4871
+ import path30 from "node:path";
4872
+ import fs24 from "fs-extra";
4037
4873
 
4038
4874
  // src/install/header.ts
4039
4875
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -4119,40 +4955,40 @@ function hashContent(content2) {
4119
4955
  }
4120
4956
  function sourceVersion() {
4121
4957
  try {
4122
- const pkgPath = path21.join(packageRoot(), "package.json");
4123
- const pkg = JSON.parse(fs16.readFileSync(pkgPath, "utf8"));
4958
+ const pkgPath = path30.join(packageRoot(), "package.json");
4959
+ const pkg = JSON.parse(fs24.readFileSync(pkgPath, "utf8"));
4124
4960
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
4125
4961
  } catch {
4126
4962
  return "haus@0.0.0";
4127
4963
  }
4128
4964
  }
4129
4965
  function globalSrcDir() {
4130
- return path21.join(packageRoot(), "library", "global");
4966
+ return path30.join(packageRoot(), "library", "global");
4131
4967
  }
4132
4968
  function collectSourceFiles(srcDir, claudeDir) {
4133
4969
  const entries = [];
4134
- const skillsDir = path21.join(srcDir, "skills");
4135
- if (fs16.pathExistsSync(skillsDir)) {
4136
- for (const skillName of fs16.readdirSync(skillsDir)) {
4137
- const skillFile = path21.join(skillsDir, skillName, "SKILL.md");
4138
- if (fs16.pathExistsSync(skillFile)) {
4970
+ const skillsDir = path30.join(srcDir, "skills");
4971
+ if (fs24.pathExistsSync(skillsDir)) {
4972
+ for (const skillName of fs24.readdirSync(skillsDir)) {
4973
+ const skillFile = path30.join(skillsDir, skillName, "SKILL.md");
4974
+ if (fs24.pathExistsSync(skillFile)) {
4139
4975
  entries.push({
4140
4976
  stableId: `skill.${skillName}`,
4141
- srcRelPath: path21.join("library", "global", "skills", skillName, "SKILL.md"),
4142
- destPath: path21.join(claudeDir, "skills", skillName, "SKILL.md")
4977
+ srcRelPath: path30.join("library", "global", "skills", skillName, "SKILL.md"),
4978
+ destPath: path30.join(claudeDir, "skills", skillName, "SKILL.md")
4143
4979
  });
4144
4980
  }
4145
4981
  }
4146
4982
  }
4147
- const commandsDir = path21.join(srcDir, "commands");
4148
- if (fs16.pathExistsSync(commandsDir)) {
4149
- for (const fileName of fs16.readdirSync(commandsDir)) {
4983
+ const commandsDir = path30.join(srcDir, "commands");
4984
+ if (fs24.pathExistsSync(commandsDir)) {
4985
+ for (const fileName of fs24.readdirSync(commandsDir)) {
4150
4986
  if (!fileName.endsWith(".md")) continue;
4151
4987
  const commandName = fileName.slice(0, -".md".length);
4152
4988
  entries.push({
4153
4989
  stableId: `command.${commandName}`,
4154
- srcRelPath: path21.join("library", "global", "commands", fileName),
4155
- destPath: path21.join(claudeDir, "commands", fileName)
4990
+ srcRelPath: path30.join("library", "global", "commands", fileName),
4991
+ destPath: path30.join(claudeDir, "commands", fileName)
4156
4992
  });
4157
4993
  }
4158
4994
  }
@@ -4170,13 +5006,15 @@ async function applyInstall(options = {}) {
4170
5006
  created: [],
4171
5007
  updated: [],
4172
5008
  skipped: [],
5009
+ userOwned: [],
5010
+ userEdited: [],
4173
5011
  deleted: [],
4174
5012
  hookIds: [],
4175
5013
  drift: false
4176
5014
  };
4177
5015
  const manifestFiles = [];
4178
5016
  for (const entry of sourceFiles) {
4179
- const srcPath = path21.join(packageRoot(), entry.srcRelPath);
5017
+ const srcPath = path30.join(packageRoot(), entry.srcRelPath);
4180
5018
  const rawContent = await readText(srcPath);
4181
5019
  if (rawContent === void 0) {
4182
5020
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -4196,7 +5034,7 @@ async function applyInstall(options = {}) {
4196
5034
  }
4197
5035
  continue;
4198
5036
  }
4199
- const destExists = fs16.pathExistsSync(entry.destPath);
5037
+ const destExists = fs24.pathExistsSync(entry.destPath);
4200
5038
  if (destExists) {
4201
5039
  const currentContent = await readText(entry.destPath);
4202
5040
  if (currentContent !== void 0) {
@@ -4204,11 +5042,13 @@ async function applyInstall(options = {}) {
4204
5042
  if (!hasHeader) {
4205
5043
  warn(`Refusing to overwrite user-owned file: ${entry.destPath}`);
4206
5044
  result.skipped.push(entry.destPath);
5045
+ result.userOwned.push(entry.destPath);
4207
5046
  continue;
4208
5047
  }
4209
5048
  if (existing && hashContent(currentContent) !== existing.hash && !force) {
4210
5049
  warn(`User edited haus file (skipping): ${entry.destPath} \u2014 use --force to overwrite`);
4211
5050
  result.skipped.push(entry.destPath);
5051
+ result.userEdited.push(entry.destPath);
4212
5052
  manifestFiles.push(existing);
4213
5053
  continue;
4214
5054
  }
@@ -4232,25 +5072,29 @@ async function applyInstall(options = {}) {
4232
5072
  schemaVersion: SCHEMA_VERSION2
4233
5073
  });
4234
5074
  }
4235
- const fragmentPath = path21.join(srcDir, "settings-fragments", "hooks.json");
4236
- const fragments = await loadHooksFragment(fragmentPath);
4237
- const settings = await readSettings();
4238
- const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
4239
- const { settings: deniedSettings } = mergeDenyRules(hookSettings, buildDenyRules());
4240
- const { settings: allowedSettings } = mergeAllowRules(deniedSettings, buildAllowRules());
4241
- const { settings: mergedSettings } = mergeAskRules(allowedSettings, buildAskRules());
4242
- result.hookIds = addedIds;
5075
+ let mergedSettings;
5076
+ if (!check) {
5077
+ const fragmentPath = path30.join(srcDir, "settings-fragments", "hooks.json");
5078
+ const fragments = await loadHooksFragment(fragmentPath);
5079
+ const settings = await readSettings();
5080
+ const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
5081
+ const { settings: deniedSettings } = mergeDenyRules(hookSettings, buildDenyRules());
5082
+ const { settings: allowedSettings } = mergeAllowRules(deniedSettings, buildAllowRules());
5083
+ const merged = mergeAskRules(allowedSettings, buildAskRules());
5084
+ mergedSettings = merged.settings;
5085
+ result.hookIds = addedIds;
5086
+ }
4243
5087
  if (!check && existingManifest) {
4244
5088
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
4245
5089
  for (const entry of existingManifest.files) {
4246
5090
  if (currentDestPaths.has(entry.destPath)) continue;
4247
- if (!fs16.pathExistsSync(entry.destPath)) continue;
5091
+ if (!fs24.pathExistsSync(entry.destPath)) continue;
4248
5092
  const content2 = await readText(entry.destPath);
4249
5093
  if (!content2) continue;
4250
5094
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
4251
5095
  const currentHash = hashContent(content2);
4252
5096
  if (hasHeader && currentHash === entry.hash) {
4253
- if (!dryRun) await fs16.remove(entry.destPath);
5097
+ if (!dryRun) await fs24.remove(entry.destPath);
4254
5098
  result.deleted.push(entry.destPath);
4255
5099
  } else {
4256
5100
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -4258,10 +5102,10 @@ async function applyInstall(options = {}) {
4258
5102
  }
4259
5103
  }
4260
5104
  }
4261
- if (!dryRun && !check) {
5105
+ if (!dryRun && !check && mergedSettings) {
4262
5106
  await writeSettings(mergedSettings);
4263
5107
  const manifest = buildManifest(source, manifestFiles, [
4264
- .../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...addedIds])
5108
+ .../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...result.hookIds])
4265
5109
  ]);
4266
5110
  await writeManifest(manifest);
4267
5111
  }
@@ -4281,9 +5125,10 @@ function printApplyResult(result, dryRun) {
4281
5125
  log(`${prefix}Deleted (orphaned):`);
4282
5126
  result.deleted.forEach((p) => log(` x ${p}`));
4283
5127
  }
4284
- if (result.skipped.length) {
5128
+ const silentlySkipped = result.skipped.filter((p) => !result.userOwned.includes(p));
5129
+ if (silentlySkipped.length) {
4285
5130
  log(`${prefix}Skipped:`);
4286
- result.skipped.forEach((p) => log(` - ${p}`));
5131
+ silentlySkipped.forEach((p) => log(` - ${p}`));
4287
5132
  }
4288
5133
  if (result.hookIds.length) {
4289
5134
  log(`${prefix}Hooks added: ${result.hookIds.join(", ")}`);
@@ -4331,7 +5176,7 @@ function buildCompanionSuggestions(tools, isInstalled) {
4331
5176
  }
4332
5177
  return lines;
4333
5178
  }
4334
- function commandExists(bin) {
5179
+ function commandExists2(bin) {
4335
5180
  try {
4336
5181
  const r = execaSync("sh", ["-c", 'command -v -- "$1"', "probe", bin], { reject: false });
4337
5182
  return r.exitCode === 0;
@@ -4340,7 +5185,7 @@ function commandExists(bin) {
4340
5185
  }
4341
5186
  }
4342
5187
  function printCompanionToolSuggestions(deps = {}) {
4343
- const isInstalled = deps.isInstalled ?? commandExists;
5188
+ const isInstalled = deps.isInstalled ?? commandExists2;
4344
5189
  const emit2 = deps.log ?? log;
4345
5190
  const lines = buildCompanionSuggestions(COMPANION_TOOLS, isInstalled);
4346
5191
  for (const line2 of lines) emit2(line2);
@@ -4402,7 +5247,7 @@ async function runRecommend(options) {
4402
5247
  `Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`)`
4403
5248
  );
4404
5249
  }
4405
- for (const w of result.warnings.filter((w2) => w2.startsWith("--include:"))) log(w);
5250
+ for (const w of result.warnings) log(`- WARN: ${w}`);
4406
5251
  }
4407
5252
 
4408
5253
  // src/commands/refresh.ts
@@ -4418,8 +5263,8 @@ async function runRefresh() {
4418
5263
  }
4419
5264
 
4420
5265
  // src/install/scaffold.ts
4421
- import path22 from "node:path";
4422
- import fs17 from "fs-extra";
5266
+ import path31 from "node:path";
5267
+ import fs25 from "fs-extra";
4423
5268
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4424
5269
  const scaffolded = [];
4425
5270
  const skipped = [];
@@ -4432,27 +5277,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4432
5277
  skipped.push(name);
4433
5278
  if (outcome === "skipped-exists") skippedExisting.push(name);
4434
5279
  };
4435
- const realRoot = await fs17.realpath(catalogRoot).catch(() => null);
5280
+ const realRoot = await fs25.realpath(catalogRoot).catch(() => null);
4436
5281
  for (const item of items) {
4437
5282
  if (item.type !== "config") continue;
4438
- const sourcePath = path22.resolve(catalogRoot, item.path);
4439
- const rel = path22.relative(catalogRoot, sourcePath);
4440
- const escapes = rel === "" || path22.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
5283
+ const sourcePath = path31.resolve(catalogRoot, item.path);
5284
+ const rel = path31.relative(catalogRoot, sourcePath);
5285
+ const escapes = rel === "" || path31.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
4441
5286
  if (escapes) {
4442
5287
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
4443
5288
  continue;
4444
5289
  }
4445
- const realSource = await fs17.realpath(sourcePath).catch(() => null);
5290
+ const realSource = await fs25.realpath(sourcePath).catch(() => null);
4446
5291
  if (!realRoot || !realSource) {
4447
5292
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4448
5293
  continue;
4449
5294
  }
4450
- const realRel = path22.relative(realRoot, realSource);
4451
- if (realRel !== "" && (path22.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
5295
+ const realRel = path31.relative(realRoot, realSource);
5296
+ if (realRel !== "" && (path31.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
4452
5297
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
4453
5298
  continue;
4454
5299
  }
4455
- const stat = await fs17.lstat(sourcePath).catch(() => null);
5300
+ const stat = await fs25.lstat(sourcePath).catch(() => null);
4456
5301
  if (!stat) {
4457
5302
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4458
5303
  continue;
@@ -4462,14 +5307,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4462
5307
  continue;
4463
5308
  }
4464
5309
  if (stat.isFile()) {
4465
- const filename = path22.basename(sourcePath);
4466
- const dest = path22.join(projectRoot, filename);
5310
+ const filename = path31.basename(sourcePath);
5311
+ const dest = path31.join(projectRoot, filename);
4467
5312
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
4468
5313
  } else if (stat.isDirectory()) {
4469
- const entries = await fs17.readdir(sourcePath);
5314
+ const entries = await fs25.readdir(sourcePath);
4470
5315
  for (const entry of entries) {
4471
- const src = path22.join(sourcePath, entry);
4472
- const dest = path22.join(projectRoot, entry);
5316
+ const src = path31.join(sourcePath, entry);
5317
+ const dest = path31.join(projectRoot, entry);
4473
5318
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
4474
5319
  }
4475
5320
  }
@@ -4477,42 +5322,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4477
5322
  return { scaffolded, skipped, skippedExisting };
4478
5323
  }
4479
5324
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
4480
- const destDisplay = path22.relative(projectRoot, dest) || path22.basename(dest);
4481
- const srcStat = await fs17.lstat(src).catch(() => null);
5325
+ const destDisplay = path31.relative(projectRoot, dest) || path31.basename(dest);
5326
+ const srcStat = await fs25.lstat(src).catch(() => null);
4482
5327
  if (!srcStat) {
4483
- warn(`Skipping ${path22.basename(src)}: source not found`);
5328
+ warn(`Skipping ${path31.basename(src)}: source not found`);
4484
5329
  return "skipped-refused";
4485
5330
  }
4486
5331
  if (srcStat.isSymbolicLink()) {
4487
- warn(`Skipping ${path22.basename(src)}: source is a symlink`);
5332
+ warn(`Skipping ${path31.basename(src)}: source is a symlink`);
4488
5333
  return "skipped-refused";
4489
5334
  }
4490
- const exists = await fs17.pathExists(dest);
5335
+ const exists = await fs25.pathExists(dest);
4491
5336
  if (exists && !opts.force) {
4492
5337
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
4493
5338
  return "skipped-exists";
4494
5339
  }
4495
5340
  if (opts.dryRun) {
4496
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path22.basename(dest)} (${itemId})`);
5341
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path31.basename(dest)} (${itemId})`);
4497
5342
  return "scaffolded";
4498
5343
  }
4499
5344
  if (exists && opts.force) {
4500
- await fs17.remove(dest);
5345
+ await fs25.remove(dest);
4501
5346
  }
4502
- await fs17.ensureDir(path22.dirname(dest));
4503
- await fs17.copy(src, dest, {
5347
+ await fs25.ensureDir(path31.dirname(dest));
5348
+ await fs25.copy(src, dest, {
4504
5349
  overwrite: true,
4505
5350
  dereference: false,
4506
5351
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
4507
5352
  filter: (s) => {
4508
5353
  try {
4509
- return !fs17.lstatSync(s).isSymbolicLink();
5354
+ return !fs25.lstatSync(s).isSymbolicLink();
4510
5355
  } catch {
4511
5356
  return false;
4512
5357
  }
4513
5358
  }
4514
5359
  });
4515
- log(`${exists ? "Overwrote" : "Created"} ${path22.basename(dest)} (${itemId})`);
5360
+ log(`${exists ? "Overwrote" : "Created"} ${path31.basename(dest)} (${itemId})`);
4516
5361
  return "scaffolded";
4517
5362
  }
4518
5363
 
@@ -4546,13 +5391,12 @@ async function runScaffold(ids, options) {
4546
5391
  log(`\u2713 Scaffolded: ${result.scaffolded.join(", ")}`);
4547
5392
  }
4548
5393
  }
4549
- if (result.skipped.length > 0) {
4550
- log(`Skipped: ${result.skipped.join(", ")}`);
4551
- if (result.skippedExisting.length > 0) {
4552
- log(
4553
- `Already present (re-run with --force to overwrite): ${result.skippedExisting.join(", ")}`
4554
- );
4555
- }
5394
+ const refused = result.skipped.filter((name) => !result.skippedExisting.includes(name));
5395
+ if (refused.length > 0) {
5396
+ log(`Skipped (source unavailable): ${refused.join(", ")}`);
5397
+ }
5398
+ if (result.skippedExisting.length > 0) {
5399
+ log(`Already present (re-run with --force to overwrite): ${result.skippedExisting.join(", ")}`);
4556
5400
  }
4557
5401
  }
4558
5402
 
@@ -4566,11 +5410,12 @@ async function runScan(options) {
4566
5410
  log("Haus scan complete");
4567
5411
  log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
4568
5412
  log(`Package manager: ${result.packageManager}`);
5413
+ for (const w of result.warnings) log(`- WARN: ${w}`);
4569
5414
  }
4570
5415
 
4571
5416
  // src/commands/undo.ts
4572
- import path23 from "node:path";
4573
- import fs18 from "fs-extra";
5417
+ import path32 from "node:path";
5418
+ import fs26 from "fs-extra";
4574
5419
 
4575
5420
  // src/claude/managed-paths.ts
4576
5421
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md", "commands/haus-doctor.md"];
@@ -4587,74 +5432,77 @@ async function collectManagedPaths(root) {
4587
5432
  const lock = await readJson(hausPath(root, "haus.lock.json"));
4588
5433
  for (const row of lock ?? []) {
4589
5434
  for (const rel of row.paths ?? []) {
4590
- paths.add(path23.resolve(root, rel));
5435
+ paths.add(path32.resolve(root, rel));
4591
5436
  }
4592
5437
  }
4593
5438
  const existing = [];
4594
5439
  for (const abs of paths) {
4595
- if (await fs18.pathExists(abs)) existing.push(abs);
5440
+ if (await fs26.pathExists(abs)) existing.push(abs);
4596
5441
  }
4597
5442
  return existing;
4598
5443
  }
4599
5444
  async function settingsHasHausContent(root) {
4600
5445
  const settingsPath = claudePath(root, "settings.json");
4601
- if (!await fs18.pathExists(settingsPath)) return false;
5446
+ if (!await fs26.pathExists(settingsPath)) return false;
4602
5447
  const settings = await readProjectSettings(root);
4603
5448
  return settings._haus != null;
4604
5449
  }
4605
5450
  async function claudeMdHasHausBlock(root) {
4606
- const filePath = path23.join(root, "CLAUDE.md");
4607
- if (!await fs18.pathExists(filePath)) return false;
4608
- const text = await fs18.readFile(filePath, "utf8");
5451
+ const filePath = path32.join(root, "CLAUDE.md");
5452
+ if (!await fs26.pathExists(filePath)) return false;
5453
+ const text = await fs26.readFile(filePath, "utf8");
4609
5454
  return text.includes(BLOCK_BEGIN);
4610
5455
  }
4611
5456
  async function stripProjectSettings(root) {
4612
5457
  const settingsPath = claudePath(root, "settings.json");
4613
- if (!await fs18.pathExists(settingsPath)) return false;
5458
+ if (!await fs26.pathExists(settingsPath)) return false;
4614
5459
  let settings = await readProjectSettings(root);
4615
5460
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4616
5461
  const hasContent = Object.keys(settings).length > 0;
4617
5462
  if (hasContent) {
4618
5463
  await writeProjectSettings(root, settings);
4619
- log(`Stripped haus rules from ${path23.relative(root, settingsPath)} (user settings preserved).`);
5464
+ log(`Stripped haus rules from ${path32.relative(root, settingsPath)} (user settings preserved).`);
4620
5465
  return true;
4621
5466
  }
4622
- await fs18.remove(settingsPath);
4623
- log(`Removed ${path23.relative(root, settingsPath)} (no user-owned settings remained).`);
5467
+ await fs26.remove(settingsPath);
5468
+ log(`Removed ${path32.relative(root, settingsPath)} (no user-owned settings remained).`);
4624
5469
  return true;
4625
5470
  }
4626
5471
  async function stripRootClaudeMd(root) {
4627
- const filePath = path23.join(root, "CLAUDE.md");
4628
- if (!await fs18.pathExists(filePath)) return false;
4629
- const prev = await fs18.readFile(filePath, "utf8");
5472
+ const filePath = path32.join(root, "CLAUDE.md");
5473
+ if (!await fs26.pathExists(filePath)) return false;
5474
+ const prev = await fs26.readFile(filePath, "utf8");
4630
5475
  if (!prev.includes(BLOCK_BEGIN)) return false;
4631
5476
  const next = stripHausBlock(prev);
4632
5477
  if (next.length === 0) {
4633
- await fs18.remove(filePath);
5478
+ await fs26.remove(filePath);
4634
5479
  log("Removed CLAUDE.md (only contained haus import block).");
4635
5480
  } else {
4636
- await fs18.writeFile(filePath, next, "utf8");
5481
+ await fs26.writeFile(filePath, next, "utf8");
4637
5482
  log("Removed haus import block from CLAUDE.md (user content preserved).");
4638
5483
  }
4639
5484
  return true;
4640
5485
  }
4641
5486
  async function pruneDirIfEmpty(dir) {
4642
- if (!await fs18.pathExists(dir)) return;
4643
- const entries = await fs18.readdir(dir);
4644
- if (entries.length === 0) await fs18.remove(dir);
5487
+ if (!await fs26.pathExists(dir)) return;
5488
+ try {
5489
+ const entries = await fs26.readdir(dir);
5490
+ if (entries.length === 0) await fs26.remove(dir);
5491
+ } catch {
5492
+ }
4645
5493
  }
4646
5494
  async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
4647
5495
  if (managedAbsPaths.length === 0) return;
4648
5496
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4649
5497
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
4650
5498
  for (const abs of managedAbsPaths) {
4651
- if (!await fs18.pathExists(abs)) continue;
4652
- const rel = path23.relative(root, abs);
4653
- const backupPath = path23.join(backupRoot, rel);
4654
- await fs18.ensureDir(path23.dirname(backupPath));
4655
- await fs18.copy(abs, backupPath);
5499
+ if (!await fs26.pathExists(abs)) continue;
5500
+ const rel = path32.relative(root, abs);
5501
+ const backupPath = path32.join(backupRoot, rel);
5502
+ await fs26.ensureDir(path32.dirname(backupPath));
5503
+ await fs26.copy(abs, backupPath);
4656
5504
  }
4657
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path23.relative(root, backupRoot)}.`);
5505
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path32.relative(root, backupRoot)}.`);
4658
5506
  }
4659
5507
  async function runUndo(options) {
4660
5508
  const root = process.cwd();
@@ -4665,7 +5513,7 @@ async function runUndo(options) {
4665
5513
  log("Nothing to remove: no haus-managed files found in this directory.");
4666
5514
  return;
4667
5515
  }
4668
- const relTargets = managed.map((p) => path23.relative(root, p));
5516
+ const relTargets = managed.map((p) => path32.relative(root, p));
4669
5517
  const summaryParts = [...relTargets];
4670
5518
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
4671
5519
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -4682,9 +5530,9 @@ User-owned .claude/ files will be preserved.`
4682
5530
  }
4683
5531
  await backupManagedFilesBeforeUndo(root, managed);
4684
5532
  for (const abs of managed) {
4685
- if (!await fs18.pathExists(abs)) continue;
4686
- await fs18.remove(abs);
4687
- log(`Removed ${path23.relative(root, abs)}`);
5533
+ if (!await fs26.pathExists(abs)) continue;
5534
+ await fs26.remove(abs);
5535
+ log(`Removed ${path32.relative(root, abs)}`);
4688
5536
  }
4689
5537
  if (stripSettings) await stripProjectSettings(root);
4690
5538
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -4695,8 +5543,8 @@ User-owned .claude/ files will be preserved.`
4695
5543
 
4696
5544
  // src/install/uninstall.ts
4697
5545
  import crypto3 from "node:crypto";
4698
- import path24 from "node:path";
4699
- import fs19 from "fs-extra";
5546
+ import path33 from "node:path";
5547
+ import fs27 from "fs-extra";
4700
5548
  async function runUninstall(options = {}) {
4701
5549
  const { force = false } = options;
4702
5550
  const manifest = await readManifest();
@@ -4706,7 +5554,7 @@ async function runUninstall(options = {}) {
4706
5554
  return result;
4707
5555
  }
4708
5556
  for (const entry of manifest.files) {
4709
- const exists = fs19.pathExistsSync(entry.destPath);
5557
+ const exists = fs27.pathExistsSync(entry.destPath);
4710
5558
  if (!exists) continue;
4711
5559
  const content2 = await readText(entry.destPath);
4712
5560
  if (content2 === void 0) continue;
@@ -4724,22 +5572,25 @@ async function runUninstall(options = {}) {
4724
5572
  result.skipped.push(entry.destPath);
4725
5573
  continue;
4726
5574
  }
4727
- await fs19.remove(entry.destPath);
4728
- await pruneEmptyDir(path24.dirname(entry.destPath));
5575
+ await fs27.remove(entry.destPath);
5576
+ await pruneEmptyDir(path33.dirname(entry.destPath));
4729
5577
  result.deleted.push(entry.destPath);
4730
5578
  }
4731
5579
  const settings = await readSettings();
4732
5580
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4733
5581
  await writeSettings(stripped);
4734
5582
  result.hooksStripped = true;
4735
- const hausDir = path24.join(globalClaudeDir(), "haus");
5583
+ const hausDir = path33.join(globalClaudeDir(), "haus");
4736
5584
  const manifestPath2 = hausManifestPath();
4737
- if (fs19.pathExistsSync(manifestPath2)) {
4738
- await fs19.remove(manifestPath2);
5585
+ if (fs27.pathExistsSync(manifestPath2)) {
5586
+ await fs27.remove(manifestPath2);
4739
5587
  }
4740
- if (fs19.pathExistsSync(hausDir)) {
4741
- const remaining = await fs19.readdir(hausDir);
4742
- if (remaining.length === 0) await fs19.remove(hausDir);
5588
+ if (fs27.pathExistsSync(hausDir)) {
5589
+ try {
5590
+ const remaining = await fs27.readdir(hausDir);
5591
+ if (remaining.length === 0) await fs27.remove(hausDir);
5592
+ } catch {
5593
+ }
4743
5594
  }
4744
5595
  return result;
4745
5596
  }
@@ -4770,12 +5621,12 @@ async function runUninstallCommand(options) {
4770
5621
  }
4771
5622
 
4772
5623
  // src/commands/update.ts
4773
- import path26 from "node:path";
5624
+ import path35 from "node:path";
4774
5625
 
4775
5626
  // src/claude/refresh-project.ts
4776
- import fs20 from "fs-extra";
5627
+ import fs28 from "fs-extra";
4777
5628
  async function isHausProject(root) {
4778
- if (await fs20.pathExists(hausPath(root, "recommendation.json"))) return true;
5629
+ if (await fs28.pathExists(hausPath(root, "recommendation.json"))) return true;
4779
5630
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
4780
5631
  if (result.status === "ok") return result.value._haus != null;
4781
5632
  return false;
@@ -4810,7 +5661,7 @@ function summarizeLockDiff(before, after) {
4810
5661
 
4811
5662
  // src/update/lockfile.ts
4812
5663
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
4813
- import path25 from "node:path";
5664
+ import path34 from "node:path";
4814
5665
  async function checkLock(root) {
4815
5666
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
4816
5667
  const hasValidVersions = lock.every(
@@ -4841,7 +5692,7 @@ async function applyLock(root) {
4841
5692
  try {
4842
5693
  const backupDir = hausPath(root, "backups");
4843
5694
  await mkdir(backupDir, { recursive: true });
4844
- await copyFile(lockPath, path25.join(backupDir, `haus.lock.${Date.now()}.json`));
5695
+ await copyFile(lockPath, path34.join(backupDir, `haus.lock.${Date.now()}.json`));
4845
5696
  } catch {
4846
5697
  }
4847
5698
  const enriched = await Promise.all(
@@ -4863,7 +5714,7 @@ function diffLock(before, after) {
4863
5714
  }
4864
5715
  async function hasLocalOverrides(root) {
4865
5716
  try {
4866
- await readFile2(path25.join(root, ".claude", "settings.json"), "utf8");
5717
+ await readFile2(path34.join(root, ".claude", "settings.json"), "utf8");
4867
5718
  return true;
4868
5719
  } catch {
4869
5720
  return false;
@@ -4875,7 +5726,7 @@ var NPM_PACKAGE_NAME2 = "@haus-tech/haus-workflow";
4875
5726
  async function runUpdate(options) {
4876
5727
  const root = process.cwd();
4877
5728
  if (options.check) {
4878
- const pkgJson2 = await readJson(path26.join(packageRoot(), "package.json"));
5729
+ const pkgJson2 = await readJson(path35.join(packageRoot(), "package.json"));
4879
5730
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
4880
5731
  const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
4881
5732
  checkLock(root),
@@ -4901,11 +5752,10 @@ async function runUpdate(options) {
4901
5752
  2
4902
5753
  )
4903
5754
  );
4904
- if (!status.ok) process.exitCode = 1;
4905
- if (status.driftCount > 0) process.exitCode = 1;
5755
+ if (status.count > 0 && !status.ok) process.exitCode = 1;
4906
5756
  return;
4907
5757
  }
4908
- const pkgJson = await readJson(path26.join(packageRoot(), "package.json"));
5758
+ const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
4909
5759
  const currentVersion = pkgJson?.version ?? "0.0.0";
4910
5760
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4911
5761
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -4963,9 +5813,9 @@ async function refreshGlobalInstall() {
4963
5813
  } else {
4964
5814
  log("~/.claude already up to date.");
4965
5815
  }
4966
- if (result.skipped.length > 0) {
5816
+ if (result.userEdited.length > 0) {
4967
5817
  log(
4968
- `Preserved ${result.skipped.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
5818
+ `Preserved ${result.userEdited.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
4969
5819
  );
4970
5820
  }
4971
5821
  } catch (err) {
@@ -4982,16 +5832,16 @@ async function detectGlobalInstallDrift() {
4982
5832
  }
4983
5833
 
4984
5834
  // src/commands/validate-catalog.ts
4985
- import path28 from "node:path";
5835
+ import path37 from "node:path";
4986
5836
 
4987
5837
  // src/catalog/validate-core.ts
4988
- import fs21 from "node:fs";
4989
- import path27 from "node:path";
5838
+ import fs29 from "node:fs";
5839
+ import path36 from "node:path";
4990
5840
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
4991
5841
  var MANIFEST_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
4992
5842
  function isSafeCatalogPath2(itemPath) {
4993
- if (!itemPath || path27.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
4994
- const normalized = path27.normalize(itemPath);
5843
+ if (!itemPath || path36.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
5844
+ const normalized = path36.normalize(itemPath);
4995
5845
  return !normalized.startsWith("..") && !normalized.includes("/..");
4996
5846
  }
4997
5847
  function auditForbiddenStacks(items) {
@@ -5102,10 +5952,19 @@ function checkRequiredFrontmatter(text, label) {
5102
5952
  }
5103
5953
  return failures;
5104
5954
  }
5955
+ function readForAudit(absPath, label, failures) {
5956
+ try {
5957
+ return fs29.readFileSync(absPath, "utf8");
5958
+ } catch (err) {
5959
+ failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
5960
+ return null;
5961
+ }
5962
+ }
5105
5963
  function auditTemplateContent(manifestDir, absPath, itemId) {
5106
- const rel = path27.relative(manifestDir, absPath);
5107
- const text = fs21.readFileSync(absPath, "utf8");
5964
+ const rel = path36.relative(manifestDir, absPath);
5108
5965
  const failures = [];
5966
+ const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
5967
+ if (text === null) return failures;
5109
5968
  const lines = text.split(/\r?\n/);
5110
5969
  for (let i = 0; i < lines.length; i++) {
5111
5970
  const line2 = lines[i] ?? "";
@@ -5124,44 +5983,47 @@ function auditShippedFiles(manifestDir, items) {
5124
5983
  const failures = [];
5125
5984
  for (const item of items) {
5126
5985
  if (!item.path || !isSafeCatalogPath2(item.path)) continue;
5127
- const absPath = path27.join(manifestDir, item.path);
5986
+ const absPath = path36.join(manifestDir, item.path);
5128
5987
  if (item.type === "skill") {
5129
- const skillMd = path27.join(absPath, "SKILL.md");
5130
- if (!fs21.existsSync(skillMd)) {
5131
- failures.push(`${item.id}: missing ${path27.relative(manifestDir, skillMd)}`);
5988
+ const skillMd = path36.join(absPath, "SKILL.md");
5989
+ if (!fs29.existsSync(skillMd)) {
5990
+ failures.push(`${item.id}: missing ${path36.relative(manifestDir, skillMd)}`);
5132
5991
  continue;
5133
5992
  }
5134
- const text = fs21.readFileSync(skillMd, "utf8");
5993
+ const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
5994
+ if (text === null) continue;
5135
5995
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
5136
5996
  failures.push(
5137
- ...auditForbiddenTagsInText(text, `${item.id}: ${path27.relative(manifestDir, skillMd)}`)
5997
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path36.relative(manifestDir, skillMd)}`)
5138
5998
  );
5139
5999
  } else if (item.type === "agent") {
5140
- if (!fs21.existsSync(absPath)) {
6000
+ if (!fs29.existsSync(absPath)) {
5141
6001
  failures.push(`${item.id}: missing agent file ${item.path}`);
5142
6002
  continue;
5143
6003
  }
5144
- const text = fs21.readFileSync(absPath, "utf8");
5145
- const rel = path27.relative(manifestDir, absPath);
6004
+ const rel = path36.relative(manifestDir, absPath);
6005
+ const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6006
+ if (text === null) continue;
5146
6007
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
5147
6008
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
5148
6009
  } else if (item.type === "template") {
5149
- if (!fs21.existsSync(absPath)) {
6010
+ if (!fs29.existsSync(absPath)) {
5150
6011
  failures.push(`${item.id}: missing template file ${item.path}`);
5151
6012
  continue;
5152
6013
  }
5153
6014
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
5154
6015
  } else if (item.type === "command") {
5155
- if (!fs21.existsSync(absPath)) {
6016
+ if (!fs29.existsSync(absPath)) {
5156
6017
  failures.push(`${item.id}: missing command file ${item.path}`);
5157
6018
  continue;
5158
6019
  }
5159
- const text = fs21.readFileSync(absPath, "utf8");
5160
- const rel = path27.relative(manifestDir, absPath);
6020
+ const rel = path36.relative(manifestDir, absPath);
6021
+ const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6022
+ if (text === null) continue;
5161
6023
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
5162
6024
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
5163
6025
  } else if (item.type === "config") {
5164
- if (!fs21.existsSync(absPath)) {
6026
+ if (!fs29.existsSync(absPath)) {
5165
6027
  failures.push(`${item.id}: missing config path ${item.path}`);
5166
6028
  }
5167
6029
  }
@@ -5174,10 +6036,19 @@ var DIR_ITEM_TYPE = {
5174
6036
  templates: "template",
5175
6037
  commands: "command"
5176
6038
  };
5177
- function walkMd(dir, fn) {
5178
- for (const entry of fs21.readdirSync(dir, { withFileTypes: true })) {
5179
- const full = path27.join(dir, entry.name);
5180
- if (entry.isDirectory()) walkMd(full, fn);
6039
+ function walkMd(dir, fn, failures) {
6040
+ let entries;
6041
+ try {
6042
+ entries = fs29.readdirSync(dir, { withFileTypes: true });
6043
+ } catch (err) {
6044
+ failures.push(
6045
+ `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
6046
+ );
6047
+ return;
6048
+ }
6049
+ for (const entry of entries) {
6050
+ const full = path36.join(dir, entry.name);
6051
+ if (entry.isDirectory()) walkMd(full, fn, failures);
5181
6052
  else if (entry.name.endsWith(".md")) fn(full);
5182
6053
  }
5183
6054
  }
@@ -5185,26 +6056,31 @@ function auditMarkdownContent(manifestDir, items) {
5185
6056
  const failures = [];
5186
6057
  const pathSourceMap = buildItemPathSourceMap(items);
5187
6058
  for (const dir of ["skills", "agents", "templates", "commands"]) {
5188
- const abs = path27.join(manifestDir, dir);
5189
- if (!fs21.existsSync(abs)) continue;
6059
+ const abs = path36.join(manifestDir, dir);
6060
+ if (!fs29.existsSync(abs)) continue;
5190
6061
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
5191
- walkMd(abs, (file) => {
5192
- const text = fs21.readFileSync(file, "utf8");
5193
- const rel = path27.relative(manifestDir, file);
5194
- const norm = rel.replace(/\\/g, "/");
5195
- const source = resolveMarkdownItemSource(norm, pathSourceMap);
5196
- const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
5197
- const lines = text.split(/\r?\n/);
5198
- for (let i = 0; i < lines.length; i++) {
5199
- const line2 = lines[i] ?? "";
5200
- if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
5201
- failures.push(`${rel}:${i + 1}: risky install pattern`);
5202
- if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
5203
- failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
6062
+ walkMd(
6063
+ abs,
6064
+ (file) => {
6065
+ const rel = path36.relative(manifestDir, file);
6066
+ const text = readForAudit(file, rel, failures);
6067
+ if (text === null) return;
6068
+ const norm = rel.replace(/\\/g, "/");
6069
+ const source = resolveMarkdownItemSource(norm, pathSourceMap);
6070
+ const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
6071
+ const lines = text.split(/\r?\n/);
6072
+ for (let i = 0; i < lines.length; i++) {
6073
+ const line2 = lines[i] ?? "";
6074
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
6075
+ failures.push(`${rel}:${i + 1}: risky install pattern`);
6076
+ if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
6077
+ failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
6078
+ }
5204
6079
  }
5205
- }
5206
- if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
5207
- });
6080
+ if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
6081
+ },
6082
+ failures
6083
+ );
5208
6084
  }
5209
6085
  return failures;
5210
6086
  }
@@ -5227,8 +6103,8 @@ async function runValidateCatalog(manifestPath2) {
5227
6103
  process.exitCode = 1;
5228
6104
  return;
5229
6105
  }
5230
- const abs = path28.resolve(process.cwd(), manifestPath2);
5231
- const manifestDir = path28.dirname(abs);
6106
+ const abs = path37.resolve(process.cwd(), manifestPath2);
6107
+ const manifestDir = path37.dirname(abs);
5232
6108
  const data = await readJson(abs);
5233
6109
  if (!data?.items) {
5234
6110
  error(`Could not read catalog manifest at ${abs}`);
@@ -5246,7 +6122,7 @@ async function runValidateCatalog(manifestPath2) {
5246
6122
 
5247
6123
  // src/commands/workspace.ts
5248
6124
  import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
5249
- import path35 from "node:path";
6125
+ import path44 from "node:path";
5250
6126
 
5251
6127
  // src/commands/workspace/aggregate.ts
5252
6128
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -5300,7 +6176,7 @@ ${summaries.map(
5300
6176
  }
5301
6177
 
5302
6178
  // src/commands/workspace/config.ts
5303
- import path29 from "node:path";
6179
+ import path38 from "node:path";
5304
6180
  import YAML from "yaml";
5305
6181
  var WORKSPACE_FILE = "haus.workspace.yaml";
5306
6182
  function parseWorkspaceConfig(text) {
@@ -5323,11 +6199,11 @@ function parseWorkspaceConfig(text) {
5323
6199
  };
5324
6200
  }
5325
6201
  async function readWorkspaceConfig(workspaceRoot) {
5326
- return parseWorkspaceConfig(await readText(path29.join(workspaceRoot, WORKSPACE_FILE)));
6202
+ return parseWorkspaceConfig(await readText(path38.join(workspaceRoot, WORKSPACE_FILE)));
5327
6203
  }
5328
6204
 
5329
6205
  // src/commands/workspace/discover.ts
5330
- import path30 from "node:path";
6206
+ import path39 from "node:path";
5331
6207
  import fg4 from "fast-glob";
5332
6208
  import YAML2 from "yaml";
5333
6209
  var DEFAULT_MAX_DEPTH = 3;
@@ -5350,13 +6226,16 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
5350
6226
  onlyFiles: false,
5351
6227
  deep: maxDepth,
5352
6228
  followSymbolicLinks: false,
6229
+ // Discovery walks arbitrary directories under the workspace root; an
6230
+ // unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
6231
+ suppressErrors: true,
5353
6232
  ignore: IGNORE
5354
6233
  });
5355
6234
  const gitDirs = /* @__PURE__ */ new Set();
5356
6235
  const manifestDirs = /* @__PURE__ */ new Set();
5357
6236
  for (const match of matches) {
5358
- const base = path30.posix.basename(match);
5359
- const dir = path30.posix.dirname(match);
6237
+ const base = path39.posix.basename(match);
6238
+ const dir = path39.posix.dirname(match);
5360
6239
  const owner = dir === "." ? "." : dir;
5361
6240
  if (base === ".git") gitDirs.add(owner);
5362
6241
  else manifestDirs.add(owner);
@@ -5372,9 +6251,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
5372
6251
  }
5373
6252
  repoRoots.sort((a, b) => a.localeCompare(b));
5374
6253
  return mapWithConcurrency(repoRoots, async (relDir) => {
5375
- const absDir = path30.resolve(workspaceRoot, relDir);
5376
- const pkg = await readJson(path30.join(absDir, "package.json"));
5377
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path30.basename(relDir === "." ? workspaceRoot : absDir);
6254
+ const absDir = path39.resolve(workspaceRoot, relDir);
6255
+ const pkg = await readJson(path39.join(absDir, "package.json"));
6256
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path39.basename(relDir === "." ? workspaceRoot : absDir);
5378
6257
  let role = "auto";
5379
6258
  try {
5380
6259
  const scan = await scanProject(absDir);
@@ -5417,7 +6296,7 @@ function renderWorkspaceYaml(config) {
5417
6296
  });
5418
6297
  }
5419
6298
  async function runDiscover(workspaceRoot, opts = {}) {
5420
- const yamlPath = path30.join(workspaceRoot, "haus.workspace.yaml");
6299
+ const yamlPath = path39.join(workspaceRoot, "haus.workspace.yaml");
5421
6300
  const existingText = await readText(yamlPath);
5422
6301
  const existing = parseWorkspaceConfig(existingText);
5423
6302
  if (existingText && !existing) {
@@ -5435,34 +6314,37 @@ async function runDiscover(workspaceRoot, opts = {}) {
5435
6314
  }
5436
6315
  const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
5437
6316
  const yamlText = renderWorkspaceYaml(merged);
5438
- if (opts.json) {
5439
- log(JSON.stringify({ discovered, config: merged }, null, 2));
5440
- }
5441
6317
  if (opts.write) {
5442
6318
  await writeText(yamlPath, yamlText);
5443
- log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
6319
+ if (opts.json) {
6320
+ log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
6321
+ } else {
6322
+ log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
6323
+ }
5444
6324
  return;
5445
6325
  }
5446
- if (!opts.json) {
5447
- log("Proposed haus.workspace.yaml (run with --write to persist):\n");
5448
- log(yamlText);
6326
+ if (opts.json) {
6327
+ log(JSON.stringify({ discovered, config: merged }, null, 2));
6328
+ return;
5449
6329
  }
6330
+ log("Proposed haus.workspace.yaml (run with --write to persist):\n");
6331
+ log(yamlText);
5450
6332
  }
5451
6333
 
5452
6334
  // src/commands/workspace/doctor.ts
5453
6335
  import { existsSync as existsSync3 } from "node:fs";
5454
- import path32 from "node:path";
6336
+ import path41 from "node:path";
5455
6337
 
5456
6338
  // src/commands/workspace/manifest.ts
5457
6339
  import { readFileSync as readFileSync3 } from "node:fs";
5458
- import path31 from "node:path";
6340
+ import path40 from "node:path";
5459
6341
  var MANIFEST_FILE = "workspace.manifest.json";
5460
6342
  function manifestPath(workspaceRoot) {
5461
6343
  return hausPath(workspaceRoot, MANIFEST_FILE);
5462
6344
  }
5463
6345
  function hausVersion() {
5464
6346
  try {
5465
- const pkg = JSON.parse(readFileSync3(path31.join(packageRoot(), "package.json"), "utf8"));
6347
+ const pkg = JSON.parse(readFileSync3(path40.join(packageRoot(), "package.json"), "utf8"));
5466
6348
  return pkg.version ?? "0.0.0";
5467
6349
  } catch {
5468
6350
  return "0.0.0";
@@ -5528,7 +6410,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
5528
6410
  }
5529
6411
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
5530
6412
  for (const repo of config.repos) {
5531
- const repoRoot = path32.resolve(workspaceRoot, repo.path);
6413
+ const repoRoot = path41.resolve(workspaceRoot, repo.path);
5532
6414
  const entry = manifestByName.get(repo.name);
5533
6415
  if (!entry) {
5534
6416
  flag({
@@ -5602,11 +6484,11 @@ function emit(args) {
5602
6484
 
5603
6485
  // src/commands/workspace/setup.ts
5604
6486
  import { existsSync as existsSync4, statSync } from "node:fs";
5605
- import path34 from "node:path";
6487
+ import path43 from "node:path";
5606
6488
 
5607
6489
  // src/claude/write-workspace-claude-md.ts
5608
- import path33 from "node:path";
5609
- import fs22 from "fs-extra";
6490
+ import path42 from "node:path";
6491
+ import fs30 from "fs-extra";
5610
6492
  function buildWorkspaceImportBlock(client, members) {
5611
6493
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
5612
6494
  const body = [
@@ -5624,25 +6506,27 @@ ${BLOCK_END}`;
5624
6506
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5625
6507
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
5626
6508
  const dryRun = opts.dryRun ?? false;
5627
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path33.join(workspaceRoot, "CLAUDE.md");
5628
- const prev = await fs22.pathExists(filePath) ? await fs22.readFile(filePath, "utf8") : "";
6509
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path42.join(workspaceRoot, "CLAUDE.md");
6510
+ const say = opts.quiet ? () => {
6511
+ } : log;
6512
+ const prev = await fs30.pathExists(filePath) ? await fs30.readFile(filePath, "utf8") : "";
5629
6513
  const next = opts.collision ? `${block}
5630
6514
  ` : injectHausBlock(prev, block);
5631
6515
  const printable = displayPath(workspaceRoot, filePath);
5632
6516
  if (dryRun) {
5633
6517
  if (!prev) {
5634
- log(createUnifiedDiff(printable, "", next));
6518
+ say(createUnifiedDiff(printable, "", next));
5635
6519
  } else if (hasTextChanged(prev, next)) {
5636
- log(createUnifiedDiff(printable, prev, next));
6520
+ say(createUnifiedDiff(printable, prev, next));
5637
6521
  } else {
5638
- log(`${printable}: unchanged`);
6522
+ say(`${printable}: unchanged`);
5639
6523
  }
5640
6524
  return filePath;
5641
6525
  }
5642
6526
  if (hasTextChanged(prev, next) && prev.length > 0) {
5643
6527
  const diffText = createUnifiedDiff(printable, prev, next);
5644
6528
  const summary = summarizeDiff(diffText);
5645
- log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
6529
+ say(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
5646
6530
  }
5647
6531
  await writeText(filePath, next);
5648
6532
  return filePath;
@@ -5650,20 +6534,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5650
6534
 
5651
6535
  // src/commands/workspace/setup.ts
5652
6536
  function resolveWorkspaceRoot(start = process.cwd()) {
5653
- let dir = path34.resolve(start);
6537
+ let dir = path43.resolve(start);
5654
6538
  for (; ; ) {
5655
- if (existsSync4(path34.join(dir, WORKSPACE_FILE))) return dir;
5656
- const parent = path34.dirname(dir);
5657
- if (parent === dir) return path34.resolve(start);
6539
+ if (existsSync4(path43.join(dir, WORKSPACE_FILE))) return dir;
6540
+ const parent = path43.dirname(dir);
6541
+ if (parent === dir) return path43.resolve(start);
5658
6542
  dir = parent;
5659
6543
  }
5660
6544
  }
5661
6545
  function isRootRepo(workspaceRoot, repoPath) {
5662
- return path34.resolve(workspaceRoot, repoPath) === path34.resolve(workspaceRoot);
6546
+ return path43.resolve(workspaceRoot, repoPath) === path43.resolve(workspaceRoot);
5663
6547
  }
5664
6548
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
5665
6549
  const apply = options.write ?? false;
5666
- const configText = await readText(path34.join(workspaceRoot, WORKSPACE_FILE));
6550
+ const configText = await readText(path43.join(workspaceRoot, WORKSPACE_FILE));
5667
6551
  if (!configText) {
5668
6552
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5669
6553
  process.exitCode = 1;
@@ -5675,22 +6559,34 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5675
6559
  process.exitCode = 1;
5676
6560
  return { workspaceRoot, statuses: [], written: [] };
5677
6561
  }
6562
+ const say = options.json ? () => {
6563
+ } : log;
5678
6564
  const onlySet = options.only && options.only.length > 0 ? new Set(options.only) : void 0;
6565
+ if (onlySet) {
6566
+ const unknown = [...onlySet].filter((name) => !config.repos.some((r) => r.name === name));
6567
+ if (unknown.length > 0) {
6568
+ error(`--only matched no configured repo(s): ${unknown.join(", ")}`);
6569
+ process.exitCode = 1;
6570
+ return { workspaceRoot, statuses: [], written: [] };
6571
+ }
6572
+ }
5679
6573
  const repos = onlySet ? config.repos.filter((r) => onlySet.has(r.name)) : config.repos;
5680
6574
  const statuses = [];
5681
6575
  const aggregateInputs = [];
5682
6576
  for (const repo of repos) {
5683
- const repoRoot = path34.resolve(workspaceRoot, repo.path);
5684
- log(`
6577
+ const repoRoot = path43.resolve(workspaceRoot, repo.path);
6578
+ say(`
5685
6579
  \u2192 ${repo.name} (${repo.path})`);
5686
6580
  try {
5687
6581
  if (!existsSync4(repoRoot) || !statSync(repoRoot).isDirectory()) {
5688
6582
  throw new Error(`Repo path is not a directory: ${repo.path}`);
5689
6583
  }
5690
6584
  const res = await runSetupCore(repoRoot, {
5691
- json: options.json,
5692
6585
  apply,
5693
- dryRun: options.dryRun
6586
+ dryRun: options.dryRun,
6587
+ // Under --json, mute the inner pipeline entirely; we serialize the
6588
+ // aggregated result below. Otherwise let it print its normal human output.
6589
+ quiet: options.json
5694
6590
  });
5695
6591
  statuses.push({
5696
6592
  name: repo.name,
@@ -5730,9 +6626,10 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5730
6626
  client: config.client,
5731
6627
  members: config.repos.map((r) => ({ name: r.name, path: r.path })),
5732
6628
  collision,
5733
- dryRun: options.dryRun
6629
+ dryRun: options.dryRun,
6630
+ quiet: options.json
5734
6631
  });
5735
- written.push(docPath);
6632
+ if (!options.dryRun) written.push(docPath);
5736
6633
  }
5737
6634
  if (apply && !options.dryRun) {
5738
6635
  const statusByName = new Map(statuses.map((s) => [s.name, s]));
@@ -5748,7 +6645,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5748
6645
  const status = statusByName.get(repo.name);
5749
6646
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
5750
6647
  if (status?.status === "ok") {
5751
- const lock = await checkLock(path34.resolve(workspaceRoot, repo.path));
6648
+ const lock = await checkLock(path43.resolve(workspaceRoot, repo.path));
5752
6649
  manifestRepos.push({
5753
6650
  name: repo.name,
5754
6651
  path: repo.path,
@@ -5797,9 +6694,17 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5797
6694
  }
5798
6695
  const ok = statuses.filter((s) => s.status === "ok").length;
5799
6696
  const failed = statuses.length - ok;
5800
- log(`
5801
- Workspace setup complete: ${ok} ok, ${failed} failed.`);
5802
- return { workspaceRoot, statuses, written };
6697
+ const result = { workspaceRoot, statuses, written };
6698
+ if (options.json) {
6699
+ log(JSON.stringify(result, null, 2));
6700
+ } else if (!apply) {
6701
+ say(`
6702
+ Workspace preview complete: ${ok} ok, ${failed} failed. Re-run with --write to apply.`);
6703
+ } else {
6704
+ say(`
6705
+ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} file(s).`);
6706
+ }
6707
+ return result;
5803
6708
  }
5804
6709
 
5805
6710
  // src/commands/workspace.ts
@@ -5812,7 +6717,9 @@ function normalizeOnly(only) {
5812
6717
  function normalizeMaxDepth(maxDepth) {
5813
6718
  if (maxDepth === void 0) return void 0;
5814
6719
  const n = typeof maxDepth === "number" ? maxDepth : Number.parseInt(maxDepth, 10);
5815
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
6720
+ if (Number.isFinite(n) && n > 0) return Math.floor(n);
6721
+ warn(`Ignoring invalid --max-depth "${String(maxDepth)}"; using the default depth.`);
6722
+ return void 0;
5816
6723
  }
5817
6724
  async function initWorkspace() {
5818
6725
  await writeText(
@@ -5828,7 +6735,7 @@ relationships: []
5828
6735
  log("Workspace initialized.");
5829
6736
  }
5830
6737
  async function scanWorkspace(workspaceRoot, opts) {
5831
- const configText = await readText(path35.join(workspaceRoot, WORKSPACE_FILE));
6738
+ const configText = await readText(path44.join(workspaceRoot, WORKSPACE_FILE));
5832
6739
  if (!configText) {
5833
6740
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5834
6741
  process.exitCode = 1;
@@ -5848,20 +6755,31 @@ async function scanWorkspace(workspaceRoot, opts) {
5848
6755
  return;
5849
6756
  }
5850
6757
  const inputs = [];
6758
+ const failures = [];
5851
6759
  for (const repo of config.repos) {
5852
- const repoRoot = path35.resolve(workspaceRoot, repo.path);
5853
- if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
5854
- throw new Error(`Repo path is not a directory: ${repo.path}`);
6760
+ const repoRoot = path44.resolve(workspaceRoot, repo.path);
6761
+ try {
6762
+ if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
6763
+ throw new Error("path is not a directory");
6764
+ }
6765
+ const result = await scanProject(repoRoot);
6766
+ inputs.push({ name: repo.name, path: repo.path, context: result });
6767
+ } catch (err) {
6768
+ failures.push(
6769
+ `${repo.name} (${repo.path}): ${err instanceof Error ? err.message : String(err)}`
6770
+ );
5855
6771
  }
5856
- const result = await scanProject(repoRoot);
5857
- inputs.push({ name: repo.name, path: repo.path, context: result });
5858
6772
  }
5859
6773
  const written = await writeWorkspaceArtifacts(workspaceRoot, inputs, config.relationships);
5860
6774
  if (opts.json) {
5861
- log(JSON.stringify({ written }, null, 2));
6775
+ log(JSON.stringify({ written, scanned: inputs.length, failed: failures }, null, 2));
5862
6776
  } else {
5863
- log(`Workspace scan complete. Wrote ${written.length} artifact(s) under .haus-workflow/.`);
6777
+ log(
6778
+ `Workspace scan complete. Scanned ${inputs.length} repo(s); wrote ${written.length} artifact(s) under .haus-workflow/.`
6779
+ );
6780
+ for (const f of failures) warn(`- skipped ${f}`);
5864
6781
  }
6782
+ if (failures.length > 0) process.exitCode = 1;
5865
6783
  }
5866
6784
  async function runWorkspace(action, options = {}) {
5867
6785
  if (action === "init") {
@@ -5899,7 +6817,7 @@ async function runWorkspace(action, options = {}) {
5899
6817
  // src/cli.ts
5900
6818
  function cliVersion() {
5901
6819
  try {
5902
- const pkgPath = path36.join(packageRoot(), "package.json");
6820
+ const pkgPath = path45.join(packageRoot(), "package.json");
5903
6821
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5904
6822
  return pkg.version ?? "0.0.0";
5905
6823
  } catch {
@@ -5909,7 +6827,7 @@ function cliVersion() {
5909
6827
  var program = new Command();
5910
6828
  function validateRuntimeNodeVersion() {
5911
6829
  try {
5912
- const pkgPath = path36.join(packageRoot(), "package.json");
6830
+ const pkgPath = path45.join(packageRoot(), "package.json");
5913
6831
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5914
6832
  const requiredRange = pkg.engines?.node;
5915
6833
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -5930,6 +6848,7 @@ program.command("recommend").option("--json").option(
5930
6848
  ).action(runRecommend);
5931
6849
  program.command("setup-project").option("--json").action(runSetupProject);
5932
6850
  program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
6851
+ program.command("fetch-refs").description("Fetch and cache llms.txt content from catalog item references").option("--all", "Fetch for all catalog items (default)").option("--id <id>", "Fetch for a single catalog item by id").option("--json", "Emit machine-readable JSON summary to stdout").action(runFetchRefs);
5933
6852
  program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
5934
6853
  "--ids <ids...>",
5935
6854
  "Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
@@ -5958,6 +6877,17 @@ program.command("uninstall").option("--force").action(runUninstallCommand);
5958
6877
  var guard = program.command("guard");
5959
6878
  guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
5960
6879
  guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
6880
+ function registerDecisionsCommand(name) {
6881
+ const cmd = program.command(name).description("Architecture decision records (ADR) gate and drafts");
6882
+ cmd.command("check").option("--staged", "Check staged changes only").option("--range <range>", "Git revision range (e.g. origin/main..HEAD)").action((opts) => runDecisions("check", void 0, opts));
6883
+ cmd.command("suggest").option("--from-hook", "Emit JSON for Claude Code Stop hook").option("--title <title>", "Draft title").action(
6884
+ (opts) => runDecisions("suggest", void 0, opts)
6885
+ );
6886
+ cmd.command("next-number").action(() => runDecisions("next-number"));
6887
+ cmd.command("validate").argument("[path]", "Decision markdown file").action((filePath) => runDecisions("validate", filePath));
6888
+ }
6889
+ registerDecisionsCommand("decisions");
6890
+ registerDecisionsCommand("adr");
5961
6891
  var workspace = program.command("workspace");
5962
6892
  workspace.command("init").action(() => runWorkspace("init"));
5963
6893
  workspace.command("discover").description("Auto-find member repos and write/merge haus.workspace.yaml").option("--write", "Persist haus.workspace.yaml (default previews only)").option("--json", "Output the discovered repos and proposed config as JSON").option("--max-depth <n>", "Max directory depth to traverse (default 3)").option("--client <name>", "Set the workspace client name").action((opts) => runWorkspace("discover", opts));