@haus-tech/haus-workflow 0.30.0 → 0.31.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,11 +2,11 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { readFileSync as readFileSync4 } from "node:fs";
5
- import path36 from "node:path";
5
+ import path37 from "node:path";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/commands/apply.ts
9
- import path13 from "node:path";
9
+ import path14 from "node:path";
10
10
  import checkbox from "@inquirer/checkbox";
11
11
 
12
12
  // src/catalog/remote-catalog.ts
@@ -182,7 +182,7 @@ var validation_rules_default = {
182
182
  "vendure3",
183
183
  "nestjs",
184
184
  "graphql",
185
- "nx21",
185
+ "nx",
186
186
  "turbo",
187
187
  "nextjs",
188
188
  "react19",
@@ -1043,8 +1043,8 @@ async function getCacheManifestAge() {
1043
1043
  }
1044
1044
 
1045
1045
  // src/claude/write-claude-files.ts
1046
- import path12 from "node:path";
1047
- import fs12 from "fs-extra";
1046
+ import path13 from "node:path";
1047
+ import fs13 from "fs-extra";
1048
1048
 
1049
1049
  // src/catalog/load-catalog.ts
1050
1050
  import path4 from "node:path";
@@ -1660,8 +1660,8 @@ function buildDenyRules() {
1660
1660
  for (const command of DENY_COMMANDS) {
1661
1661
  rules.push(`Bash(${command}:*)`);
1662
1662
  }
1663
- for (const path37 of DENY_PATHS) {
1664
- const pattern = DENY_DIRS.has(path37) ? `${path37}/**` : path37;
1663
+ for (const path38 of DENY_PATHS) {
1664
+ const pattern = DENY_DIRS.has(path38) ? `${path38}/**` : path38;
1665
1665
  for (const tool of FILE_TOOLS) {
1666
1666
  rules.push(`${tool}(${pattern})`);
1667
1667
  }
@@ -1851,9 +1851,74 @@ async function verifyProjectSettingsHooksContract(root) {
1851
1851
  return { ok: true, message: "settings.json carries required haus hook contract." };
1852
1852
  }
1853
1853
 
1854
- // src/claude/write-root-claude-md.ts
1854
+ // src/claude/write-prettierignore.ts
1855
1855
  import path9 from "node:path";
1856
1856
  import fs8 from "fs-extra";
1857
+ var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
1858
+ var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
1859
+ var IGNORED_PATHS = [".haus-workflow/"];
1860
+ function buildPrettierIgnoreBlock() {
1861
+ return [
1862
+ PRETTIERIGNORE_BEGIN,
1863
+ "# haus-owned files \u2014 do not reformat (keeps tamper-detection hashes stable)",
1864
+ ...IGNORED_PATHS,
1865
+ PRETTIERIGNORE_END
1866
+ ].join("\n");
1867
+ }
1868
+ function findLineMarker(content2, marker, from = 0) {
1869
+ let idx = content2.indexOf(marker, from);
1870
+ while (idx !== -1) {
1871
+ const lineStart = idx === 0 || content2[idx - 1] === "\n";
1872
+ const after = idx + marker.length;
1873
+ const lineEnd = after === content2.length || content2[after] === "\n" || content2[after] === "\r";
1874
+ if (lineStart && lineEnd) return { start: idx, end: after };
1875
+ idx = content2.indexOf(marker, idx + marker.length);
1876
+ }
1877
+ return null;
1878
+ }
1879
+ function findBlockRange(content2) {
1880
+ const begin = findLineMarker(content2, PRETTIERIGNORE_BEGIN);
1881
+ if (!begin) return null;
1882
+ const end = findLineMarker(content2, PRETTIERIGNORE_END, begin.end);
1883
+ if (!end) return null;
1884
+ return { start: begin.start, end: end.end };
1885
+ }
1886
+ function injectPrettierIgnoreBlock(existing, block) {
1887
+ const range = findBlockRange(existing);
1888
+ if (range) {
1889
+ const before = existing.slice(0, range.start);
1890
+ const after = existing.slice(range.end);
1891
+ return `${before}${block}${after}`.replace(/\n*$/, "\n");
1892
+ }
1893
+ const loneBegin = findLineMarker(existing, PRETTIERIGNORE_BEGIN);
1894
+ if (loneBegin) {
1895
+ const before = existing.slice(0, loneBegin.start).trimEnd();
1896
+ if (before.length === 0) return `${block}
1897
+ `;
1898
+ return `${before}
1899
+
1900
+ ${block}
1901
+ `;
1902
+ }
1903
+ const trimmed = existing.trimEnd();
1904
+ if (trimmed.length === 0) return `${block}
1905
+ `;
1906
+ return `${trimmed}
1907
+
1908
+ ${block}
1909
+ `;
1910
+ }
1911
+ async function writePrettierIgnore(root, dryRun) {
1912
+ const filePath = path9.join(root, ".prettierignore");
1913
+ const prev = await fs8.pathExists(filePath) ? await fs8.readFile(filePath, "utf8") : "";
1914
+ const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
1915
+ await writeManagedText(root, filePath, next, dryRun);
1916
+ return filePath;
1917
+ }
1918
+
1919
+ // src/claude/write-root-claude-md.ts
1920
+ import path10 from "node:path";
1921
+ import fs9 from "fs-extra";
1857
1922
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
1858
1923
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
1859
1924
  var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
@@ -1863,7 +1928,7 @@ function buildImportBlock() {
1863
1928
  ${IMPORT_CONTENT}
1864
1929
  ${BLOCK_END}`;
1865
1930
  }
1866
- function findLineMarker(content2, marker, from = 0) {
1931
+ function findLineMarker2(content2, marker, from = 0) {
1867
1932
  let idx = content2.indexOf(marker, from);
1868
1933
  while (idx !== -1) {
1869
1934
  const lineStart = idx === 0 || content2[idx - 1] === "\n";
@@ -1875,9 +1940,9 @@ function findLineMarker(content2, marker, from = 0) {
1875
1940
  return null;
1876
1941
  }
1877
1942
  function findImportBlockRange(content2) {
1878
- const begin = findLineMarker(content2, BLOCK_BEGIN);
1943
+ const begin = findLineMarker2(content2, BLOCK_BEGIN);
1879
1944
  if (!begin) return null;
1880
- const end = findLineMarker(content2, BLOCK_END, begin.end);
1945
+ const end = findLineMarker2(content2, BLOCK_END, begin.end);
1881
1946
  if (!end) return null;
1882
1947
  return { beginStart: begin.start, beginEnd: begin.end, endStart: end.start, endEnd: end.end };
1883
1948
  }
@@ -1897,7 +1962,7 @@ function injectHausBlock(existing, block) {
1897
1962
  const after = existing.slice(range.endEnd);
1898
1963
  return `${before}${block}${after}`;
1899
1964
  }
1900
- const loneBegin = findLineMarker(existing, BLOCK_BEGIN);
1965
+ const loneBegin = findLineMarker2(existing, BLOCK_BEGIN);
1901
1966
  if (loneBegin) {
1902
1967
  const before = existing.slice(0, loneBegin.start).trimEnd();
1903
1968
  if (before.length === 0) return `${block}
@@ -1918,21 +1983,21 @@ ${block}
1918
1983
  `;
1919
1984
  }
1920
1985
  async function writeRootClaudeMd(root, dryRun) {
1921
- const filePath = path9.join(root, "CLAUDE.md");
1986
+ const filePath = path10.join(root, "CLAUDE.md");
1922
1987
  const block = buildImportBlock();
1923
- const prev = await fs8.pathExists(filePath) ? await fs8.readFile(filePath, "utf8") : "";
1988
+ const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
1924
1989
  const next = injectHausBlock(prev, block);
1925
1990
  await writeManagedText(root, filePath, next, dryRun);
1926
1991
  return filePath;
1927
1992
  }
1928
1993
 
1929
1994
  // src/claude/write-workflow-config.ts
1930
- import path11 from "node:path";
1931
- import fs10 from "fs-extra";
1995
+ import path12 from "node:path";
1996
+ import fs11 from "fs-extra";
1932
1997
 
1933
1998
  // src/claude/derive-workflow-config.ts
1934
- import path10 from "node:path";
1935
- import fs9 from "fs-extra";
1999
+ import path11 from "node:path";
2000
+ import fs10 from "fs-extra";
1936
2001
  function binCmd(pm, bin, args) {
1937
2002
  const tail = args ? ` ${args}` : "";
1938
2003
  if (pm === "yarn") return `yarn ${bin}${tail}`;
@@ -1941,7 +2006,7 @@ function binCmd(pm, bin, args) {
1941
2006
  }
1942
2007
  async function deriveWorkflowConfig(root, ctx) {
1943
2008
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
1944
- const pkg = await readJson(path10.join(root, "package.json"));
2009
+ const pkg = await readJson(path11.join(root, "package.json"));
1945
2010
  const scripts = pkg?.scripts ?? {};
1946
2011
  const deps = new Set(ctx.dependencies);
1947
2012
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -1951,7 +2016,7 @@ async function deriveWorkflowConfig(root, ctx) {
1951
2016
  return null;
1952
2017
  };
1953
2018
  const hasDep = (name) => deps.has(name);
1954
- const exists = (rel) => fs9.pathExistsSync(path10.join(root, rel));
2019
+ const exists = (rel) => fs10.pathExistsSync(path11.join(root, rel));
1955
2020
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
1956
2021
  const hasCypress = hasDep("cypress");
1957
2022
  const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
@@ -2019,7 +2084,7 @@ var FALLBACK_CONTEXT = {
2019
2084
  async function writeWorkflowConfig(root, dryRun, opts = {}) {
2020
2085
  const destPath = hausPath(root, "workflow-config.md");
2021
2086
  const printable = displayPath(root, destPath);
2022
- const exists = await fs10.pathExists(destPath);
2087
+ const exists = await fs11.pathExists(destPath);
2023
2088
  if (exists && !opts.refill) {
2024
2089
  if (dryRun) log(printable + ": exists (project-owned, skipping)");
2025
2090
  return null;
@@ -2027,11 +2092,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2027
2092
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
2028
2093
  ...FALLBACK_CONTEXT,
2029
2094
  root,
2030
- repoName: path11.basename(root)
2095
+ repoName: path12.basename(root)
2031
2096
  };
2032
2097
  const values = await deriveWorkflowConfig(root, ctx);
2033
2098
  if (exists) {
2034
- const current = await fs10.readFile(destPath, "utf8");
2099
+ const current = await fs11.readFile(destPath, "utf8");
2035
2100
  const refilled = refillContent(current, values);
2036
2101
  if (refilled === current) {
2037
2102
  if (dryRun) log(printable + ": no blank fields to refill");
@@ -2053,7 +2118,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2053
2118
  }
2054
2119
 
2055
2120
  // src/claude/write-workflow.ts
2056
- import fs11 from "fs-extra";
2121
+ import fs12 from "fs-extra";
2057
2122
  var STABLE_ID = "template.workflow";
2058
2123
  function makeWorkflowHeader(pkgVersion, contentHash) {
2059
2124
  return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
@@ -2072,8 +2137,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
2072
2137
  ${templateContent}`;
2073
2138
  const destPath = hausPath(root, "WORKFLOW.md");
2074
2139
  const printable = displayPath(root, destPath);
2075
- if (await fs11.pathExists(destPath)) {
2076
- const existing = await fs11.readFile(destPath, "utf8");
2140
+ if (await fs12.pathExists(destPath)) {
2141
+ const existing = await fs12.readFile(destPath, "utf8");
2077
2142
  const firstLine = existing.split("\n")[0] ?? "";
2078
2143
  const parsed = parseHausManagedHeader(firstLine);
2079
2144
  if (!parsed) {
@@ -2107,7 +2172,7 @@ ${templateContent}`;
2107
2172
  }
2108
2173
  }
2109
2174
  if (dryRun) {
2110
- const prev = await fs11.pathExists(destPath) ? await fs11.readFile(destPath, "utf8") : "";
2175
+ const prev = await fs12.pathExists(destPath) ? await fs12.readFile(destPath, "utf8") : "";
2111
2176
  if (!prev) {
2112
2177
  log(createUnifiedDiff(printable, "", next));
2113
2178
  } else {
@@ -2140,7 +2205,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2140
2205
  estimatedTokenReductionPct: 0
2141
2206
  };
2142
2207
  const pkgRoot = packageRoot();
2143
- const hausVersion2 = (await readJson(path12.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2208
+ const hausVersion2 = (await readJson(path13.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2144
2209
  const coreFiles = [
2145
2210
  claudePath(root, "settings.json"),
2146
2211
  claudePath(root, "rules", "haus.md"),
@@ -2151,10 +2216,12 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2151
2216
  const workflowConfigPath = await writeWorkflowConfig(root, dryRun, {
2152
2217
  refill: opts.refillConfig
2153
2218
  });
2219
+ const prettierIgnorePath = await writePrettierIgnore(root, dryRun);
2154
2220
  const p6Files = [
2155
2221
  rootClaudeMdPath,
2156
2222
  ...workflowPath ? [workflowPath] : [],
2157
- ...workflowConfigPath ? [workflowConfigPath] : []
2223
+ ...workflowConfigPath ? [workflowConfigPath] : [],
2224
+ prettierIgnorePath
2158
2225
  ];
2159
2226
  const files = dryRun ? [...coreFiles, ...p6Files] : [...coreFiles, ...p6Files, hausPath(root, "haus.lock.json")];
2160
2227
  if (dryRun) {
@@ -2171,8 +2238,8 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2171
2238
  dryRun
2172
2239
  );
2173
2240
  const legacyReviewPath = claudePath(root, "commands", "haus-review.md");
2174
- if (await fs12.pathExists(legacyReviewPath)) {
2175
- const content2 = await fs12.readFile(legacyReviewPath, "utf8");
2241
+ if (await fs13.pathExists(legacyReviewPath)) {
2242
+ const content2 = await fs13.readFile(legacyReviewPath, "utf8");
2176
2243
  const stub = 'Run `haus context --task "code review"` then review diff.';
2177
2244
  if (content2 === stub || content2 === `${stub}
2178
2245
  ` || content2 === `${stub}\r
@@ -2180,7 +2247,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2180
2247
  if (dryRun) {
2181
2248
  log(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
2182
2249
  } else {
2183
- await fs12.remove(legacyReviewPath);
2250
+ await fs13.remove(legacyReviewPath);
2184
2251
  }
2185
2252
  }
2186
2253
  }
@@ -2208,8 +2275,8 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2208
2275
  dryRun
2209
2276
  );
2210
2277
  const legacySecurityPath = claudePath(root, "rules", "security.md");
2211
- if (await fs12.pathExists(legacySecurityPath)) {
2212
- const content2 = await fs12.readFile(legacySecurityPath, "utf8");
2278
+ if (await fs13.pathExists(legacySecurityPath)) {
2279
+ const content2 = await fs13.readFile(legacySecurityPath, "utf8");
2213
2280
  const stub = "- Never read secrets.\n- Block dangerous shell commands.";
2214
2281
  if (content2 === stub || content2 === `${stub}
2215
2282
  ` || content2 === `${stub}\r
@@ -2217,7 +2284,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2217
2284
  if (dryRun) {
2218
2285
  log(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
2219
2286
  } else {
2220
- await fs12.remove(legacySecurityPath);
2287
+ await fs13.remove(legacySecurityPath);
2221
2288
  }
2222
2289
  }
2223
2290
  }
@@ -2232,11 +2299,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2232
2299
  ];
2233
2300
  for (const rel of LEGACY_PRUNED_ARTIFACTS) {
2234
2301
  const legacyPath = hausPath(root, rel);
2235
- if (await fs12.pathExists(legacyPath)) {
2302
+ if (await fs13.pathExists(legacyPath)) {
2236
2303
  if (dryRun) {
2237
2304
  log(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
2238
2305
  } else {
2239
- await fs12.remove(legacyPath);
2306
+ await fs13.remove(legacyPath);
2240
2307
  }
2241
2308
  }
2242
2309
  }
@@ -2278,10 +2345,10 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2278
2345
  );
2279
2346
  continue;
2280
2347
  }
2281
- const destination = claudePath(root, target, path12.basename(sourcePath));
2282
- if (await fs12.pathExists(sourcePath)) {
2348
+ const destination = claudePath(root, target, path13.basename(sourcePath));
2349
+ if (await fs13.pathExists(sourcePath)) {
2283
2350
  if (dryRun) {
2284
- const exists = await fs12.pathExists(destination);
2351
+ const exists = await fs13.pathExists(destination);
2285
2352
  log(
2286
2353
  `${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
2287
2354
  );
@@ -2291,17 +2358,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2291
2358
  dryRun: false
2292
2359
  });
2293
2360
  } else {
2294
- await fs12.ensureDir(path12.dirname(destination));
2295
- await fs12.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2361
+ await fs13.ensureDir(path13.dirname(destination));
2362
+ await fs13.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2296
2363
  }
2297
2364
  files.push(destination);
2298
- const relPaths = [path12.relative(root, destination)];
2365
+ const relPaths = [path13.relative(root, destination)];
2299
2366
  if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
2300
2367
  const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
2301
2368
  if (sharedRel) {
2302
2369
  superpowersSharedInstalled = true;
2303
2370
  relPaths.push(sharedRel);
2304
- files.push(path12.join(root, sharedRel));
2371
+ files.push(path13.join(root, sharedRel));
2305
2372
  }
2306
2373
  }
2307
2374
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -2368,7 +2435,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2368
2435
  if (relPaths.length === 0) continue;
2369
2436
  const existing = [];
2370
2437
  for (const rel of relPaths) {
2371
- if (await fs12.pathExists(path12.join(root, rel))) existing.push(rel);
2438
+ if (await fs13.pathExists(path13.join(root, rel))) existing.push(rel);
2372
2439
  }
2373
2440
  if (existing.length === 0) continue;
2374
2441
  const pruneReason = deprecated ? "deprecated" : "stale";
@@ -2386,21 +2453,28 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2386
2453
  continue;
2387
2454
  }
2388
2455
  for (const rel of existing) {
2389
- const abs = path12.join(root, rel);
2456
+ const abs = path13.join(root, rel);
2390
2457
  if (dryRun) {
2391
2458
  log(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2392
2459
  continue;
2393
2460
  }
2394
- await fs12.remove(abs);
2395
- await pruneEmptyDir(path12.dirname(abs));
2461
+ await fs13.remove(abs);
2462
+ await pruneEmptyDir(path13.dirname(abs));
2396
2463
  log(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2397
2464
  }
2398
2465
  }
2399
2466
  }
2400
2467
 
2468
+ // src/utils/args.ts
2469
+ function parseIdList(value) {
2470
+ if (!value) return [];
2471
+ const raw = Array.isArray(value) ? value : [value];
2472
+ return raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0);
2473
+ }
2474
+
2401
2475
  // src/commands/apply.ts
2402
2476
  async function cacheHasItems() {
2403
- const data = await readJson(path13.join(getCacheDir(), "manifest.json"));
2477
+ const data = await readJson(path14.join(getCacheDir(), "manifest.json"));
2404
2478
  return Array.isArray(data?.items) && data.items.length > 0;
2405
2479
  }
2406
2480
  function installableRecommendedItems(recommended) {
@@ -2414,6 +2488,34 @@ async function runApply(options) {
2414
2488
  const root = process.cwd();
2415
2489
  const isDryRun = Boolean(options.dryRun) && !options.write;
2416
2490
  let selectedIds;
2491
+ const explicitIds = parseIdList(options.ids);
2492
+ if (explicitIds.length > 0) {
2493
+ if (options.select) {
2494
+ error("Use either --select (interactive) or --ids (explicit), not both");
2495
+ process.exitCode = 1;
2496
+ return;
2497
+ }
2498
+ const rec = await readJson(hausPath(root, "recommendation.json"));
2499
+ const recommended = rec?.recommended ?? [];
2500
+ const installableIds = new Set(installableRecommendedItems(recommended).map((i) => i.id));
2501
+ const configIds = new Set(recommended.filter((i) => i.install === false).map((i) => i.id));
2502
+ const skippedIds = new Set((rec?.skipped ?? []).map((s) => s.id));
2503
+ for (const id of explicitIds.filter((id2) => !installableIds.has(id2))) {
2504
+ if (configIds.has(id)) {
2505
+ warn(`--ids: "${id}" is a config item \u2014 install it with \`haus scaffold ${id}\`. Ignoring.`);
2506
+ } else if (skippedIds.has(id)) {
2507
+ warn(
2508
+ `--ids: "${id}" is not currently recommended (skipped) \u2014 add it with \`haus recommend --include ${id}\` first. Ignoring.`
2509
+ );
2510
+ } else {
2511
+ warn(
2512
+ `--ids: "${id}" is not in recommendation.json \u2014 run \`haus recommend\` (or \`--include ${id}\`) first. Ignoring.`
2513
+ );
2514
+ }
2515
+ }
2516
+ selectedIds = explicitIds.filter((id) => installableIds.has(id));
2517
+ log(`Applying ${selectedIds.length} explicitly selected catalog item(s).`);
2518
+ }
2417
2519
  if (options.select) {
2418
2520
  if (!process.stdin.isTTY) {
2419
2521
  error("--select requires an interactive terminal (stdin is not a TTY)");
@@ -2499,7 +2601,7 @@ async function runCatalogAudit() {
2499
2601
 
2500
2602
  // src/commands/clone.ts
2501
2603
  import { existsSync as existsSync2 } from "node:fs";
2502
- import path14 from "node:path";
2604
+ import path15 from "node:path";
2503
2605
 
2504
2606
  // src/utils/exec.ts
2505
2607
  import { execa } from "execa";
@@ -2552,9 +2654,9 @@ async function runClone(url, opts = {}) {
2552
2654
  process.exitCode = 1;
2553
2655
  return;
2554
2656
  }
2555
- const target = path14.resolve(opts.dir?.trim() || repoNameFromUrl(url));
2657
+ const target = path15.resolve(opts.dir?.trim() || repoNameFromUrl(url));
2556
2658
  if (existsSync2(target)) {
2557
- log(`\u2022 ${path14.basename(target)} already present at ${target} \u2014 skipped`);
2659
+ log(`\u2022 ${path15.basename(target)} already present at ${target} \u2014 skipped`);
2558
2660
  return;
2559
2661
  }
2560
2662
  if (opts.dryRun) {
@@ -2571,11 +2673,11 @@ async function runClone(url, opts = {}) {
2571
2673
  }
2572
2674
 
2573
2675
  // src/commands/doctor.ts
2574
- import path19 from "node:path";
2575
- import fs14 from "fs-extra";
2676
+ import path20 from "node:path";
2677
+ import fs15 from "fs-extra";
2576
2678
 
2577
2679
  // src/scanner/scan-project.ts
2578
- import path18 from "node:path";
2680
+ import path19 from "node:path";
2579
2681
 
2580
2682
  // src/utils/audit-checks.ts
2581
2683
  function isRecord(v) {
@@ -2602,8 +2704,8 @@ function compareVersions(a, b) {
2602
2704
  }
2603
2705
 
2604
2706
  // src/scanner/detect-package-manager.ts
2605
- import path15 from "node:path";
2606
- import fs13 from "fs-extra";
2707
+ import path16 from "node:path";
2708
+ import fs14 from "fs-extra";
2607
2709
  function detectPackageManager(root, packageManagerField) {
2608
2710
  const field = String(packageManagerField ?? "").trim();
2609
2711
  if (field.startsWith("yarn@")) {
@@ -2621,9 +2723,9 @@ function detectPackageManager(root, packageManagerField) {
2621
2723
  if (satisfiesVersion(version, ">=9")) return "npm";
2622
2724
  return "unknown";
2623
2725
  }
2624
- if (fs13.existsSync(path15.join(root, "yarn.lock"))) return "yarn";
2625
- if (fs13.existsSync(path15.join(root, "pnpm-lock.yaml"))) return "pnpm";
2626
- if (fs13.existsSync(path15.join(root, "package-lock.json"))) return "npm";
2726
+ if (fs14.existsSync(path16.join(root, "yarn.lock"))) return "yarn";
2727
+ if (fs14.existsSync(path16.join(root, "pnpm-lock.yaml"))) return "pnpm";
2728
+ if (fs14.existsSync(path16.join(root, "package-lock.json"))) return "npm";
2627
2729
  return "unknown";
2628
2730
  }
2629
2731
 
@@ -2825,7 +2927,7 @@ function runDetection(ctx, rules = STACK_RULES) {
2825
2927
  }
2826
2928
 
2827
2929
  // src/scanner/detection.ts
2828
- import path16 from "node:path";
2930
+ import path17 from "node:path";
2829
2931
  var UNSUPPORTED_MARKERS = {
2830
2932
  "requirements.txt": "python",
2831
2933
  "pyproject.toml": "python",
@@ -2879,14 +2981,14 @@ function finalizeRoles(registryRoles, deps, files) {
2879
2981
  function collectUnsupportedSignals(files) {
2880
2982
  return [
2881
2983
  ...new Set(
2882
- files.map((f) => UNSUPPORTED_MARKERS[path16.basename(f)]).filter((s) => Boolean(s))
2984
+ files.map((f) => UNSUPPORTED_MARKERS[path17.basename(f)]).filter((s) => Boolean(s))
2883
2985
  )
2884
2986
  ].sort();
2885
2987
  }
2886
2988
 
2887
2989
  // src/scanner/render.ts
2888
2990
  import { readFile } from "node:fs/promises";
2889
- import path17 from "node:path";
2991
+ import path18 from "node:path";
2890
2992
  async function buildContentBlob(root, files) {
2891
2993
  const candidates = files.filter(
2892
2994
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -2894,7 +2996,7 @@ async function buildContentBlob(root, files) {
2894
2996
  const slice = candidates.slice(0, 300);
2895
2997
  const parts = await mapWithConcurrency(slice, async (rel) => {
2896
2998
  try {
2897
- return await readFile(path17.join(root, rel), "utf8");
2999
+ return await readFile(path18.join(root, rel), "utf8");
2898
3000
  } catch {
2899
3001
  return "";
2900
3002
  }
@@ -2981,8 +3083,8 @@ var SAFE_FILES = [
2981
3083
  "Gemfile"
2982
3084
  ];
2983
3085
  async function scanProject(root) {
2984
- const pkg = await readJson(path18.join(root, "package.json"));
2985
- const composer = await readJson(path18.join(root, "composer.json"));
3086
+ const pkg = await readJson(path19.join(root, "package.json"));
3087
+ const composer = await readJson(path19.join(root, "composer.json"));
2986
3088
  const files = await listFiles(root, SAFE_FILES);
2987
3089
  const safeFiles = files.filter((f) => !blocked(f));
2988
3090
  const deps = dependencySet(pkg, composer);
@@ -3011,7 +3113,7 @@ async function scanProject(root) {
3011
3113
  const context = {
3012
3114
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3013
3115
  root,
3014
- repoName: String(pkg?.name ?? path18.basename(root)),
3116
+ repoName: String(pkg?.name ?? path19.basename(root)),
3015
3117
  packageManager,
3016
3118
  repoRoles: roles,
3017
3119
  detectedStacks: stacks,
@@ -3108,7 +3210,7 @@ async function runDoctor(options) {
3108
3210
  } else {
3109
3211
  ok(`- HOOKS OK: ${hooks.message}`);
3110
3212
  }
3111
- const rootClaudeMdPath = path19.join(root, "CLAUDE.md");
3213
+ const rootClaudeMdPath = path20.join(root, "CLAUDE.md");
3112
3214
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
3113
3215
  if (!rootClaudeMdContent) {
3114
3216
  flag(
@@ -3136,7 +3238,7 @@ async function runDoctor(options) {
3136
3238
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
3137
3239
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
3138
3240
  for (const target of importTargets) {
3139
- if (!await fs14.pathExists(hausPath(root, target))) {
3241
+ if (!await fs15.pathExists(hausPath(root, target))) {
3140
3242
  flag(
3141
3243
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
3142
3244
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -3147,7 +3249,7 @@ async function runDoctor(options) {
3147
3249
  }
3148
3250
  }
3149
3251
  const workflowPath = hausPath(root, "WORKFLOW.md");
3150
- const workflowExists = await fs14.pathExists(workflowPath);
3252
+ const workflowExists = await fs15.pathExists(workflowPath);
3151
3253
  if (!workflowExists) {
3152
3254
  flag(
3153
3255
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -3170,15 +3272,15 @@ async function runDoctor(options) {
3170
3272
  "haus apply --write --force"
3171
3273
  );
3172
3274
  } else {
3173
- const cachePath = path19.join(getCacheDir(), "templates/agentic-workflow-standard.md");
3174
- const bundledPath = path19.join(
3275
+ const cachePath = path20.join(getCacheDir(), "templates/agentic-workflow-standard.md");
3276
+ const bundledPath = path20.join(
3175
3277
  packageRoot(),
3176
3278
  "library",
3177
3279
  "global",
3178
3280
  "templates",
3179
3281
  "agentic-workflow-standard.md"
3180
3282
  );
3181
- const templatePath = await fs14.pathExists(cachePath) ? cachePath : bundledPath;
3283
+ const templatePath = await fs15.pathExists(cachePath) ? cachePath : bundledPath;
3182
3284
  const templateContent = await readText(templatePath);
3183
3285
  if (storedHashMatch && templateContent) {
3184
3286
  const currentHash = hashText(normaliseLF(templateContent));
@@ -3198,7 +3300,7 @@ async function runDoctor(options) {
3198
3300
  }
3199
3301
  }
3200
3302
  const workflowConfigPath = hausPath(root, "workflow-config.md");
3201
- const workflowConfigExists = await fs14.pathExists(workflowConfigPath);
3303
+ const workflowConfigExists = await fs15.pathExists(workflowConfigPath);
3202
3304
  if (!workflowConfigExists) {
3203
3305
  flag(
3204
3306
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -3206,7 +3308,7 @@ async function runDoctor(options) {
3206
3308
  "haus apply --write"
3207
3309
  );
3208
3310
  } else {
3209
- const cfg = await fs14.readFile(workflowConfigPath, "utf8");
3311
+ const cfg = await fs15.readFile(workflowConfigPath, "utf8");
3210
3312
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
3211
3313
  if (unfilled > 0) {
3212
3314
  flag(
@@ -3218,6 +3320,18 @@ async function runDoctor(options) {
3218
3320
  ok("- .haus-workflow/workflow-config.md: OK (project-owned)");
3219
3321
  }
3220
3322
  }
3323
+ if (workflowExists) {
3324
+ const prettierIgnore = await readText(path20.join(root, ".prettierignore")) ?? "";
3325
+ if (!prettierIgnore.split("\n").some((l) => l.trim() === ".haus-workflow/")) {
3326
+ flag(
3327
+ "- .prettierignore: not protecting .haus-workflow/ (run `haus apply --write`)",
3328
+ 'The formatter may reformat managed files and trigger false "modified locally" reports',
3329
+ "haus apply --write"
3330
+ );
3331
+ } else {
3332
+ ok("- .prettierignore: protects .haus-workflow/");
3333
+ }
3334
+ }
3221
3335
  const cacheAgeMs = await getCacheManifestAge();
3222
3336
  if (cacheAgeMs === null) {
3223
3337
  flag(
@@ -3237,7 +3351,7 @@ async function runDoctor(options) {
3237
3351
  ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
3238
3352
  }
3239
3353
  }
3240
- const pkgJson = await readJson(path19.join(packageRoot(), "package.json"));
3354
+ const pkgJson = await readJson(path20.join(packageRoot(), "package.json"));
3241
3355
  const currentVersion = pkgJson?.version ?? "0.0.0";
3242
3356
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
3243
3357
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -3459,8 +3573,8 @@ async function runGuard(kind, _options) {
3459
3573
  }
3460
3574
 
3461
3575
  // src/commands/init.ts
3462
- import path20 from "node:path";
3463
- import fs15 from "fs-extra";
3576
+ import path21 from "node:path";
3577
+ import fs16 from "fs-extra";
3464
3578
 
3465
3579
  // src/utils/prompts.ts
3466
3580
  import { stdin as input, stdout as output } from "node:process";
@@ -3546,7 +3660,7 @@ function mergeRecommendationWarnings(context) {
3546
3660
  }
3547
3661
 
3548
3662
  // src/recommender/recommend.ts
3549
- async function recommend(root, context) {
3663
+ async function recommend(root, context, opts = {}) {
3550
3664
  const items = await loadCatalog(root);
3551
3665
  const sources = await readJson(
3552
3666
  hausPath(root, "sources-report.json")
@@ -3719,6 +3833,8 @@ async function recommend(root, context) {
3719
3833
  }
3720
3834
  }
3721
3835
  applyCoInstallSuppression(recommended, skipped);
3836
+ const includeWarnings = applyManualIncludes(opts.include ?? [], items, recommended, skipped);
3837
+ const optInEligible = buildOptInEligible(items, skipped);
3722
3838
  recommended.sort((a, b) => a.id.localeCompare(b.id));
3723
3839
  skipped.sort((a, b) => a.id.localeCompare(b.id));
3724
3840
  const installableRecommended = recommended.filter((item) => item.install);
@@ -3729,7 +3845,8 @@ async function recommend(root, context) {
3729
3845
  return {
3730
3846
  recommended,
3731
3847
  skipped,
3732
- warnings: mergeRecommendationWarnings(context),
3848
+ optInEligible,
3849
+ warnings: [...mergeRecommendationWarnings(context), ...includeWarnings],
3733
3850
  estimatedContextTokens,
3734
3851
  selectedRules,
3735
3852
  skippedRules,
@@ -3815,6 +3932,80 @@ function applyCoInstallSuppression(recommended, skipped) {
3815
3932
  });
3816
3933
  }
3817
3934
  }
3935
+ var HARD_SKIP_CODES = /* @__PURE__ */ new Set([
3936
+ "unsupported-policy",
3937
+ "deprecated",
3938
+ "curated-not-approved",
3939
+ "curated-risk-blocked",
3940
+ "sensitive-policy",
3941
+ "source-trust",
3942
+ "source-approval"
3943
+ ]);
3944
+ function applyManualIncludes(include, items, recommended, skipped) {
3945
+ const warnings = [];
3946
+ if (include.length === 0) return warnings;
3947
+ const byId = new Map(items.map((i) => [i.id, i]));
3948
+ const recommendedIds = new Set(recommended.map((r) => r.id));
3949
+ for (const id of include) {
3950
+ const item = byId.get(id);
3951
+ if (!item) {
3952
+ warnings.push(`--include: unknown catalog id "${id}" (not in manifest)`);
3953
+ continue;
3954
+ }
3955
+ if (recommendedIds.has(id)) continue;
3956
+ const skipIdx = skipped.findIndex((s) => s.id === id);
3957
+ const skipCode = skipIdx >= 0 ? skipped[skipIdx].skipReasons[0]?.code : void 0;
3958
+ if (skipCode && HARD_SKIP_CODES.has(skipCode)) {
3959
+ warnings.push(`--include: "${id}" cannot be force-installed (blocked by ${skipCode})`);
3960
+ continue;
3961
+ }
3962
+ if (skipCode === "requires-any-unsatisfied") {
3963
+ warnings.push(`--include: "${id}" added manually though its requiresAny gate is unsatisfied`);
3964
+ }
3965
+ if (skipIdx >= 0) skipped.splice(skipIdx, 1);
3966
+ const isConfig = item.type === "config";
3967
+ recommended.push({
3968
+ id: item.id,
3969
+ type: item.type,
3970
+ reason: "manually included",
3971
+ reasons: [
3972
+ {
3973
+ code: "manual-include",
3974
+ message: "manually included via --include",
3975
+ signal: "selection:manual"
3976
+ }
3977
+ ],
3978
+ selectionMode: "manual",
3979
+ install: !isConfig,
3980
+ tags: item.tags,
3981
+ ecosystem: item.ecosystem,
3982
+ tokenEstimate: item.tokenEstimate
3983
+ });
3984
+ recommendedIds.add(id);
3985
+ }
3986
+ return warnings;
3987
+ }
3988
+ function buildOptInEligible(items, skipped) {
3989
+ const byId = new Map(items.map((i) => [i.id, i]));
3990
+ const eligible = [];
3991
+ for (const entry of skipped) {
3992
+ if (entry.skipReasons[0]?.code !== "requires-any-unsatisfied") continue;
3993
+ const item = byId.get(entry.id);
3994
+ if (!item?.optInTier || !item.optInGroup) continue;
3995
+ eligible.push({
3996
+ id: item.id,
3997
+ type: item.type,
3998
+ title: item.title,
3999
+ optInTier: item.optInTier,
4000
+ optInGroup: item.optInGroup,
4001
+ purpose: item.purpose,
4002
+ tokenEstimate: item.tokenEstimate,
4003
+ requires: entry.skipReasons[0]?.signal ?? describeRequiresAny(item.requiresAny ?? [])
4004
+ });
4005
+ }
4006
+ eligible.sort((a, b) => a.optInGroup.localeCompare(b.optInGroup) || a.id.localeCompare(b.id));
4007
+ return eligible;
4008
+ }
3818
4009
 
3819
4010
  // src/commands/setup-core.ts
3820
4011
  async function runSetupCore(root, opts) {
@@ -3907,8 +4098,8 @@ async function runSetupProject(options) {
3907
4098
  // src/commands/init.ts
3908
4099
  async function runInit(options) {
3909
4100
  const root = process.cwd();
3910
- const hausDir = path20.join(root, ".haus-workflow");
3911
- const alreadyInit = await fs15.pathExists(hausDir);
4101
+ const hausDir = path21.join(root, ".haus-workflow");
4102
+ const alreadyInit = await fs16.pathExists(hausDir);
3912
4103
  if (alreadyInit) {
3913
4104
  log("Haus AI already initialized in this project.");
3914
4105
  log("Run `haus setup-project` to reconfigure.");
@@ -3920,8 +4111,8 @@ async function runInit(options) {
3920
4111
 
3921
4112
  // src/install/apply.ts
3922
4113
  import crypto2 from "node:crypto";
3923
- import path21 from "node:path";
3924
- import fs16 from "fs-extra";
4114
+ import path22 from "node:path";
4115
+ import fs17 from "fs-extra";
3925
4116
 
3926
4117
  // src/install/header.ts
3927
4118
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -4007,40 +4198,40 @@ function hashContent(content2) {
4007
4198
  }
4008
4199
  function sourceVersion() {
4009
4200
  try {
4010
- const pkgPath = path21.join(packageRoot(), "package.json");
4011
- const pkg = JSON.parse(fs16.readFileSync(pkgPath, "utf8"));
4201
+ const pkgPath = path22.join(packageRoot(), "package.json");
4202
+ const pkg = JSON.parse(fs17.readFileSync(pkgPath, "utf8"));
4012
4203
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
4013
4204
  } catch {
4014
4205
  return "haus@0.0.0";
4015
4206
  }
4016
4207
  }
4017
4208
  function globalSrcDir() {
4018
- return path21.join(packageRoot(), "library", "global");
4209
+ return path22.join(packageRoot(), "library", "global");
4019
4210
  }
4020
4211
  function collectSourceFiles(srcDir, claudeDir) {
4021
4212
  const entries = [];
4022
- const skillsDir = path21.join(srcDir, "skills");
4023
- if (fs16.pathExistsSync(skillsDir)) {
4024
- for (const skillName of fs16.readdirSync(skillsDir)) {
4025
- const skillFile = path21.join(skillsDir, skillName, "SKILL.md");
4026
- if (fs16.pathExistsSync(skillFile)) {
4213
+ const skillsDir = path22.join(srcDir, "skills");
4214
+ if (fs17.pathExistsSync(skillsDir)) {
4215
+ for (const skillName of fs17.readdirSync(skillsDir)) {
4216
+ const skillFile = path22.join(skillsDir, skillName, "SKILL.md");
4217
+ if (fs17.pathExistsSync(skillFile)) {
4027
4218
  entries.push({
4028
4219
  stableId: `skill.${skillName}`,
4029
- srcRelPath: path21.join("library", "global", "skills", skillName, "SKILL.md"),
4030
- destPath: path21.join(claudeDir, "skills", skillName, "SKILL.md")
4220
+ srcRelPath: path22.join("library", "global", "skills", skillName, "SKILL.md"),
4221
+ destPath: path22.join(claudeDir, "skills", skillName, "SKILL.md")
4031
4222
  });
4032
4223
  }
4033
4224
  }
4034
4225
  }
4035
- const commandsDir = path21.join(srcDir, "commands");
4036
- if (fs16.pathExistsSync(commandsDir)) {
4037
- for (const fileName of fs16.readdirSync(commandsDir)) {
4226
+ const commandsDir = path22.join(srcDir, "commands");
4227
+ if (fs17.pathExistsSync(commandsDir)) {
4228
+ for (const fileName of fs17.readdirSync(commandsDir)) {
4038
4229
  if (!fileName.endsWith(".md")) continue;
4039
4230
  const commandName = fileName.slice(0, -".md".length);
4040
4231
  entries.push({
4041
4232
  stableId: `command.${commandName}`,
4042
- srcRelPath: path21.join("library", "global", "commands", fileName),
4043
- destPath: path21.join(claudeDir, "commands", fileName)
4233
+ srcRelPath: path22.join("library", "global", "commands", fileName),
4234
+ destPath: path22.join(claudeDir, "commands", fileName)
4044
4235
  });
4045
4236
  }
4046
4237
  }
@@ -4064,7 +4255,7 @@ async function applyInstall(options = {}) {
4064
4255
  };
4065
4256
  const manifestFiles = [];
4066
4257
  for (const entry of sourceFiles) {
4067
- const srcPath = path21.join(packageRoot(), entry.srcRelPath);
4258
+ const srcPath = path22.join(packageRoot(), entry.srcRelPath);
4068
4259
  const rawContent = await readText(srcPath);
4069
4260
  if (rawContent === void 0) {
4070
4261
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -4084,7 +4275,7 @@ async function applyInstall(options = {}) {
4084
4275
  }
4085
4276
  continue;
4086
4277
  }
4087
- const destExists = fs16.pathExistsSync(entry.destPath);
4278
+ const destExists = fs17.pathExistsSync(entry.destPath);
4088
4279
  if (destExists) {
4089
4280
  const currentContent = await readText(entry.destPath);
4090
4281
  if (currentContent !== void 0) {
@@ -4120,7 +4311,7 @@ async function applyInstall(options = {}) {
4120
4311
  schemaVersion: SCHEMA_VERSION2
4121
4312
  });
4122
4313
  }
4123
- const fragmentPath = path21.join(srcDir, "settings-fragments", "hooks.json");
4314
+ const fragmentPath = path22.join(srcDir, "settings-fragments", "hooks.json");
4124
4315
  const fragments = await loadHooksFragment(fragmentPath);
4125
4316
  const settings = await readSettings();
4126
4317
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -4132,13 +4323,13 @@ async function applyInstall(options = {}) {
4132
4323
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
4133
4324
  for (const entry of existingManifest.files) {
4134
4325
  if (currentDestPaths.has(entry.destPath)) continue;
4135
- if (!fs16.pathExistsSync(entry.destPath)) continue;
4326
+ if (!fs17.pathExistsSync(entry.destPath)) continue;
4136
4327
  const content2 = await readText(entry.destPath);
4137
4328
  if (!content2) continue;
4138
4329
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
4139
4330
  const currentHash = hashContent(content2);
4140
4331
  if (hasHeader && currentHash === entry.hash) {
4141
- if (!dryRun) await fs16.remove(entry.destPath);
4332
+ if (!dryRun) await fs17.remove(entry.destPath);
4142
4333
  result.deleted.push(entry.destPath);
4143
4334
  } else {
4144
4335
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -4275,7 +4466,8 @@ async function runInstall(options) {
4275
4466
  async function runRecommend(options) {
4276
4467
  const root = process.cwd();
4277
4468
  const context = await readContextOrScan(root);
4278
- const result = await recommend(root, context);
4469
+ const include = parseIdList(options.include);
4470
+ const result = await recommend(root, context, { include });
4279
4471
  await writeJson(hausPath(root, "recommendation.json"), result);
4280
4472
  if (options.json) {
4281
4473
  log(JSON.stringify(result, null, 2));
@@ -4284,6 +4476,12 @@ async function runRecommend(options) {
4284
4476
  log("Haus recommendation ready");
4285
4477
  log(`Recommended: ${result.recommended.length}`);
4286
4478
  log(`Skipped: ${result.skipped.length}`);
4479
+ if (result.optInEligible && result.optInEligible.length > 0) {
4480
+ log(
4481
+ `Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`)`
4482
+ );
4483
+ }
4484
+ for (const w of result.warnings.filter((w2) => w2.startsWith("--include:"))) log(w);
4287
4485
  }
4288
4486
 
4289
4487
  // src/commands/refresh.ts
@@ -4299,8 +4497,8 @@ async function runRefresh() {
4299
4497
  }
4300
4498
 
4301
4499
  // src/install/scaffold.ts
4302
- import path22 from "node:path";
4303
- import fs17 from "fs-extra";
4500
+ import path23 from "node:path";
4501
+ import fs18 from "fs-extra";
4304
4502
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4305
4503
  const scaffolded = [];
4306
4504
  const skipped = [];
@@ -4313,27 +4511,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4313
4511
  skipped.push(name);
4314
4512
  if (outcome === "skipped-exists") skippedExisting.push(name);
4315
4513
  };
4316
- const realRoot = await fs17.realpath(catalogRoot).catch(() => null);
4514
+ const realRoot = await fs18.realpath(catalogRoot).catch(() => null);
4317
4515
  for (const item of items) {
4318
4516
  if (item.type !== "config") continue;
4319
- const sourcePath = path22.resolve(catalogRoot, item.path);
4320
- const rel = path22.relative(catalogRoot, sourcePath);
4321
- const escapes = rel === "" || path22.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
4517
+ const sourcePath = path23.resolve(catalogRoot, item.path);
4518
+ const rel = path23.relative(catalogRoot, sourcePath);
4519
+ const escapes = rel === "" || path23.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
4322
4520
  if (escapes) {
4323
4521
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
4324
4522
  continue;
4325
4523
  }
4326
- const realSource = await fs17.realpath(sourcePath).catch(() => null);
4524
+ const realSource = await fs18.realpath(sourcePath).catch(() => null);
4327
4525
  if (!realRoot || !realSource) {
4328
4526
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4329
4527
  continue;
4330
4528
  }
4331
- const realRel = path22.relative(realRoot, realSource);
4332
- if (realRel !== "" && (path22.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
4529
+ const realRel = path23.relative(realRoot, realSource);
4530
+ if (realRel !== "" && (path23.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
4333
4531
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
4334
4532
  continue;
4335
4533
  }
4336
- const stat = await fs17.lstat(sourcePath).catch(() => null);
4534
+ const stat = await fs18.lstat(sourcePath).catch(() => null);
4337
4535
  if (!stat) {
4338
4536
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4339
4537
  continue;
@@ -4343,56 +4541,57 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4343
4541
  continue;
4344
4542
  }
4345
4543
  if (stat.isFile()) {
4346
- const filename = path22.basename(sourcePath);
4347
- const dest = path22.join(projectRoot, filename);
4348
- record(filename, await scaffoldFile(sourcePath, dest, item.id, opts));
4544
+ const filename = path23.basename(sourcePath);
4545
+ const dest = path23.join(projectRoot, filename);
4546
+ record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
4349
4547
  } else if (stat.isDirectory()) {
4350
- const entries = await fs17.readdir(sourcePath);
4548
+ const entries = await fs18.readdir(sourcePath);
4351
4549
  for (const entry of entries) {
4352
- const src = path22.join(sourcePath, entry);
4353
- const dest = path22.join(projectRoot, entry);
4354
- record(entry, await scaffoldFile(src, dest, item.id, opts));
4550
+ const src = path23.join(sourcePath, entry);
4551
+ const dest = path23.join(projectRoot, entry);
4552
+ record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
4355
4553
  }
4356
4554
  }
4357
4555
  }
4358
4556
  return { scaffolded, skipped, skippedExisting };
4359
4557
  }
4360
- async function scaffoldFile(src, dest, itemId, opts) {
4361
- const srcStat = await fs17.lstat(src).catch(() => null);
4558
+ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
4559
+ const destDisplay = path23.relative(projectRoot, dest) || path23.basename(dest);
4560
+ const srcStat = await fs18.lstat(src).catch(() => null);
4362
4561
  if (!srcStat) {
4363
- warn(`Skipping ${path22.basename(src)}: source not found`);
4562
+ warn(`Skipping ${path23.basename(src)}: source not found`);
4364
4563
  return "skipped-refused";
4365
4564
  }
4366
4565
  if (srcStat.isSymbolicLink()) {
4367
- warn(`Skipping ${path22.basename(src)}: source is a symlink`);
4566
+ warn(`Skipping ${path23.basename(src)}: source is a symlink`);
4368
4567
  return "skipped-refused";
4369
4568
  }
4370
- const exists = await fs17.pathExists(dest);
4569
+ const exists = await fs18.pathExists(dest);
4371
4570
  if (exists && !opts.force) {
4372
- warn(`Skipping ${path22.basename(dest)}: already exists (use --force to overwrite)`);
4571
+ warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
4373
4572
  return "skipped-exists";
4374
4573
  }
4375
4574
  if (opts.dryRun) {
4376
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path22.basename(dest)} (${itemId})`);
4575
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path23.basename(dest)} (${itemId})`);
4377
4576
  return "scaffolded";
4378
4577
  }
4379
4578
  if (exists && opts.force) {
4380
- await fs17.remove(dest);
4579
+ await fs18.remove(dest);
4381
4580
  }
4382
- await fs17.ensureDir(path22.dirname(dest));
4383
- await fs17.copy(src, dest, {
4581
+ await fs18.ensureDir(path23.dirname(dest));
4582
+ await fs18.copy(src, dest, {
4384
4583
  overwrite: true,
4385
4584
  dereference: false,
4386
4585
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
4387
4586
  filter: (s) => {
4388
4587
  try {
4389
- return !fs17.lstatSync(s).isSymbolicLink();
4588
+ return !fs18.lstatSync(s).isSymbolicLink();
4390
4589
  } catch {
4391
4590
  return false;
4392
4591
  }
4393
4592
  }
4394
4593
  });
4395
- log(`${exists ? "Overwrote" : "Created"} ${path22.basename(dest)} (${itemId})`);
4594
+ log(`${exists ? "Overwrote" : "Created"} ${path23.basename(dest)} (${itemId})`);
4396
4595
  return "scaffolded";
4397
4596
  }
4398
4597
 
@@ -4449,8 +4648,8 @@ async function runScan(options) {
4449
4648
  }
4450
4649
 
4451
4650
  // src/commands/undo.ts
4452
- import path23 from "node:path";
4453
- import fs18 from "fs-extra";
4651
+ import path24 from "node:path";
4652
+ import fs19 from "fs-extra";
4454
4653
 
4455
4654
  // src/claude/managed-paths.ts
4456
4655
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md", "commands/haus-doctor.md"];
@@ -4467,74 +4666,74 @@ async function collectManagedPaths(root) {
4467
4666
  const lock = await readJson(hausPath(root, "haus.lock.json"));
4468
4667
  for (const row of lock ?? []) {
4469
4668
  for (const rel of row.paths ?? []) {
4470
- paths.add(path23.resolve(root, rel));
4669
+ paths.add(path24.resolve(root, rel));
4471
4670
  }
4472
4671
  }
4473
4672
  const existing = [];
4474
4673
  for (const abs of paths) {
4475
- if (await fs18.pathExists(abs)) existing.push(abs);
4674
+ if (await fs19.pathExists(abs)) existing.push(abs);
4476
4675
  }
4477
4676
  return existing;
4478
4677
  }
4479
4678
  async function settingsHasHausContent(root) {
4480
4679
  const settingsPath = claudePath(root, "settings.json");
4481
- if (!await fs18.pathExists(settingsPath)) return false;
4680
+ if (!await fs19.pathExists(settingsPath)) return false;
4482
4681
  const settings = await readProjectSettings(root);
4483
4682
  return settings._haus != null;
4484
4683
  }
4485
4684
  async function claudeMdHasHausBlock(root) {
4486
- const filePath = path23.join(root, "CLAUDE.md");
4487
- if (!await fs18.pathExists(filePath)) return false;
4488
- const text = await fs18.readFile(filePath, "utf8");
4685
+ const filePath = path24.join(root, "CLAUDE.md");
4686
+ if (!await fs19.pathExists(filePath)) return false;
4687
+ const text = await fs19.readFile(filePath, "utf8");
4489
4688
  return text.includes(BLOCK_BEGIN);
4490
4689
  }
4491
4690
  async function stripProjectSettings(root) {
4492
4691
  const settingsPath = claudePath(root, "settings.json");
4493
- if (!await fs18.pathExists(settingsPath)) return false;
4692
+ if (!await fs19.pathExists(settingsPath)) return false;
4494
4693
  let settings = await readProjectSettings(root);
4495
4694
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4496
4695
  const hasContent = Object.keys(settings).length > 0;
4497
4696
  if (hasContent) {
4498
4697
  await writeProjectSettings(root, settings);
4499
- log(`Stripped haus rules from ${path23.relative(root, settingsPath)} (user settings preserved).`);
4698
+ log(`Stripped haus rules from ${path24.relative(root, settingsPath)} (user settings preserved).`);
4500
4699
  return true;
4501
4700
  }
4502
- await fs18.remove(settingsPath);
4503
- log(`Removed ${path23.relative(root, settingsPath)} (no user-owned settings remained).`);
4701
+ await fs19.remove(settingsPath);
4702
+ log(`Removed ${path24.relative(root, settingsPath)} (no user-owned settings remained).`);
4504
4703
  return true;
4505
4704
  }
4506
4705
  async function stripRootClaudeMd(root) {
4507
- const filePath = path23.join(root, "CLAUDE.md");
4508
- if (!await fs18.pathExists(filePath)) return false;
4509
- const prev = await fs18.readFile(filePath, "utf8");
4706
+ const filePath = path24.join(root, "CLAUDE.md");
4707
+ if (!await fs19.pathExists(filePath)) return false;
4708
+ const prev = await fs19.readFile(filePath, "utf8");
4510
4709
  if (!prev.includes(BLOCK_BEGIN)) return false;
4511
4710
  const next = stripHausBlock(prev);
4512
4711
  if (next.length === 0) {
4513
- await fs18.remove(filePath);
4712
+ await fs19.remove(filePath);
4514
4713
  log("Removed CLAUDE.md (only contained haus import block).");
4515
4714
  } else {
4516
- await fs18.writeFile(filePath, next, "utf8");
4715
+ await fs19.writeFile(filePath, next, "utf8");
4517
4716
  log("Removed haus import block from CLAUDE.md (user content preserved).");
4518
4717
  }
4519
4718
  return true;
4520
4719
  }
4521
4720
  async function pruneDirIfEmpty(dir) {
4522
- if (!await fs18.pathExists(dir)) return;
4523
- const entries = await fs18.readdir(dir);
4524
- if (entries.length === 0) await fs18.remove(dir);
4721
+ if (!await fs19.pathExists(dir)) return;
4722
+ const entries = await fs19.readdir(dir);
4723
+ if (entries.length === 0) await fs19.remove(dir);
4525
4724
  }
4526
4725
  async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
4527
4726
  if (managedAbsPaths.length === 0) return;
4528
4727
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4529
4728
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
4530
4729
  for (const abs of managedAbsPaths) {
4531
- if (!await fs18.pathExists(abs)) continue;
4532
- const rel = path23.relative(root, abs);
4533
- const backupPath = path23.join(backupRoot, rel);
4534
- await fs18.ensureDir(path23.dirname(backupPath));
4535
- await fs18.copy(abs, backupPath);
4730
+ if (!await fs19.pathExists(abs)) continue;
4731
+ const rel = path24.relative(root, abs);
4732
+ const backupPath = path24.join(backupRoot, rel);
4733
+ await fs19.ensureDir(path24.dirname(backupPath));
4734
+ await fs19.copy(abs, backupPath);
4536
4735
  }
4537
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path23.relative(root, backupRoot)}.`);
4736
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path24.relative(root, backupRoot)}.`);
4538
4737
  }
4539
4738
  async function runUndo(options) {
4540
4739
  const root = process.cwd();
@@ -4545,7 +4744,7 @@ async function runUndo(options) {
4545
4744
  log("Nothing to remove: no haus-managed files found in this directory.");
4546
4745
  return;
4547
4746
  }
4548
- const relTargets = managed.map((p) => path23.relative(root, p));
4747
+ const relTargets = managed.map((p) => path24.relative(root, p));
4549
4748
  const summaryParts = [...relTargets];
4550
4749
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
4551
4750
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -4562,9 +4761,9 @@ User-owned .claude/ files will be preserved.`
4562
4761
  }
4563
4762
  await backupManagedFilesBeforeUndo(root, managed);
4564
4763
  for (const abs of managed) {
4565
- if (!await fs18.pathExists(abs)) continue;
4566
- await fs18.remove(abs);
4567
- log(`Removed ${path23.relative(root, abs)}`);
4764
+ if (!await fs19.pathExists(abs)) continue;
4765
+ await fs19.remove(abs);
4766
+ log(`Removed ${path24.relative(root, abs)}`);
4568
4767
  }
4569
4768
  if (stripSettings) await stripProjectSettings(root);
4570
4769
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -4575,8 +4774,8 @@ User-owned .claude/ files will be preserved.`
4575
4774
 
4576
4775
  // src/install/uninstall.ts
4577
4776
  import crypto3 from "node:crypto";
4578
- import path24 from "node:path";
4579
- import fs19 from "fs-extra";
4777
+ import path25 from "node:path";
4778
+ import fs20 from "fs-extra";
4580
4779
  async function runUninstall(options = {}) {
4581
4780
  const { force = false } = options;
4582
4781
  const manifest = await readManifest();
@@ -4586,7 +4785,7 @@ async function runUninstall(options = {}) {
4586
4785
  return result;
4587
4786
  }
4588
4787
  for (const entry of manifest.files) {
4589
- const exists = fs19.pathExistsSync(entry.destPath);
4788
+ const exists = fs20.pathExistsSync(entry.destPath);
4590
4789
  if (!exists) continue;
4591
4790
  const content2 = await readText(entry.destPath);
4592
4791
  if (content2 === void 0) continue;
@@ -4604,22 +4803,22 @@ async function runUninstall(options = {}) {
4604
4803
  result.skipped.push(entry.destPath);
4605
4804
  continue;
4606
4805
  }
4607
- await fs19.remove(entry.destPath);
4608
- await pruneEmptyDir(path24.dirname(entry.destPath));
4806
+ await fs20.remove(entry.destPath);
4807
+ await pruneEmptyDir(path25.dirname(entry.destPath));
4609
4808
  result.deleted.push(entry.destPath);
4610
4809
  }
4611
4810
  const settings = await readSettings();
4612
4811
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4613
4812
  await writeSettings(stripped);
4614
4813
  result.hooksStripped = true;
4615
- const hausDir = path24.join(globalClaudeDir(), "haus");
4814
+ const hausDir = path25.join(globalClaudeDir(), "haus");
4616
4815
  const manifestPath2 = hausManifestPath();
4617
- if (fs19.pathExistsSync(manifestPath2)) {
4618
- await fs19.remove(manifestPath2);
4816
+ if (fs20.pathExistsSync(manifestPath2)) {
4817
+ await fs20.remove(manifestPath2);
4619
4818
  }
4620
- if (fs19.pathExistsSync(hausDir)) {
4621
- const remaining = await fs19.readdir(hausDir);
4622
- if (remaining.length === 0) await fs19.remove(hausDir);
4819
+ if (fs20.pathExistsSync(hausDir)) {
4820
+ const remaining = await fs20.readdir(hausDir);
4821
+ if (remaining.length === 0) await fs20.remove(hausDir);
4623
4822
  }
4624
4823
  return result;
4625
4824
  }
@@ -4650,12 +4849,12 @@ async function runUninstallCommand(options) {
4650
4849
  }
4651
4850
 
4652
4851
  // src/commands/update.ts
4653
- import path26 from "node:path";
4852
+ import path27 from "node:path";
4654
4853
 
4655
4854
  // src/claude/refresh-project.ts
4656
- import fs20 from "fs-extra";
4855
+ import fs21 from "fs-extra";
4657
4856
  async function isHausProject(root) {
4658
- if (await fs20.pathExists(hausPath(root, "recommendation.json"))) return true;
4857
+ if (await fs21.pathExists(hausPath(root, "recommendation.json"))) return true;
4659
4858
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
4660
4859
  if (result.status === "ok") return result.value._haus != null;
4661
4860
  return false;
@@ -4690,7 +4889,7 @@ function summarizeLockDiff(before, after) {
4690
4889
 
4691
4890
  // src/update/lockfile.ts
4692
4891
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
4693
- import path25 from "node:path";
4892
+ import path26 from "node:path";
4694
4893
  async function checkLock(root) {
4695
4894
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
4696
4895
  const hasValidVersions = lock.every(
@@ -4721,7 +4920,7 @@ async function applyLock(root) {
4721
4920
  try {
4722
4921
  const backupDir = hausPath(root, "backups");
4723
4922
  await mkdir(backupDir, { recursive: true });
4724
- await copyFile(lockPath, path25.join(backupDir, `haus.lock.${Date.now()}.json`));
4923
+ await copyFile(lockPath, path26.join(backupDir, `haus.lock.${Date.now()}.json`));
4725
4924
  } catch {
4726
4925
  }
4727
4926
  const enriched = await Promise.all(
@@ -4743,7 +4942,7 @@ function diffLock(before, after) {
4743
4942
  }
4744
4943
  async function hasLocalOverrides(root) {
4745
4944
  try {
4746
- await readFile2(path25.join(root, ".claude", "settings.json"), "utf8");
4945
+ await readFile2(path26.join(root, ".claude", "settings.json"), "utf8");
4747
4946
  return true;
4748
4947
  } catch {
4749
4948
  return false;
@@ -4755,7 +4954,7 @@ var NPM_PACKAGE_NAME2 = "@haus-tech/haus-workflow";
4755
4954
  async function runUpdate(options) {
4756
4955
  const root = process.cwd();
4757
4956
  if (options.check) {
4758
- const pkgJson2 = await readJson(path26.join(packageRoot(), "package.json"));
4957
+ const pkgJson2 = await readJson(path27.join(packageRoot(), "package.json"));
4759
4958
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
4760
4959
  const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
4761
4960
  checkLock(root),
@@ -4785,7 +4984,7 @@ async function runUpdate(options) {
4785
4984
  if (status.driftCount > 0) process.exitCode = 1;
4786
4985
  return;
4787
4986
  }
4788
- const pkgJson = await readJson(path26.join(packageRoot(), "package.json"));
4987
+ const pkgJson = await readJson(path27.join(packageRoot(), "package.json"));
4789
4988
  const currentVersion = pkgJson?.version ?? "0.0.0";
4790
4989
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4791
4990
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -4862,16 +5061,16 @@ async function detectGlobalInstallDrift() {
4862
5061
  }
4863
5062
 
4864
5063
  // src/commands/validate-catalog.ts
4865
- import path28 from "node:path";
5064
+ import path29 from "node:path";
4866
5065
 
4867
5066
  // src/catalog/validate-core.ts
4868
- import fs21 from "node:fs";
4869
- import path27 from "node:path";
5067
+ import fs22 from "node:fs";
5068
+ import path28 from "node:path";
4870
5069
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
4871
5070
  var MANIFEST_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
4872
5071
  function isSafeCatalogPath2(itemPath) {
4873
- if (!itemPath || path27.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
4874
- const normalized = path27.normalize(itemPath);
5072
+ if (!itemPath || path28.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
5073
+ const normalized = path28.normalize(itemPath);
4875
5074
  return !normalized.startsWith("..") && !normalized.includes("/..");
4876
5075
  }
4877
5076
  function auditForbiddenStacks(items) {
@@ -4885,6 +5084,39 @@ function auditForbiddenStacks(items) {
4885
5084
  }
4886
5085
  return failures;
4887
5086
  }
5087
+ var OPT_IN_TIERS = /* @__PURE__ */ new Set(["workflow", "ops", "review", "design"]);
5088
+ function auditOptInMetadata(items) {
5089
+ const failures = [];
5090
+ for (const item of items) {
5091
+ const hasTier = item.optInTier !== void 0;
5092
+ const hasGroup = item.optInGroup !== void 0;
5093
+ if (hasTier !== hasGroup) {
5094
+ failures.push(`${item.id}: optInTier and optInGroup must be set together`);
5095
+ }
5096
+ if (hasTier) {
5097
+ if (!OPT_IN_TIERS.has(item.optInTier)) {
5098
+ failures.push(
5099
+ `${item.id}: invalid optInTier "${item.optInTier}" (expected one of ${[...OPT_IN_TIERS].join(", ")})`
5100
+ );
5101
+ }
5102
+ if (typeof item.optInGroup !== "string" || item.optInGroup.length === 0) {
5103
+ failures.push(`${item.id}: optInGroup must be a non-empty string`);
5104
+ }
5105
+ if (item.default === true) {
5106
+ failures.push(`${item.id}: opt-in items (optInTier set) must not be default:true`);
5107
+ }
5108
+ if (typeof item.purpose !== "string" || item.purpose.length === 0) {
5109
+ failures.push(`${item.id}: opt-in items (optInTier set) must have a non-empty purpose`);
5110
+ }
5111
+ }
5112
+ const requiresAny = Array.isArray(item.requiresAny) ? item.requiresAny : [];
5113
+ const roleOnly = requiresAny.length > 0 && requiresAny.every((clause) => clause && clause.role);
5114
+ if (item.default !== true && roleOnly && !hasTier) {
5115
+ failures.push(`${item.id}: role-gated opt-in item missing optInTier/optInGroup`);
5116
+ }
5117
+ }
5118
+ return failures;
5119
+ }
4888
5120
  function auditManifestStructure(manifestVersion, items) {
4889
5121
  const failures = [];
4890
5122
  const seenIds = /* @__PURE__ */ new Map();
@@ -4950,8 +5182,8 @@ function checkRequiredFrontmatter(text, label) {
4950
5182
  return failures;
4951
5183
  }
4952
5184
  function auditTemplateContent(manifestDir, absPath, itemId) {
4953
- const rel = path27.relative(manifestDir, absPath);
4954
- const text = fs21.readFileSync(absPath, "utf8");
5185
+ const rel = path28.relative(manifestDir, absPath);
5186
+ const text = fs22.readFileSync(absPath, "utf8");
4955
5187
  const failures = [];
4956
5188
  const lines = text.split(/\r?\n/);
4957
5189
  for (let i = 0; i < lines.length; i++) {
@@ -4971,44 +5203,44 @@ function auditShippedFiles(manifestDir, items) {
4971
5203
  const failures = [];
4972
5204
  for (const item of items) {
4973
5205
  if (!item.path || !isSafeCatalogPath2(item.path)) continue;
4974
- const absPath = path27.join(manifestDir, item.path);
5206
+ const absPath = path28.join(manifestDir, item.path);
4975
5207
  if (item.type === "skill") {
4976
- const skillMd = path27.join(absPath, "SKILL.md");
4977
- if (!fs21.existsSync(skillMd)) {
4978
- failures.push(`${item.id}: missing ${path27.relative(manifestDir, skillMd)}`);
5208
+ const skillMd = path28.join(absPath, "SKILL.md");
5209
+ if (!fs22.existsSync(skillMd)) {
5210
+ failures.push(`${item.id}: missing ${path28.relative(manifestDir, skillMd)}`);
4979
5211
  continue;
4980
5212
  }
4981
- const text = fs21.readFileSync(skillMd, "utf8");
5213
+ const text = fs22.readFileSync(skillMd, "utf8");
4982
5214
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
4983
5215
  failures.push(
4984
- ...auditForbiddenTagsInText(text, `${item.id}: ${path27.relative(manifestDir, skillMd)}`)
5216
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path28.relative(manifestDir, skillMd)}`)
4985
5217
  );
4986
5218
  } else if (item.type === "agent") {
4987
- if (!fs21.existsSync(absPath)) {
5219
+ if (!fs22.existsSync(absPath)) {
4988
5220
  failures.push(`${item.id}: missing agent file ${item.path}`);
4989
5221
  continue;
4990
5222
  }
4991
- const text = fs21.readFileSync(absPath, "utf8");
4992
- const rel = path27.relative(manifestDir, absPath);
5223
+ const text = fs22.readFileSync(absPath, "utf8");
5224
+ const rel = path28.relative(manifestDir, absPath);
4993
5225
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
4994
5226
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
4995
5227
  } else if (item.type === "template") {
4996
- if (!fs21.existsSync(absPath)) {
5228
+ if (!fs22.existsSync(absPath)) {
4997
5229
  failures.push(`${item.id}: missing template file ${item.path}`);
4998
5230
  continue;
4999
5231
  }
5000
5232
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
5001
5233
  } else if (item.type === "command") {
5002
- if (!fs21.existsSync(absPath)) {
5234
+ if (!fs22.existsSync(absPath)) {
5003
5235
  failures.push(`${item.id}: missing command file ${item.path}`);
5004
5236
  continue;
5005
5237
  }
5006
- const text = fs21.readFileSync(absPath, "utf8");
5007
- const rel = path27.relative(manifestDir, absPath);
5238
+ const text = fs22.readFileSync(absPath, "utf8");
5239
+ const rel = path28.relative(manifestDir, absPath);
5008
5240
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
5009
5241
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
5010
5242
  } else if (item.type === "config") {
5011
- if (!fs21.existsSync(absPath)) {
5243
+ if (!fs22.existsSync(absPath)) {
5012
5244
  failures.push(`${item.id}: missing config path ${item.path}`);
5013
5245
  }
5014
5246
  }
@@ -5022,8 +5254,8 @@ var DIR_ITEM_TYPE = {
5022
5254
  commands: "command"
5023
5255
  };
5024
5256
  function walkMd(dir, fn) {
5025
- for (const entry of fs21.readdirSync(dir, { withFileTypes: true })) {
5026
- const full = path27.join(dir, entry.name);
5257
+ for (const entry of fs22.readdirSync(dir, { withFileTypes: true })) {
5258
+ const full = path28.join(dir, entry.name);
5027
5259
  if (entry.isDirectory()) walkMd(full, fn);
5028
5260
  else if (entry.name.endsWith(".md")) fn(full);
5029
5261
  }
@@ -5032,12 +5264,12 @@ function auditMarkdownContent(manifestDir, items) {
5032
5264
  const failures = [];
5033
5265
  const pathSourceMap = buildItemPathSourceMap(items);
5034
5266
  for (const dir of ["skills", "agents", "templates", "commands"]) {
5035
- const abs = path27.join(manifestDir, dir);
5036
- if (!fs21.existsSync(abs)) continue;
5267
+ const abs = path28.join(manifestDir, dir);
5268
+ if (!fs22.existsSync(abs)) continue;
5037
5269
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
5038
5270
  walkMd(abs, (file) => {
5039
- const text = fs21.readFileSync(file, "utf8");
5040
- const rel = path27.relative(manifestDir, file);
5271
+ const text = fs22.readFileSync(file, "utf8");
5272
+ const rel = path28.relative(manifestDir, file);
5041
5273
  const norm = rel.replace(/\\/g, "/");
5042
5274
  const source = resolveMarkdownItemSource(norm, pathSourceMap);
5043
5275
  const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
@@ -5058,6 +5290,7 @@ function auditMarkdownContent(manifestDir, items) {
5058
5290
  function validateCatalogData(manifestDir, manifestVersion, items) {
5059
5291
  const failures = [
5060
5292
  ...auditManifestStructure(manifestVersion, items),
5293
+ ...auditOptInMetadata(items),
5061
5294
  ...auditForbiddenStacks(items),
5062
5295
  ...auditShippedFiles(manifestDir, items),
5063
5296
  ...auditMarkdownContent(manifestDir, items),
@@ -5073,8 +5306,8 @@ async function runValidateCatalog(manifestPath2) {
5073
5306
  process.exitCode = 1;
5074
5307
  return;
5075
5308
  }
5076
- const abs = path28.resolve(process.cwd(), manifestPath2);
5077
- const manifestDir = path28.dirname(abs);
5309
+ const abs = path29.resolve(process.cwd(), manifestPath2);
5310
+ const manifestDir = path29.dirname(abs);
5078
5311
  const data = await readJson(abs);
5079
5312
  if (!data?.items) {
5080
5313
  error(`Could not read catalog manifest at ${abs}`);
@@ -5092,7 +5325,7 @@ async function runValidateCatalog(manifestPath2) {
5092
5325
 
5093
5326
  // src/commands/workspace.ts
5094
5327
  import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
5095
- import path35 from "node:path";
5328
+ import path36 from "node:path";
5096
5329
 
5097
5330
  // src/commands/workspace/aggregate.ts
5098
5331
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -5146,7 +5379,7 @@ ${summaries.map(
5146
5379
  }
5147
5380
 
5148
5381
  // src/commands/workspace/config.ts
5149
- import path29 from "node:path";
5382
+ import path30 from "node:path";
5150
5383
  import YAML from "yaml";
5151
5384
  var WORKSPACE_FILE = "haus.workspace.yaml";
5152
5385
  function parseWorkspaceConfig(text) {
@@ -5169,11 +5402,11 @@ function parseWorkspaceConfig(text) {
5169
5402
  };
5170
5403
  }
5171
5404
  async function readWorkspaceConfig(workspaceRoot) {
5172
- return parseWorkspaceConfig(await readText(path29.join(workspaceRoot, WORKSPACE_FILE)));
5405
+ return parseWorkspaceConfig(await readText(path30.join(workspaceRoot, WORKSPACE_FILE)));
5173
5406
  }
5174
5407
 
5175
5408
  // src/commands/workspace/discover.ts
5176
- import path30 from "node:path";
5409
+ import path31 from "node:path";
5177
5410
  import fg4 from "fast-glob";
5178
5411
  import YAML2 from "yaml";
5179
5412
  var DEFAULT_MAX_DEPTH = 3;
@@ -5201,8 +5434,8 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
5201
5434
  const gitDirs = /* @__PURE__ */ new Set();
5202
5435
  const manifestDirs = /* @__PURE__ */ new Set();
5203
5436
  for (const match of matches) {
5204
- const base = path30.posix.basename(match);
5205
- const dir = path30.posix.dirname(match);
5437
+ const base = path31.posix.basename(match);
5438
+ const dir = path31.posix.dirname(match);
5206
5439
  const owner = dir === "." ? "." : dir;
5207
5440
  if (base === ".git") gitDirs.add(owner);
5208
5441
  else manifestDirs.add(owner);
@@ -5218,9 +5451,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
5218
5451
  }
5219
5452
  repoRoots.sort((a, b) => a.localeCompare(b));
5220
5453
  return mapWithConcurrency(repoRoots, async (relDir) => {
5221
- const absDir = path30.resolve(workspaceRoot, relDir);
5222
- const pkg = await readJson(path30.join(absDir, "package.json"));
5223
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path30.basename(relDir === "." ? workspaceRoot : absDir);
5454
+ const absDir = path31.resolve(workspaceRoot, relDir);
5455
+ const pkg = await readJson(path31.join(absDir, "package.json"));
5456
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path31.basename(relDir === "." ? workspaceRoot : absDir);
5224
5457
  let role = "auto";
5225
5458
  try {
5226
5459
  const scan = await scanProject(absDir);
@@ -5263,7 +5496,7 @@ function renderWorkspaceYaml(config) {
5263
5496
  });
5264
5497
  }
5265
5498
  async function runDiscover(workspaceRoot, opts = {}) {
5266
- const yamlPath = path30.join(workspaceRoot, "haus.workspace.yaml");
5499
+ const yamlPath = path31.join(workspaceRoot, "haus.workspace.yaml");
5267
5500
  const existingText = await readText(yamlPath);
5268
5501
  const existing = parseWorkspaceConfig(existingText);
5269
5502
  if (existingText && !existing) {
@@ -5297,18 +5530,18 @@ async function runDiscover(workspaceRoot, opts = {}) {
5297
5530
 
5298
5531
  // src/commands/workspace/doctor.ts
5299
5532
  import { existsSync as existsSync3 } from "node:fs";
5300
- import path32 from "node:path";
5533
+ import path33 from "node:path";
5301
5534
 
5302
5535
  // src/commands/workspace/manifest.ts
5303
5536
  import { readFileSync as readFileSync3 } from "node:fs";
5304
- import path31 from "node:path";
5537
+ import path32 from "node:path";
5305
5538
  var MANIFEST_FILE = "workspace.manifest.json";
5306
5539
  function manifestPath(workspaceRoot) {
5307
5540
  return hausPath(workspaceRoot, MANIFEST_FILE);
5308
5541
  }
5309
5542
  function hausVersion() {
5310
5543
  try {
5311
- const pkg = JSON.parse(readFileSync3(path31.join(packageRoot(), "package.json"), "utf8"));
5544
+ const pkg = JSON.parse(readFileSync3(path32.join(packageRoot(), "package.json"), "utf8"));
5312
5545
  return pkg.version ?? "0.0.0";
5313
5546
  } catch {
5314
5547
  return "0.0.0";
@@ -5374,7 +5607,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
5374
5607
  }
5375
5608
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
5376
5609
  for (const repo of config.repos) {
5377
- const repoRoot = path32.resolve(workspaceRoot, repo.path);
5610
+ const repoRoot = path33.resolve(workspaceRoot, repo.path);
5378
5611
  const entry = manifestByName.get(repo.name);
5379
5612
  if (!entry) {
5380
5613
  flag({
@@ -5448,11 +5681,11 @@ function emit(args) {
5448
5681
 
5449
5682
  // src/commands/workspace/setup.ts
5450
5683
  import { existsSync as existsSync4, statSync } from "node:fs";
5451
- import path34 from "node:path";
5684
+ import path35 from "node:path";
5452
5685
 
5453
5686
  // src/claude/write-workspace-claude-md.ts
5454
- import path33 from "node:path";
5455
- import fs22 from "fs-extra";
5687
+ import path34 from "node:path";
5688
+ import fs23 from "fs-extra";
5456
5689
  function buildWorkspaceImportBlock(client, members) {
5457
5690
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
5458
5691
  const body = [
@@ -5470,8 +5703,8 @@ ${BLOCK_END}`;
5470
5703
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5471
5704
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
5472
5705
  const dryRun = opts.dryRun ?? false;
5473
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path33.join(workspaceRoot, "CLAUDE.md");
5474
- const prev = await fs22.pathExists(filePath) ? await fs22.readFile(filePath, "utf8") : "";
5706
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path34.join(workspaceRoot, "CLAUDE.md");
5707
+ const prev = await fs23.pathExists(filePath) ? await fs23.readFile(filePath, "utf8") : "";
5475
5708
  const next = opts.collision ? `${block}
5476
5709
  ` : injectHausBlock(prev, block);
5477
5710
  const printable = displayPath(workspaceRoot, filePath);
@@ -5496,20 +5729,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5496
5729
 
5497
5730
  // src/commands/workspace/setup.ts
5498
5731
  function resolveWorkspaceRoot(start = process.cwd()) {
5499
- let dir = path34.resolve(start);
5732
+ let dir = path35.resolve(start);
5500
5733
  for (; ; ) {
5501
- if (existsSync4(path34.join(dir, WORKSPACE_FILE))) return dir;
5502
- const parent = path34.dirname(dir);
5503
- if (parent === dir) return path34.resolve(start);
5734
+ if (existsSync4(path35.join(dir, WORKSPACE_FILE))) return dir;
5735
+ const parent = path35.dirname(dir);
5736
+ if (parent === dir) return path35.resolve(start);
5504
5737
  dir = parent;
5505
5738
  }
5506
5739
  }
5507
5740
  function isRootRepo(workspaceRoot, repoPath) {
5508
- return path34.resolve(workspaceRoot, repoPath) === path34.resolve(workspaceRoot);
5741
+ return path35.resolve(workspaceRoot, repoPath) === path35.resolve(workspaceRoot);
5509
5742
  }
5510
5743
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
5511
5744
  const apply = options.write ?? false;
5512
- const configText = await readText(path34.join(workspaceRoot, WORKSPACE_FILE));
5745
+ const configText = await readText(path35.join(workspaceRoot, WORKSPACE_FILE));
5513
5746
  if (!configText) {
5514
5747
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5515
5748
  process.exitCode = 1;
@@ -5526,7 +5759,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5526
5759
  const statuses = [];
5527
5760
  const aggregateInputs = [];
5528
5761
  for (const repo of repos) {
5529
- const repoRoot = path34.resolve(workspaceRoot, repo.path);
5762
+ const repoRoot = path35.resolve(workspaceRoot, repo.path);
5530
5763
  log(`
5531
5764
  \u2192 ${repo.name} (${repo.path})`);
5532
5765
  try {
@@ -5594,7 +5827,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5594
5827
  const status = statusByName.get(repo.name);
5595
5828
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
5596
5829
  if (status?.status === "ok") {
5597
- const lock = await checkLock(path34.resolve(workspaceRoot, repo.path));
5830
+ const lock = await checkLock(path35.resolve(workspaceRoot, repo.path));
5598
5831
  manifestRepos.push({
5599
5832
  name: repo.name,
5600
5833
  path: repo.path,
@@ -5674,7 +5907,7 @@ relationships: []
5674
5907
  log("Workspace initialized.");
5675
5908
  }
5676
5909
  async function scanWorkspace(workspaceRoot, opts) {
5677
- const configText = await readText(path35.join(workspaceRoot, WORKSPACE_FILE));
5910
+ const configText = await readText(path36.join(workspaceRoot, WORKSPACE_FILE));
5678
5911
  if (!configText) {
5679
5912
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5680
5913
  process.exitCode = 1;
@@ -5695,7 +5928,7 @@ async function scanWorkspace(workspaceRoot, opts) {
5695
5928
  }
5696
5929
  const inputs = [];
5697
5930
  for (const repo of config.repos) {
5698
- const repoRoot = path35.resolve(workspaceRoot, repo.path);
5931
+ const repoRoot = path36.resolve(workspaceRoot, repo.path);
5699
5932
  if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
5700
5933
  throw new Error(`Repo path is not a directory: ${repo.path}`);
5701
5934
  }
@@ -5745,7 +5978,7 @@ async function runWorkspace(action, options = {}) {
5745
5978
  // src/cli.ts
5746
5979
  function cliVersion() {
5747
5980
  try {
5748
- const pkgPath = path36.join(packageRoot(), "package.json");
5981
+ const pkgPath = path37.join(packageRoot(), "package.json");
5749
5982
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5750
5983
  return pkg.version ?? "0.0.0";
5751
5984
  } catch {
@@ -5755,7 +5988,7 @@ function cliVersion() {
5755
5988
  var program = new Command();
5756
5989
  function validateRuntimeNodeVersion() {
5757
5990
  try {
5758
- const pkgPath = path36.join(packageRoot(), "package.json");
5991
+ const pkgPath = path37.join(packageRoot(), "package.json");
5759
5992
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5760
5993
  const requiredRange = pkg.engines?.node;
5761
5994
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -5770,10 +6003,16 @@ function validateRuntimeNodeVersion() {
5770
6003
  validateRuntimeNodeVersion();
5771
6004
  program.name("haus").description("Haus AI workflow CLI").version(cliVersion());
5772
6005
  program.command("scan").option("--json").action(runScan);
5773
- program.command("recommend").option("--json").action(runRecommend);
6006
+ program.command("recommend").option("--json").option(
6007
+ "--include <ids...>",
6008
+ "Force opt-in catalog items into the recommendation as manual selections (space- or comma-separated ids)"
6009
+ ).action(runRecommend);
5774
6010
  program.command("setup-project").option("--json").action(runSetupProject);
5775
6011
  program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
5776
6012
  program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
6013
+ "--ids <ids...>",
6014
+ "Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
6015
+ ).option(
5777
6016
  "--allow-empty-cache",
5778
6017
  "Apply core files only when catalog cache is empty (skip catalog items without error)"
5779
6018
  ).option(