@haus-tech/haus-workflow 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync as readFileSync3 } from "fs";
5
- import path28 from "path";
4
+ import { readFileSync as readFileSync4 } from "fs";
5
+ import path34 from "path";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/commands/apply.ts
@@ -242,6 +242,16 @@ async function listFiles(root, patterns) {
242
242
  function hashText(value) {
243
243
  return `sha256-${crypto.createHash("sha256").update(value).digest("hex")}`;
244
244
  }
245
+ async function mapWithConcurrency(items, fn, concurrency = 24) {
246
+ const size = Number.isFinite(concurrency) ? Math.max(1, Math.floor(concurrency)) : 24;
247
+ const results = new Array(items.length);
248
+ for (let i = 0; i < items.length; i += size) {
249
+ const batch = items.slice(i, i + size);
250
+ const settled = await Promise.all(batch.map((item, j) => fn(item, i + j)));
251
+ for (let j = 0; j < settled.length; j += 1) results[i + j] = settled[j];
252
+ }
253
+ return results;
254
+ }
245
255
 
246
256
  // src/update/hash-installed.ts
247
257
  var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
@@ -439,8 +449,8 @@ function buildDenyRules() {
439
449
  for (const command of DANGEROUS_COMMANDS) {
440
450
  rules.push(`Bash(${command}:*)`);
441
451
  }
442
- for (const path29 of SENSITIVE_PATHS) {
443
- const pattern = SENSITIVE_DIRS.has(path29) ? `${path29}/**` : path29;
452
+ for (const path35 of SENSITIVE_PATHS) {
453
+ const pattern = SENSITIVE_DIRS.has(path35) ? `${path35}/**` : path35;
444
454
  for (const tool of FILE_TOOLS) {
445
455
  rules.push(`${tool}(${pattern})`);
446
456
  }
@@ -804,7 +814,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
804
814
  estimatedTokenReductionPct: 0
805
815
  };
806
816
  const pkgRoot = packageRoot();
807
- const hausVersion = (await readJson(path9.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
817
+ const hausVersion2 = (await readJson(path9.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
808
818
  const coreFiles = [
809
819
  claudePath(root, "settings.json"),
810
820
  claudePath(root, "rules", "haus.md"),
@@ -813,7 +823,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
813
823
  claudePath(root, "commands", "haus-review.md")
814
824
  ];
815
825
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
816
- const workflowPath = await writeWorkflow(root, hausVersion, dryRun);
826
+ const workflowPath = await writeWorkflow(root, hausVersion2, dryRun);
817
827
  const workflowConfigPath = await writeWorkflowConfig(root, dryRun, {
818
828
  refill: opts.refillConfig
819
829
  });
@@ -860,9 +870,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
860
870
  dryRun
861
871
  );
862
872
  const fixtureManifestPath = process.env["HAUS_FIXTURE_CATALOG"];
863
- const manifestPath = fixtureManifestPath ?? path9.join(pkgRoot, "library", "catalog", "manifest.json");
864
- const manifestDir = path9.dirname(manifestPath);
865
- const manifest = await readJson(manifestPath) ?? { items: [] };
873
+ const manifestPath2 = fixtureManifestPath ?? path9.join(pkgRoot, "library", "catalog", "manifest.json");
874
+ const manifestDir = path9.dirname(manifestPath2);
875
+ const manifest = await readJson(manifestPath2) ?? { items: [] };
866
876
  const manifestById = new Map((manifest.items ?? []).map((item) => [item.id, item]));
867
877
  const cacheManifest = await readJson(
868
878
  path9.join(CACHE_DIR, "manifest.json")
@@ -933,7 +943,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
933
943
  id: r.id,
934
944
  type: r.type,
935
945
  source: isCurated ? "curated" : "haus",
936
- version: hausVersion,
946
+ version: hausVersion2,
937
947
  catalogRef: CATALOG_REF,
938
948
  hash: await hashInstalledPaths(root, relPaths),
939
949
  installMode: "copied",
@@ -1069,32 +1079,202 @@ async function loadCatalog(root) {
1069
1079
  return data?.items ?? [];
1070
1080
  }
1071
1081
 
1082
+ // library/catalog/validation-rules.json
1083
+ var validation_rules_default = {
1084
+ forbiddenTags: [
1085
+ "python",
1086
+ "django",
1087
+ "go",
1088
+ "rust",
1089
+ "java",
1090
+ "spring",
1091
+ "kotlin",
1092
+ "swift",
1093
+ "android",
1094
+ "flutter",
1095
+ "dart",
1096
+ "c++",
1097
+ "perl",
1098
+ "defi",
1099
+ "trading"
1100
+ ],
1101
+ bannedAgentPhrases: ["autonomous", "swarm", "delegate", "orchestrat", "marketplace"],
1102
+ requiredSkillSections: ["## Use when", "## Do not use when"],
1103
+ requiredAgentSections: ["## Use when", "## Do not use when", "## Verification"],
1104
+ riskyInstallPatterns: [
1105
+ { source: "\\bnpx\\s+-y\\b", flags: "i" },
1106
+ { source: "\\bnpx\\s+--yes\\b", flags: "i" },
1107
+ { source: "\\byarn\\s+dlx\\b", flags: "i" },
1108
+ { source: "\\bpnpm\\s+dlx\\b", flags: "i" }
1109
+ ],
1110
+ allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
1111
+ anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
1112
+ httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
1113
+ placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
1114
+ allowedStacks: [
1115
+ "haus",
1116
+ "security",
1117
+ "quality",
1118
+ "frontend",
1119
+ "backend",
1120
+ "testing",
1121
+ "review",
1122
+ "workflow",
1123
+ "reference-pack",
1124
+ "core-skill",
1125
+ "workflow-skill",
1126
+ "stack-skill",
1127
+ "review-skill",
1128
+ "agent",
1129
+ "hook",
1130
+ "rule",
1131
+ "react",
1132
+ "typescript",
1133
+ "php",
1134
+ "csharp",
1135
+ "vendure",
1136
+ "vendure3",
1137
+ "nestjs",
1138
+ "graphql",
1139
+ "nx21",
1140
+ "turbo",
1141
+ "nextjs",
1142
+ "react19",
1143
+ "typescript5",
1144
+ "vite8",
1145
+ "tanstack-query",
1146
+ "tanstack-router",
1147
+ "radix",
1148
+ "radix-ui",
1149
+ "shadcn",
1150
+ "shadcn-ui",
1151
+ "tailwind",
1152
+ "tailwindcss",
1153
+ "scss",
1154
+ "scss-modules",
1155
+ "vue",
1156
+ "expressjs",
1157
+ "soup-base",
1158
+ "laravel",
1159
+ "laravel-nova",
1160
+ "wordpress",
1161
+ "bedrock",
1162
+ "elementor-pro",
1163
+ "acf-pro",
1164
+ "jetengine",
1165
+ "dotnet",
1166
+ "oidc",
1167
+ "azure-ad",
1168
+ "bankid",
1169
+ "myid",
1170
+ "cgi",
1171
+ "crypto",
1172
+ "collection2",
1173
+ "postgresql",
1174
+ "mariadb",
1175
+ "mssql",
1176
+ "elasticsearch",
1177
+ "yarn4",
1178
+ "pnpm89",
1179
+ "playwright",
1180
+ "testing-library",
1181
+ "phpunit",
1182
+ "storybook",
1183
+ "wisest",
1184
+ "vitest",
1185
+ "jest",
1186
+ "redis",
1187
+ "sanity",
1188
+ "strapi",
1189
+ "prisma",
1190
+ "cms",
1191
+ "database",
1192
+ "mysql",
1193
+ "saml2",
1194
+ "next-auth",
1195
+ "auth",
1196
+ "expo",
1197
+ "react-native",
1198
+ "mobile",
1199
+ "i18next",
1200
+ "i18n",
1201
+ "bullmq",
1202
+ "queue",
1203
+ "sentry",
1204
+ "observability",
1205
+ "tooling",
1206
+ "prettier",
1207
+ "eslint",
1208
+ "missing-prettier",
1209
+ "missing-eslint",
1210
+ "docker",
1211
+ "pm2",
1212
+ "deployer-php",
1213
+ "stripe",
1214
+ "qliro",
1215
+ "supabase",
1216
+ "payments"
1217
+ ],
1218
+ alwaysAllowedTags: [
1219
+ "haus",
1220
+ "security",
1221
+ "quality",
1222
+ "review",
1223
+ "workflow",
1224
+ "baseline",
1225
+ "project-instructions"
1226
+ ],
1227
+ patternTagSuffixes: ["-patterns"]
1228
+ };
1229
+
1230
+ // src/catalog/validation-rules.ts
1231
+ var toRegExp = (r) => new RegExp(r.source, r.flags);
1232
+ var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
1233
+ var BANNED_AGENT_PHRASES = validation_rules_default.bannedAgentPhrases;
1234
+ var REQUIRED_SKILL_SECTIONS = validation_rules_default.requiredSkillSections;
1235
+ var REQUIRED_AGENT_SECTIONS = validation_rules_default.requiredAgentSections;
1236
+ var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
1237
+ var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
1238
+ var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
1239
+ var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
1240
+ var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
1241
+ var ALLOWED_STACKS = validation_rules_default.allowedStacks;
1242
+ var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
1243
+ var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
1244
+ var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
1245
+ function isTagAllowed(tag) {
1246
+ const lower = tag.toLowerCase();
1247
+ if (ALLOWED_SET.has(lower)) return true;
1248
+ return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
1249
+ }
1250
+ function auditDisallowedTags(items) {
1251
+ const failures = [];
1252
+ for (const item of items) {
1253
+ if (!item.id) continue;
1254
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
1255
+ if (!isTagAllowed(tag)) failures.push(`${item.id}: tag not in allowlist: "${tag}"`);
1256
+ }
1257
+ }
1258
+ return failures;
1259
+ }
1260
+
1072
1261
  // src/commands/catalog-audit.ts
1073
- var FORBIDDEN = [
1074
- "python",
1075
- "django",
1076
- "go",
1077
- "rust",
1078
- "java",
1079
- "spring",
1080
- "kotlin",
1081
- "swift",
1082
- "android",
1083
- "flutter",
1084
- "dart",
1085
- "c++",
1086
- "perl",
1087
- "defi",
1088
- "trading"
1089
- ];
1090
- async function runCatalogAudit() {
1091
- const items = await loadCatalog(process.cwd());
1262
+ function auditForbiddenTags(items) {
1092
1263
  const failures = [];
1264
+ const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
1093
1265
  for (const item of items) {
1094
- const text = `${item.id} ${item.tags.join(" ")}`.toLowerCase();
1095
- for (const word of FORBIDDEN)
1096
- if (text.includes(word)) failures.push(`${item.id} has unsupported tag ${word}`);
1266
+ for (const tag of item.tags) {
1267
+ if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
1268
+ }
1269
+ for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
1270
+ if (forbidden.has(token)) failures.push(`${item.id} has unsupported tag ${token}`);
1271
+ }
1097
1272
  }
1273
+ return failures;
1274
+ }
1275
+ async function runCatalogAudit() {
1276
+ const items = await loadCatalog(process.cwd());
1277
+ const failures = auditForbiddenTags(items);
1098
1278
  if (failures.length) {
1099
1279
  failures.forEach((f) => error(f));
1100
1280
  process.exitCode = 1;
@@ -1831,20 +2011,13 @@ async function buildContentBlob(root, files) {
1831
2011
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
1832
2012
  );
1833
2013
  const slice = candidates.slice(0, 300);
1834
- const CHUNK = 24;
1835
- const parts = [];
1836
- for (let i = 0; i < slice.length; i += CHUNK) {
1837
- const batch = await Promise.all(
1838
- slice.slice(i, i + CHUNK).map(async (rel) => {
1839
- try {
1840
- return await readFile(path15.join(root, rel), "utf8");
1841
- } catch {
1842
- return "";
1843
- }
1844
- })
1845
- );
1846
- parts.push(...batch);
1847
- }
2014
+ const parts = await mapWithConcurrency(slice, async (rel) => {
2015
+ try {
2016
+ return await readFile(path15.join(root, rel), "utf8");
2017
+ } catch {
2018
+ return "";
2019
+ }
2020
+ });
1848
2021
  return parts.join("\n");
1849
2022
  }
1850
2023
  function renderSummary(context) {
@@ -1957,10 +2130,9 @@ async function scanProject(root, mode = "fast") {
1957
2130
  composer: isRecord(composer?.require) ? Object.keys(composer.require) : []
1958
2131
  };
1959
2132
  const scanHashes = Object.fromEntries(
1960
- await Promise.all(
1961
- safeFiles.map(
1962
- async (f) => [f, hashText(await readFile2(path16.join(root, f), "utf8"))]
1963
- )
2133
+ await mapWithConcurrency(
2134
+ safeFiles,
2135
+ async (f) => [f, hashText(await readFile2(path16.join(root, f), "utf8"))]
1964
2136
  )
1965
2137
  );
1966
2138
  const repoSummary = renderSummary(context);
@@ -2376,6 +2548,24 @@ async function runGuard(kind, _options) {
2376
2548
  import path18 from "path";
2377
2549
  import fs12 from "fs-extra";
2378
2550
 
2551
+ // src/utils/prompts.ts
2552
+ import { stdin as input, stdout as output } from "process";
2553
+ import readline from "readline/promises";
2554
+ async function ask(question) {
2555
+ const rl = readline.createInterface({ input, output });
2556
+ try {
2557
+ const answer = await rl.question(`${question}
2558
+ > `);
2559
+ return answer.trim();
2560
+ } finally {
2561
+ rl.close();
2562
+ }
2563
+ }
2564
+ async function confirm(question) {
2565
+ const answer = (await ask(`${question} [y/N]`)).toLowerCase();
2566
+ return answer === "y" || answer === "yes";
2567
+ }
2568
+
2379
2569
  // src/utils/exec.ts
2380
2570
  import { execa } from "execa";
2381
2571
  async function runCommand(command, args = [], options = {}) {
@@ -2660,69 +2850,16 @@ function buildStackSet(context) {
2660
2850
  );
2661
2851
  }
2662
2852
 
2663
- // src/utils/prompts.ts
2664
- import { stdin as input, stdout as output } from "process";
2665
- import readline from "readline/promises";
2666
- async function ask(question) {
2667
- const rl = readline.createInterface({ input, output });
2668
- try {
2669
- const answer = await rl.question(`${question}
2670
- > `);
2671
- return answer.trim();
2672
- } finally {
2673
- rl.close();
2674
- }
2675
- }
2676
- async function confirm(question) {
2677
- const answer = (await ask(`${question} [y/N]`)).toLowerCase();
2678
- return answer === "y" || answer === "yes";
2679
- }
2680
-
2681
- // src/commands/setup-project.ts
2682
- var GUIDED_QUESTIONS = [
2683
- "What is this project for?",
2684
- "Is it for a client, internal Haus work, or experimentation?",
2685
- "What should Claude help with most?",
2686
- "Is this project connected to other repositories?",
2687
- "Are there parts of the project Claude should avoid touching?",
2688
- "Are there client-specific rules or sensitive areas?",
2689
- "Do you want a minimal, standard, or strict setup?"
2690
- ];
2691
- async function runSetupProject(options) {
2692
- const root = process.cwd();
2693
- let mode = options.guided ? "guided" : "fast";
2694
- if (!options.guided && !options.fast && !options.json) {
2695
- log("How do you want to set this project up?");
2696
- log("1. Guided setup - I'll ask a few simple questions, then scan the project.");
2697
- log("2. Fast setup - I'll only scan the project and recommend defaults.");
2698
- const choice = await ask("Choose 1 or 2");
2699
- mode = choice === "1" ? "guided" : "fast";
2700
- }
2701
- if (mode === "guided") {
2702
- const existing = await readJson(hausPath(root, "setup-answers.json")) ?? {};
2703
- const merged = {};
2704
- for (const question of GUIDED_QUESTIONS) {
2705
- if (options.json) {
2706
- merged[question] = existing[question] ?? "pending-user-answer";
2707
- continue;
2708
- }
2709
- const prefilled = existing[question];
2710
- if (prefilled && prefilled !== "pending-user-answer" && prefilled !== "no-answer") {
2711
- merged[question] = prefilled;
2712
- continue;
2713
- }
2714
- const answer = await ask(question);
2715
- merged[question] = answer || prefilled || "no-answer";
2716
- }
2717
- await writeJson(hausPath(root, "setup-answers.json"), merged);
2718
- }
2719
- const scanResult = await scanProject(root, mode);
2720
- if (options.json) {
2721
- log(JSON.stringify(scanResult, null, 2));
2722
- } else {
2723
- log("Haus scan complete");
2724
- log(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
2725
- log(`Package manager: ${scanResult.packageManager}`);
2853
+ // src/commands/setup-core.ts
2854
+ async function runSetupCore(root, opts) {
2855
+ const { mode, json, apply, dryRun, confirm: confirm2 } = opts;
2856
+ const scanResult = await scanProject(root, mode);
2857
+ if (json) {
2858
+ log(JSON.stringify(scanResult, null, 2));
2859
+ } else {
2860
+ log("Haus scan complete");
2861
+ log(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
2862
+ log(`Package manager: ${scanResult.packageManager}`);
2726
2863
  }
2727
2864
  const context = await readContextOrScan(root);
2728
2865
  const recommendation = await recommend(root, context);
@@ -2733,7 +2870,7 @@ async function runSetupProject(options) {
2733
2870
  { id: "haus.rule.context-minimal", enabled: true },
2734
2871
  { id: "haus.rule.security", enabled: true }
2735
2872
  ]);
2736
- if (options.json) {
2873
+ if (json) {
2737
2874
  log(JSON.stringify(recommendation, null, 2));
2738
2875
  } else {
2739
2876
  log("Haus recommendation ready");
@@ -2744,6 +2881,7 @@ async function runSetupProject(options) {
2744
2881
  const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
2745
2882
  log(`Repo: ${context.repoName}`);
2746
2883
  for (const warning of warningLines) log(`- WARN: ${warning}`);
2884
+ const hooksOk = hooks.ok;
2747
2885
  if (hooks.skipped) {
2748
2886
  log(`- HOOKS: (skipped) ${hooks.message}`);
2749
2887
  } else if (!hooks.ok) {
@@ -2752,17 +2890,29 @@ async function runSetupProject(options) {
2752
2890
  } else {
2753
2891
  log(`- HOOKS OK: ${hooks.message}`);
2754
2892
  }
2755
- if (options.json) return;
2756
- const approved = await confirm("Approve and write Claude files now?");
2757
- if (!approved) {
2758
- log("Setup reviewed. No files written.");
2759
- log("Next step: run `haus apply --write` when ready.");
2760
- return;
2893
+ const baseResult = {
2894
+ root,
2895
+ repoName: context.repoName,
2896
+ roles: scanResult.repoRoles,
2897
+ recommendedCount: recommendation.recommended.length,
2898
+ warnings: warningLines,
2899
+ hooksOk,
2900
+ written: []
2901
+ };
2902
+ if (!apply) return baseResult;
2903
+ if (confirm2) {
2904
+ const approved = await confirm2();
2905
+ if (!approved) {
2906
+ log("Setup reviewed. No files written.");
2907
+ log("Next step: run `haus apply --write` when ready.");
2908
+ return baseResult;
2909
+ }
2761
2910
  }
2762
- const files = await writeClaudeFiles(root, false);
2911
+ const files = await writeClaudeFiles(root, dryRun ?? false);
2763
2912
  log("Applied files:");
2764
2913
  files.forEach((f) => log(`- ${displayPath(root, f)}`));
2765
2914
  const hooksAfter = await verifyProjectSettingsHooksContract(root);
2915
+ const hooksOkAfter = hooksAfter.ok;
2766
2916
  if (hooksAfter.skipped) {
2767
2917
  log(`- HOOKS: (skipped) ${hooksAfter.message}`);
2768
2918
  } else if (!hooksAfter.ok) {
@@ -2771,6 +2921,54 @@ async function runSetupProject(options) {
2771
2921
  } else {
2772
2922
  log(`- HOOKS OK: ${hooksAfter.message}`);
2773
2923
  }
2924
+ return { ...baseResult, hooksOk: hooksOkAfter, written: files };
2925
+ }
2926
+
2927
+ // src/commands/setup-project.ts
2928
+ var GUIDED_QUESTIONS = [
2929
+ "What is this project for?",
2930
+ "Is it for a client, internal Haus work, or experimentation?",
2931
+ "What should Claude help with most?",
2932
+ "Is this project connected to other repositories?",
2933
+ "Are there parts of the project Claude should avoid touching?",
2934
+ "Are there client-specific rules or sensitive areas?",
2935
+ "Do you want a minimal, standard, or strict setup?"
2936
+ ];
2937
+ async function runSetupProject(options) {
2938
+ const root = process.cwd();
2939
+ let mode = options.guided ? "guided" : "fast";
2940
+ if (!options.guided && !options.fast && !options.json) {
2941
+ log("How do you want to set this project up?");
2942
+ log("1. Guided setup - I'll ask a few simple questions, then scan the project.");
2943
+ log("2. Fast setup - I'll only scan the project and recommend defaults.");
2944
+ const choice = await ask("Choose 1 or 2");
2945
+ mode = choice === "1" ? "guided" : "fast";
2946
+ }
2947
+ if (mode === "guided") {
2948
+ const existing = await readJson(hausPath(root, "setup-answers.json")) ?? {};
2949
+ const merged = {};
2950
+ for (const question of GUIDED_QUESTIONS) {
2951
+ if (options.json) {
2952
+ merged[question] = existing[question] ?? "pending-user-answer";
2953
+ continue;
2954
+ }
2955
+ const prefilled = existing[question];
2956
+ if (prefilled && prefilled !== "pending-user-answer" && prefilled !== "no-answer") {
2957
+ merged[question] = prefilled;
2958
+ continue;
2959
+ }
2960
+ const answer = await ask(question);
2961
+ merged[question] = answer || prefilled || "no-answer";
2962
+ }
2963
+ await writeJson(hausPath(root, "setup-answers.json"), merged);
2964
+ }
2965
+ await runSetupCore(root, {
2966
+ mode,
2967
+ json: options.json,
2968
+ apply: !options.json,
2969
+ dryRun: false,
2970
+ confirm: () => confirm("Approve and write Claude files now?")
2971
+ });
2774
2972
  }
2775
2973
 
2776
2974
  // src/commands/init.ts
@@ -3340,9 +3538,9 @@ async function runUninstall(options = {}) {
3340
3538
  await writeSettings(stripped);
3341
3539
  result.hooksStripped = true;
3342
3540
  const hausDir = path23.join(globalClaudeDir(), "haus");
3343
- const manifestPath = hausManifestPath();
3344
- if (fs16.pathExistsSync(manifestPath)) {
3345
- await fs16.remove(manifestPath);
3541
+ const manifestPath2 = hausManifestPath();
3542
+ if (fs16.pathExistsSync(manifestPath2)) {
3543
+ await fs16.remove(manifestPath2);
3346
3544
  }
3347
3545
  if (fs16.pathExistsSync(hausDir)) {
3348
3546
  const remaining = await fs16.readdir(hausDir);
@@ -3525,187 +3723,6 @@ async function runUpdate(options) {
3525
3723
  // src/commands/validate-catalog.ts
3526
3724
  import fs17 from "fs";
3527
3725
  import path26 from "path";
3528
-
3529
- // library/catalog/validation-rules.json
3530
- var validation_rules_default = {
3531
- forbiddenTags: [
3532
- "python",
3533
- "django",
3534
- "go",
3535
- "rust",
3536
- "java",
3537
- "spring",
3538
- "kotlin",
3539
- "swift",
3540
- "android",
3541
- "flutter",
3542
- "dart",
3543
- "c++",
3544
- "perl",
3545
- "defi",
3546
- "trading"
3547
- ],
3548
- bannedAgentPhrases: ["autonomous", "swarm", "delegate", "orchestrat", "marketplace"],
3549
- requiredSkillSections: ["## Use when", "## Do not use when"],
3550
- requiredAgentSections: ["## Use when", "## Do not use when", "## Verification"],
3551
- riskyInstallPatterns: [
3552
- { source: "\\bnpx\\s+-y\\b", flags: "i" },
3553
- { source: "\\bnpx\\s+--yes\\b", flags: "i" },
3554
- { source: "\\byarn\\s+dlx\\b", flags: "i" },
3555
- { source: "\\bpnpm\\s+dlx\\b", flags: "i" }
3556
- ],
3557
- allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
3558
- anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
3559
- httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
3560
- placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
3561
- allowedStacks: [
3562
- "haus",
3563
- "security",
3564
- "quality",
3565
- "frontend",
3566
- "backend",
3567
- "testing",
3568
- "review",
3569
- "workflow",
3570
- "reference-pack",
3571
- "core-skill",
3572
- "workflow-skill",
3573
- "stack-skill",
3574
- "review-skill",
3575
- "agent",
3576
- "hook",
3577
- "rule",
3578
- "react",
3579
- "typescript",
3580
- "php",
3581
- "csharp",
3582
- "vendure",
3583
- "vendure3",
3584
- "nestjs",
3585
- "graphql",
3586
- "nx21",
3587
- "turbo",
3588
- "nextjs",
3589
- "react19",
3590
- "typescript5",
3591
- "vite8",
3592
- "tanstack-query",
3593
- "tanstack-router",
3594
- "radix",
3595
- "radix-ui",
3596
- "shadcn",
3597
- "shadcn-ui",
3598
- "tailwind",
3599
- "tailwindcss",
3600
- "scss",
3601
- "scss-modules",
3602
- "vue",
3603
- "expressjs",
3604
- "soup-base",
3605
- "laravel",
3606
- "laravel-nova",
3607
- "wordpress",
3608
- "bedrock",
3609
- "elementor-pro",
3610
- "acf-pro",
3611
- "jetengine",
3612
- "dotnet",
3613
- "oidc",
3614
- "azure-ad",
3615
- "bankid",
3616
- "myid",
3617
- "cgi",
3618
- "crypto",
3619
- "collection2",
3620
- "postgresql",
3621
- "mariadb",
3622
- "mssql",
3623
- "elasticsearch",
3624
- "yarn4",
3625
- "pnpm89",
3626
- "playwright",
3627
- "testing-library",
3628
- "phpunit",
3629
- "storybook",
3630
- "wisest",
3631
- "vitest",
3632
- "jest",
3633
- "redis",
3634
- "sanity",
3635
- "strapi",
3636
- "prisma",
3637
- "cms",
3638
- "database",
3639
- "mysql",
3640
- "saml2",
3641
- "next-auth",
3642
- "auth",
3643
- "expo",
3644
- "react-native",
3645
- "mobile",
3646
- "i18next",
3647
- "i18n",
3648
- "bullmq",
3649
- "queue",
3650
- "sentry",
3651
- "observability",
3652
- "tooling",
3653
- "prettier",
3654
- "eslint",
3655
- "missing-prettier",
3656
- "missing-eslint",
3657
- "docker",
3658
- "pm2",
3659
- "deployer-php",
3660
- "stripe",
3661
- "qliro",
3662
- "supabase",
3663
- "payments"
3664
- ],
3665
- alwaysAllowedTags: [
3666
- "haus",
3667
- "security",
3668
- "quality",
3669
- "review",
3670
- "workflow",
3671
- "baseline",
3672
- "project-instructions"
3673
- ],
3674
- patternTagSuffixes: ["-patterns"]
3675
- };
3676
-
3677
- // src/catalog/validation-rules.ts
3678
- var toRegExp = (r) => new RegExp(r.source, r.flags);
3679
- var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
3680
- var BANNED_AGENT_PHRASES = validation_rules_default.bannedAgentPhrases;
3681
- var REQUIRED_SKILL_SECTIONS = validation_rules_default.requiredSkillSections;
3682
- var REQUIRED_AGENT_SECTIONS = validation_rules_default.requiredAgentSections;
3683
- var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
3684
- var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
3685
- var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
3686
- var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
3687
- var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
3688
- var ALLOWED_STACKS = validation_rules_default.allowedStacks;
3689
- var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
3690
- var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
3691
- var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
3692
- function isTagAllowed(tag) {
3693
- const lower = tag.toLowerCase();
3694
- if (ALLOWED_SET.has(lower)) return true;
3695
- return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
3696
- }
3697
- function auditDisallowedTags(items) {
3698
- const failures = [];
3699
- for (const item of items) {
3700
- if (!item.id) continue;
3701
- for (const tag of Array.isArray(item.tags) ? item.tags : []) {
3702
- if (!isTagAllowed(tag)) failures.push(`${item.id}: tag not in allowlist: "${tag}"`);
3703
- }
3704
- }
3705
- return failures;
3706
- }
3707
-
3708
- // src/commands/validate-catalog.ts
3709
3726
  function auditForbiddenStacks(items) {
3710
3727
  const failures = [];
3711
3728
  for (const item of items) {
@@ -3840,13 +3857,13 @@ function walkMd(dir, fn) {
3840
3857
  else if (entry.name.endsWith(".md")) fn(full);
3841
3858
  }
3842
3859
  }
3843
- async function runValidateCatalog(manifestPath) {
3844
- if (!manifestPath) {
3860
+ async function runValidateCatalog(manifestPath2) {
3861
+ if (!manifestPath2) {
3845
3862
  error("Usage: haus validate-catalog <path/to/manifest.json>");
3846
3863
  process.exitCode = 1;
3847
3864
  return;
3848
3865
  }
3849
- const abs = path26.resolve(process.cwd(), manifestPath);
3866
+ const abs = path26.resolve(process.cwd(), manifestPath2);
3850
3867
  const manifestDir = path26.dirname(abs);
3851
3868
  const data = await readJson(abs);
3852
3869
  if (!data?.items) {
@@ -3876,77 +3893,665 @@ async function runValidateCatalog(manifestPath) {
3876
3893
  }
3877
3894
 
3878
3895
  // src/commands/workspace.ts
3896
+ import { existsSync as existsSync4, statSync as statSync2 } from "fs";
3897
+ import path33 from "path";
3898
+
3899
+ // src/commands/workspace/aggregate.ts
3900
+ async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
3901
+ const summaries = repos.map((repo) => ({
3902
+ name: repo.name,
3903
+ path: repo.path,
3904
+ roles: repo.context.repoRoles ?? [],
3905
+ packageManager: repo.context.packageManager,
3906
+ deps: repo.context.dependencies ?? []
3907
+ }));
3908
+ const ownership = {};
3909
+ for (const repo of summaries) {
3910
+ for (const dep2 of repo.deps) {
3911
+ ownership[dep2] ??= [];
3912
+ ownership[dep2].push(repo.name);
3913
+ }
3914
+ }
3915
+ const roles = [...new Set(summaries.flatMap((r) => r.roles))].sort();
3916
+ const crossRepoHints = [...new Set(repos.flatMap((r) => r.context.crossRepoHints ?? []))].sort();
3917
+ const summaryPath = hausPath(workspaceRoot, "workspace-summary.json");
3918
+ const ownershipPath = hausPath(workspaceRoot, "dependency-ownership-map.json");
3919
+ const crossRepoPath = hausPath(workspaceRoot, "cross-repo-summary.md");
3920
+ const contextMapPath = hausPath(workspaceRoot, "workspace-context-map.json");
3921
+ await writeJson(summaryPath, {
3922
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3923
+ repos: summaries
3924
+ });
3925
+ await writeJson(ownershipPath, ownership);
3926
+ await writeText(
3927
+ crossRepoPath,
3928
+ `# Cross Repo Summary
3929
+
3930
+ ${summaries.map(
3931
+ (repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
3932
+ ).join("\n")}
3933
+ `
3934
+ );
3935
+ await writeJson(contextMapPath, {
3936
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3937
+ roles,
3938
+ crossRepoHints,
3939
+ repos: summaries.map((r) => ({
3940
+ name: r.name,
3941
+ path: r.path,
3942
+ roles: r.roles,
3943
+ packageManager: r.packageManager
3944
+ })),
3945
+ relationships
3946
+ });
3947
+ return [summaryPath, ownershipPath, crossRepoPath, contextMapPath];
3948
+ }
3949
+
3950
+ // src/commands/workspace/config.ts
3879
3951
  import path27 from "path";
3880
3952
  import YAML from "yaml";
3881
- async function runWorkspace(action) {
3882
- if (action === "init") {
3883
- await writeText(
3884
- "haus.workspace.yaml",
3885
- `client: unknown
3886
- repos:
3887
- - name: current
3888
- path: .
3889
- role: auto
3890
- relationships: []
3891
- `
3953
+ var WORKSPACE_FILE = "haus.workspace.yaml";
3954
+ function parseWorkspaceConfig(text) {
3955
+ if (!text) return void 0;
3956
+ let parsed;
3957
+ try {
3958
+ parsed = YAML.parse(text);
3959
+ } catch {
3960
+ return void 0;
3961
+ }
3962
+ if (!parsed || typeof parsed !== "object") return void 0;
3963
+ const obj = parsed;
3964
+ const repos = Array.isArray(obj.repos) ? obj.repos.filter(
3965
+ (r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
3966
+ ) : [];
3967
+ return {
3968
+ client: typeof obj.client === "string" ? obj.client : "unknown",
3969
+ repos,
3970
+ relationships: Array.isArray(obj.relationships) ? obj.relationships : []
3971
+ };
3972
+ }
3973
+ async function readWorkspaceConfig(workspaceRoot) {
3974
+ return parseWorkspaceConfig(await readText(path27.join(workspaceRoot, WORKSPACE_FILE)));
3975
+ }
3976
+
3977
+ // src/commands/workspace/discover.ts
3978
+ import path28 from "path";
3979
+ import fg3 from "fast-glob";
3980
+ import YAML2 from "yaml";
3981
+ var DEFAULT_MAX_DEPTH = 3;
3982
+ var REPO_MARKERS = ["**/.git", "**/package.json", "**/composer.json"];
3983
+ var IGNORE = [
3984
+ "**/node_modules/**",
3985
+ "**/.git/**",
3986
+ "**/vendor/**",
3987
+ "**/dist/**",
3988
+ "**/.haus-workflow/**"
3989
+ ];
3990
+ function isDescendant(child, ancestor) {
3991
+ if (ancestor === ".") return child !== ".";
3992
+ return child === ancestor ? false : child.startsWith(`${ancestor}/`);
3993
+ }
3994
+ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
3995
+ const matches = await fg3(REPO_MARKERS, {
3996
+ cwd: workspaceRoot,
3997
+ dot: true,
3998
+ onlyFiles: false,
3999
+ deep: maxDepth,
4000
+ followSymbolicLinks: false,
4001
+ ignore: IGNORE
4002
+ });
4003
+ const gitDirs = /* @__PURE__ */ new Set();
4004
+ const manifestDirs = /* @__PURE__ */ new Set();
4005
+ for (const match of matches) {
4006
+ const base = path28.posix.basename(match);
4007
+ const dir = path28.posix.dirname(match);
4008
+ const owner = dir === "." ? "." : dir;
4009
+ if (base === ".git") gitDirs.add(owner);
4010
+ else manifestDirs.add(owner);
4011
+ }
4012
+ const repoRoots = [...gitDirs];
4013
+ const manifestSorted = [...manifestDirs].sort(
4014
+ (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
4015
+ );
4016
+ for (const dir of manifestSorted) {
4017
+ if (gitDirs.has(dir)) continue;
4018
+ if (repoRoots.some((root) => isDescendant(dir, root))) continue;
4019
+ repoRoots.push(dir);
4020
+ }
4021
+ repoRoots.sort((a, b) => a.localeCompare(b));
4022
+ return mapWithConcurrency(repoRoots, async (relDir) => {
4023
+ const absDir = path28.resolve(workspaceRoot, relDir);
4024
+ const pkg = await readJson(path28.join(absDir, "package.json"));
4025
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path28.basename(relDir === "." ? workspaceRoot : absDir);
4026
+ let role = "auto";
4027
+ try {
4028
+ const scan = await scanProject(absDir, "fast");
4029
+ if (scan.repoRoles[0]) role = scan.repoRoles[0];
4030
+ } catch {
4031
+ }
4032
+ return { name, path: relDir === "." ? "." : relDir, role };
4033
+ });
4034
+ }
4035
+ function mergeWorkspaceConfig(existing, discovered, opts = {}) {
4036
+ const existingRepos = existing?.repos ?? [];
4037
+ const byPath = new Map(existingRepos.map((r) => [r.path, r]));
4038
+ for (const repo of discovered) {
4039
+ if (!byPath.has(repo.path)) {
4040
+ byPath.set(repo.path, { name: repo.name, path: repo.path, role: repo.role });
4041
+ }
4042
+ }
4043
+ const ordered = [];
4044
+ const seen = /* @__PURE__ */ new Set();
4045
+ for (const repo of existingRepos) {
4046
+ ordered.push(byPath.get(repo.path));
4047
+ seen.add(repo.path);
4048
+ }
4049
+ for (const repo of discovered) {
4050
+ if (seen.has(repo.path)) continue;
4051
+ ordered.push(byPath.get(repo.path));
4052
+ seen.add(repo.path);
4053
+ }
4054
+ return {
4055
+ client: opts.client ?? existing?.client ?? "unknown",
4056
+ repos: ordered,
4057
+ relationships: existing?.relationships ?? []
4058
+ };
4059
+ }
4060
+ function renderWorkspaceYaml(config2) {
4061
+ return YAML2.stringify({
4062
+ client: config2.client,
4063
+ repos: config2.repos.map((r) => ({ name: r.name, path: r.path, role: r.role ?? "auto" })),
4064
+ relationships: config2.relationships
4065
+ });
4066
+ }
4067
+ async function runDiscover(workspaceRoot, opts = {}) {
4068
+ const yamlPath = path28.join(workspaceRoot, "haus.workspace.yaml");
4069
+ const existingText = await readText(yamlPath);
4070
+ const existing = parseWorkspaceConfig(existingText);
4071
+ if (existingText && !existing) {
4072
+ error(
4073
+ "Existing haus.workspace.yaml is malformed \u2014 fix or remove it before running discover (refusing to overwrite)."
3892
4074
  );
3893
- log("Workspace initialized.");
4075
+ process.exitCode = 1;
3894
4076
  return;
3895
4077
  }
3896
- const configText = await readText("haus.workspace.yaml");
3897
- if (!configText) {
3898
- error("Missing haus.workspace.yaml. Run `haus workspace init` first.");
4078
+ const discovered = await discoverRepos(workspaceRoot, opts.maxDepth ?? DEFAULT_MAX_DEPTH);
4079
+ if (discovered.length === 0) {
4080
+ error("No repos discovered under the workspace root.");
3899
4081
  process.exitCode = 1;
3900
4082
  return;
3901
4083
  }
3902
- const config2 = YAML.parse(configText);
3903
- const repos = config2.repos ?? [];
3904
- if (repos.length === 0) {
3905
- error("No repos configured in haus.workspace.yaml.");
3906
- process.exitCode = 1;
4084
+ const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
4085
+ const yamlText = renderWorkspaceYaml(merged);
4086
+ if (opts.json) {
4087
+ log(JSON.stringify({ discovered, config: merged }, null, 2));
4088
+ }
4089
+ if (opts.write) {
4090
+ await writeText(yamlPath, yamlText);
4091
+ log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
3907
4092
  return;
3908
4093
  }
3909
- const summaries = [];
3910
- const ownership = {};
3911
- for (const repo of repos) {
3912
- const repoRoot = path27.resolve(process.cwd(), repo.path);
3913
- const result = await scanProject(repoRoot, "fast");
3914
- summaries.push({
4094
+ if (!opts.json) {
4095
+ log("Proposed haus.workspace.yaml (run with --write to persist):\n");
4096
+ log(yamlText);
4097
+ }
4098
+ }
4099
+
4100
+ // src/commands/workspace/doctor.ts
4101
+ import { existsSync as existsSync2 } from "fs";
4102
+ import path30 from "path";
4103
+
4104
+ // src/commands/workspace/manifest.ts
4105
+ import { readFileSync as readFileSync3 } from "fs";
4106
+ import path29 from "path";
4107
+ var MANIFEST_FILE = "workspace.manifest.json";
4108
+ function manifestPath(workspaceRoot) {
4109
+ return hausPath(workspaceRoot, MANIFEST_FILE);
4110
+ }
4111
+ function hausVersion() {
4112
+ try {
4113
+ const pkg = JSON.parse(readFileSync3(path29.join(packageRoot(), "package.json"), "utf8"));
4114
+ return pkg.version ?? "0.0.0";
4115
+ } catch {
4116
+ return "0.0.0";
4117
+ }
4118
+ }
4119
+ function buildManifest2(opts) {
4120
+ const now = opts.now ?? (/* @__PURE__ */ new Date()).toISOString();
4121
+ const version = opts.version ?? hausVersion();
4122
+ return {
4123
+ version: 1,
4124
+ generatedAt: now,
4125
+ hausVersion: version,
4126
+ client: opts.client,
4127
+ repos: opts.repos.map((repo) => ({
3915
4128
  name: repo.name,
3916
4129
  path: repo.path,
3917
- roles: result.repoRoles,
3918
- packageManager: result.packageManager,
3919
- deps: result.dependencies
4130
+ role: repo.role,
4131
+ lastSetupAt: repo.lastSetupAt !== void 0 ? repo.lastSetupAt : repo.status === "ok" ? now : null,
4132
+ hausVersionAtSetup: repo.hausVersionAtSetup !== void 0 ? repo.hausVersionAtSetup : repo.status === "ok" ? version : null,
4133
+ lockItemCount: repo.lockItemCount,
4134
+ catalogRef: repo.catalogRef,
4135
+ status: repo.status,
4136
+ ...repo.error ? { error: repo.error } : {}
4137
+ }))
4138
+ };
4139
+ }
4140
+ async function readManifest2(workspaceRoot) {
4141
+ return readJson(manifestPath(workspaceRoot));
4142
+ }
4143
+ async function writeWorkspaceManifest(workspaceRoot, manifest) {
4144
+ const target = manifestPath(workspaceRoot);
4145
+ await writeJson(target, manifest);
4146
+ return target;
4147
+ }
4148
+
4149
+ // src/commands/workspace/doctor.ts
4150
+ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
4151
+ const config2 = await readWorkspaceConfig(workspaceRoot);
4152
+ const manifest = await readManifest2(workspaceRoot);
4153
+ const currentVersion = hausVersion();
4154
+ const drift = [];
4155
+ const detail = [];
4156
+ const ok = (text) => detail.push({ stream: "log", text });
4157
+ const flag = (item) => {
4158
+ drift.push(item);
4159
+ detail.push({ stream: "warn", text: `- ${item.repo}: ${item.detail}` });
4160
+ };
4161
+ if (!config2) {
4162
+ flag({
4163
+ repo: "(workspace)",
4164
+ kind: "no-config",
4165
+ detail: "Missing or malformed haus.workspace.yaml \u2014 run `haus workspace discover --write` or `init`."
3920
4166
  });
3921
- for (const dep2 of result.dependencies) {
3922
- ownership[dep2] ??= [];
3923
- ownership[dep2].push(repo.name);
4167
+ return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
4168
+ }
4169
+ if (!manifest) {
4170
+ flag({
4171
+ repo: "(workspace)",
4172
+ kind: "no-manifest",
4173
+ detail: "No workspace.manifest.json \u2014 run `haus workspace setup --write` first."
4174
+ });
4175
+ return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
4176
+ }
4177
+ const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
4178
+ for (const repo of config2.repos) {
4179
+ const repoRoot = path30.resolve(workspaceRoot, repo.path);
4180
+ const entry = manifestByName.get(repo.name);
4181
+ if (!entry) {
4182
+ flag({
4183
+ repo: repo.name,
4184
+ kind: "missing-from-manifest",
4185
+ detail: "Configured in yaml but absent from the manifest \u2014 run `haus workspace setup --write`."
4186
+ });
4187
+ continue;
4188
+ }
4189
+ const driftBefore = drift.length;
4190
+ if (entry.status === "failed") {
4191
+ flag({
4192
+ repo: repo.name,
4193
+ kind: "failed",
4194
+ detail: `Last setup failed${entry.error ? `: ${entry.error}` : ""}.`
4195
+ });
4196
+ }
4197
+ if (entry.hausVersionAtSetup && entry.hausVersionAtSetup !== currentVersion) {
4198
+ flag({
4199
+ repo: repo.name,
4200
+ kind: "version-mismatch",
4201
+ detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
4202
+ });
4203
+ }
4204
+ if (!existsSync2(claudePath(repoRoot))) {
4205
+ flag({
4206
+ repo: repo.name,
4207
+ kind: "missing-claude",
4208
+ detail: "Missing .claude/ \u2014 run `haus workspace setup --write`."
4209
+ });
4210
+ }
4211
+ const lock = await checkLock(repoRoot);
4212
+ if (!existsSync2(hausPath(repoRoot, "haus.lock.json"))) {
4213
+ flag({
4214
+ repo: repo.name,
4215
+ kind: "missing-lock",
4216
+ detail: "Missing .haus-workflow/haus.lock.json \u2014 run `haus workspace setup --write`."
4217
+ });
4218
+ } else if (lock.count > 0 && !lock.ok) {
4219
+ flag({
4220
+ repo: repo.name,
4221
+ kind: "invalid-lock",
4222
+ detail: "haus.lock.json present but invalid \u2014 re-run `haus workspace setup --write`."
4223
+ });
4224
+ }
4225
+ if (drift.length === driftBefore) {
4226
+ ok(`- ${repo.name}: OK (${lock.count} lock item(s))`);
3924
4227
  }
3925
4228
  }
3926
- await writeJson(".haus-workflow/workspace-summary.json", {
3927
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3928
- repos: summaries
3929
- });
3930
- await writeJson(".haus-workflow/dependency-ownership-map.json", ownership);
3931
- await writeText(
3932
- ".haus-workflow/cross-repo-summary.md",
3933
- `# Cross Repo Summary
4229
+ return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
4230
+ }
4231
+ function emit(args) {
4232
+ const { workspaceRoot, manifest, drift, detail } = args;
4233
+ if (args.json) {
4234
+ log(JSON.stringify({ manifest: manifest ?? null, drift }, null, 2));
4235
+ } else {
4236
+ if (drift.length === 0) {
4237
+ log("\u2705 Workspace is set up and healthy.");
4238
+ } else {
4239
+ log(`\u26A0\uFE0F ${drift.length} workspace drift item(s) need attention:`);
4240
+ }
4241
+ log("Haus Workspace Doctor");
4242
+ for (const line2 of detail) {
4243
+ if (line2.stream === "warn") warn(line2.text);
4244
+ else log(line2.text);
4245
+ }
4246
+ }
4247
+ if (drift.length > 0) process.exitCode = 1;
4248
+ return { workspaceRoot, manifest, drift };
4249
+ }
3934
4250
 
3935
- ${summaries.map(
3936
- (repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
3937
- ).join("\n")}
4251
+ // src/commands/workspace/setup.ts
4252
+ import { existsSync as existsSync3, statSync } from "fs";
4253
+ import path32 from "path";
4254
+
4255
+ // src/claude/write-workspace-claude-md.ts
4256
+ import path31 from "path";
4257
+ import fs18 from "fs-extra";
4258
+ function buildWorkspaceImportBlock(client, members) {
4259
+ const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
4260
+ const body = [
4261
+ "@.haus-workflow/cross-repo-summary.md",
4262
+ "",
4263
+ `# Workspace: ${client}`,
4264
+ "",
4265
+ "Member repos:",
4266
+ ...memberLines
4267
+ ].join("\n");
4268
+ return `${BLOCK_BEGIN}
4269
+ ${body}
4270
+ ${BLOCK_END}`;
4271
+ }
4272
+ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
4273
+ const block = buildWorkspaceImportBlock(opts.client, opts.members);
4274
+ const dryRun = opts.dryRun ?? false;
4275
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path31.join(workspaceRoot, "CLAUDE.md");
4276
+ const prev = await fs18.pathExists(filePath) ? await fs18.readFile(filePath, "utf8") : "";
4277
+ const next = opts.collision ? `${block}
4278
+ ` : injectHausBlock(prev, block);
4279
+ const printable = displayPath(workspaceRoot, filePath);
4280
+ if (dryRun) {
4281
+ if (!prev) {
4282
+ log(createUnifiedDiff(printable, "", next));
4283
+ } else if (hasTextChanged(prev, next)) {
4284
+ log(createUnifiedDiff(printable, prev, next));
4285
+ } else {
4286
+ log(`${printable}: unchanged`);
4287
+ }
4288
+ return filePath;
4289
+ }
4290
+ if (hasTextChanged(prev, next) && prev.length > 0) {
4291
+ const diffText = createUnifiedDiff(printable, prev, next);
4292
+ const summary = summarizeDiff(diffText);
4293
+ log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
4294
+ }
4295
+ await writeText(filePath, next);
4296
+ return filePath;
4297
+ }
4298
+
4299
+ // src/commands/workspace/setup.ts
4300
+ function resolveWorkspaceRoot(start = process.cwd()) {
4301
+ let dir = path32.resolve(start);
4302
+ for (; ; ) {
4303
+ if (existsSync3(path32.join(dir, WORKSPACE_FILE))) return dir;
4304
+ const parent = path32.dirname(dir);
4305
+ if (parent === dir) return path32.resolve(start);
4306
+ dir = parent;
4307
+ }
4308
+ }
4309
+ function isRootRepo(workspaceRoot, repoPath) {
4310
+ return path32.resolve(workspaceRoot, repoPath) === path32.resolve(workspaceRoot);
4311
+ }
4312
+ async function runWorkspaceSetup(workspaceRoot, options = {}) {
4313
+ const mode = options.mode ?? "fast";
4314
+ const apply = options.write ?? false;
4315
+ const configText = await readText(path32.join(workspaceRoot, WORKSPACE_FILE));
4316
+ if (!configText) {
4317
+ error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
4318
+ process.exitCode = 1;
4319
+ return { workspaceRoot, statuses: [], written: [] };
4320
+ }
4321
+ const config2 = parseWorkspaceConfig(configText);
4322
+ if (!config2 || config2.repos.length === 0) {
4323
+ error(`No repos configured in ${WORKSPACE_FILE}.`);
4324
+ process.exitCode = 1;
4325
+ return { workspaceRoot, statuses: [], written: [] };
4326
+ }
4327
+ const onlySet = options.only && options.only.length > 0 ? new Set(options.only) : void 0;
4328
+ const repos = onlySet ? config2.repos.filter((r) => onlySet.has(r.name)) : config2.repos;
4329
+ const statuses = [];
4330
+ const aggregateInputs = [];
4331
+ for (const repo of repos) {
4332
+ const repoRoot = path32.resolve(workspaceRoot, repo.path);
4333
+ log(`
4334
+ \u2192 ${repo.name} (${repo.path})`);
4335
+ try {
4336
+ if (!existsSync3(repoRoot) || !statSync(repoRoot).isDirectory()) {
4337
+ throw new Error(`Repo path is not a directory: ${repo.path}`);
4338
+ }
4339
+ const res = await runSetupCore(repoRoot, {
4340
+ mode,
4341
+ json: options.json,
4342
+ apply,
4343
+ dryRun: options.dryRun
4344
+ });
4345
+ statuses.push({
4346
+ name: repo.name,
4347
+ path: repo.path,
4348
+ root: repoRoot,
4349
+ status: "ok",
4350
+ roles: res.roles,
4351
+ recommendedCount: res.recommendedCount
4352
+ });
4353
+ const context = await readContextOrScan(repoRoot);
4354
+ aggregateInputs.push({ name: repo.name, path: repo.path, context });
4355
+ } catch (err) {
4356
+ const message = err instanceof Error ? err.message : String(err);
4357
+ statuses.push({
4358
+ name: repo.name,
4359
+ path: repo.path,
4360
+ root: repoRoot,
4361
+ status: "failed",
4362
+ error: message
4363
+ });
4364
+ if (!options.continueOnError) throw err;
4365
+ error(`Setup failed for ${repo.name}: ${message}`);
4366
+ }
4367
+ }
4368
+ const written = [];
4369
+ if (apply && aggregateInputs.length > 0) {
4370
+ const collision = config2.repos.some((r) => isRootRepo(workspaceRoot, r.path));
4371
+ if (!options.dryRun) {
4372
+ const artifacts = await writeWorkspaceArtifacts(
4373
+ workspaceRoot,
4374
+ aggregateInputs,
4375
+ config2.relationships
4376
+ );
4377
+ written.push(...artifacts);
4378
+ }
4379
+ const docPath = await writeWorkspaceClaudeMd(workspaceRoot, {
4380
+ client: config2.client,
4381
+ members: config2.repos.map((r) => ({ name: r.name, path: r.path })),
4382
+ collision,
4383
+ dryRun: options.dryRun
4384
+ });
4385
+ written.push(docPath);
4386
+ }
4387
+ if (apply && !options.dryRun) {
4388
+ const statusByName = new Map(statuses.map((s) => [s.name, s]));
4389
+ const prior = await readManifest2(workspaceRoot);
4390
+ if (!prior && existsSync3(manifestPath(workspaceRoot))) {
4391
+ warn(
4392
+ "Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
4393
+ );
4394
+ }
4395
+ const priorByName = new Map((prior?.repos ?? []).map((r) => [r.name, r]));
4396
+ const manifestRepos = [];
4397
+ for (const repo of config2.repos) {
4398
+ const status = statusByName.get(repo.name);
4399
+ const role = repo.role ?? status?.roles?.[0] ?? "auto";
4400
+ if (status?.status === "ok") {
4401
+ const lock = await checkLock(path32.resolve(workspaceRoot, repo.path));
4402
+ manifestRepos.push({
4403
+ name: repo.name,
4404
+ path: repo.path,
4405
+ role,
4406
+ status: "ok",
4407
+ lockItemCount: lock.count,
4408
+ catalogRef: lock.catalogRef
4409
+ });
4410
+ } else if (status?.status === "failed") {
4411
+ manifestRepos.push({
4412
+ name: repo.name,
4413
+ path: repo.path,
4414
+ role,
4415
+ status: "failed",
4416
+ lockItemCount: 0,
4417
+ catalogRef: null,
4418
+ error: status.error
4419
+ });
4420
+ } else {
4421
+ const carried = priorByName.get(repo.name);
4422
+ manifestRepos.push(
4423
+ carried ? {
4424
+ name: carried.name,
4425
+ path: carried.path,
4426
+ role: carried.role,
4427
+ status: carried.status,
4428
+ lockItemCount: carried.lockItemCount,
4429
+ catalogRef: carried.catalogRef,
4430
+ lastSetupAt: carried.lastSetupAt,
4431
+ hausVersionAtSetup: carried.hausVersionAtSetup,
4432
+ ...carried.error ? { error: carried.error } : {}
4433
+ } : {
4434
+ name: repo.name,
4435
+ path: repo.path,
4436
+ role,
4437
+ status: "pending",
4438
+ lockItemCount: 0,
4439
+ catalogRef: null
4440
+ }
4441
+ );
4442
+ }
4443
+ }
4444
+ const manifest = buildManifest2({ client: config2.client, repos: manifestRepos });
4445
+ const manifestFile = await writeWorkspaceManifest(workspaceRoot, manifest);
4446
+ written.push(manifestFile);
4447
+ }
4448
+ const ok = statuses.filter((s) => s.status === "ok").length;
4449
+ const failed = statuses.length - ok;
4450
+ log(`
4451
+ Workspace setup complete: ${ok} ok, ${failed} failed.`);
4452
+ return { workspaceRoot, statuses, written };
4453
+ }
4454
+
4455
+ // src/commands/workspace.ts
4456
+ function normalizeOnly(only) {
4457
+ if (!only) return void 0;
4458
+ const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
4459
+ const cleaned = list.map((s) => s.trim()).filter(Boolean);
4460
+ return cleaned.length > 0 ? cleaned : void 0;
4461
+ }
4462
+ function normalizeMaxDepth(maxDepth) {
4463
+ if (maxDepth === void 0) return void 0;
4464
+ const n = typeof maxDepth === "number" ? maxDepth : Number.parseInt(maxDepth, 10);
4465
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
4466
+ }
4467
+ async function initWorkspace() {
4468
+ await writeText(
4469
+ WORKSPACE_FILE,
4470
+ `client: unknown
4471
+ repos:
4472
+ - name: current
4473
+ path: .
4474
+ role: auto
4475
+ relationships: []
3938
4476
  `
3939
4477
  );
3940
- log(
3941
- "Workspace scan complete. Wrote .haus-workflow/workspace-summary.json, cross-repo-summary.md, dependency-ownership-map.json"
3942
- );
4478
+ log("Workspace initialized.");
4479
+ }
4480
+ async function scanWorkspace(workspaceRoot, opts) {
4481
+ const configText = await readText(path33.join(workspaceRoot, WORKSPACE_FILE));
4482
+ if (!configText) {
4483
+ error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
4484
+ process.exitCode = 1;
4485
+ return;
4486
+ }
4487
+ const config2 = parseWorkspaceConfig(configText);
4488
+ if (!config2) {
4489
+ error(
4490
+ `Malformed ${WORKSPACE_FILE}. Fix the YAML or re-run \`haus workspace discover --write\`.`
4491
+ );
4492
+ process.exitCode = 1;
4493
+ return;
4494
+ }
4495
+ if (config2.repos.length === 0) {
4496
+ error(`No repos configured in ${WORKSPACE_FILE}.`);
4497
+ process.exitCode = 1;
4498
+ return;
4499
+ }
4500
+ const inputs = [];
4501
+ for (const repo of config2.repos) {
4502
+ const repoRoot = path33.resolve(workspaceRoot, repo.path);
4503
+ if (!existsSync4(repoRoot) || !statSync2(repoRoot).isDirectory()) {
4504
+ throw new Error(`Repo path is not a directory: ${repo.path}`);
4505
+ }
4506
+ const result = await scanProject(repoRoot, "fast");
4507
+ inputs.push({ name: repo.name, path: repo.path, context: result });
4508
+ }
4509
+ const written = await writeWorkspaceArtifacts(workspaceRoot, inputs, config2.relationships);
4510
+ if (opts.json) {
4511
+ log(JSON.stringify({ written }, null, 2));
4512
+ } else {
4513
+ log(`Workspace scan complete. Wrote ${written.length} artifact(s) under .haus-workflow/.`);
4514
+ }
4515
+ }
4516
+ async function runWorkspace(action, options = {}) {
4517
+ if (action === "init") {
4518
+ await initWorkspace();
4519
+ return;
4520
+ }
4521
+ const workspaceRoot = resolveWorkspaceRoot();
4522
+ switch (action) {
4523
+ case "discover":
4524
+ await runDiscover(workspaceRoot, {
4525
+ write: options.write,
4526
+ json: options.json,
4527
+ maxDepth: normalizeMaxDepth(options.maxDepth),
4528
+ client: options.client
4529
+ });
4530
+ return;
4531
+ case "scan":
4532
+ await scanWorkspace(workspaceRoot, { json: options.json });
4533
+ return;
4534
+ case "setup":
4535
+ await runWorkspaceSetup(workspaceRoot, {
4536
+ mode: options.guided ? "guided" : "fast",
4537
+ write: options.write,
4538
+ dryRun: options.dryRun,
4539
+ json: options.json,
4540
+ continueOnError: options.continueOnError,
4541
+ only: normalizeOnly(options.only)
4542
+ });
4543
+ return;
4544
+ case "doctor":
4545
+ await runWorkspaceDoctor(workspaceRoot, { json: options.json });
4546
+ return;
4547
+ }
3943
4548
  }
3944
4549
 
3945
4550
  // src/cli.ts
3946
4551
  function cliVersion() {
3947
4552
  try {
3948
- const pkgPath = path28.join(packageRoot(), "package.json");
3949
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
4553
+ const pkgPath = path34.join(packageRoot(), "package.json");
4554
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
3950
4555
  return pkg.version ?? "0.0.0";
3951
4556
  } catch {
3952
4557
  return "0.0.0";
@@ -3955,8 +4560,8 @@ function cliVersion() {
3955
4560
  var program = new Command();
3956
4561
  function validateRuntimeNodeVersion() {
3957
4562
  try {
3958
- const pkgPath = path28.join(packageRoot(), "package.json");
3959
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
4563
+ const pkgPath = path34.join(packageRoot(), "package.json");
4564
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
3960
4565
  const requiredRange = pkg.engines?.node;
3961
4566
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
3962
4567
  throw new Error(`Node ${process.version} does not satisfy required range ${requiredRange}`);
@@ -3999,7 +4604,10 @@ config.command("disable <key>").description("Disable a hook (hook.context)").act
3999
4604
  config.command("status <key>").description("Show current state of a hook (hook.context)").action((key) => runConfig(key, "status"));
4000
4605
  var workspace = program.command("workspace");
4001
4606
  workspace.command("init").action(() => runWorkspace("init"));
4002
- workspace.command("scan").action(() => runWorkspace("scan"));
4607
+ workspace.command("discover").description("Auto-find member repos and write/merge haus.workspace.yaml").option("--write", "Persist haus.workspace.yaml (default previews only)").option("--json", "Output the discovered repos and proposed config as JSON").option("--max-depth <n>", "Max directory depth to traverse (default 3)").option("--client <name>", "Set the workspace client name").action((opts) => runWorkspace("discover", opts));
4608
+ workspace.command("scan").description("Aggregate a cross-repo summary from a fast scan of each repo").option("--json", "Output the written artifact paths as JSON").action((opts) => runWorkspace("scan", opts));
4609
+ workspace.command("setup").description("Per-repo setup loop + workspace layer + manifest").option("--write", "Apply changes (default previews only)").option("--dry-run", "Preview changes without writing").option("--json", "Emit machine-readable per-repo output").option("--fast", "Skip interactive prompts (default)").option("--guided", "Enable guided Q&A per repo").option("--continue-on-error", "Keep going past a failed repo (default fail-fast)").option("--only <names>", "Restrict to comma-separated repo names").action((opts) => runWorkspace("setup", opts));
4610
+ workspace.command("doctor").description("Report workspace drift against the manifest").option("--json", "Output the manifest and drift array as JSON").action((opts) => runWorkspace("doctor", opts));
4003
4611
  program.parseAsync(process.argv).catch((err) => {
4004
4612
  const message = err instanceof Error ? err.message : String(err);
4005
4613
  error(message);