@haus-tech/haus-workflow 0.29.0 → 0.31.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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.31.0](https://github.com/WeAreHausTech/haus-workflow/compare/v0.30.0...v0.31.0) (2026-06-23)
4
+
5
+ ### Features
6
+
7
+ - opt-in UX for tiered skills + config scaffold ([#141](https://github.com/WeAreHausTech/haus-workflow/issues/141)) ([f60207b](https://github.com/WeAreHausTech/haus-workflow/commit/f60207b78af4651656d457c6f80665bd25deaa7f)), closes [#33](https://github.com/WeAreHausTech/haus-workflow/issues/33) [#33](https://github.com/WeAreHausTech/haus-workflow/issues/33)
8
+
9
+ ## [0.30.0](https://github.com/WeAreHausTech/haus-workflow/compare/v0.29.0...v0.30.0) (2026-06-22)
10
+
11
+ ### Features
12
+
13
+ - auto recommend config setups ([#137](https://github.com/WeAreHausTech/haus-workflow/issues/137)) ([56e7941](https://github.com/WeAreHausTech/haus-workflow/commit/56e79413a6cfe00e35f5e48abd03baeeb377214f))
14
+ - **cloneandsetup:** reuse local env values, never commit secrets ([#130](https://github.com/WeAreHausTech/haus-workflow/issues/130)) ([cf74a19](https://github.com/WeAreHausTech/haus-workflow/commit/cf74a196d369fbc59798de97e76bbcd3120155f7))
15
+ - co-install suppression and recommender gates ([#134](https://github.com/WeAreHausTech/haus-workflow/issues/134)) ([4ca1f20](https://github.com/WeAreHausTech/haus-workflow/commit/4ca1f20a6b90645cd7007d195e6cc474558cc971))
16
+ - curated source tools exempt from non-tsx npx ban and update archetype dependencies ([#132](https://github.com/WeAreHausTech/haus-workflow/issues/132)) ([a63d0b2](https://github.com/WeAreHausTech/haus-workflow/commit/a63d0b224ee9d1fd7b686b9aa3979562228f2333))
17
+ - prune deprecated catalog items on apply/update ([#126](https://github.com/WeAreHausTech/haus-workflow/issues/126)) ([fdf6ecc](https://github.com/WeAreHausTech/haus-workflow/commit/fdf6ecc1d36f4eb090cdaa8d9b044381c5836c77))
18
+ - stripe server gate [#139](https://github.com/WeAreHausTech/haus-workflow/issues/139) ([fb2dc35](https://github.com/WeAreHausTech/haus-workflow/commit/fb2dc35b0d332cdd6f3c7dcbaebef94dce06899c))
19
+
3
20
  ## [0.29.0](https://github.com/WeAreHausTech/haus-workflow/compare/v0.28.1...v0.29.0) (2026-06-17)
4
21
 
5
22
  ### Features
package/README.md CHANGED
@@ -82,8 +82,14 @@ Content lives in [`haus-workflow-catalog`](https://github.com/WeAreHausTech/haus
82
82
  (version pinned in `library/catalog/manifest.json`). Fetched at runtime from the latest release tag (override with `HAUS_CATALOG_REF`; fallback `main` when no release tag resolves).
83
83
  Validation rules sync from catalog → `library/catalog/validation-rules.json` (ADR-0001).
84
84
 
85
- On `haus apply` / `haus update`, items **removed from the catalog** are pruned from the
86
- project when their on-disk copy still matches the lock hash; user-edited copies are kept.
85
+ On `haus apply` / `haus update`, items **removed from the catalog** or marked
86
+ **deprecated** are pruned from the project when their on-disk copy still matches the
87
+ lock hash; user-edited copies are kept. Approved items deselected via `--select` are
88
+ not pruned.
89
+
90
+ > **Upgrading from a pre-`0.30.0` CLI:** deprecated-skill pruning shipped in `0.30.0`.
91
+ > If an older CLI left deprecated skills on disk, upgrade first
92
+ > (`npm i -g @haus-tech/haus-workflow`) then run `haus update` to prune them.
87
93
 
88
94
  ## Internal docs
89
95
 
package/dist/cli.js CHANGED
@@ -154,7 +154,7 @@ var validation_rules_default = {
154
154
  ],
155
155
  allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
156
156
  anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
157
- npxTsxOnlyExemptTypes: ["agent"],
157
+ npxTsxOnlyExemptSources: ["curated"],
158
158
  httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
159
159
  placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
160
160
  allowedStacks: [
@@ -274,7 +274,29 @@ var REQUIRED_SKILL_FRONTMATTER = validation_rules_default.requiredSkillFrontmatt
274
274
  var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
275
275
  var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
276
276
  var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
277
- var NPX_TSX_ONLY_EXEMPT_TYPES = validation_rules_default.npxTsxOnlyExemptTypes ?? [];
277
+ var NPX_TSX_ONLY_EXEMPT_SOURCES = validation_rules_default.npxTsxOnlyExemptSources ?? [];
278
+ function isNpxTsxOnlyExempt(_itemType, itemSource) {
279
+ return Boolean(itemSource && NPX_TSX_ONLY_EXEMPT_SOURCES.includes(itemSource));
280
+ }
281
+ function buildItemPathSourceMap(items) {
282
+ const map = /* @__PURE__ */ new Map();
283
+ for (const item of items) {
284
+ if (!item.path) continue;
285
+ map.set(String(item.path).replace(/\\/g, "/"), item.source);
286
+ }
287
+ return map;
288
+ }
289
+ function resolveMarkdownItemSource(relPath, pathSourceMap) {
290
+ const norm = relPath.replace(/\\/g, "/");
291
+ let cursor = norm;
292
+ while (true) {
293
+ if (pathSourceMap.has(cursor)) return pathSourceMap.get(cursor);
294
+ const slash = cursor.lastIndexOf("/");
295
+ if (slash === -1) break;
296
+ cursor = cursor.slice(0, slash);
297
+ }
298
+ return void 0;
299
+ }
278
300
  var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
279
301
  var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
280
302
  var ALLOWED_STACKS = validation_rules_default.allowedStacks;
@@ -353,7 +375,7 @@ ${extractUseWhenSection(text)}`;
353
375
  // src/catalog/ingest-catalog.ts
354
376
  function validateCatalogItem(item, content2) {
355
377
  const label = item.id;
356
- const checkNonTsxNpx = !NPX_TSX_ONLY_EXEMPT_TYPES.includes(item.type);
378
+ const checkNonTsxNpx = !isNpxTsxOnlyExempt(item.type, item.source);
357
379
  const lines = content2.split(/\r?\n/);
358
380
  for (let i = 0; i < lines.length; i++) {
359
381
  const line2 = lines[i] ?? "";
@@ -803,7 +825,12 @@ async function syncDirectoryFromPrefix(item, catalogPrefix, destDir, base, opts)
803
825
  for (const file of fetched) {
804
826
  if (file.kind !== "text" || !isMarkdownPath(file.rel)) continue;
805
827
  const verdict = validateCatalogItem(
806
- { id: item.id, type: "skill", path: catalogPrefix },
828
+ {
829
+ id: item.id,
830
+ type: "skill",
831
+ path: catalogPrefix,
832
+ ..."source" in item && item.source ? { source: item.source } : {}
833
+ },
807
834
  file.body
808
835
  );
809
836
  if (!verdict.ok) {
@@ -2291,7 +2318,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2291
2318
  `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
2292
2319
  );
2293
2320
  }
2294
- await cleanupStaleCatalogItems(root, new Set(manifestById.keys()), dryRun);
2321
+ await cleanupStaleCatalogItems(root, manifestById, dryRun);
2295
2322
  if (dryRun) return [...new Set(files)];
2296
2323
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
2297
2324
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
@@ -2328,11 +2355,15 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2328
2355
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
2329
2356
  return [...new Set(files)];
2330
2357
  }
2331
- async function cleanupStaleCatalogItems(root, knownIds, dryRun) {
2358
+ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2332
2359
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
2333
2360
  if (!prevLock?.length) return;
2334
2361
  for (const entry of prevLock) {
2335
- if (!entry.id || knownIds.has(entry.id)) continue;
2362
+ if (!entry.id) continue;
2363
+ const manifestItem = manifestById.get(entry.id);
2364
+ const removedFromManifest = manifestItem === void 0;
2365
+ const deprecated = manifestItem?.reviewStatus === "deprecated";
2366
+ if (!removedFromManifest && !deprecated) continue;
2336
2367
  const relPaths = entry.paths ?? [];
2337
2368
  if (relPaths.length === 0) continue;
2338
2369
  const existing = [];
@@ -2340,37 +2371,48 @@ async function cleanupStaleCatalogItems(root, knownIds, dryRun) {
2340
2371
  if (await fs12.pathExists(path12.join(root, rel))) existing.push(rel);
2341
2372
  }
2342
2373
  if (existing.length === 0) continue;
2374
+ const pruneReason = deprecated ? "deprecated" : "stale";
2343
2375
  if (entry.hash === void 0) {
2344
2376
  warn(
2345
- `Stale catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
2377
+ `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
2346
2378
  );
2347
2379
  continue;
2348
2380
  }
2349
2381
  const currentHash = await hashInstalledPaths(root, relPaths);
2350
2382
  if (currentHash !== entry.hash) {
2351
2383
  warn(
2352
- `Stale catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
2384
+ `${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
2353
2385
  );
2354
2386
  continue;
2355
2387
  }
2356
2388
  for (const rel of existing) {
2357
2389
  const abs = path12.join(root, rel);
2358
2390
  if (dryRun) {
2359
- log(`[dry-run] would remove stale ${displayPath(root, abs)} (${entry.id})`);
2391
+ log(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2360
2392
  continue;
2361
2393
  }
2362
2394
  await fs12.remove(abs);
2363
2395
  await pruneEmptyDir(path12.dirname(abs));
2364
- log(`Removed stale ${displayPath(root, abs)} (${entry.id})`);
2396
+ log(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2365
2397
  }
2366
2398
  }
2367
2399
  }
2368
2400
 
2401
+ // src/utils/args.ts
2402
+ function parseIdList(value) {
2403
+ if (!value) return [];
2404
+ const raw = Array.isArray(value) ? value : [value];
2405
+ return raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0);
2406
+ }
2407
+
2369
2408
  // src/commands/apply.ts
2370
2409
  async function cacheHasItems() {
2371
2410
  const data = await readJson(path13.join(getCacheDir(), "manifest.json"));
2372
2411
  return Array.isArray(data?.items) && data.items.length > 0;
2373
2412
  }
2413
+ function installableRecommendedItems(recommended) {
2414
+ return recommended.filter((item) => item.install !== false);
2415
+ }
2374
2416
  async function runApply(options) {
2375
2417
  if (!options.dryRun && !options.write) {
2376
2418
  log("Use --dry-run or --write");
@@ -2379,6 +2421,34 @@ async function runApply(options) {
2379
2421
  const root = process.cwd();
2380
2422
  const isDryRun = Boolean(options.dryRun) && !options.write;
2381
2423
  let selectedIds;
2424
+ const explicitIds = parseIdList(options.ids);
2425
+ if (explicitIds.length > 0) {
2426
+ if (options.select) {
2427
+ error("Use either --select (interactive) or --ids (explicit), not both");
2428
+ process.exitCode = 1;
2429
+ return;
2430
+ }
2431
+ const rec = await readJson(hausPath(root, "recommendation.json"));
2432
+ const recommended = rec?.recommended ?? [];
2433
+ const installableIds = new Set(installableRecommendedItems(recommended).map((i) => i.id));
2434
+ const configIds = new Set(recommended.filter((i) => i.install === false).map((i) => i.id));
2435
+ const skippedIds = new Set((rec?.skipped ?? []).map((s) => s.id));
2436
+ for (const id of explicitIds.filter((id2) => !installableIds.has(id2))) {
2437
+ if (configIds.has(id)) {
2438
+ warn(`--ids: "${id}" is a config item \u2014 install it with \`haus scaffold ${id}\`. Ignoring.`);
2439
+ } else if (skippedIds.has(id)) {
2440
+ warn(
2441
+ `--ids: "${id}" is not currently recommended (skipped) \u2014 add it with \`haus recommend --include ${id}\` first. Ignoring.`
2442
+ );
2443
+ } else {
2444
+ warn(
2445
+ `--ids: "${id}" is not in recommendation.json \u2014 run \`haus recommend\` (or \`--include ${id}\`) first. Ignoring.`
2446
+ );
2447
+ }
2448
+ }
2449
+ selectedIds = explicitIds.filter((id) => installableIds.has(id));
2450
+ log(`Applying ${selectedIds.length} explicitly selected catalog item(s).`);
2451
+ }
2382
2452
  if (options.select) {
2383
2453
  if (!process.stdin.isTTY) {
2384
2454
  error("--select requires an interactive terminal (stdin is not a TTY)");
@@ -2389,28 +2459,31 @@ async function runApply(options) {
2389
2459
  if (!rec) {
2390
2460
  log("No recommendation.json found \u2014 run `haus recommend` first. Writing core files only.");
2391
2461
  selectedIds = [];
2392
- } else if (rec.recommended.length === 0) {
2393
- log("Recommendation contains no catalog items. Writing core files only.");
2394
- selectedIds = [];
2395
2462
  } else {
2396
- const items = rec.recommended;
2397
- const choices = items.map((item) => ({
2398
- name: `${item.id} [${item.selectionMode}] \u2014 ${item.reason}`,
2399
- value: item.id,
2400
- checked: true
2401
- }));
2402
- const chosen = await checkbox({
2403
- message: "Select catalog items to apply (space to toggle, enter to confirm):",
2404
- choices,
2405
- pageSize: Math.min(20, items.length + 2)
2406
- });
2407
- selectedIds = chosen;
2408
- log(`Selected ${selectedIds.length} of ${items.length} catalog items.`);
2463
+ const items = installableRecommendedItems(rec.recommended);
2464
+ if (items.length === 0) {
2465
+ log("Recommendation contains no catalog items. Writing core files only.");
2466
+ selectedIds = [];
2467
+ } else {
2468
+ const choices = items.map((item) => ({
2469
+ name: `${item.id} [${item.selectionMode}] \u2014 ${item.reason}`,
2470
+ value: item.id,
2471
+ checked: true
2472
+ }));
2473
+ const chosen = await checkbox({
2474
+ message: "Select catalog items to apply (space to toggle, enter to confirm):",
2475
+ choices,
2476
+ pageSize: Math.min(20, items.length + 2)
2477
+ });
2478
+ selectedIds = chosen;
2479
+ log(`Selected ${selectedIds.length} of ${items.length} catalog items.`);
2480
+ }
2409
2481
  }
2410
2482
  }
2411
2483
  if (!options.allowEmptyCache && !process.env["HAUS_FIXTURE_CATALOG"]) {
2412
2484
  const rec = await readJson(hausPath(root, "recommendation.json"));
2413
- const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : rec?.recommended.length ?? 0;
2485
+ const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
2486
+ const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : installableRecommended.length;
2414
2487
  if (catalogItemCount > 0 && !await cacheHasItems()) {
2415
2488
  const message = "No catalog content found. Run `haus update` first.";
2416
2489
  if (isDryRun) {
@@ -2705,7 +2778,7 @@ var STACK_RULES = [
2705
2778
  { stack: ["tooling", "missing-eslint"], any: [depAbsent("eslint")] },
2706
2779
  {
2707
2780
  stack: ["tooling", "stripe"],
2708
- any: [dep("@stripe/stripe-js"), dep("@stripe/react-stripe-js")]
2781
+ any: [dep("stripe"), dep("@stripe/stripe-js"), dep("@stripe/react-stripe-js")]
2709
2782
  },
2710
2783
  { stack: ["tooling", "qliro"], any: [dep("@haus-tech/qliro-plugin")] },
2711
2784
  {
@@ -3508,7 +3581,7 @@ function mergeRecommendationWarnings(context) {
3508
3581
  }
3509
3582
 
3510
3583
  // src/recommender/recommend.ts
3511
- async function recommend(root, context) {
3584
+ async function recommend(root, context, opts = {}) {
3512
3585
  const items = await loadCatalog(root);
3513
3586
  const sources = await readJson(
3514
3587
  hausPath(root, "sources-report.json")
@@ -3543,15 +3616,6 @@ async function recommend(root, context) {
3543
3616
  skip(item.id, "unsupported-policy", "Unsupported stack policy");
3544
3617
  continue;
3545
3618
  }
3546
- if (item.type === "config") {
3547
- skip(
3548
- item.id,
3549
- "config-scaffold-only",
3550
- "Config item \u2014 install with `haus scaffold`",
3551
- "type:config"
3552
- );
3553
- continue;
3554
- }
3555
3619
  if (item.reviewStatus === "deprecated") {
3556
3620
  skip(item.id, "deprecated", "Catalog item is deprecated", "reviewStatus:deprecated");
3557
3621
  continue;
@@ -3608,6 +3672,19 @@ async function recommend(root, context) {
3608
3672
  );
3609
3673
  continue;
3610
3674
  }
3675
+ if (item.id === "haus.ecc-database-reviewer" && !roleSet.has("database")) {
3676
+ skip(item.id, "required-role-missing", "Required role missing: database", "role:database");
3677
+ continue;
3678
+ }
3679
+ if (item.id === "haus.ecc-typescript-reviewer" && (stackSet.has("react") || stackSet.has("react19") || stackSet.has("nextjs") || stackSet.has("next") || roleSet.has("react-app") || roleSet.has("next-app"))) {
3680
+ skip(
3681
+ item.id,
3682
+ "co-install-react-reviewer",
3683
+ "Skip typescript-reviewer on React/Next stacks (use react-reviewer)",
3684
+ "stack:react|nextjs|role:react-app|next-app"
3685
+ );
3686
+ continue;
3687
+ }
3611
3688
  const isDefaultBaseline = item.default === true;
3612
3689
  const reasons = [];
3613
3690
  const push = (code, message, signal) => reasons.push({ code, message, signal });
@@ -3652,13 +3729,22 @@ async function recommend(root, context) {
3652
3729
  }
3653
3730
  const hasEvidence = reasons.some((r) => r.code !== "default-baseline");
3654
3731
  if (isDefaultBaseline || hasEvidence) {
3732
+ const isConfig = item.type === "config";
3733
+ const entryReasons = isConfig ? [
3734
+ ...reasons,
3735
+ {
3736
+ code: "config-scaffold",
3737
+ message: "Install with `haus scaffold`",
3738
+ signal: "type:config"
3739
+ }
3740
+ ] : reasons;
3655
3741
  recommended.push({
3656
3742
  id: item.id,
3657
3743
  type: item.type,
3658
- reason: reasons.length ? reasons.map((x) => x.message).join(", ") : "eligible",
3659
- reasons,
3744
+ reason: entryReasons.length ? entryReasons.map((x) => x.message).join(", ") : "eligible",
3745
+ reasons: entryReasons,
3660
3746
  selectionMode: isDefaultBaseline && !hasEvidence ? "baseline" : "matched",
3661
- install: true,
3747
+ install: !isConfig,
3662
3748
  tags: item.tags,
3663
3749
  ecosystem: item.ecosystem,
3664
3750
  tokenEstimate: item.tokenEstimate
@@ -3667,16 +3753,21 @@ async function recommend(root, context) {
3667
3753
  skip(item.id, "no-role-stack-match", "No role/stack match");
3668
3754
  }
3669
3755
  }
3756
+ applyCoInstallSuppression(recommended, skipped);
3757
+ const includeWarnings = applyManualIncludes(opts.include ?? [], items, recommended, skipped);
3758
+ const optInEligible = buildOptInEligible(items, skipped);
3670
3759
  recommended.sort((a, b) => a.id.localeCompare(b.id));
3671
3760
  skipped.sort((a, b) => a.id.localeCompare(b.id));
3672
- const selectedRules = recommended.length;
3761
+ const installableRecommended = recommended.filter((item) => item.install);
3762
+ const selectedRules = installableRecommended.length;
3673
3763
  const skippedRules = skipped.length;
3674
3764
  const estimatedContextTokens = estimateContextTokens(selectedRules);
3675
3765
  const estimatedTokenReductionPct = tokenReductionPct(selectedRules, skippedRules);
3676
3766
  return {
3677
3767
  recommended,
3678
3768
  skipped,
3679
- warnings: mergeRecommendationWarnings(context),
3769
+ optInEligible,
3770
+ warnings: [...mergeRecommendationWarnings(context), ...includeWarnings],
3680
3771
  estimatedContextTokens,
3681
3772
  selectedRules,
3682
3773
  skippedRules,
@@ -3690,6 +3781,152 @@ function buildStackSet(context) {
3690
3781
  )
3691
3782
  );
3692
3783
  }
3784
+ function applyCoInstallSuppression(recommended, skipped) {
3785
+ const recommendedIds = new Set(recommended.map((r) => r.id));
3786
+ const rules = [
3787
+ {
3788
+ suppress: "haus.sentry-sentry-workflow",
3789
+ whenAnyRecommended: [
3790
+ "haus.sentry-sentry-php-sdk",
3791
+ "haus.sentry-sentry-nextjs-sdk",
3792
+ "haus.sentry-sentry-node-sdk",
3793
+ "haus.sentry-sentry-nestjs-sdk"
3794
+ ],
3795
+ code: "co-install-sentry-sdk",
3796
+ message: "Skip sentry-workflow when a stack-specific Sentry SDK skill is selected",
3797
+ signal: "co-install:sentry-sdk"
3798
+ },
3799
+ {
3800
+ suppress: "haus.ecc-e2e-runner",
3801
+ whenAnyRecommended: ["haus.ecc-e2e-testing"],
3802
+ code: "co-install-e2e-skill",
3803
+ message: "Skip e2e-runner when e2e-testing skill covers patterns",
3804
+ signal: "co-install:e2e-testing"
3805
+ },
3806
+ {
3807
+ suppress: "haus.oh-my-claudecode-test-engineer",
3808
+ whenAnyRecommended: ["haus.ecc-e2e-testing", "haus.ecc-e2e-runner"],
3809
+ code: "co-install-e2e-coverage",
3810
+ message: "Skip oh-my test-engineer when ECC e2e skill or agent is selected",
3811
+ signal: "co-install:e2e-coverage"
3812
+ },
3813
+ {
3814
+ suppress: "haus.wshobson-js-testing-patterns",
3815
+ whenAnyRecommended: ["haus.ecc-react-testing"],
3816
+ code: "co-install-react-testing",
3817
+ message: "Skip js-testing-patterns when ecc-react-testing covers component tests",
3818
+ signal: "co-install:react-testing"
3819
+ },
3820
+ {
3821
+ suppress: "haus.superpowers-test-driven-development",
3822
+ whenAnyRecommended: ["haus.wshobson-js-testing-patterns", "haus.ecc-react-testing"],
3823
+ code: "co-install-stack-testing",
3824
+ message: "Skip TDD superpower when a stack testing skill is selected",
3825
+ signal: "co-install:stack-testing"
3826
+ },
3827
+ {
3828
+ suppress: "haus.superpowers-specifying-gates",
3829
+ whenAnyRecommended: ["haus.superpowers-checking-gates"],
3830
+ code: "co-install-gate-workflow",
3831
+ message: "Skip specifying-gates when checking-gates is selected",
3832
+ signal: "co-install:checking-gates"
3833
+ },
3834
+ {
3835
+ suppress: "haus.ecc-redis-patterns",
3836
+ whenAnyRecommended: ["haus.redis-redis-connections"],
3837
+ code: "co-install-redis-official",
3838
+ message: "Skip ecc-redis-patterns when official redis-connections skill is selected",
3839
+ signal: "co-install:redis-connections"
3840
+ }
3841
+ ];
3842
+ for (const rule of rules) {
3843
+ if (!recommendedIds.has(rule.suppress)) continue;
3844
+ if (!rule.whenAnyRecommended.some((id) => recommendedIds.has(id))) continue;
3845
+ const idx = recommended.findIndex((r) => r.id === rule.suppress);
3846
+ if (idx === -1) continue;
3847
+ const [removed] = recommended.splice(idx, 1);
3848
+ recommendedIds.delete(rule.suppress);
3849
+ skipped.push({
3850
+ id: removed.id,
3851
+ reason: rule.message,
3852
+ skipReasons: [{ code: rule.code, message: rule.message, signal: rule.signal }]
3853
+ });
3854
+ }
3855
+ }
3856
+ var HARD_SKIP_CODES = /* @__PURE__ */ new Set([
3857
+ "unsupported-policy",
3858
+ "deprecated",
3859
+ "curated-not-approved",
3860
+ "curated-risk-blocked",
3861
+ "sensitive-policy",
3862
+ "source-trust",
3863
+ "source-approval"
3864
+ ]);
3865
+ function applyManualIncludes(include, items, recommended, skipped) {
3866
+ const warnings = [];
3867
+ if (include.length === 0) return warnings;
3868
+ const byId = new Map(items.map((i) => [i.id, i]));
3869
+ const recommendedIds = new Set(recommended.map((r) => r.id));
3870
+ for (const id of include) {
3871
+ const item = byId.get(id);
3872
+ if (!item) {
3873
+ warnings.push(`--include: unknown catalog id "${id}" (not in manifest)`);
3874
+ continue;
3875
+ }
3876
+ if (recommendedIds.has(id)) continue;
3877
+ const skipIdx = skipped.findIndex((s) => s.id === id);
3878
+ const skipCode = skipIdx >= 0 ? skipped[skipIdx].skipReasons[0]?.code : void 0;
3879
+ if (skipCode && HARD_SKIP_CODES.has(skipCode)) {
3880
+ warnings.push(`--include: "${id}" cannot be force-installed (blocked by ${skipCode})`);
3881
+ continue;
3882
+ }
3883
+ if (skipCode === "requires-any-unsatisfied") {
3884
+ warnings.push(`--include: "${id}" added manually though its requiresAny gate is unsatisfied`);
3885
+ }
3886
+ if (skipIdx >= 0) skipped.splice(skipIdx, 1);
3887
+ const isConfig = item.type === "config";
3888
+ recommended.push({
3889
+ id: item.id,
3890
+ type: item.type,
3891
+ reason: "manually included",
3892
+ reasons: [
3893
+ {
3894
+ code: "manual-include",
3895
+ message: "manually included via --include",
3896
+ signal: "selection:manual"
3897
+ }
3898
+ ],
3899
+ selectionMode: "manual",
3900
+ install: !isConfig,
3901
+ tags: item.tags,
3902
+ ecosystem: item.ecosystem,
3903
+ tokenEstimate: item.tokenEstimate
3904
+ });
3905
+ recommendedIds.add(id);
3906
+ }
3907
+ return warnings;
3908
+ }
3909
+ function buildOptInEligible(items, skipped) {
3910
+ const byId = new Map(items.map((i) => [i.id, i]));
3911
+ const eligible = [];
3912
+ for (const entry of skipped) {
3913
+ if (entry.skipReasons[0]?.code !== "requires-any-unsatisfied") continue;
3914
+ const item = byId.get(entry.id);
3915
+ if (!item?.optInTier || !item.optInGroup) continue;
3916
+ eligible.push({
3917
+ id: item.id,
3918
+ type: item.type,
3919
+ title: item.title,
3920
+ optInTier: item.optInTier,
3921
+ optInGroup: item.optInGroup,
3922
+ purpose: item.purpose,
3923
+ tokenEstimate: item.tokenEstimate,
3924
+ requires: entry.skipReasons[0]?.signal ?? describeRequiresAny(item.requiresAny ?? [])
3925
+ });
3926
+ }
3927
+ eligible.sort((a, b) => a.optInGroup.localeCompare(b.optInGroup) || a.id.localeCompare(b.id));
3928
+ return eligible;
3929
+ }
3693
3930
 
3694
3931
  // src/commands/setup-core.ts
3695
3932
  async function runSetupCore(root, opts) {
@@ -4150,7 +4387,8 @@ async function runInstall(options) {
4150
4387
  async function runRecommend(options) {
4151
4388
  const root = process.cwd();
4152
4389
  const context = await readContextOrScan(root);
4153
- const result = await recommend(root, context);
4390
+ const include = parseIdList(options.include);
4391
+ const result = await recommend(root, context, { include });
4154
4392
  await writeJson(hausPath(root, "recommendation.json"), result);
4155
4393
  if (options.json) {
4156
4394
  log(JSON.stringify(result, null, 2));
@@ -4159,6 +4397,12 @@ async function runRecommend(options) {
4159
4397
  log("Haus recommendation ready");
4160
4398
  log(`Recommended: ${result.recommended.length}`);
4161
4399
  log(`Skipped: ${result.skipped.length}`);
4400
+ if (result.optInEligible && result.optInEligible.length > 0) {
4401
+ log(
4402
+ `Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`)`
4403
+ );
4404
+ }
4405
+ for (const w of result.warnings.filter((w2) => w2.startsWith("--include:"))) log(w);
4162
4406
  }
4163
4407
 
4164
4408
  // src/commands/refresh.ts
@@ -4220,19 +4464,20 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4220
4464
  if (stat.isFile()) {
4221
4465
  const filename = path22.basename(sourcePath);
4222
4466
  const dest = path22.join(projectRoot, filename);
4223
- record(filename, await scaffoldFile(sourcePath, dest, item.id, opts));
4467
+ record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
4224
4468
  } else if (stat.isDirectory()) {
4225
4469
  const entries = await fs17.readdir(sourcePath);
4226
4470
  for (const entry of entries) {
4227
4471
  const src = path22.join(sourcePath, entry);
4228
4472
  const dest = path22.join(projectRoot, entry);
4229
- record(entry, await scaffoldFile(src, dest, item.id, opts));
4473
+ record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
4230
4474
  }
4231
4475
  }
4232
4476
  }
4233
4477
  return { scaffolded, skipped, skippedExisting };
4234
4478
  }
4235
- async function scaffoldFile(src, dest, itemId, opts) {
4479
+ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
4480
+ const destDisplay = path22.relative(projectRoot, dest) || path22.basename(dest);
4236
4481
  const srcStat = await fs17.lstat(src).catch(() => null);
4237
4482
  if (!srcStat) {
4238
4483
  warn(`Skipping ${path22.basename(src)}: source not found`);
@@ -4244,7 +4489,7 @@ async function scaffoldFile(src, dest, itemId, opts) {
4244
4489
  }
4245
4490
  const exists = await fs17.pathExists(dest);
4246
4491
  if (exists && !opts.force) {
4247
- warn(`Skipping ${path22.basename(dest)}: already exists (use --force to overwrite)`);
4492
+ warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
4248
4493
  return "skipped-exists";
4249
4494
  }
4250
4495
  if (opts.dryRun) {
@@ -4760,6 +5005,39 @@ function auditForbiddenStacks(items) {
4760
5005
  }
4761
5006
  return failures;
4762
5007
  }
5008
+ var OPT_IN_TIERS = /* @__PURE__ */ new Set(["workflow", "ops", "review", "design"]);
5009
+ function auditOptInMetadata(items) {
5010
+ const failures = [];
5011
+ for (const item of items) {
5012
+ const hasTier = item.optInTier !== void 0;
5013
+ const hasGroup = item.optInGroup !== void 0;
5014
+ if (hasTier !== hasGroup) {
5015
+ failures.push(`${item.id}: optInTier and optInGroup must be set together`);
5016
+ }
5017
+ if (hasTier) {
5018
+ if (!OPT_IN_TIERS.has(item.optInTier)) {
5019
+ failures.push(
5020
+ `${item.id}: invalid optInTier "${item.optInTier}" (expected one of ${[...OPT_IN_TIERS].join(", ")})`
5021
+ );
5022
+ }
5023
+ if (typeof item.optInGroup !== "string" || item.optInGroup.length === 0) {
5024
+ failures.push(`${item.id}: optInGroup must be a non-empty string`);
5025
+ }
5026
+ if (item.default === true) {
5027
+ failures.push(`${item.id}: opt-in items (optInTier set) must not be default:true`);
5028
+ }
5029
+ if (typeof item.purpose !== "string" || item.purpose.length === 0) {
5030
+ failures.push(`${item.id}: opt-in items (optInTier set) must have a non-empty purpose`);
5031
+ }
5032
+ }
5033
+ const requiresAny = Array.isArray(item.requiresAny) ? item.requiresAny : [];
5034
+ const roleOnly = requiresAny.length > 0 && requiresAny.every((clause) => clause && clause.role);
5035
+ if (item.default !== true && roleOnly && !hasTier) {
5036
+ failures.push(`${item.id}: role-gated opt-in item missing optInTier/optInGroup`);
5037
+ }
5038
+ }
5039
+ return failures;
5040
+ }
4763
5041
  function auditManifestStructure(manifestVersion, items) {
4764
5042
  const failures = [];
4765
5043
  const seenIds = /* @__PURE__ */ new Map();
@@ -4903,15 +5181,19 @@ function walkMd(dir, fn) {
4903
5181
  else if (entry.name.endsWith(".md")) fn(full);
4904
5182
  }
4905
5183
  }
4906
- function auditMarkdownContent(manifestDir) {
5184
+ function auditMarkdownContent(manifestDir, items) {
4907
5185
  const failures = [];
5186
+ const pathSourceMap = buildItemPathSourceMap(items);
4908
5187
  for (const dir of ["skills", "agents", "templates", "commands"]) {
4909
5188
  const abs = path27.join(manifestDir, dir);
4910
5189
  if (!fs21.existsSync(abs)) continue;
4911
- const checkNonTsxNpx = !NPX_TSX_ONLY_EXEMPT_TYPES.includes(DIR_ITEM_TYPE[dir] ?? "");
5190
+ const dirType = DIR_ITEM_TYPE[dir] ?? "";
4912
5191
  walkMd(abs, (file) => {
4913
5192
  const text = fs21.readFileSync(file, "utf8");
4914
5193
  const rel = path27.relative(manifestDir, file);
5194
+ const norm = rel.replace(/\\/g, "/");
5195
+ const source = resolveMarkdownItemSource(norm, pathSourceMap);
5196
+ const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
4915
5197
  const lines = text.split(/\r?\n/);
4916
5198
  for (let i = 0; i < lines.length; i++) {
4917
5199
  const line2 = lines[i] ?? "";
@@ -4921,7 +5203,7 @@ function auditMarkdownContent(manifestDir) {
4921
5203
  failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
4922
5204
  }
4923
5205
  }
4924
- if (!rel.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
5206
+ if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
4925
5207
  });
4926
5208
  }
4927
5209
  return failures;
@@ -4929,9 +5211,10 @@ function auditMarkdownContent(manifestDir) {
4929
5211
  function validateCatalogData(manifestDir, manifestVersion, items) {
4930
5212
  const failures = [
4931
5213
  ...auditManifestStructure(manifestVersion, items),
5214
+ ...auditOptInMetadata(items),
4932
5215
  ...auditForbiddenStacks(items),
4933
5216
  ...auditShippedFiles(manifestDir, items),
4934
- ...auditMarkdownContent(manifestDir),
5217
+ ...auditMarkdownContent(manifestDir, items),
4935
5218
  ...auditDisallowedTags(items)
4936
5219
  ];
4937
5220
  return { ok: failures.length === 0, failures, items };
@@ -5641,10 +5924,16 @@ function validateRuntimeNodeVersion() {
5641
5924
  validateRuntimeNodeVersion();
5642
5925
  program.name("haus").description("Haus AI workflow CLI").version(cliVersion());
5643
5926
  program.command("scan").option("--json").action(runScan);
5644
- program.command("recommend").option("--json").action(runRecommend);
5927
+ program.command("recommend").option("--json").option(
5928
+ "--include <ids...>",
5929
+ "Force opt-in catalog items into the recommendation as manual selections (space- or comma-separated ids)"
5930
+ ).action(runRecommend);
5645
5931
  program.command("setup-project").option("--json").action(runSetupProject);
5646
5932
  program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
5647
5933
  program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
5934
+ "--ids <ids...>",
5935
+ "Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
5936
+ ).option(
5648
5937
  "--allow-empty-cache",
5649
5938
  "Apply core files only when catalog cache is empty (skip catalog items without error)"
5650
5939
  ).option(