@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 +17 -0
- package/README.md +8 -2
- package/dist/cli.js +343 -54
- package/library/catalog/manifest.json +1939 -2242
- package/library/catalog/validation-rules.json +1 -1
- package/library/global/commands/haus-cloneandsetup.md +2 -2
- package/library/global/commands/haus-setup.md +29 -5
- package/library/global/skills/haus-workflow/SKILL.md +30 -0
- package/package.json +1 -1
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**
|
|
86
|
-
project when their on-disk copy still matches the
|
|
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
|
-
|
|
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
|
|
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 = !
|
|
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
|
-
{
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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
|
|
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 (!
|
|
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").
|
|
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(
|