@haus-tech/haus-workflow 0.26.1 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,13 +2,12 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { readFileSync as readFileSync4 } from "fs";
5
- import path34 from "path";
5
+ import path35 from "path";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/commands/apply.ts
9
9
  import path13 from "path";
10
10
  import checkbox from "@inquirer/checkbox";
11
- import fs13 from "fs-extra";
12
11
 
13
12
  // src/catalog/remote-catalog.ts
14
13
  import os from "os";
@@ -20,17 +19,44 @@ import crypto from "crypto";
20
19
  import path from "path";
21
20
  import fg from "fast-glob";
22
21
  import fs from "fs-extra";
23
- async function readJson(file) {
22
+ var MalformedJsonFileError = class extends Error {
23
+ constructor(filePath, backupPath) {
24
+ super(`Refusing to modify ${filePath}: invalid JSON. Backup written to ${backupPath}`);
25
+ this.filePath = filePath;
26
+ this.backupPath = backupPath;
27
+ }
28
+ filePath;
29
+ backupPath;
30
+ name = "MalformedJsonFileError";
31
+ };
32
+ async function readJsonDetailed(file) {
24
33
  try {
25
- return await fs.readJson(file);
26
- } catch {
27
- return void 0;
34
+ if (!await fs.pathExists(file)) return { status: "missing" };
35
+ const raw = await fs.readFile(file, "utf8");
36
+ try {
37
+ return { status: "ok", value: JSON.parse(raw) };
38
+ } catch (error2) {
39
+ return { status: "invalid", error: error2 };
40
+ }
41
+ } catch (error2) {
42
+ if (error2.code === "ENOENT") return { status: "missing" };
43
+ throw error2;
28
44
  }
29
45
  }
46
+ async function backupMalformedJsonFile(file) {
47
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
48
+ const backupPath = `${file}.haus-malformed-${stamp}.bak`;
49
+ await fs.copy(file, backupPath);
50
+ return backupPath;
51
+ }
52
+ async function readJson(file) {
53
+ const result = await readJsonDetailed(file);
54
+ if (result.status === "ok") return result.value;
55
+ return void 0;
56
+ }
30
57
  async function writeJson(file, value) {
31
- await fs.ensureDir(path.dirname(file));
32
- await fs.writeFile(file, `${JSON.stringify(value, null, 2)}
33
- `, "utf8");
58
+ await atomicWriteText(file, `${JSON.stringify(value, null, 2)}
59
+ `);
34
60
  }
35
61
  async function pruneEmptyDir(dir) {
36
62
  try {
@@ -47,8 +73,17 @@ async function readText(file) {
47
73
  }
48
74
  }
49
75
  async function writeText(file, value) {
50
- await fs.ensureDir(path.dirname(file));
51
- await fs.writeFile(file, value, "utf8");
76
+ await atomicWriteText(file, value);
77
+ }
78
+ async function atomicWriteText(file, value) {
79
+ const dir = path.dirname(file);
80
+ await fs.ensureDir(dir);
81
+ const tempPath = path.join(
82
+ dir,
83
+ `.tmp-haus-write-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
84
+ );
85
+ await fs.writeFile(tempPath, value, "utf8");
86
+ await fs.move(tempPath, file, { overwrite: true });
52
87
  }
53
88
  async function listFiles(root, patterns) {
54
89
  const files = await fg(patterns, {
@@ -85,7 +120,7 @@ var error = (msg, ...args) => {
85
120
  };
86
121
 
87
122
  // src/catalog/constants.ts
88
- var CATALOG_REPO_URL = "https://raw.githubusercontent.com/wearehaustech/haus-workflow-catalog";
123
+ var CATALOG_REPO_URL = "https://raw.githubusercontent.com/WeAreHausTech/haus-workflow-catalog";
89
124
  var CATALOG_GITHUB_API_URL = "https://api.github.com/repos/WeAreHausTech/haus-workflow-catalog";
90
125
  var SUPERPOWERS_SHARED_CATALOG_REL = "skills/superpowers/shared";
91
126
  var CATALOG_REF = process.env.HAUS_CATALOG_REF;
@@ -342,6 +377,21 @@ var POLLUTION_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constru
342
377
  function isNonEmptyString(value) {
343
378
  return typeof value === "string" && value.length > 0;
344
379
  }
380
+ function isStringArray(value) {
381
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
382
+ }
383
+ var REQUIRES_ANY_KEYS = ["stack", "dependency", "packageNamePattern", "role"];
384
+ function isRequiresAnyClause(value) {
385
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
386
+ const keys = Object.keys(value);
387
+ if (keys.length !== 1) return false;
388
+ const key = keys[0];
389
+ if (!REQUIRES_ANY_KEYS.includes(key)) return false;
390
+ return typeof value[key] === "string";
391
+ }
392
+ function isRequiresAnyArray(value) {
393
+ return Array.isArray(value) && value.every(isRequiresAnyClause);
394
+ }
345
395
  function safeParse(json) {
346
396
  return JSON.parse(json, (key, value) => {
347
397
  if (POLLUTION_KEYS.has(key)) return void 0;
@@ -400,6 +450,18 @@ function parseManifest(json) {
400
450
  return { ok: false, error: `${item.id}: curated item missing riskLevel` };
401
451
  }
402
452
  }
453
+ if (!isStringArray(item.tags)) {
454
+ return { ok: false, error: `${item.id}: tags must be a string array` };
455
+ }
456
+ if (!isStringArray(item.repoRoles)) {
457
+ return { ok: false, error: `${item.id}: repoRoles must be a string array` };
458
+ }
459
+ if (item.requiresAny !== void 0 && !isRequiresAnyArray(item.requiresAny)) {
460
+ return { ok: false, error: `${item.id}: requiresAny must be an array of clause objects` };
461
+ }
462
+ if (typeof item.tokenEstimate !== "number" || !Number.isFinite(item.tokenEstimate)) {
463
+ return { ok: false, error: `${item.id}: tokenEstimate must be a finite number` };
464
+ }
403
465
  items.push(raw);
404
466
  }
405
467
  return { ok: true, manifest: { version: root.version, items } };
@@ -818,6 +880,17 @@ async function syncRemoteCatalog() {
818
880
  return { newItems, refreshed, unchanged, failed };
819
881
  }
820
882
  var CATALOG_TAGS_API_URL = "https://api.github.com/repos/WeAreHausTech/haus-workflow-catalog/tags";
883
+ function parseSemverTag(tag) {
884
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag);
885
+ if (!match) return null;
886
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
887
+ }
888
+ function compareSemver(a, b) {
889
+ for (let i = 0; i < 3; i += 1) {
890
+ if (a[i] !== b[i]) return a[i] - b[i];
891
+ }
892
+ return 0;
893
+ }
821
894
  async function fetchLatestCatalogTag() {
822
895
  if (process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
823
896
  try {
@@ -827,7 +900,16 @@ async function fetchLatestCatalogTag() {
827
900
  });
828
901
  if (!res.ok) return null;
829
902
  const tags = await res.json();
830
- return tags[0]?.name ?? null;
903
+ const valid = tags.map((tag) => {
904
+ const name = typeof tag.name === "string" ? tag.name : "";
905
+ const semver2 = parseSemverTag(name);
906
+ return semver2 ? { name, semver: semver2 } : null;
907
+ }).filter(
908
+ (entry) => entry !== null
909
+ );
910
+ if (valid.length === 0) return null;
911
+ valid.sort((a, b) => compareSemver(b.semver, a.semver));
912
+ return valid[0].name;
831
913
  } catch {
832
914
  return null;
833
915
  }
@@ -841,6 +923,226 @@ async function getCacheManifestAge() {
841
923
  }
842
924
  }
843
925
 
926
+ // src/claude/write-claude-files.ts
927
+ import path12 from "path";
928
+ import fs12 from "fs-extra";
929
+
930
+ // src/catalog/load-catalog.ts
931
+ import path4 from "path";
932
+
933
+ // src/utils/paths.ts
934
+ import { existsSync, readFileSync } from "fs";
935
+ import os2 from "os";
936
+ import path3 from "path";
937
+ import { fileURLToPath } from "url";
938
+ var HAUS_DIR = ".haus-workflow";
939
+ function hausPath(root, ...parts) {
940
+ return path3.join(root, HAUS_DIR, ...parts);
941
+ }
942
+ function claudePath(root, ...parts) {
943
+ return path3.join(root, ".claude", ...parts);
944
+ }
945
+ function displayPath(root, targetPath) {
946
+ const rel = path3.relative(root, targetPath).replace(/\\/g, "/");
947
+ if (rel && !rel.startsWith("../") && rel !== "..") {
948
+ return rel.startsWith("./") ? rel : `./${rel}`;
949
+ }
950
+ const home = os2.homedir();
951
+ const normalized = targetPath.replace(/\\/g, "/");
952
+ if (home && home.trim().length > 0) {
953
+ const homeRel = path3.relative(home, targetPath).replace(/\\/g, "/");
954
+ if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
955
+ return `~/${homeRel}`;
956
+ }
957
+ }
958
+ return normalized;
959
+ }
960
+ function packageRoot() {
961
+ let dir = path3.dirname(fileURLToPath(import.meta.url));
962
+ for (let i = 0; i < 12; i++) {
963
+ const pkgPath = path3.join(dir, "package.json");
964
+ if (existsSync(pkgPath)) {
965
+ try {
966
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
967
+ if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
968
+ } catch {
969
+ }
970
+ }
971
+ const parent = path3.dirname(dir);
972
+ if (parent === dir) break;
973
+ dir = parent;
974
+ }
975
+ const file = fileURLToPath(import.meta.url);
976
+ return path3.resolve(path3.dirname(file), "../..");
977
+ }
978
+
979
+ // src/catalog/load-catalog.ts
980
+ async function loadCatalogContext(root) {
981
+ const envPath = process.env["HAUS_FIXTURE_CATALOG"];
982
+ if (envPath) {
983
+ const data2 = await readJson(envPath);
984
+ return {
985
+ items: data2?.items ?? [],
986
+ contentRoot: path4.dirname(envPath),
987
+ source: "fixture"
988
+ };
989
+ }
990
+ const cacheDir = getCacheDir();
991
+ const cacheManifestPath = path4.join(cacheDir, "manifest.json");
992
+ const cacheData = await readJson(cacheManifestPath);
993
+ if (cacheData?.items?.length) {
994
+ return { items: cacheData.items, contentRoot: cacheDir, source: "cache" };
995
+ }
996
+ const localManifest = path4.join(root, "library/catalog/manifest.json");
997
+ const localData = await readJson(localManifest);
998
+ if (localData?.items?.length) {
999
+ return {
1000
+ items: localData.items,
1001
+ contentRoot: path4.dirname(localManifest),
1002
+ source: "local"
1003
+ };
1004
+ }
1005
+ const packageManifest = path4.join(packageRoot(), "library/catalog/manifest.json");
1006
+ const data = await readJson(packageManifest);
1007
+ return {
1008
+ items: data?.items ?? [],
1009
+ contentRoot: path4.dirname(packageManifest),
1010
+ source: "bundled"
1011
+ };
1012
+ }
1013
+ async function loadCatalog(root) {
1014
+ const ctx = await loadCatalogContext(root);
1015
+ return ctx.items;
1016
+ }
1017
+ function catalogItemContentPath(contentRoot, item) {
1018
+ return path4.join(contentRoot, item.path);
1019
+ }
1020
+
1021
+ // src/update/hash-installed.ts
1022
+ import path5 from "path";
1023
+ import fg2 from "fast-glob";
1024
+ import fs3 from "fs-extra";
1025
+
1026
+ // src/claude/haus-managed-header.ts
1027
+ function parseHausManagedAttrs(line2) {
1028
+ const match = /^<!--\s+HAUS-MANAGED\b(.*?)-->$/.exec(line2.trim());
1029
+ if (!match) return null;
1030
+ const raw = match[1] ?? "";
1031
+ const pairs = [...raw.matchAll(/\b([a-z]+)=([^\s>]+)/g)];
1032
+ const attrs = {};
1033
+ for (const [, key, value] of pairs) attrs[key] = value;
1034
+ if (!attrs.id) return null;
1035
+ return {
1036
+ id: attrs.id,
1037
+ v: attrs.v,
1038
+ source: attrs.source,
1039
+ hash: attrs.hash
1040
+ };
1041
+ }
1042
+
1043
+ // src/claude/managed-template.ts
1044
+ function normaliseLF(content2) {
1045
+ return content2.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1046
+ }
1047
+ var SCHEMA_VERSION = 1;
1048
+ function parseHausManagedHeader(line2) {
1049
+ const attrs = parseHausManagedAttrs(line2);
1050
+ if (!attrs?.id) return null;
1051
+ const parsedV = attrs.v && /^\d+$/.test(attrs.v) ? Number(attrs.v) : void 0;
1052
+ const hash = attrs.hash && /^sha256-[a-f0-9]+$/.test(attrs.hash) ? attrs.hash : void 0;
1053
+ return {
1054
+ id: attrs.id,
1055
+ v: parsedV,
1056
+ source: attrs.source,
1057
+ hash
1058
+ };
1059
+ }
1060
+
1061
+ // src/update/hash-installed.ts
1062
+ var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
1063
+ async function hashInstalledPaths(root, relPaths) {
1064
+ if (relPaths.length === 0) {
1065
+ return hashText(EMPTY_LOCK_PATHS_TOKEN);
1066
+ }
1067
+ const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
1068
+ const fileDigests = [];
1069
+ for (const rel of normalized) {
1070
+ const abs = path5.join(root, rel);
1071
+ if (!await fs3.pathExists(abs)) continue;
1072
+ const stat = await fs3.stat(abs);
1073
+ if (stat.isFile()) {
1074
+ const body = await fs3.readFile(abs, "utf8");
1075
+ fileDigests.push({ rel, digest: hashText(normaliseLF(body)) });
1076
+ continue;
1077
+ }
1078
+ if (!stat.isDirectory()) continue;
1079
+ const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
1080
+ for (const sub of inner.sort()) {
1081
+ const relFile = path5.join(rel, sub).replace(/\\/g, "/");
1082
+ const absFile = path5.join(abs, sub);
1083
+ const body = await fs3.readFile(absFile, "utf8");
1084
+ fileDigests.push({ rel: relFile, digest: hashText(normaliseLF(body)) });
1085
+ }
1086
+ }
1087
+ if (fileDigests.length === 0) {
1088
+ return hashText(`${EMPTY_LOCK_PATHS_TOKEN}|${normalized.join("|")}`);
1089
+ }
1090
+ fileDigests.sort((a, b) => a.rel.localeCompare(b.rel));
1091
+ return hashText(fileDigests.map((f) => `${f.rel}=${f.digest}`).join("|"));
1092
+ }
1093
+
1094
+ // src/claude/managed-write.ts
1095
+ import fs4 from "fs-extra";
1096
+
1097
+ // src/utils/diff.ts
1098
+ import { createTwoFilesPatch } from "diff";
1099
+ function hasTextChanged(before, after) {
1100
+ return before !== after;
1101
+ }
1102
+ function createUnifiedDiff(filePath, before, after) {
1103
+ return createTwoFilesPatch(filePath, filePath, before, after, "before", "after", {
1104
+ context: 3
1105
+ });
1106
+ }
1107
+ function summarizeDiff(diffText) {
1108
+ const lines = diffText.split("\n");
1109
+ let additions = 0;
1110
+ let deletions = 0;
1111
+ for (const line2 of lines) {
1112
+ if (line2.startsWith("+++ ") || line2.startsWith("--- ")) continue;
1113
+ if (line2.startsWith("+")) additions += 1;
1114
+ if (line2.startsWith("-")) deletions += 1;
1115
+ }
1116
+ return { additions, deletions };
1117
+ }
1118
+
1119
+ // src/claude/managed-write.ts
1120
+ async function writeManagedText(root, filePath, nextText, dryRun) {
1121
+ const prev = await fs4.pathExists(filePath) ? await fs4.readFile(filePath, "utf8") : "";
1122
+ const printable = displayPath(root, filePath);
1123
+ if (dryRun) {
1124
+ if (!prev) {
1125
+ log(createUnifiedDiff(printable, "", nextText));
1126
+ } else if (hasTextChanged(prev, nextText)) {
1127
+ log(createUnifiedDiff(printable, prev, nextText));
1128
+ } else {
1129
+ log(`${printable}: unchanged`);
1130
+ }
1131
+ return;
1132
+ }
1133
+ if (hasTextChanged(prev, nextText) && prev.length > 0) {
1134
+ const diffText = createUnifiedDiff(printable, prev, nextText);
1135
+ const summary = summarizeDiff(diffText);
1136
+ log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
1137
+ }
1138
+ await writeText(filePath, nextText);
1139
+ }
1140
+ async function writeManagedJson(root, filePath, value, dryRun) {
1141
+ const nextText = `${JSON.stringify(value, null, 2)}
1142
+ `;
1143
+ await writeManagedText(root, filePath, nextText, dryRun);
1144
+ }
1145
+
844
1146
  // src/install/allow-rules.ts
845
1147
  var ALLOWED_SUBCOMMANDS = ["setup-project", "apply", "doctor", "scan", "recommend"];
846
1148
  function buildAllowRules() {
@@ -848,18 +1150,18 @@ function buildAllowRules() {
848
1150
  }
849
1151
 
850
1152
  // src/install/settings-merge.ts
851
- import path4 from "path";
852
- import fs3 from "fs-extra";
1153
+ import path7 from "path";
1154
+ import fs5 from "fs-extra";
853
1155
 
854
1156
  // src/install/manifest.ts
855
- import os2 from "os";
856
- import path3 from "path";
1157
+ import os3 from "os";
1158
+ import path6 from "path";
857
1159
  var MANIFEST_SCHEMA = "haus-install-manifest/1";
858
1160
  function globalClaudeDir() {
859
- return path3.join(os2.homedir(), ".claude");
1161
+ return path6.join(os3.homedir(), ".claude");
860
1162
  }
861
1163
  function hausManifestPath() {
862
- return path3.join(globalClaudeDir(), "haus", "install-manifest.json");
1164
+ return path6.join(globalClaudeDir(), "haus", "install-manifest.json");
863
1165
  }
864
1166
  async function readManifest() {
865
1167
  return readJson(hausManifestPath());
@@ -879,11 +1181,15 @@ function buildManifest(source, files, hooks) {
879
1181
 
880
1182
  // src/install/settings-merge.ts
881
1183
  function settingsJsonPath() {
882
- return path4.join(globalClaudeDir(), "settings.json");
1184
+ return path7.join(globalClaudeDir(), "settings.json");
883
1185
  }
884
1186
  async function readSettings() {
885
- const parsed = await readJson(settingsJsonPath());
886
- return parsed ?? {};
1187
+ const filePath = settingsJsonPath();
1188
+ const result = await readJsonDetailed(filePath);
1189
+ if (result.status === "ok") return result.value;
1190
+ if (result.status === "missing") return {};
1191
+ const backupPath = await backupMalformedJsonFile(filePath);
1192
+ throw new MalformedJsonFileError(filePath, backupPath);
887
1193
  }
888
1194
  async function writeSettings(settings) {
889
1195
  await writeJson(settingsJsonPath(), settings);
@@ -897,6 +1203,14 @@ function collectEventHookCommands(entries) {
897
1203
  }
898
1204
  return cmds;
899
1205
  }
1206
+ function pruneEntryCommands(entry, shouldDrop) {
1207
+ const keptHooks = (entry.hooks ?? []).filter((hook) => {
1208
+ const cmd = hook.command ?? "";
1209
+ return !cmd || !shouldDrop(cmd);
1210
+ });
1211
+ if (keptHooks.length === 0) return null;
1212
+ return { ...entry, hooks: keptHooks };
1213
+ }
900
1214
  var RETIRED_HAUS_HOOK_COMMANDS = ["haus context --from-hook"];
901
1215
  var RETIRED_HAUS_HOOK_IDS = ["hook.context", "haus.context-hook"];
902
1216
  function reconcileRetiredHausHooks(updated, fragments, priorHaus, addedIds, addedCommands) {
@@ -910,10 +1224,7 @@ function reconcileRetiredHausHooks(updated, fragments, priorHaus, addedIds, adde
910
1224
  }
911
1225
  const prunedHooks = {};
912
1226
  for (const [event, entries] of Object.entries(updated.hooks ?? {})) {
913
- const kept = entries.filter((entry) => {
914
- const cmds = (entry.hooks ?? []).map((h) => h.command).filter(Boolean);
915
- return cmds.length === 0 || !cmds.some((cmd) => retiredCommands.has(cmd));
916
- });
1227
+ const kept = entries.map((entry) => pruneEntryCommands(entry, (cmd) => retiredCommands.has(cmd))).filter((entry) => entry !== null);
917
1228
  if (kept.length > 0) prunedHooks[event] = kept;
918
1229
  }
919
1230
  updated.hooks = prunedHooks;
@@ -1064,10 +1375,12 @@ function stripHausHooks(settings) {
1064
1375
  const updated = { ...settings };
1065
1376
  updated.hooks = {};
1066
1377
  for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
1067
- const kept = entries.filter((entry) => {
1068
- const cmd = entry.hooks[0]?.command ?? "";
1069
- return usePrefix ? !cmd.startsWith("haus ") : !ownedCommands.has(cmd);
1070
- });
1378
+ const kept = entries.map(
1379
+ (entry) => pruneEntryCommands(
1380
+ entry,
1381
+ (cmd) => usePrefix ? cmd.startsWith("haus ") : ownedCommands.has(cmd)
1382
+ )
1383
+ ).filter((entry) => entry !== null);
1071
1384
  if (kept.length > 0) updated.hooks[event] = kept;
1072
1385
  }
1073
1386
  const { _haus: _, ...rest } = updated;
@@ -1113,7 +1426,7 @@ function stripHausAsk(settings) {
1113
1426
  async function loadHooksFragment(fragmentPath) {
1114
1427
  let raw;
1115
1428
  try {
1116
- raw = await fs3.readJson(fragmentPath);
1429
+ raw = await fs5.readJson(fragmentPath);
1117
1430
  } catch {
1118
1431
  return [];
1119
1432
  }
@@ -1126,6 +1439,8 @@ var DENY_COMMANDS = [
1126
1439
  "sudo",
1127
1440
  "chmod -R 777",
1128
1441
  "git push --force",
1442
+ "git push --force-with-lease",
1443
+ "git push -f",
1129
1444
  "drop database",
1130
1445
  "truncate table",
1131
1446
  "npm publish",
@@ -1153,6 +1468,20 @@ var DENY_PATHS = [
1153
1468
  "certs"
1154
1469
  ];
1155
1470
  var DENY_DIRS = /* @__PURE__ */ new Set(["customer-data", "secrets", "certs"]);
1471
+ var DENY_PATH_REGEXES = [
1472
+ /(^|\/)[^/]+\.pem$/i,
1473
+ /(^|\/)[^/]+\.key$/i,
1474
+ /(^|\/)[^/]+\.p12$/i,
1475
+ /(^|\/)[^/]+\.pfx$/i,
1476
+ /(^|\/)id_rsa$/i,
1477
+ /(^|\/)id_ed25519$/i,
1478
+ /(^|\/)customer-data(\/|$)/i,
1479
+ /(^|\/)secrets(\/|$)/i,
1480
+ /(^|\/)certs(\/|$)/i
1481
+ ];
1482
+ function normalizePathForMatch(candidate) {
1483
+ return candidate.replace(/\\/g, "/").replace(/\/+/g, "/").trim();
1484
+ }
1156
1485
  var ASK_PATHS = [
1157
1486
  { pattern: ".env", tools: ["Edit", "Write"] },
1158
1487
  { pattern: ".env.*", tools: ["Edit", "Write"] },
@@ -1190,275 +1519,77 @@ var SENSITIVE_ITEM_KEYWORDS = [
1190
1519
  ".pem",
1191
1520
  ".key"
1192
1521
  ];
1193
-
1194
- // src/security/ask-rules.ts
1195
- function buildAskRules() {
1196
- const rules = [];
1197
- for (const command of ASK_COMMANDS) {
1198
- rules.push(`Bash(${command}:*)`);
1199
- }
1200
- for (const { pattern, tools } of ASK_PATHS) {
1201
- for (const tool of tools) {
1202
- rules.push(`${tool}(${pattern})`);
1203
- }
1204
- }
1205
- return [...new Set(rules)];
1206
- }
1207
-
1208
- // src/security/deny-rules.ts
1209
- var FILE_TOOLS = ["Read", "Edit", "Write"];
1210
- function buildDenyRules() {
1211
- const rules = [];
1212
- for (const command of DENY_COMMANDS) {
1213
- rules.push(`Bash(${command}:*)`);
1214
- }
1215
- for (const path35 of DENY_PATHS) {
1216
- const pattern = DENY_DIRS.has(path35) ? `${path35}/**` : path35;
1217
- for (const tool of FILE_TOOLS) {
1218
- rules.push(`${tool}(${pattern})`);
1219
- }
1220
- }
1221
- return [...new Set(rules)];
1222
- }
1223
-
1224
- // src/utils/paths.ts
1225
- import { existsSync, readFileSync } from "fs";
1226
- import os3 from "os";
1227
- import path5 from "path";
1228
- import { fileURLToPath } from "url";
1229
- var HAUS_DIR = ".haus-workflow";
1230
- function hausPath(root, ...parts) {
1231
- return path5.join(root, HAUS_DIR, ...parts);
1232
- }
1233
- function claudePath(root, ...parts) {
1234
- return path5.join(root, ".claude", ...parts);
1235
- }
1236
- function displayPath(root, targetPath) {
1237
- const rel = path5.relative(root, targetPath).replace(/\\/g, "/");
1238
- if (rel && !rel.startsWith("../") && rel !== "..") {
1239
- return rel.startsWith("./") ? rel : `./${rel}`;
1240
- }
1241
- const home = os3.homedir();
1242
- const normalized = targetPath.replace(/\\/g, "/");
1243
- if (home && home.trim().length > 0) {
1244
- const homeRel = path5.relative(home, targetPath).replace(/\\/g, "/");
1245
- if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
1246
- return `~/${homeRel}`;
1247
- }
1248
- }
1249
- return normalized;
1250
- }
1251
- function packageRoot() {
1252
- let dir = path5.dirname(fileURLToPath(import.meta.url));
1253
- for (let i = 0; i < 12; i++) {
1254
- const pkgPath = path5.join(dir, "package.json");
1255
- if (existsSync(pkgPath)) {
1256
- try {
1257
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1258
- if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
1259
- } catch {
1260
- }
1261
- }
1262
- const parent = path5.dirname(dir);
1263
- if (parent === dir) break;
1264
- dir = parent;
1265
- }
1266
- const file = fileURLToPath(import.meta.url);
1267
- return path5.resolve(path5.dirname(file), "../..");
1268
- }
1269
-
1270
- // src/claude/merge-project-settings.ts
1271
- var PROJECT_HOOK_FRAGMENTS = [
1272
- {
1273
- id: "haus.guard-file",
1274
- gate: "keep",
1275
- event: "PreToolUse",
1276
- matcher: "Read|Edit|Write",
1277
- command: "haus guard file-access --from-hook"
1278
- },
1279
- {
1280
- id: "haus.guard-bash",
1281
- gate: "keep",
1282
- event: "PreToolUse",
1283
- matcher: "Bash",
1284
- command: "haus guard bash --from-hook"
1285
- }
1286
- ];
1287
- async function readProjectSettings(root) {
1288
- const parsed = await readJson(claudePath(root, "settings.json"));
1289
- return parsed ?? {};
1290
- }
1291
- async function writeProjectSettings(root, settings) {
1292
- await writeJson(claudePath(root, "settings.json"), settings);
1293
- }
1294
- async function mergeProjectSettings(root) {
1295
- const base = await readProjectSettings(root);
1296
- const { settings: withHooks } = mergeHooks(base, PROJECT_HOOK_FRAGMENTS);
1297
- const { settings: withDeny } = mergeDenyRules(withHooks, buildDenyRules());
1298
- const { settings: withAllow } = mergeAllowRules(withDeny, buildAllowRules());
1299
- const { settings: merged } = mergeAskRules(withAllow, buildAskRules());
1300
- return merged;
1301
- }
1302
- async function applyProjectSettingsMerge(root) {
1303
- const merged = await mergeProjectSettings(root);
1304
- await writeProjectSettings(root, merged);
1305
- return merged;
1306
- }
1307
-
1308
- // src/claude/write-claude-files.ts
1309
- import path12 from "path";
1310
- import fs12 from "fs-extra";
1311
-
1312
- // src/catalog/load-catalog.ts
1313
- import path6 from "path";
1314
- async function loadCatalogContext(root) {
1315
- const envPath = process.env["HAUS_FIXTURE_CATALOG"];
1316
- if (envPath) {
1317
- const data2 = await readJson(envPath);
1318
- return {
1319
- items: data2?.items ?? [],
1320
- contentRoot: path6.dirname(envPath),
1321
- source: "fixture"
1322
- };
1323
- }
1324
- const cacheDir = getCacheDir();
1325
- const cacheManifestPath = path6.join(cacheDir, "manifest.json");
1326
- const cacheData = await readJson(cacheManifestPath);
1327
- if (cacheData?.items?.length) {
1328
- return { items: cacheData.items, contentRoot: cacheDir, source: "cache" };
1329
- }
1330
- const localManifest = path6.join(root, "library/catalog/manifest.json");
1331
- const localData = await readJson(localManifest);
1332
- if (localData?.items?.length) {
1333
- return {
1334
- items: localData.items,
1335
- contentRoot: path6.dirname(localManifest),
1336
- source: "local"
1337
- };
1338
- }
1339
- const packageManifest = path6.join(packageRoot(), "library/catalog/manifest.json");
1340
- const data = await readJson(packageManifest);
1341
- return {
1342
- items: data?.items ?? [],
1343
- contentRoot: path6.dirname(packageManifest),
1344
- source: "bundled"
1345
- };
1346
- }
1347
- async function loadCatalog(root) {
1348
- const ctx = await loadCatalogContext(root);
1349
- return ctx.items;
1350
- }
1351
- function catalogItemContentPath(contentRoot, item) {
1352
- return path6.join(contentRoot, item.path);
1353
- }
1354
-
1355
- // src/update/hash-installed.ts
1356
- import path7 from "path";
1357
- import fg2 from "fast-glob";
1358
- import fs4 from "fs-extra";
1359
-
1360
- // src/claude/managed-template.ts
1361
- function normaliseLF(content2) {
1362
- return content2.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1363
- }
1364
- var SCHEMA_VERSION = 1;
1365
- function parseHausManagedHeader(line2) {
1366
- const match = line2.match(/<!-- HAUS-MANAGED id=([\w.:-]+)/);
1367
- if (!match) return null;
1368
- const vMatch = line2.match(/\bv=(\d+)/);
1369
- const sourceMatch = line2.match(/\bsource=([^\s]+)/);
1370
- const hashMatch = line2.match(/hash=(sha256-[a-f0-9]+)/);
1371
- return {
1372
- id: match[1],
1373
- v: vMatch ? Number(vMatch[1]) : void 0,
1374
- source: sourceMatch?.[1],
1375
- hash: hashMatch?.[1]
1376
- };
1377
- }
1378
-
1379
- // src/update/hash-installed.ts
1380
- var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
1381
- async function hashInstalledPaths(root, relPaths) {
1382
- if (relPaths.length === 0) {
1383
- return hashText(EMPTY_LOCK_PATHS_TOKEN);
1522
+
1523
+ // src/security/ask-rules.ts
1524
+ function buildAskRules() {
1525
+ const rules = [];
1526
+ for (const command of ASK_COMMANDS) {
1527
+ rules.push(`Bash(${command}:*)`);
1384
1528
  }
1385
- const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
1386
- const fileDigests = [];
1387
- for (const rel of normalized) {
1388
- const abs = path7.join(root, rel);
1389
- if (!await fs4.pathExists(abs)) continue;
1390
- const stat = await fs4.stat(abs);
1391
- if (stat.isFile()) {
1392
- const body = await fs4.readFile(abs, "utf8");
1393
- fileDigests.push({ rel, digest: hashText(normaliseLF(body)) });
1394
- continue;
1395
- }
1396
- if (!stat.isDirectory()) continue;
1397
- const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
1398
- for (const sub of inner.sort()) {
1399
- const relFile = path7.join(rel, sub).replace(/\\/g, "/");
1400
- const absFile = path7.join(abs, sub);
1401
- const body = await fs4.readFile(absFile, "utf8");
1402
- fileDigests.push({ rel: relFile, digest: hashText(normaliseLF(body)) });
1529
+ for (const { pattern, tools } of ASK_PATHS) {
1530
+ for (const tool of tools) {
1531
+ rules.push(`${tool}(${pattern})`);
1403
1532
  }
1404
1533
  }
1405
- if (fileDigests.length === 0) {
1406
- return hashText(`${EMPTY_LOCK_PATHS_TOKEN}|${normalized.join("|")}`);
1407
- }
1408
- fileDigests.sort((a, b) => a.rel.localeCompare(b.rel));
1409
- return hashText(fileDigests.map((f) => `${f.rel}=${f.digest}`).join("|"));
1534
+ return [...new Set(rules)];
1410
1535
  }
1411
1536
 
1412
- // src/claude/managed-write.ts
1413
- import fs5 from "fs-extra";
1414
-
1415
- // src/utils/diff.ts
1416
- import { createTwoFilesPatch } from "diff";
1417
- function hasTextChanged(before, after) {
1418
- return before !== after;
1419
- }
1420
- function createUnifiedDiff(filePath, before, after) {
1421
- return createTwoFilesPatch(filePath, filePath, before, after, "before", "after", {
1422
- context: 3
1423
- });
1424
- }
1425
- function summarizeDiff(diffText) {
1426
- const lines = diffText.split("\n");
1427
- let additions = 0;
1428
- let deletions = 0;
1429
- for (const line2 of lines) {
1430
- if (line2.startsWith("+++ ") || line2.startsWith("--- ")) continue;
1431
- if (line2.startsWith("+")) additions += 1;
1432
- if (line2.startsWith("-")) deletions += 1;
1537
+ // src/security/deny-rules.ts
1538
+ var FILE_TOOLS = ["Read", "Edit", "Write"];
1539
+ function buildDenyRules() {
1540
+ const rules = [];
1541
+ for (const command of DENY_COMMANDS) {
1542
+ rules.push(`Bash(${command}:*)`);
1433
1543
  }
1434
- return { additions, deletions };
1435
- }
1436
-
1437
- // src/claude/managed-write.ts
1438
- async function writeManagedText(root, filePath, nextText, dryRun) {
1439
- const prev = await fs5.pathExists(filePath) ? await fs5.readFile(filePath, "utf8") : "";
1440
- const printable = displayPath(root, filePath);
1441
- if (dryRun) {
1442
- if (!prev) {
1443
- log(createUnifiedDiff(printable, "", nextText));
1444
- } else if (hasTextChanged(prev, nextText)) {
1445
- log(createUnifiedDiff(printable, prev, nextText));
1446
- } else {
1447
- log(`${printable}: unchanged`);
1544
+ for (const path36 of DENY_PATHS) {
1545
+ const pattern = DENY_DIRS.has(path36) ? `${path36}/**` : path36;
1546
+ for (const tool of FILE_TOOLS) {
1547
+ rules.push(`${tool}(${pattern})`);
1448
1548
  }
1449
- return;
1450
1549
  }
1451
- if (hasTextChanged(prev, nextText) && prev.length > 0) {
1452
- const diffText = createUnifiedDiff(printable, prev, nextText);
1453
- const summary = summarizeDiff(diffText);
1454
- log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
1550
+ return [...new Set(rules)];
1551
+ }
1552
+
1553
+ // src/claude/merge-project-settings.ts
1554
+ var PROJECT_HOOK_FRAGMENTS = [
1555
+ {
1556
+ id: "haus.guard-file",
1557
+ gate: "keep",
1558
+ event: "PreToolUse",
1559
+ matcher: "Read|Edit|Write",
1560
+ command: "haus guard file-access --from-hook"
1561
+ },
1562
+ {
1563
+ id: "haus.guard-bash",
1564
+ gate: "keep",
1565
+ event: "PreToolUse",
1566
+ matcher: "Bash",
1567
+ command: "haus guard bash --from-hook"
1455
1568
  }
1456
- await writeText(filePath, nextText);
1569
+ ];
1570
+ async function readProjectSettings(root) {
1571
+ const filePath = claudePath(root, "settings.json");
1572
+ const result = await readJsonDetailed(filePath);
1573
+ if (result.status === "ok") return result.value;
1574
+ if (result.status === "missing") return {};
1575
+ const backupPath = await backupMalformedJsonFile(filePath);
1576
+ throw new MalformedJsonFileError(filePath, backupPath);
1457
1577
  }
1458
- async function writeManagedJson(root, filePath, value, dryRun) {
1459
- const nextText = `${JSON.stringify(value, null, 2)}
1460
- `;
1461
- await writeManagedText(root, filePath, nextText, dryRun);
1578
+ async function writeProjectSettings(root, settings) {
1579
+ await writeJson(claudePath(root, "settings.json"), settings);
1580
+ }
1581
+ async function mergeProjectSettings(root) {
1582
+ const base = await readProjectSettings(root);
1583
+ const { settings: withHooks } = mergeHooks(base, PROJECT_HOOK_FRAGMENTS);
1584
+ const { settings: withDeny } = mergeDenyRules(withHooks, buildDenyRules());
1585
+ const { settings: withAllow } = mergeAllowRules(withDeny, buildAllowRules());
1586
+ const { settings: merged } = mergeAskRules(withAllow, buildAskRules());
1587
+ return merged;
1588
+ }
1589
+ async function applyProjectSettingsMerge(root) {
1590
+ const merged = await mergeProjectSettings(root);
1591
+ await writeProjectSettings(root, merged);
1592
+ return merged;
1462
1593
  }
1463
1594
 
1464
1595
  // src/claude/superpowers-install.ts
@@ -1613,25 +1744,50 @@ function buildImportBlock() {
1613
1744
  ${IMPORT_CONTENT}
1614
1745
  ${BLOCK_END}`;
1615
1746
  }
1747
+ function findLineMarker(content2, marker, from = 0) {
1748
+ let idx = content2.indexOf(marker, from);
1749
+ while (idx !== -1) {
1750
+ const lineStart = idx === 0 || content2[idx - 1] === "\n";
1751
+ const after = idx + marker.length;
1752
+ const lineEnd = after === content2.length || content2[after] === "\n" || content2[after] === "\r";
1753
+ if (lineStart && lineEnd) return { start: idx, end: after };
1754
+ idx = content2.indexOf(marker, idx + marker.length);
1755
+ }
1756
+ return null;
1757
+ }
1758
+ function findImportBlockRange(content2) {
1759
+ const begin = findLineMarker(content2, BLOCK_BEGIN);
1760
+ if (!begin) return null;
1761
+ const end = findLineMarker(content2, BLOCK_END, begin.end);
1762
+ if (!end) return null;
1763
+ return { beginStart: begin.start, beginEnd: begin.end, endStart: end.start, endEnd: end.end };
1764
+ }
1616
1765
  function stripHausBlock(existing) {
1617
- const beginIdx = existing.indexOf(BLOCK_BEGIN);
1618
- if (beginIdx === -1) return existing;
1619
- const endIdx = existing.indexOf(BLOCK_END, beginIdx + BLOCK_BEGIN.length);
1620
- if (endIdx === -1) return existing;
1621
- const before = existing.slice(0, beginIdx);
1622
- const after = existing.slice(endIdx + BLOCK_END.length);
1766
+ const range = findImportBlockRange(existing);
1767
+ if (!range) return existing;
1768
+ const before = existing.slice(0, range.beginStart);
1769
+ const after = existing.slice(range.endEnd);
1623
1770
  const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
1624
1771
  return merged.length > 0 ? `${merged}
1625
1772
  ` : "";
1626
1773
  }
1627
1774
  function injectHausBlock(existing, block) {
1628
- const beginIdx = existing.indexOf(BLOCK_BEGIN);
1629
- const endIdx = beginIdx === -1 ? -1 : existing.indexOf(BLOCK_END, beginIdx + BLOCK_BEGIN.length);
1630
- if (beginIdx !== -1 && endIdx !== -1) {
1631
- const before = existing.slice(0, beginIdx);
1632
- const after = existing.slice(endIdx + BLOCK_END.length);
1775
+ const range = findImportBlockRange(existing);
1776
+ if (range) {
1777
+ const before = existing.slice(0, range.beginStart);
1778
+ const after = existing.slice(range.endEnd);
1633
1779
  return `${before}${block}${after}`;
1634
1780
  }
1781
+ const loneBegin = findLineMarker(existing, BLOCK_BEGIN);
1782
+ if (loneBegin) {
1783
+ const before = existing.slice(0, loneBegin.start).trimEnd();
1784
+ if (before.length === 0) return `${block}
1785
+ `;
1786
+ return `${before}
1787
+
1788
+ ${block}
1789
+ `;
1790
+ }
1635
1791
  const trimmed = existing.trimEnd();
1636
1792
  if (trimmed.length === 0) {
1637
1793
  return `${block}
@@ -1816,6 +1972,12 @@ ${templateContent}`;
1816
1972
  return null;
1817
1973
  }
1818
1974
  const existingContent = existing.slice(firstLine.length + 1);
1975
+ if (!parsed.hash && !force) {
1976
+ warn(
1977
+ `${printable}: managed header missing hash \u2014 cannot verify tamper status, skipping. Use --force to overwrite.`
1978
+ );
1979
+ return null;
1980
+ }
1819
1981
  if (parsed.hash && hashText(normaliseLF(existingContent)) !== parsed.hash && !force) {
1820
1982
  warn(`${printable}: content modified by user \u2014 skipping. Use --force to overwrite.`);
1821
1983
  return null;
@@ -2174,18 +2336,6 @@ async function runApply(options) {
2174
2336
  files.forEach((f) => log(`- ${displayPath(root, f)}`));
2175
2337
  }
2176
2338
  }
2177
- async function isHausProject(root) {
2178
- if (await fs13.pathExists(hausPath(root, "recommendation.json"))) return true;
2179
- if (await fs13.pathExists(claudePath(root, "settings.json"))) {
2180
- const settings = await readProjectSettings(root);
2181
- if (settings._haus != null) return true;
2182
- }
2183
- return false;
2184
- }
2185
- async function refreshProjectApply(root) {
2186
- if (!await isHausProject(root)) return [];
2187
- return writeClaudeFiles(root, false, void 0, { refillConfig: false });
2188
- }
2189
2339
 
2190
2340
  // src/commands/catalog-audit.ts
2191
2341
  function auditForbiddenTags(items) {
@@ -2287,7 +2437,7 @@ async function runClone(url, opts = {}) {
2287
2437
 
2288
2438
  // src/commands/doctor.ts
2289
2439
  import path19 from "path";
2290
- import fs15 from "fs-extra";
2440
+ import fs14 from "fs-extra";
2291
2441
 
2292
2442
  // src/scanner/scan-project.ts
2293
2443
  import path18 from "path";
@@ -2318,7 +2468,7 @@ function compareVersions(a, b) {
2318
2468
 
2319
2469
  // src/scanner/detect-package-manager.ts
2320
2470
  import path15 from "path";
2321
- import fs14 from "fs-extra";
2471
+ import fs13 from "fs-extra";
2322
2472
  function detectPackageManager(root, packageManagerField) {
2323
2473
  const field = String(packageManagerField ?? "").trim();
2324
2474
  if (field.startsWith("yarn@")) {
@@ -2336,9 +2486,9 @@ function detectPackageManager(root, packageManagerField) {
2336
2486
  if (satisfiesVersion(version, ">=9")) return "npm";
2337
2487
  return "unknown";
2338
2488
  }
2339
- if (fs14.existsSync(path15.join(root, "yarn.lock"))) return "yarn";
2340
- if (fs14.existsSync(path15.join(root, "pnpm-lock.yaml"))) return "pnpm";
2341
- if (fs14.existsSync(path15.join(root, "package-lock.json"))) return "npm";
2489
+ if (fs13.existsSync(path15.join(root, "yarn.lock"))) return "yarn";
2490
+ if (fs13.existsSync(path15.join(root, "pnpm-lock.yaml"))) return "pnpm";
2491
+ if (fs13.existsSync(path15.join(root, "package-lock.json"))) return "npm";
2342
2492
  return "unknown";
2343
2493
  }
2344
2494
 
@@ -2851,7 +3001,7 @@ async function runDoctor(options) {
2851
3001
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
2852
3002
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
2853
3003
  for (const target of importTargets) {
2854
- if (!await fs15.pathExists(hausPath(root, target))) {
3004
+ if (!await fs14.pathExists(hausPath(root, target))) {
2855
3005
  flag(
2856
3006
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
2857
3007
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -2862,7 +3012,7 @@ async function runDoctor(options) {
2862
3012
  }
2863
3013
  }
2864
3014
  const workflowPath = hausPath(root, "WORKFLOW.md");
2865
- const workflowExists = await fs15.pathExists(workflowPath);
3015
+ const workflowExists = await fs14.pathExists(workflowPath);
2866
3016
  if (!workflowExists) {
2867
3017
  flag(
2868
3018
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -2893,7 +3043,7 @@ async function runDoctor(options) {
2893
3043
  "templates",
2894
3044
  "agentic-workflow-standard.md"
2895
3045
  );
2896
- const templatePath = await fs15.pathExists(cachePath) ? cachePath : bundledPath;
3046
+ const templatePath = await fs14.pathExists(cachePath) ? cachePath : bundledPath;
2897
3047
  const templateContent = await readText(templatePath);
2898
3048
  if (storedHashMatch && templateContent) {
2899
3049
  const currentHash = hashText(normaliseLF(templateContent));
@@ -2913,7 +3063,7 @@ async function runDoctor(options) {
2913
3063
  }
2914
3064
  }
2915
3065
  const workflowConfigPath = hausPath(root, "workflow-config.md");
2916
- const workflowConfigExists = await fs15.pathExists(workflowConfigPath);
3066
+ const workflowConfigExists = await fs14.pathExists(workflowConfigPath);
2917
3067
  if (!workflowConfigExists) {
2918
3068
  flag(
2919
3069
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -2921,7 +3071,7 @@ async function runDoctor(options) {
2921
3071
  "haus apply --write"
2922
3072
  );
2923
3073
  } else {
2924
- const cfg = await fs15.readFile(workflowConfigPath, "utf8");
3074
+ const cfg = await fs14.readFile(workflowConfigPath, "utf8");
2925
3075
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
2926
3076
  if (unfilled > 0) {
2927
3077
  flag(
@@ -2961,9 +3111,6 @@ async function runDoctor(options) {
2961
3111
  `A newer haus (${npmStatus.latest}) is available`,
2962
3112
  `npm install -g ${NPM_PACKAGE_NAME}`
2963
3113
  );
2964
- if (!process.env["HAUS_FIXTURE_CATALOG"]) {
2965
- process.exitCode = 1;
2966
- }
2967
3114
  } else if (npmStatus.latest !== null) {
2968
3115
  ok(`- CLI: ${currentVersion} (up to date)`);
2969
3116
  } else {
@@ -3110,15 +3257,23 @@ async function runExplainRecommendation(options) {
3110
3257
  import { readFileSync as readFileSync2 } from "fs";
3111
3258
 
3112
3259
  // src/security/guard-bash.ts
3260
+ function matchesDenyToken(command, denyPhrase) {
3261
+ if (denyPhrase === "sudo") {
3262
+ return /(?:^|[|;&]\s*)sudo\b/i.test(command);
3263
+ }
3264
+ const normalizedCommand = command.toLowerCase().replace(/\s+/g, " ").trim();
3265
+ return normalizedCommand.includes(denyPhrase.toLowerCase());
3266
+ }
3113
3267
  function guardBash(command) {
3114
- const matched = DENY_COMMANDS.find((token) => command.includes(token));
3268
+ const matched = DENY_COMMANDS.find((denyPhrase) => matchesDenyToken(command, denyPhrase));
3115
3269
  if (matched) return `I didn't run that \u2014 it can permanently change or delete things: ${command}`;
3116
3270
  return void 0;
3117
3271
  }
3118
3272
 
3119
3273
  // src/security/guard-file-access.ts
3120
3274
  function guardFileAccess(candidate) {
3121
- const matched = DENY_PATHS.find((token) => candidate.includes(token.replace(/\*/g, "")));
3275
+ const normalized = normalizePathForMatch(candidate);
3276
+ const matched = DENY_PATH_REGEXES.find((re) => re.test(normalized));
3122
3277
  if (matched)
3123
3278
  return `I didn't open ${candidate} \u2014 it looks like it holds secrets or sensitive data`;
3124
3279
  return void 0;
@@ -3170,7 +3325,7 @@ async function runGuard(kind, _options) {
3170
3325
 
3171
3326
  // src/commands/init.ts
3172
3327
  import path20 from "path";
3173
- import fs16 from "fs-extra";
3328
+ import fs15 from "fs-extra";
3174
3329
 
3175
3330
  // src/utils/prompts.ts
3176
3331
  import { stdin as input, stdout as output } from "process";
@@ -3518,7 +3673,7 @@ async function runSetupProject(options) {
3518
3673
  async function runInit(options) {
3519
3674
  const root = process.cwd();
3520
3675
  const hausDir = path20.join(root, ".haus-workflow");
3521
- const alreadyInit = await fs16.pathExists(hausDir);
3676
+ const alreadyInit = await fs15.pathExists(hausDir);
3522
3677
  if (alreadyInit) {
3523
3678
  log("Haus AI already initialized in this project.");
3524
3679
  log("Run `haus setup-project` to reconfigure.");
@@ -3531,7 +3686,7 @@ async function runInit(options) {
3531
3686
  // src/install/apply.ts
3532
3687
  import crypto2 from "crypto";
3533
3688
  import path21 from "path";
3534
- import fs17 from "fs-extra";
3689
+ import fs16 from "fs-extra";
3535
3690
 
3536
3691
  // src/install/header.ts
3537
3692
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -3539,11 +3694,9 @@ var MD_SUFFIX = " -->";
3539
3694
  var FM_FENCE = "---";
3540
3695
  var FM_KEY = "haus_managed";
3541
3696
  function parseAttrs(raw) {
3542
- const idMatch = /\bid=(\S+)/.exec(raw);
3543
- const vMatch = /\bv=(\S+)/.exec(raw);
3544
- const srcMatch = /\bsource=(\S+)/.exec(raw);
3545
- if (!idMatch || !vMatch || !srcMatch) return void 0;
3546
- return { stableId: idMatch[1], schemaVersion: vMatch[1], source: srcMatch[1] };
3697
+ const attrs = parseHausManagedAttrs(raw) ?? parseHausManagedAttrs(`<!-- HAUS-MANAGED ${raw.trim()} -->`);
3698
+ if (!attrs?.id || !attrs.v || !attrs.source) return void 0;
3699
+ return { stableId: attrs.id, schemaVersion: attrs.v, source: attrs.source };
3547
3700
  }
3548
3701
  function isFence(line2) {
3549
3702
  return (line2 ?? "").replace(/\r$/, "") === FM_FENCE;
@@ -3620,7 +3773,7 @@ function hashContent(content2) {
3620
3773
  function sourceVersion() {
3621
3774
  try {
3622
3775
  const pkgPath = path21.join(packageRoot(), "package.json");
3623
- const pkg = JSON.parse(fs17.readFileSync(pkgPath, "utf8"));
3776
+ const pkg = JSON.parse(fs16.readFileSync(pkgPath, "utf8"));
3624
3777
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
3625
3778
  } catch {
3626
3779
  return "haus@0.0.0";
@@ -3632,10 +3785,10 @@ function globalSrcDir() {
3632
3785
  function collectSourceFiles(srcDir, claudeDir) {
3633
3786
  const entries = [];
3634
3787
  const skillsDir = path21.join(srcDir, "skills");
3635
- if (fs17.pathExistsSync(skillsDir)) {
3636
- for (const skillName of fs17.readdirSync(skillsDir)) {
3788
+ if (fs16.pathExistsSync(skillsDir)) {
3789
+ for (const skillName of fs16.readdirSync(skillsDir)) {
3637
3790
  const skillFile = path21.join(skillsDir, skillName, "SKILL.md");
3638
- if (fs17.pathExistsSync(skillFile)) {
3791
+ if (fs16.pathExistsSync(skillFile)) {
3639
3792
  entries.push({
3640
3793
  stableId: `skill.${skillName}`,
3641
3794
  srcRelPath: path21.join("library", "global", "skills", skillName, "SKILL.md"),
@@ -3645,8 +3798,8 @@ function collectSourceFiles(srcDir, claudeDir) {
3645
3798
  }
3646
3799
  }
3647
3800
  const commandsDir = path21.join(srcDir, "commands");
3648
- if (fs17.pathExistsSync(commandsDir)) {
3649
- for (const fileName of fs17.readdirSync(commandsDir)) {
3801
+ if (fs16.pathExistsSync(commandsDir)) {
3802
+ for (const fileName of fs16.readdirSync(commandsDir)) {
3650
3803
  if (!fileName.endsWith(".md")) continue;
3651
3804
  const commandName = fileName.slice(0, -".md".length);
3652
3805
  entries.push({
@@ -3696,7 +3849,7 @@ async function applyInstall(options = {}) {
3696
3849
  }
3697
3850
  continue;
3698
3851
  }
3699
- const destExists = fs17.pathExistsSync(entry.destPath);
3852
+ const destExists = fs16.pathExistsSync(entry.destPath);
3700
3853
  if (destExists) {
3701
3854
  const currentContent = await readText(entry.destPath);
3702
3855
  if (currentContent !== void 0) {
@@ -3744,13 +3897,13 @@ async function applyInstall(options = {}) {
3744
3897
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
3745
3898
  for (const entry of existingManifest.files) {
3746
3899
  if (currentDestPaths.has(entry.destPath)) continue;
3747
- if (!fs17.pathExistsSync(entry.destPath)) continue;
3900
+ if (!fs16.pathExistsSync(entry.destPath)) continue;
3748
3901
  const content2 = await readText(entry.destPath);
3749
3902
  if (!content2) continue;
3750
3903
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
3751
3904
  const currentHash = hashContent(content2);
3752
3905
  if (hasHeader && currentHash === entry.hash) {
3753
- if (!dryRun) await fs17.remove(entry.destPath);
3906
+ if (!dryRun) await fs16.remove(entry.destPath);
3754
3907
  result.deleted.push(entry.destPath);
3755
3908
  } else {
3756
3909
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -3761,8 +3914,7 @@ async function applyInstall(options = {}) {
3761
3914
  if (!dryRun && !check) {
3762
3915
  await writeSettings(mergedSettings);
3763
3916
  const manifest = buildManifest(source, manifestFiles, [
3764
- ...existingManifest?.hooks ?? [],
3765
- ...addedIds
3917
+ .../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...addedIds])
3766
3918
  ]);
3767
3919
  await writeManifest(manifest);
3768
3920
  }
@@ -3794,6 +3946,59 @@ function printApplyResult(result, dryRun) {
3794
3946
  }
3795
3947
  }
3796
3948
 
3949
+ // src/install/companion-tools.ts
3950
+ import { execaSync } from "execa";
3951
+ var COMPANION_TOOLS = [
3952
+ {
3953
+ bin: "caveman",
3954
+ label: "Caveman",
3955
+ blurb: "Ultra-compressed Claude Code responses \u2014 cuts output tokens ~75%.",
3956
+ installCmds: [
3957
+ "curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash"
3958
+ ],
3959
+ url: "https://github.com/JuliusBrussee/caveman"
3960
+ },
3961
+ {
3962
+ bin: "rtk",
3963
+ label: "RTK (Rust Token Killer)",
3964
+ blurb: "Token-optimized CLI proxy \u2014 60-90% savings on dev operations.",
3965
+ installCmds: [
3966
+ "brew install rtk",
3967
+ "cargo install --git https://github.com/rtk-ai/rtk # if Homebrew is unavailable"
3968
+ ],
3969
+ url: "https://github.com/rtk-ai/rtk",
3970
+ note: 'Another crate named "rtk" (Rust Type Kit) exists on crates.io \u2014 use the commands above, not a bare `cargo install rtk`.'
3971
+ }
3972
+ ];
3973
+ function buildCompanionSuggestions(tools, isInstalled) {
3974
+ const missing = tools.filter((t) => !isInstalled(t.bin));
3975
+ if (missing.length === 0) return [];
3976
+ const lines = ["", "Optional token-saving tools you can add:"];
3977
+ for (const tool of missing) {
3978
+ lines.push("", ` ${tool.label} \u2014 ${tool.blurb}`);
3979
+ for (const cmd of tool.installCmds) {
3980
+ lines.push(` ${cmd}`);
3981
+ }
3982
+ if (tool.note) lines.push(` note: ${tool.note}`);
3983
+ lines.push(` ${tool.url}`);
3984
+ }
3985
+ return lines;
3986
+ }
3987
+ function commandExists(bin) {
3988
+ try {
3989
+ const r = execaSync("sh", ["-c", 'command -v -- "$1"', "probe", bin], { reject: false });
3990
+ return r.exitCode === 0;
3991
+ } catch {
3992
+ return false;
3993
+ }
3994
+ }
3995
+ function printCompanionToolSuggestions(deps = {}) {
3996
+ const isInstalled = deps.isInstalled ?? commandExists;
3997
+ const emit2 = deps.log ?? log;
3998
+ const lines = buildCompanionSuggestions(COMPANION_TOOLS, isInstalled);
3999
+ for (const line2 of lines) emit2(line2);
4000
+ }
4001
+
3797
4002
  // src/commands/install.ts
3798
4003
  async function runInstall(options) {
3799
4004
  try {
@@ -3806,6 +4011,7 @@ async function runInstall(options) {
3806
4011
  if (options.check && result.drift) {
3807
4012
  process.exitCode = 1;
3808
4013
  } else if (!options.check && !options.dryRun) {
4014
+ printCompanionToolSuggestions();
3809
4015
  const total = result.created.length + result.updated.length;
3810
4016
  if (options.postinstall) {
3811
4017
  log("haus configured Claude Code for you:");
@@ -3871,7 +4077,7 @@ async function runScan(options) {
3871
4077
 
3872
4078
  // src/commands/undo.ts
3873
4079
  import path22 from "path";
3874
- import fs18 from "fs-extra";
4080
+ import fs17 from "fs-extra";
3875
4081
 
3876
4082
  // src/claude/managed-paths.ts
3877
4083
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md", "commands/haus-doctor.md"];
@@ -3893,25 +4099,25 @@ async function collectManagedPaths(root) {
3893
4099
  }
3894
4100
  const existing = [];
3895
4101
  for (const abs of paths) {
3896
- if (await fs18.pathExists(abs)) existing.push(abs);
4102
+ if (await fs17.pathExists(abs)) existing.push(abs);
3897
4103
  }
3898
4104
  return existing;
3899
4105
  }
3900
4106
  async function settingsHasHausContent(root) {
3901
4107
  const settingsPath = claudePath(root, "settings.json");
3902
- if (!await fs18.pathExists(settingsPath)) return false;
4108
+ if (!await fs17.pathExists(settingsPath)) return false;
3903
4109
  const settings = await readProjectSettings(root);
3904
4110
  return settings._haus != null;
3905
4111
  }
3906
4112
  async function claudeMdHasHausBlock(root) {
3907
4113
  const filePath = path22.join(root, "CLAUDE.md");
3908
- if (!await fs18.pathExists(filePath)) return false;
3909
- const text = await fs18.readFile(filePath, "utf8");
4114
+ if (!await fs17.pathExists(filePath)) return false;
4115
+ const text = await fs17.readFile(filePath, "utf8");
3910
4116
  return text.includes(BLOCK_BEGIN);
3911
4117
  }
3912
4118
  async function stripProjectSettings(root) {
3913
4119
  const settingsPath = claudePath(root, "settings.json");
3914
- if (!await fs18.pathExists(settingsPath)) return false;
4120
+ if (!await fs17.pathExists(settingsPath)) return false;
3915
4121
  let settings = await readProjectSettings(root);
3916
4122
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
3917
4123
  const hasContent = Object.keys(settings).length > 0;
@@ -3920,29 +4126,42 @@ async function stripProjectSettings(root) {
3920
4126
  log(`Stripped haus rules from ${path22.relative(root, settingsPath)} (user settings preserved).`);
3921
4127
  return true;
3922
4128
  }
3923
- await fs18.remove(settingsPath);
4129
+ await fs17.remove(settingsPath);
3924
4130
  log(`Removed ${path22.relative(root, settingsPath)} (no user-owned settings remained).`);
3925
4131
  return true;
3926
4132
  }
3927
4133
  async function stripRootClaudeMd(root) {
3928
4134
  const filePath = path22.join(root, "CLAUDE.md");
3929
- if (!await fs18.pathExists(filePath)) return false;
3930
- const prev = await fs18.readFile(filePath, "utf8");
4135
+ if (!await fs17.pathExists(filePath)) return false;
4136
+ const prev = await fs17.readFile(filePath, "utf8");
3931
4137
  if (!prev.includes(BLOCK_BEGIN)) return false;
3932
4138
  const next = stripHausBlock(prev);
3933
4139
  if (next.length === 0) {
3934
- await fs18.remove(filePath);
4140
+ await fs17.remove(filePath);
3935
4141
  log("Removed CLAUDE.md (only contained haus import block).");
3936
4142
  } else {
3937
- await fs18.writeFile(filePath, next, "utf8");
4143
+ await fs17.writeFile(filePath, next, "utf8");
3938
4144
  log("Removed haus import block from CLAUDE.md (user content preserved).");
3939
4145
  }
3940
4146
  return true;
3941
4147
  }
3942
4148
  async function pruneDirIfEmpty(dir) {
3943
- if (!await fs18.pathExists(dir)) return;
3944
- const entries = await fs18.readdir(dir);
3945
- if (entries.length === 0) await fs18.remove(dir);
4149
+ if (!await fs17.pathExists(dir)) return;
4150
+ const entries = await fs17.readdir(dir);
4151
+ if (entries.length === 0) await fs17.remove(dir);
4152
+ }
4153
+ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
4154
+ if (managedAbsPaths.length === 0) return;
4155
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4156
+ const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
4157
+ for (const abs of managedAbsPaths) {
4158
+ if (!await fs17.pathExists(abs)) continue;
4159
+ const rel = path22.relative(root, abs);
4160
+ const backupPath = path22.join(backupRoot, rel);
4161
+ await fs17.ensureDir(path22.dirname(backupPath));
4162
+ await fs17.copy(abs, backupPath);
4163
+ }
4164
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path22.relative(root, backupRoot)}.`);
3946
4165
  }
3947
4166
  async function runUndo(options) {
3948
4167
  const root = process.cwd();
@@ -3968,9 +4187,10 @@ User-owned .claude/ files will be preserved.`
3968
4187
  return;
3969
4188
  }
3970
4189
  }
4190
+ await backupManagedFilesBeforeUndo(root, managed);
3971
4191
  for (const abs of managed) {
3972
- if (!await fs18.pathExists(abs)) continue;
3973
- await fs18.remove(abs);
4192
+ if (!await fs17.pathExists(abs)) continue;
4193
+ await fs17.remove(abs);
3974
4194
  log(`Removed ${path22.relative(root, abs)}`);
3975
4195
  }
3976
4196
  if (stripSettings) await stripProjectSettings(root);
@@ -3983,7 +4203,7 @@ User-owned .claude/ files will be preserved.`
3983
4203
  // src/install/uninstall.ts
3984
4204
  import crypto3 from "crypto";
3985
4205
  import path23 from "path";
3986
- import fs19 from "fs-extra";
4206
+ import fs18 from "fs-extra";
3987
4207
  async function runUninstall(options = {}) {
3988
4208
  const { force = false } = options;
3989
4209
  const manifest = await readManifest();
@@ -3993,7 +4213,7 @@ async function runUninstall(options = {}) {
3993
4213
  return result;
3994
4214
  }
3995
4215
  for (const entry of manifest.files) {
3996
- const exists = fs19.pathExistsSync(entry.destPath);
4216
+ const exists = fs18.pathExistsSync(entry.destPath);
3997
4217
  if (!exists) continue;
3998
4218
  const content2 = await readText(entry.destPath);
3999
4219
  if (content2 === void 0) continue;
@@ -4011,7 +4231,7 @@ async function runUninstall(options = {}) {
4011
4231
  result.skipped.push(entry.destPath);
4012
4232
  continue;
4013
4233
  }
4014
- await fs19.remove(entry.destPath);
4234
+ await fs18.remove(entry.destPath);
4015
4235
  await pruneEmptyDir(path23.dirname(entry.destPath));
4016
4236
  result.deleted.push(entry.destPath);
4017
4237
  }
@@ -4021,12 +4241,12 @@ async function runUninstall(options = {}) {
4021
4241
  result.hooksStripped = true;
4022
4242
  const hausDir = path23.join(globalClaudeDir(), "haus");
4023
4243
  const manifestPath2 = hausManifestPath();
4024
- if (fs19.pathExistsSync(manifestPath2)) {
4025
- await fs19.remove(manifestPath2);
4244
+ if (fs18.pathExistsSync(manifestPath2)) {
4245
+ await fs18.remove(manifestPath2);
4026
4246
  }
4027
- if (fs19.pathExistsSync(hausDir)) {
4028
- const remaining = await fs19.readdir(hausDir);
4029
- if (remaining.length === 0) await fs19.remove(hausDir);
4247
+ if (fs18.pathExistsSync(hausDir)) {
4248
+ const remaining = await fs18.readdir(hausDir);
4249
+ if (remaining.length === 0) await fs18.remove(hausDir);
4030
4250
  }
4031
4251
  return result;
4032
4252
  }
@@ -4059,6 +4279,19 @@ async function runUninstallCommand(options) {
4059
4279
  // src/commands/update.ts
4060
4280
  import path25 from "path";
4061
4281
 
4282
+ // src/claude/refresh-project.ts
4283
+ import fs19 from "fs-extra";
4284
+ async function isHausProject(root) {
4285
+ if (await fs19.pathExists(hausPath(root, "recommendation.json"))) return true;
4286
+ const result = await readJsonDetailed(claudePath(root, "settings.json"));
4287
+ if (result.status === "ok") return result.value._haus != null;
4288
+ return false;
4289
+ }
4290
+ async function refreshProjectApply(root) {
4291
+ if (!await isHausProject(root)) return [];
4292
+ return writeClaudeFiles(root, false, void 0, { refillConfig: false });
4293
+ }
4294
+
4062
4295
  // src/update/diff-generated-files.ts
4063
4296
  function diffGeneratedFiles() {
4064
4297
  return "Generated files may change in .claude/* and .haus-workflow/haus.lock.json. Review git diff before apply.";
@@ -4256,8 +4489,18 @@ async function detectGlobalInstallDrift() {
4256
4489
  }
4257
4490
 
4258
4491
  // src/commands/validate-catalog.ts
4492
+ import path27 from "path";
4493
+
4494
+ // src/catalog/validate-core.ts
4259
4495
  import fs20 from "fs";
4260
4496
  import path26 from "path";
4497
+ var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
4498
+ var MANIFEST_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
4499
+ function isSafeCatalogPath2(itemPath) {
4500
+ if (!itemPath || path26.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
4501
+ const normalized = path26.normalize(itemPath);
4502
+ return !normalized.startsWith("..") && !normalized.includes("/..");
4503
+ }
4261
4504
  function auditForbiddenStacks(items) {
4262
4505
  const failures = [];
4263
4506
  for (const item of items) {
@@ -4269,10 +4512,15 @@ function auditForbiddenStacks(items) {
4269
4512
  }
4270
4513
  return failures;
4271
4514
  }
4272
- function auditManifestStructure(items) {
4515
+ function auditManifestStructure(manifestVersion, items) {
4273
4516
  const failures = [];
4274
4517
  const seenIds = /* @__PURE__ */ new Map();
4275
4518
  const seenPaths = /* @__PURE__ */ new Map();
4519
+ if (typeof manifestVersion !== "string" || !MANIFEST_SEMVER_RE.test(manifestVersion)) {
4520
+ failures.push(
4521
+ 'manifest.json: top-level "version" is not valid semver (expected X.Y.Z or X.Y.Z-pre)'
4522
+ );
4523
+ }
4276
4524
  for (let i = 0; i < items.length; i++) {
4277
4525
  const item = items[i];
4278
4526
  if (!item.id) {
@@ -4283,11 +4531,10 @@ function auditManifestStructure(items) {
4283
4531
  failures.push(`${item.id}: missing type`);
4284
4532
  continue;
4285
4533
  }
4286
- if (!item.source) {
4287
- failures.push(`${item.id}: missing source`);
4288
- }
4289
- if (!item.title) {
4290
- failures.push(`${item.id}: missing title`);
4534
+ if (!item.source) failures.push(`${item.id}: missing source`);
4535
+ if (!item.title) failures.push(`${item.id}: missing title`);
4536
+ if (item.version && !ITEM_SEMVER_RE.test(item.version)) {
4537
+ failures.push(`${item.id}: version "${item.version}" is not valid semver (expected X.Y.Z)`);
4291
4538
  }
4292
4539
  const prev = seenIds.get(item.id);
4293
4540
  if (prev !== void 0) {
@@ -4298,14 +4545,13 @@ function auditManifestStructure(items) {
4298
4545
  if (item.type === "skill" || item.type === "agent" || item.type === "template" || item.type === "command") {
4299
4546
  if (!item.path) {
4300
4547
  failures.push(`${item.id}: missing path`);
4548
+ } else if (!isSafeCatalogPath2(item.path)) {
4549
+ failures.push(`${item.id}: path "${item.path}" is not a safe relative path`);
4301
4550
  } else {
4302
4551
  const norm = item.path.replace(/\\/g, "/");
4303
4552
  const existing = seenPaths.get(norm);
4304
- if (existing) {
4305
- failures.push(`${item.id}: path "${norm}" already used by ${existing}`);
4306
- } else {
4307
- seenPaths.set(norm, item.id);
4308
- }
4553
+ if (existing) failures.push(`${item.id}: path "${norm}" already used by ${existing}`);
4554
+ else seenPaths.set(norm, item.id);
4309
4555
  }
4310
4556
  const isHaus = item.source === "haus";
4311
4557
  const isCuratedApproved = item.source === "curated" && item.reviewStatus === "approved";
@@ -4313,8 +4559,13 @@ function auditManifestStructure(items) {
4313
4559
  failures.push(`${item.id}: source must be "haus" or curated with reviewStatus "approved"`);
4314
4560
  }
4315
4561
  for (const ref of item.references ?? []) {
4316
- if (HTTP_URL_PATTERN.test(ref)) {
4317
- failures.push(`${item.id}: reference uses insecure http:// URL: ${ref}`);
4562
+ if (/^https?:\/\//i.test(ref)) {
4563
+ if (!ref.startsWith("https://")) {
4564
+ failures.push(`${item.id}: reference must be https:// URL: ${ref}`);
4565
+ }
4566
+ if (HTTP_URL_PATTERN.test(ref)) {
4567
+ failures.push(`${item.id}: reference uses insecure http:// URL: ${ref}`);
4568
+ }
4318
4569
  }
4319
4570
  }
4320
4571
  }
@@ -4324,16 +4575,33 @@ function auditManifestStructure(items) {
4324
4575
  function checkRequiredFrontmatter(text, label) {
4325
4576
  const failures = [];
4326
4577
  for (const key of REQUIRED_SKILL_FRONTMATTER) {
4327
- if (!extractFrontmatterValue(text, key)) {
4578
+ if (!extractFrontmatterValue(text, key))
4328
4579
  failures.push(`${label}: missing non-empty frontmatter '${key}:'`);
4580
+ }
4581
+ return failures;
4582
+ }
4583
+ function auditTemplateContent(manifestDir, absPath, itemId) {
4584
+ const rel = path26.relative(manifestDir, absPath);
4585
+ const text = fs20.readFileSync(absPath, "utf8");
4586
+ const failures = [];
4587
+ const lines = text.split(/\r?\n/);
4588
+ for (let i = 0; i < lines.length; i++) {
4589
+ const line2 = lines[i] ?? "";
4590
+ if (PLACEHOLDER_PATTERN.test(line2))
4591
+ failures.push(`${rel}:${i + 1}: TODO or placeholder in shipped content`);
4592
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
4593
+ failures.push(`${rel}:${i + 1}: risky install pattern`);
4594
+ if (ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
4595
+ failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
4329
4596
  }
4330
4597
  }
4598
+ failures.push(...auditForbiddenTagsInText(text, `${itemId}: ${rel}`));
4331
4599
  return failures;
4332
4600
  }
4333
4601
  function auditShippedFiles(manifestDir, items) {
4334
4602
  const failures = [];
4335
4603
  for (const item of items) {
4336
- if (!item.path) continue;
4604
+ if (!item.path || !isSafeCatalogPath2(item.path)) continue;
4337
4605
  const absPath = path26.join(manifestDir, item.path);
4338
4606
  if (item.type === "skill") {
4339
4607
  const skillMd = path26.join(absPath, "SKILL.md");
@@ -4374,36 +4642,22 @@ function auditShippedFiles(manifestDir, items) {
4374
4642
  }
4375
4643
  return failures;
4376
4644
  }
4377
- function auditTemplateContent(manifestDir, absPath, itemId) {
4378
- const rel = path26.relative(manifestDir, absPath);
4379
- const text = fs20.readFileSync(absPath, "utf8");
4380
- const failures = [];
4381
- const lines = text.split(/\r?\n/);
4382
- for (let i = 0; i < lines.length; i++) {
4383
- const line2 = lines[i] ?? "";
4384
- if (PLACEHOLDER_PATTERN.test(line2)) {
4385
- failures.push(`${rel}:${i + 1}: TODO or placeholder in shipped content`);
4386
- }
4387
- if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
4388
- failures.push(`${rel}:${i + 1}: risky install pattern`);
4389
- }
4390
- if (ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
4391
- failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
4392
- }
4393
- }
4394
- failures.push(...auditForbiddenTagsInText(text, `${itemId}: ${rel}`));
4395
- return failures;
4396
- }
4397
4645
  var DIR_ITEM_TYPE = {
4398
4646
  skills: "skill",
4399
4647
  agents: "agent",
4400
4648
  templates: "template",
4401
4649
  commands: "command"
4402
4650
  };
4651
+ function walkMd(dir, fn) {
4652
+ for (const entry of fs20.readdirSync(dir, { withFileTypes: true })) {
4653
+ const full = path26.join(dir, entry.name);
4654
+ if (entry.isDirectory()) walkMd(full, fn);
4655
+ else if (entry.name.endsWith(".md")) fn(full);
4656
+ }
4657
+ }
4403
4658
  function auditMarkdownContent(manifestDir) {
4404
4659
  const failures = [];
4405
- const dirs = ["skills", "agents", "templates", "commands"];
4406
- for (const dir of dirs) {
4660
+ for (const dir of ["skills", "agents", "templates", "commands"]) {
4407
4661
  const abs = path26.join(manifestDir, dir);
4408
4662
  if (!fs20.existsSync(abs)) continue;
4409
4663
  const checkNonTsxNpx = !NPX_TSX_ONLY_EXEMPT_TYPES.includes(DIR_ITEM_TYPE[dir] ?? "");
@@ -4413,65 +4667,55 @@ function auditMarkdownContent(manifestDir) {
4413
4667
  const lines = text.split(/\r?\n/);
4414
4668
  for (let i = 0; i < lines.length; i++) {
4415
4669
  const line2 = lines[i] ?? "";
4416
- if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
4670
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
4417
4671
  failures.push(`${rel}:${i + 1}: risky install pattern`);
4418
- }
4419
4672
  if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
4420
4673
  failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
4421
4674
  }
4422
4675
  }
4423
- if (!rel.includes("/references/")) {
4424
- failures.push(...auditForbiddenTagsInText(text, rel));
4425
- }
4676
+ if (!rel.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
4426
4677
  });
4427
4678
  }
4428
4679
  return failures;
4429
4680
  }
4430
- function walkMd(dir, fn) {
4431
- for (const entry of fs20.readdirSync(dir, { withFileTypes: true })) {
4432
- const full = path26.join(dir, entry.name);
4433
- if (entry.isDirectory()) walkMd(full, fn);
4434
- else if (entry.name.endsWith(".md")) fn(full);
4435
- }
4681
+ function validateCatalogData(manifestDir, manifestVersion, items) {
4682
+ const failures = [
4683
+ ...auditManifestStructure(manifestVersion, items),
4684
+ ...auditForbiddenStacks(items),
4685
+ ...auditShippedFiles(manifestDir, items),
4686
+ ...auditMarkdownContent(manifestDir),
4687
+ ...auditDisallowedTags(items)
4688
+ ];
4689
+ return { ok: failures.length === 0, failures, items };
4436
4690
  }
4691
+
4692
+ // src/commands/validate-catalog.ts
4437
4693
  async function runValidateCatalog(manifestPath2) {
4438
4694
  if (!manifestPath2) {
4439
4695
  error("Usage: haus validate-catalog <path/to/manifest.json>");
4440
4696
  process.exitCode = 1;
4441
4697
  return;
4442
4698
  }
4443
- const abs = path26.resolve(process.cwd(), manifestPath2);
4444
- const manifestDir = path26.dirname(abs);
4699
+ const abs = path27.resolve(process.cwd(), manifestPath2);
4700
+ const manifestDir = path27.dirname(abs);
4445
4701
  const data = await readJson(abs);
4446
4702
  if (!data?.items) {
4447
4703
  error(`Could not read catalog manifest at ${abs}`);
4448
4704
  process.exitCode = 1;
4449
4705
  return;
4450
4706
  }
4451
- const items = data.items;
4452
- const structureFailures = auditManifestStructure(items);
4453
- const stackFailures = auditForbiddenStacks(items);
4454
- const fileFailures = auditShippedFiles(manifestDir, items);
4455
- const contentFailures = auditMarkdownContent(manifestDir);
4456
- const tagFailures = auditDisallowedTags(items);
4457
- const allFailures = [
4458
- ...structureFailures,
4459
- ...stackFailures,
4460
- ...fileFailures,
4461
- ...contentFailures,
4462
- ...tagFailures
4463
- ];
4464
- if (allFailures.length) {
4465
- allFailures.forEach((f) => error(f));
4707
+ const result = validateCatalogData(manifestDir, data.version, data.items);
4708
+ if (!result.ok) {
4709
+ result.failures.forEach((f) => error(f));
4466
4710
  process.exitCode = 1;
4467
4711
  return;
4468
4712
  }
4469
- log(`Catalog valid. ${items.length} items checked.`);
4713
+ log(`Catalog valid. ${result.items.length} items checked.`);
4470
4714
  }
4471
4715
 
4472
4716
  // src/commands/workspace.ts
4473
4717
  import { existsSync as existsSync5, statSync as statSync2 } from "fs";
4474
- import path33 from "path";
4718
+ import path34 from "path";
4475
4719
 
4476
4720
  // src/commands/workspace/aggregate.ts
4477
4721
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -4525,7 +4769,7 @@ ${summaries.map(
4525
4769
  }
4526
4770
 
4527
4771
  // src/commands/workspace/config.ts
4528
- import path27 from "path";
4772
+ import path28 from "path";
4529
4773
  import YAML from "yaml";
4530
4774
  var WORKSPACE_FILE = "haus.workspace.yaml";
4531
4775
  function parseWorkspaceConfig(text) {
@@ -4548,11 +4792,11 @@ function parseWorkspaceConfig(text) {
4548
4792
  };
4549
4793
  }
4550
4794
  async function readWorkspaceConfig(workspaceRoot) {
4551
- return parseWorkspaceConfig(await readText(path27.join(workspaceRoot, WORKSPACE_FILE)));
4795
+ return parseWorkspaceConfig(await readText(path28.join(workspaceRoot, WORKSPACE_FILE)));
4552
4796
  }
4553
4797
 
4554
4798
  // src/commands/workspace/discover.ts
4555
- import path28 from "path";
4799
+ import path29 from "path";
4556
4800
  import fg4 from "fast-glob";
4557
4801
  import YAML2 from "yaml";
4558
4802
  var DEFAULT_MAX_DEPTH = 3;
@@ -4580,8 +4824,8 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
4580
4824
  const gitDirs = /* @__PURE__ */ new Set();
4581
4825
  const manifestDirs = /* @__PURE__ */ new Set();
4582
4826
  for (const match of matches) {
4583
- const base = path28.posix.basename(match);
4584
- const dir = path28.posix.dirname(match);
4827
+ const base = path29.posix.basename(match);
4828
+ const dir = path29.posix.dirname(match);
4585
4829
  const owner = dir === "." ? "." : dir;
4586
4830
  if (base === ".git") gitDirs.add(owner);
4587
4831
  else manifestDirs.add(owner);
@@ -4597,9 +4841,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
4597
4841
  }
4598
4842
  repoRoots.sort((a, b) => a.localeCompare(b));
4599
4843
  return mapWithConcurrency(repoRoots, async (relDir) => {
4600
- const absDir = path28.resolve(workspaceRoot, relDir);
4601
- const pkg = await readJson(path28.join(absDir, "package.json"));
4602
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path28.basename(relDir === "." ? workspaceRoot : absDir);
4844
+ const absDir = path29.resolve(workspaceRoot, relDir);
4845
+ const pkg = await readJson(path29.join(absDir, "package.json"));
4846
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path29.basename(relDir === "." ? workspaceRoot : absDir);
4603
4847
  let role = "auto";
4604
4848
  try {
4605
4849
  const scan = await scanProject(absDir);
@@ -4642,7 +4886,7 @@ function renderWorkspaceYaml(config) {
4642
4886
  });
4643
4887
  }
4644
4888
  async function runDiscover(workspaceRoot, opts = {}) {
4645
- const yamlPath = path28.join(workspaceRoot, "haus.workspace.yaml");
4889
+ const yamlPath = path29.join(workspaceRoot, "haus.workspace.yaml");
4646
4890
  const existingText = await readText(yamlPath);
4647
4891
  const existing = parseWorkspaceConfig(existingText);
4648
4892
  if (existingText && !existing) {
@@ -4676,18 +4920,18 @@ async function runDiscover(workspaceRoot, opts = {}) {
4676
4920
 
4677
4921
  // src/commands/workspace/doctor.ts
4678
4922
  import { existsSync as existsSync3 } from "fs";
4679
- import path30 from "path";
4923
+ import path31 from "path";
4680
4924
 
4681
4925
  // src/commands/workspace/manifest.ts
4682
4926
  import { readFileSync as readFileSync3 } from "fs";
4683
- import path29 from "path";
4927
+ import path30 from "path";
4684
4928
  var MANIFEST_FILE = "workspace.manifest.json";
4685
4929
  function manifestPath(workspaceRoot) {
4686
4930
  return hausPath(workspaceRoot, MANIFEST_FILE);
4687
4931
  }
4688
4932
  function hausVersion() {
4689
4933
  try {
4690
- const pkg = JSON.parse(readFileSync3(path29.join(packageRoot(), "package.json"), "utf8"));
4934
+ const pkg = JSON.parse(readFileSync3(path30.join(packageRoot(), "package.json"), "utf8"));
4691
4935
  return pkg.version ?? "0.0.0";
4692
4936
  } catch {
4693
4937
  return "0.0.0";
@@ -4753,7 +4997,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
4753
4997
  }
4754
4998
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
4755
4999
  for (const repo of config.repos) {
4756
- const repoRoot = path30.resolve(workspaceRoot, repo.path);
5000
+ const repoRoot = path31.resolve(workspaceRoot, repo.path);
4757
5001
  const entry = manifestByName.get(repo.name);
4758
5002
  if (!entry) {
4759
5003
  flag({
@@ -4827,10 +5071,10 @@ function emit(args) {
4827
5071
 
4828
5072
  // src/commands/workspace/setup.ts
4829
5073
  import { existsSync as existsSync4, statSync } from "fs";
4830
- import path32 from "path";
5074
+ import path33 from "path";
4831
5075
 
4832
5076
  // src/claude/write-workspace-claude-md.ts
4833
- import path31 from "path";
5077
+ import path32 from "path";
4834
5078
  import fs21 from "fs-extra";
4835
5079
  function buildWorkspaceImportBlock(client, members) {
4836
5080
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
@@ -4849,7 +5093,7 @@ ${BLOCK_END}`;
4849
5093
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
4850
5094
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
4851
5095
  const dryRun = opts.dryRun ?? false;
4852
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path31.join(workspaceRoot, "CLAUDE.md");
5096
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path32.join(workspaceRoot, "CLAUDE.md");
4853
5097
  const prev = await fs21.pathExists(filePath) ? await fs21.readFile(filePath, "utf8") : "";
4854
5098
  const next = opts.collision ? `${block}
4855
5099
  ` : injectHausBlock(prev, block);
@@ -4875,20 +5119,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
4875
5119
 
4876
5120
  // src/commands/workspace/setup.ts
4877
5121
  function resolveWorkspaceRoot(start = process.cwd()) {
4878
- let dir = path32.resolve(start);
5122
+ let dir = path33.resolve(start);
4879
5123
  for (; ; ) {
4880
- if (existsSync4(path32.join(dir, WORKSPACE_FILE))) return dir;
4881
- const parent = path32.dirname(dir);
4882
- if (parent === dir) return path32.resolve(start);
5124
+ if (existsSync4(path33.join(dir, WORKSPACE_FILE))) return dir;
5125
+ const parent = path33.dirname(dir);
5126
+ if (parent === dir) return path33.resolve(start);
4883
5127
  dir = parent;
4884
5128
  }
4885
5129
  }
4886
5130
  function isRootRepo(workspaceRoot, repoPath) {
4887
- return path32.resolve(workspaceRoot, repoPath) === path32.resolve(workspaceRoot);
5131
+ return path33.resolve(workspaceRoot, repoPath) === path33.resolve(workspaceRoot);
4888
5132
  }
4889
5133
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
4890
5134
  const apply = options.write ?? false;
4891
- const configText = await readText(path32.join(workspaceRoot, WORKSPACE_FILE));
5135
+ const configText = await readText(path33.join(workspaceRoot, WORKSPACE_FILE));
4892
5136
  if (!configText) {
4893
5137
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
4894
5138
  process.exitCode = 1;
@@ -4905,7 +5149,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
4905
5149
  const statuses = [];
4906
5150
  const aggregateInputs = [];
4907
5151
  for (const repo of repos) {
4908
- const repoRoot = path32.resolve(workspaceRoot, repo.path);
5152
+ const repoRoot = path33.resolve(workspaceRoot, repo.path);
4909
5153
  log(`
4910
5154
  \u2192 ${repo.name} (${repo.path})`);
4911
5155
  try {
@@ -4973,7 +5217,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
4973
5217
  const status = statusByName.get(repo.name);
4974
5218
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
4975
5219
  if (status?.status === "ok") {
4976
- const lock = await checkLock(path32.resolve(workspaceRoot, repo.path));
5220
+ const lock = await checkLock(path33.resolve(workspaceRoot, repo.path));
4977
5221
  manifestRepos.push({
4978
5222
  name: repo.name,
4979
5223
  path: repo.path,
@@ -5053,7 +5297,7 @@ relationships: []
5053
5297
  log("Workspace initialized.");
5054
5298
  }
5055
5299
  async function scanWorkspace(workspaceRoot, opts) {
5056
- const configText = await readText(path33.join(workspaceRoot, WORKSPACE_FILE));
5300
+ const configText = await readText(path34.join(workspaceRoot, WORKSPACE_FILE));
5057
5301
  if (!configText) {
5058
5302
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5059
5303
  process.exitCode = 1;
@@ -5074,7 +5318,7 @@ async function scanWorkspace(workspaceRoot, opts) {
5074
5318
  }
5075
5319
  const inputs = [];
5076
5320
  for (const repo of config.repos) {
5077
- const repoRoot = path33.resolve(workspaceRoot, repo.path);
5321
+ const repoRoot = path34.resolve(workspaceRoot, repo.path);
5078
5322
  if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
5079
5323
  throw new Error(`Repo path is not a directory: ${repo.path}`);
5080
5324
  }
@@ -5124,7 +5368,7 @@ async function runWorkspace(action, options = {}) {
5124
5368
  // src/cli.ts
5125
5369
  function cliVersion() {
5126
5370
  try {
5127
- const pkgPath = path34.join(packageRoot(), "package.json");
5371
+ const pkgPath = path35.join(packageRoot(), "package.json");
5128
5372
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5129
5373
  return pkg.version ?? "0.0.0";
5130
5374
  } catch {
@@ -5134,7 +5378,7 @@ function cliVersion() {
5134
5378
  var program = new Command();
5135
5379
  function validateRuntimeNodeVersion() {
5136
5380
  try {
5137
- const pkgPath = path34.join(packageRoot(), "package.json");
5381
+ const pkgPath = path35.join(packageRoot(), "package.json");
5138
5382
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5139
5383
  const requiredRange = pkg.engines?.node;
5140
5384
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {