@haus-tech/haus-workflow 0.27.0 → 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"] },
@@ -1179,286 +1508,88 @@ var SENSITIVE_PATH_REGEXES = [
1179
1508
  /secrets/,
1180
1509
  /(^|\/)storage\/logs(\/|$)/,
1181
1510
  /(^|\/)wp-content\/uploads(\/|$)/,
1182
- /(^|\/)uploads(\/|$)/
1183
- ];
1184
- var SENSITIVE_ITEM_KEYWORDS = [
1185
- ".env",
1186
- "secrets",
1187
- "certs",
1188
- "customer-data",
1189
- "exports",
1190
- ".pem",
1191
- ".key"
1192
- ];
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
- }
1511
+ /(^|\/)uploads(\/|$)/
1512
+ ];
1513
+ var SENSITIVE_ITEM_KEYWORDS = [
1514
+ ".env",
1515
+ "secrets",
1516
+ "certs",
1517
+ "customer-data",
1518
+ "exports",
1519
+ ".pem",
1520
+ ".key"
1521
+ ];
1378
1522
 
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);
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
  }
@@ -3925,7 +4077,7 @@ async function runScan(options) {
3925
4077
 
3926
4078
  // src/commands/undo.ts
3927
4079
  import path22 from "path";
3928
- import fs18 from "fs-extra";
4080
+ import fs17 from "fs-extra";
3929
4081
 
3930
4082
  // src/claude/managed-paths.ts
3931
4083
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md", "commands/haus-doctor.md"];
@@ -3947,25 +4099,25 @@ async function collectManagedPaths(root) {
3947
4099
  }
3948
4100
  const existing = [];
3949
4101
  for (const abs of paths) {
3950
- if (await fs18.pathExists(abs)) existing.push(abs);
4102
+ if (await fs17.pathExists(abs)) existing.push(abs);
3951
4103
  }
3952
4104
  return existing;
3953
4105
  }
3954
4106
  async function settingsHasHausContent(root) {
3955
4107
  const settingsPath = claudePath(root, "settings.json");
3956
- if (!await fs18.pathExists(settingsPath)) return false;
4108
+ if (!await fs17.pathExists(settingsPath)) return false;
3957
4109
  const settings = await readProjectSettings(root);
3958
4110
  return settings._haus != null;
3959
4111
  }
3960
4112
  async function claudeMdHasHausBlock(root) {
3961
4113
  const filePath = path22.join(root, "CLAUDE.md");
3962
- if (!await fs18.pathExists(filePath)) return false;
3963
- const text = await fs18.readFile(filePath, "utf8");
4114
+ if (!await fs17.pathExists(filePath)) return false;
4115
+ const text = await fs17.readFile(filePath, "utf8");
3964
4116
  return text.includes(BLOCK_BEGIN);
3965
4117
  }
3966
4118
  async function stripProjectSettings(root) {
3967
4119
  const settingsPath = claudePath(root, "settings.json");
3968
- if (!await fs18.pathExists(settingsPath)) return false;
4120
+ if (!await fs17.pathExists(settingsPath)) return false;
3969
4121
  let settings = await readProjectSettings(root);
3970
4122
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
3971
4123
  const hasContent = Object.keys(settings).length > 0;
@@ -3974,29 +4126,42 @@ async function stripProjectSettings(root) {
3974
4126
  log(`Stripped haus rules from ${path22.relative(root, settingsPath)} (user settings preserved).`);
3975
4127
  return true;
3976
4128
  }
3977
- await fs18.remove(settingsPath);
4129
+ await fs17.remove(settingsPath);
3978
4130
  log(`Removed ${path22.relative(root, settingsPath)} (no user-owned settings remained).`);
3979
4131
  return true;
3980
4132
  }
3981
4133
  async function stripRootClaudeMd(root) {
3982
4134
  const filePath = path22.join(root, "CLAUDE.md");
3983
- if (!await fs18.pathExists(filePath)) return false;
3984
- const prev = await fs18.readFile(filePath, "utf8");
4135
+ if (!await fs17.pathExists(filePath)) return false;
4136
+ const prev = await fs17.readFile(filePath, "utf8");
3985
4137
  if (!prev.includes(BLOCK_BEGIN)) return false;
3986
4138
  const next = stripHausBlock(prev);
3987
4139
  if (next.length === 0) {
3988
- await fs18.remove(filePath);
4140
+ await fs17.remove(filePath);
3989
4141
  log("Removed CLAUDE.md (only contained haus import block).");
3990
4142
  } else {
3991
- await fs18.writeFile(filePath, next, "utf8");
4143
+ await fs17.writeFile(filePath, next, "utf8");
3992
4144
  log("Removed haus import block from CLAUDE.md (user content preserved).");
3993
4145
  }
3994
4146
  return true;
3995
4147
  }
3996
4148
  async function pruneDirIfEmpty(dir) {
3997
- if (!await fs18.pathExists(dir)) return;
3998
- const entries = await fs18.readdir(dir);
3999
- 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)}.`);
4000
4165
  }
4001
4166
  async function runUndo(options) {
4002
4167
  const root = process.cwd();
@@ -4022,9 +4187,10 @@ User-owned .claude/ files will be preserved.`
4022
4187
  return;
4023
4188
  }
4024
4189
  }
4190
+ await backupManagedFilesBeforeUndo(root, managed);
4025
4191
  for (const abs of managed) {
4026
- if (!await fs18.pathExists(abs)) continue;
4027
- await fs18.remove(abs);
4192
+ if (!await fs17.pathExists(abs)) continue;
4193
+ await fs17.remove(abs);
4028
4194
  log(`Removed ${path22.relative(root, abs)}`);
4029
4195
  }
4030
4196
  if (stripSettings) await stripProjectSettings(root);
@@ -4037,7 +4203,7 @@ User-owned .claude/ files will be preserved.`
4037
4203
  // src/install/uninstall.ts
4038
4204
  import crypto3 from "crypto";
4039
4205
  import path23 from "path";
4040
- import fs19 from "fs-extra";
4206
+ import fs18 from "fs-extra";
4041
4207
  async function runUninstall(options = {}) {
4042
4208
  const { force = false } = options;
4043
4209
  const manifest = await readManifest();
@@ -4047,7 +4213,7 @@ async function runUninstall(options = {}) {
4047
4213
  return result;
4048
4214
  }
4049
4215
  for (const entry of manifest.files) {
4050
- const exists = fs19.pathExistsSync(entry.destPath);
4216
+ const exists = fs18.pathExistsSync(entry.destPath);
4051
4217
  if (!exists) continue;
4052
4218
  const content2 = await readText(entry.destPath);
4053
4219
  if (content2 === void 0) continue;
@@ -4065,7 +4231,7 @@ async function runUninstall(options = {}) {
4065
4231
  result.skipped.push(entry.destPath);
4066
4232
  continue;
4067
4233
  }
4068
- await fs19.remove(entry.destPath);
4234
+ await fs18.remove(entry.destPath);
4069
4235
  await pruneEmptyDir(path23.dirname(entry.destPath));
4070
4236
  result.deleted.push(entry.destPath);
4071
4237
  }
@@ -4075,12 +4241,12 @@ async function runUninstall(options = {}) {
4075
4241
  result.hooksStripped = true;
4076
4242
  const hausDir = path23.join(globalClaudeDir(), "haus");
4077
4243
  const manifestPath2 = hausManifestPath();
4078
- if (fs19.pathExistsSync(manifestPath2)) {
4079
- await fs19.remove(manifestPath2);
4244
+ if (fs18.pathExistsSync(manifestPath2)) {
4245
+ await fs18.remove(manifestPath2);
4080
4246
  }
4081
- if (fs19.pathExistsSync(hausDir)) {
4082
- const remaining = await fs19.readdir(hausDir);
4083
- 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);
4084
4250
  }
4085
4251
  return result;
4086
4252
  }
@@ -4113,6 +4279,19 @@ async function runUninstallCommand(options) {
4113
4279
  // src/commands/update.ts
4114
4280
  import path25 from "path";
4115
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
+
4116
4295
  // src/update/diff-generated-files.ts
4117
4296
  function diffGeneratedFiles() {
4118
4297
  return "Generated files may change in .claude/* and .haus-workflow/haus.lock.json. Review git diff before apply.";
@@ -4310,8 +4489,18 @@ async function detectGlobalInstallDrift() {
4310
4489
  }
4311
4490
 
4312
4491
  // src/commands/validate-catalog.ts
4492
+ import path27 from "path";
4493
+
4494
+ // src/catalog/validate-core.ts
4313
4495
  import fs20 from "fs";
4314
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
+ }
4315
4504
  function auditForbiddenStacks(items) {
4316
4505
  const failures = [];
4317
4506
  for (const item of items) {
@@ -4323,10 +4512,15 @@ function auditForbiddenStacks(items) {
4323
4512
  }
4324
4513
  return failures;
4325
4514
  }
4326
- function auditManifestStructure(items) {
4515
+ function auditManifestStructure(manifestVersion, items) {
4327
4516
  const failures = [];
4328
4517
  const seenIds = /* @__PURE__ */ new Map();
4329
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
+ }
4330
4524
  for (let i = 0; i < items.length; i++) {
4331
4525
  const item = items[i];
4332
4526
  if (!item.id) {
@@ -4337,11 +4531,10 @@ function auditManifestStructure(items) {
4337
4531
  failures.push(`${item.id}: missing type`);
4338
4532
  continue;
4339
4533
  }
4340
- if (!item.source) {
4341
- failures.push(`${item.id}: missing source`);
4342
- }
4343
- if (!item.title) {
4344
- 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)`);
4345
4538
  }
4346
4539
  const prev = seenIds.get(item.id);
4347
4540
  if (prev !== void 0) {
@@ -4352,14 +4545,13 @@ function auditManifestStructure(items) {
4352
4545
  if (item.type === "skill" || item.type === "agent" || item.type === "template" || item.type === "command") {
4353
4546
  if (!item.path) {
4354
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`);
4355
4550
  } else {
4356
4551
  const norm = item.path.replace(/\\/g, "/");
4357
4552
  const existing = seenPaths.get(norm);
4358
- if (existing) {
4359
- failures.push(`${item.id}: path "${norm}" already used by ${existing}`);
4360
- } else {
4361
- seenPaths.set(norm, item.id);
4362
- }
4553
+ if (existing) failures.push(`${item.id}: path "${norm}" already used by ${existing}`);
4554
+ else seenPaths.set(norm, item.id);
4363
4555
  }
4364
4556
  const isHaus = item.source === "haus";
4365
4557
  const isCuratedApproved = item.source === "curated" && item.reviewStatus === "approved";
@@ -4367,8 +4559,13 @@ function auditManifestStructure(items) {
4367
4559
  failures.push(`${item.id}: source must be "haus" or curated with reviewStatus "approved"`);
4368
4560
  }
4369
4561
  for (const ref of item.references ?? []) {
4370
- if (HTTP_URL_PATTERN.test(ref)) {
4371
- 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
+ }
4372
4569
  }
4373
4570
  }
4374
4571
  }
@@ -4378,16 +4575,33 @@ function auditManifestStructure(items) {
4378
4575
  function checkRequiredFrontmatter(text, label) {
4379
4576
  const failures = [];
4380
4577
  for (const key of REQUIRED_SKILL_FRONTMATTER) {
4381
- if (!extractFrontmatterValue(text, key)) {
4578
+ if (!extractFrontmatterValue(text, key))
4382
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)`);
4383
4596
  }
4384
4597
  }
4598
+ failures.push(...auditForbiddenTagsInText(text, `${itemId}: ${rel}`));
4385
4599
  return failures;
4386
4600
  }
4387
4601
  function auditShippedFiles(manifestDir, items) {
4388
4602
  const failures = [];
4389
4603
  for (const item of items) {
4390
- if (!item.path) continue;
4604
+ if (!item.path || !isSafeCatalogPath2(item.path)) continue;
4391
4605
  const absPath = path26.join(manifestDir, item.path);
4392
4606
  if (item.type === "skill") {
4393
4607
  const skillMd = path26.join(absPath, "SKILL.md");
@@ -4428,36 +4642,22 @@ function auditShippedFiles(manifestDir, items) {
4428
4642
  }
4429
4643
  return failures;
4430
4644
  }
4431
- function auditTemplateContent(manifestDir, absPath, itemId) {
4432
- const rel = path26.relative(manifestDir, absPath);
4433
- const text = fs20.readFileSync(absPath, "utf8");
4434
- const failures = [];
4435
- const lines = text.split(/\r?\n/);
4436
- for (let i = 0; i < lines.length; i++) {
4437
- const line2 = lines[i] ?? "";
4438
- if (PLACEHOLDER_PATTERN.test(line2)) {
4439
- failures.push(`${rel}:${i + 1}: TODO or placeholder in shipped content`);
4440
- }
4441
- if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
4442
- failures.push(`${rel}:${i + 1}: risky install pattern`);
4443
- }
4444
- if (ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
4445
- failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
4446
- }
4447
- }
4448
- failures.push(...auditForbiddenTagsInText(text, `${itemId}: ${rel}`));
4449
- return failures;
4450
- }
4451
4645
  var DIR_ITEM_TYPE = {
4452
4646
  skills: "skill",
4453
4647
  agents: "agent",
4454
4648
  templates: "template",
4455
4649
  commands: "command"
4456
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
+ }
4457
4658
  function auditMarkdownContent(manifestDir) {
4458
4659
  const failures = [];
4459
- const dirs = ["skills", "agents", "templates", "commands"];
4460
- for (const dir of dirs) {
4660
+ for (const dir of ["skills", "agents", "templates", "commands"]) {
4461
4661
  const abs = path26.join(manifestDir, dir);
4462
4662
  if (!fs20.existsSync(abs)) continue;
4463
4663
  const checkNonTsxNpx = !NPX_TSX_ONLY_EXEMPT_TYPES.includes(DIR_ITEM_TYPE[dir] ?? "");
@@ -4467,65 +4667,55 @@ function auditMarkdownContent(manifestDir) {
4467
4667
  const lines = text.split(/\r?\n/);
4468
4668
  for (let i = 0; i < lines.length; i++) {
4469
4669
  const line2 = lines[i] ?? "";
4470
- if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
4670
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
4471
4671
  failures.push(`${rel}:${i + 1}: risky install pattern`);
4472
- }
4473
4672
  if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
4474
4673
  failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
4475
4674
  }
4476
4675
  }
4477
- if (!rel.includes("/references/")) {
4478
- failures.push(...auditForbiddenTagsInText(text, rel));
4479
- }
4676
+ if (!rel.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
4480
4677
  });
4481
4678
  }
4482
4679
  return failures;
4483
4680
  }
4484
- function walkMd(dir, fn) {
4485
- for (const entry of fs20.readdirSync(dir, { withFileTypes: true })) {
4486
- const full = path26.join(dir, entry.name);
4487
- if (entry.isDirectory()) walkMd(full, fn);
4488
- else if (entry.name.endsWith(".md")) fn(full);
4489
- }
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 };
4490
4690
  }
4691
+
4692
+ // src/commands/validate-catalog.ts
4491
4693
  async function runValidateCatalog(manifestPath2) {
4492
4694
  if (!manifestPath2) {
4493
4695
  error("Usage: haus validate-catalog <path/to/manifest.json>");
4494
4696
  process.exitCode = 1;
4495
4697
  return;
4496
4698
  }
4497
- const abs = path26.resolve(process.cwd(), manifestPath2);
4498
- const manifestDir = path26.dirname(abs);
4699
+ const abs = path27.resolve(process.cwd(), manifestPath2);
4700
+ const manifestDir = path27.dirname(abs);
4499
4701
  const data = await readJson(abs);
4500
4702
  if (!data?.items) {
4501
4703
  error(`Could not read catalog manifest at ${abs}`);
4502
4704
  process.exitCode = 1;
4503
4705
  return;
4504
4706
  }
4505
- const items = data.items;
4506
- const structureFailures = auditManifestStructure(items);
4507
- const stackFailures = auditForbiddenStacks(items);
4508
- const fileFailures = auditShippedFiles(manifestDir, items);
4509
- const contentFailures = auditMarkdownContent(manifestDir);
4510
- const tagFailures = auditDisallowedTags(items);
4511
- const allFailures = [
4512
- ...structureFailures,
4513
- ...stackFailures,
4514
- ...fileFailures,
4515
- ...contentFailures,
4516
- ...tagFailures
4517
- ];
4518
- if (allFailures.length) {
4519
- 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));
4520
4710
  process.exitCode = 1;
4521
4711
  return;
4522
4712
  }
4523
- log(`Catalog valid. ${items.length} items checked.`);
4713
+ log(`Catalog valid. ${result.items.length} items checked.`);
4524
4714
  }
4525
4715
 
4526
4716
  // src/commands/workspace.ts
4527
4717
  import { existsSync as existsSync5, statSync as statSync2 } from "fs";
4528
- import path33 from "path";
4718
+ import path34 from "path";
4529
4719
 
4530
4720
  // src/commands/workspace/aggregate.ts
4531
4721
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -4579,7 +4769,7 @@ ${summaries.map(
4579
4769
  }
4580
4770
 
4581
4771
  // src/commands/workspace/config.ts
4582
- import path27 from "path";
4772
+ import path28 from "path";
4583
4773
  import YAML from "yaml";
4584
4774
  var WORKSPACE_FILE = "haus.workspace.yaml";
4585
4775
  function parseWorkspaceConfig(text) {
@@ -4602,11 +4792,11 @@ function parseWorkspaceConfig(text) {
4602
4792
  };
4603
4793
  }
4604
4794
  async function readWorkspaceConfig(workspaceRoot) {
4605
- return parseWorkspaceConfig(await readText(path27.join(workspaceRoot, WORKSPACE_FILE)));
4795
+ return parseWorkspaceConfig(await readText(path28.join(workspaceRoot, WORKSPACE_FILE)));
4606
4796
  }
4607
4797
 
4608
4798
  // src/commands/workspace/discover.ts
4609
- import path28 from "path";
4799
+ import path29 from "path";
4610
4800
  import fg4 from "fast-glob";
4611
4801
  import YAML2 from "yaml";
4612
4802
  var DEFAULT_MAX_DEPTH = 3;
@@ -4634,8 +4824,8 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
4634
4824
  const gitDirs = /* @__PURE__ */ new Set();
4635
4825
  const manifestDirs = /* @__PURE__ */ new Set();
4636
4826
  for (const match of matches) {
4637
- const base = path28.posix.basename(match);
4638
- const dir = path28.posix.dirname(match);
4827
+ const base = path29.posix.basename(match);
4828
+ const dir = path29.posix.dirname(match);
4639
4829
  const owner = dir === "." ? "." : dir;
4640
4830
  if (base === ".git") gitDirs.add(owner);
4641
4831
  else manifestDirs.add(owner);
@@ -4651,9 +4841,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
4651
4841
  }
4652
4842
  repoRoots.sort((a, b) => a.localeCompare(b));
4653
4843
  return mapWithConcurrency(repoRoots, async (relDir) => {
4654
- const absDir = path28.resolve(workspaceRoot, relDir);
4655
- const pkg = await readJson(path28.join(absDir, "package.json"));
4656
- 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);
4657
4847
  let role = "auto";
4658
4848
  try {
4659
4849
  const scan = await scanProject(absDir);
@@ -4696,7 +4886,7 @@ function renderWorkspaceYaml(config) {
4696
4886
  });
4697
4887
  }
4698
4888
  async function runDiscover(workspaceRoot, opts = {}) {
4699
- const yamlPath = path28.join(workspaceRoot, "haus.workspace.yaml");
4889
+ const yamlPath = path29.join(workspaceRoot, "haus.workspace.yaml");
4700
4890
  const existingText = await readText(yamlPath);
4701
4891
  const existing = parseWorkspaceConfig(existingText);
4702
4892
  if (existingText && !existing) {
@@ -4730,18 +4920,18 @@ async function runDiscover(workspaceRoot, opts = {}) {
4730
4920
 
4731
4921
  // src/commands/workspace/doctor.ts
4732
4922
  import { existsSync as existsSync3 } from "fs";
4733
- import path30 from "path";
4923
+ import path31 from "path";
4734
4924
 
4735
4925
  // src/commands/workspace/manifest.ts
4736
4926
  import { readFileSync as readFileSync3 } from "fs";
4737
- import path29 from "path";
4927
+ import path30 from "path";
4738
4928
  var MANIFEST_FILE = "workspace.manifest.json";
4739
4929
  function manifestPath(workspaceRoot) {
4740
4930
  return hausPath(workspaceRoot, MANIFEST_FILE);
4741
4931
  }
4742
4932
  function hausVersion() {
4743
4933
  try {
4744
- const pkg = JSON.parse(readFileSync3(path29.join(packageRoot(), "package.json"), "utf8"));
4934
+ const pkg = JSON.parse(readFileSync3(path30.join(packageRoot(), "package.json"), "utf8"));
4745
4935
  return pkg.version ?? "0.0.0";
4746
4936
  } catch {
4747
4937
  return "0.0.0";
@@ -4807,7 +4997,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
4807
4997
  }
4808
4998
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
4809
4999
  for (const repo of config.repos) {
4810
- const repoRoot = path30.resolve(workspaceRoot, repo.path);
5000
+ const repoRoot = path31.resolve(workspaceRoot, repo.path);
4811
5001
  const entry = manifestByName.get(repo.name);
4812
5002
  if (!entry) {
4813
5003
  flag({
@@ -4881,10 +5071,10 @@ function emit(args) {
4881
5071
 
4882
5072
  // src/commands/workspace/setup.ts
4883
5073
  import { existsSync as existsSync4, statSync } from "fs";
4884
- import path32 from "path";
5074
+ import path33 from "path";
4885
5075
 
4886
5076
  // src/claude/write-workspace-claude-md.ts
4887
- import path31 from "path";
5077
+ import path32 from "path";
4888
5078
  import fs21 from "fs-extra";
4889
5079
  function buildWorkspaceImportBlock(client, members) {
4890
5080
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
@@ -4903,7 +5093,7 @@ ${BLOCK_END}`;
4903
5093
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
4904
5094
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
4905
5095
  const dryRun = opts.dryRun ?? false;
4906
- 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");
4907
5097
  const prev = await fs21.pathExists(filePath) ? await fs21.readFile(filePath, "utf8") : "";
4908
5098
  const next = opts.collision ? `${block}
4909
5099
  ` : injectHausBlock(prev, block);
@@ -4929,20 +5119,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
4929
5119
 
4930
5120
  // src/commands/workspace/setup.ts
4931
5121
  function resolveWorkspaceRoot(start = process.cwd()) {
4932
- let dir = path32.resolve(start);
5122
+ let dir = path33.resolve(start);
4933
5123
  for (; ; ) {
4934
- if (existsSync4(path32.join(dir, WORKSPACE_FILE))) return dir;
4935
- const parent = path32.dirname(dir);
4936
- 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);
4937
5127
  dir = parent;
4938
5128
  }
4939
5129
  }
4940
5130
  function isRootRepo(workspaceRoot, repoPath) {
4941
- return path32.resolve(workspaceRoot, repoPath) === path32.resolve(workspaceRoot);
5131
+ return path33.resolve(workspaceRoot, repoPath) === path33.resolve(workspaceRoot);
4942
5132
  }
4943
5133
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
4944
5134
  const apply = options.write ?? false;
4945
- const configText = await readText(path32.join(workspaceRoot, WORKSPACE_FILE));
5135
+ const configText = await readText(path33.join(workspaceRoot, WORKSPACE_FILE));
4946
5136
  if (!configText) {
4947
5137
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
4948
5138
  process.exitCode = 1;
@@ -4959,7 +5149,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
4959
5149
  const statuses = [];
4960
5150
  const aggregateInputs = [];
4961
5151
  for (const repo of repos) {
4962
- const repoRoot = path32.resolve(workspaceRoot, repo.path);
5152
+ const repoRoot = path33.resolve(workspaceRoot, repo.path);
4963
5153
  log(`
4964
5154
  \u2192 ${repo.name} (${repo.path})`);
4965
5155
  try {
@@ -5027,7 +5217,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5027
5217
  const status = statusByName.get(repo.name);
5028
5218
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
5029
5219
  if (status?.status === "ok") {
5030
- const lock = await checkLock(path32.resolve(workspaceRoot, repo.path));
5220
+ const lock = await checkLock(path33.resolve(workspaceRoot, repo.path));
5031
5221
  manifestRepos.push({
5032
5222
  name: repo.name,
5033
5223
  path: repo.path,
@@ -5107,7 +5297,7 @@ relationships: []
5107
5297
  log("Workspace initialized.");
5108
5298
  }
5109
5299
  async function scanWorkspace(workspaceRoot, opts) {
5110
- const configText = await readText(path33.join(workspaceRoot, WORKSPACE_FILE));
5300
+ const configText = await readText(path34.join(workspaceRoot, WORKSPACE_FILE));
5111
5301
  if (!configText) {
5112
5302
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5113
5303
  process.exitCode = 1;
@@ -5128,7 +5318,7 @@ async function scanWorkspace(workspaceRoot, opts) {
5128
5318
  }
5129
5319
  const inputs = [];
5130
5320
  for (const repo of config.repos) {
5131
- const repoRoot = path33.resolve(workspaceRoot, repo.path);
5321
+ const repoRoot = path34.resolve(workspaceRoot, repo.path);
5132
5322
  if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
5133
5323
  throw new Error(`Repo path is not a directory: ${repo.path}`);
5134
5324
  }
@@ -5178,7 +5368,7 @@ async function runWorkspace(action, options = {}) {
5178
5368
  // src/cli.ts
5179
5369
  function cliVersion() {
5180
5370
  try {
5181
- const pkgPath = path34.join(packageRoot(), "package.json");
5371
+ const pkgPath = path35.join(packageRoot(), "package.json");
5182
5372
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5183
5373
  return pkg.version ?? "0.0.0";
5184
5374
  } catch {
@@ -5188,7 +5378,7 @@ function cliVersion() {
5188
5378
  var program = new Command();
5189
5379
  function validateRuntimeNodeVersion() {
5190
5380
  try {
5191
- const pkgPath = path34.join(packageRoot(), "package.json");
5381
+ const pkgPath = path35.join(packageRoot(), "package.json");
5192
5382
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5193
5383
  const requiredRange = pkg.engines?.node;
5194
5384
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {