@haus-tech/haus-workflow 0.21.0 → 0.22.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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.22.0](https://github.com/WeAreHausTech/haus-workflow/compare/v0.21.0...v0.22.0) (2026-06-11)
4
+
5
+ ### Features
6
+
7
+ - **audit:** offline, perf cleanup, CI ([#92](https://github.com/WeAreHausTech/haus-workflow/issues/92)) ([a133f06](https://github.com/WeAreHausTech/haus-workflow/commit/a133f06d52b9a9e2a15f949c443c7042ed125735))
8
+ - **catalog:** ingest chokepoint ([#91](https://github.com/WeAreHausTech/haus-workflow/issues/91)) ([712d72b](https://github.com/WeAreHausTech/haus-workflow/commit/712d72b252ae3181bcdb9ce02614ede098c5bab5))
9
+
10
+ ### Bug Fixes
11
+
12
+ - **cloneandsetup:** read repo setup docs before file-heuristics ([#95](https://github.com/WeAreHausTech/haus-workflow/issues/95)) ([8219b92](https://github.com/WeAreHausTech/haus-workflow/commit/8219b92d1c40b71ecb78661929ca57e1fe858603))
13
+
3
14
  ## [0.21.0](https://github.com/WeAreHausTech/haus-workflow/compare/v0.20.0...v0.21.0) (2026-06-11)
4
15
 
5
16
  ### Features
package/README.md CHANGED
@@ -34,12 +34,14 @@ The `project:*` tasks act on the current repo. The unprefixed verbs (`update`,
34
34
  (`~/.claude` + npm), like `npm install -g`. The short legacy names still work.
35
35
 
36
36
  ```
37
- /haus-workflow # interactive menu — pick a task
38
- /haus-workflow project:init # [project] add haus to an EXISTING repo — AI skills, commands, workflow + docs
39
- /haus-workflow project:refresh # [project] refresh .claude/ and regenerate CLAUDE.md imports
40
- /haus-workflow project:doctor # [project] health check for drift
41
- /haus-workflow update # [global] update npm package + catalog + ~/.claude/
42
- /haus-workflow catalog # [global] fetch only the latest catalog
37
+ /haus-workflow # interactive menu — pick a task
38
+ /haus-workflow project:init # [project] add haus to an EXISTING repo — AI skills, commands, workflow + docs
39
+ /haus-workflow project:clone [name] # [project] clone a workspace's repos (repos.manifest.json), or find & clone one repo by name from GitHub
40
+ /haus-workflow project:cloneandsetup [name] # [project] like project:clone, then set up each repo locally (node version, deps, .env)
41
+ /haus-workflow project:refresh # [project] refresh .claude/ and regenerate CLAUDE.md imports
42
+ /haus-workflow project:doctor # [project] health check for drift
43
+ /haus-workflow update # [global] update npm package + catalog + ~/.claude/
44
+ /haus-workflow catalog # [global] fetch only the latest catalog
43
45
  ```
44
46
 
45
47
  Without an argument, the skill presents a menu so you can pick the task. With an argument, it runs immediately.
@@ -63,6 +65,7 @@ Scans the repo, recommends context assets, and writes `.claude/` and `.haus-work
63
65
  ```bash
64
66
  haus init # first-run setup (scan → recommend → apply)
65
67
  haus setup-project # re-run setup on existing project
68
+ haus clone <url> [dir] # clone a single git repo by URL (the primitive project:clone / project:cloneandsetup loop over)
66
69
  haus scan # scan repo and write context-map
67
70
  haus recommend # recommend catalog items (binary eligibility)
68
71
  haus apply --dry-run # preview what would be written
package/dist/cli.js CHANGED
@@ -12,8 +12,66 @@ import fs12 from "fs-extra";
12
12
 
13
13
  // src/catalog/remote-catalog.ts
14
14
  import os from "os";
15
+ import path2 from "path";
16
+ import fs2 from "fs-extra";
17
+
18
+ // src/utils/fs.ts
19
+ import crypto from "crypto";
15
20
  import path from "path";
21
+ import fg from "fast-glob";
16
22
  import fs from "fs-extra";
23
+ async function readJson(file) {
24
+ try {
25
+ return await fs.readJson(file);
26
+ } catch {
27
+ return void 0;
28
+ }
29
+ }
30
+ async function writeJson(file, value) {
31
+ await fs.ensureDir(path.dirname(file));
32
+ await fs.writeFile(file, `${JSON.stringify(value, null, 2)}
33
+ `, "utf8");
34
+ }
35
+ async function pruneEmptyDir(dir) {
36
+ try {
37
+ const entries = await fs.readdir(dir);
38
+ if (entries.length === 0) await fs.remove(dir);
39
+ } catch {
40
+ }
41
+ }
42
+ async function readText(file) {
43
+ try {
44
+ return await fs.readFile(file, "utf8");
45
+ } catch {
46
+ return void 0;
47
+ }
48
+ }
49
+ async function writeText(file, value) {
50
+ await fs.ensureDir(path.dirname(file));
51
+ await fs.writeFile(file, value, "utf8");
52
+ }
53
+ async function listFiles(root, patterns) {
54
+ const files = await fg(patterns, {
55
+ cwd: root,
56
+ dot: true,
57
+ onlyFiles: true,
58
+ ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"]
59
+ });
60
+ return files.sort((a, b) => a.localeCompare(b));
61
+ }
62
+ function hashText(value) {
63
+ return `sha256-${crypto.createHash("sha256").update(value).digest("hex")}`;
64
+ }
65
+ async function mapWithConcurrency(items, fn, concurrency = 24) {
66
+ const size = Number.isFinite(concurrency) ? Math.max(1, Math.floor(concurrency)) : 24;
67
+ const results = new Array(items.length);
68
+ for (let i = 0; i < items.length; i += size) {
69
+ const batch = items.slice(i, i + size);
70
+ const settled = await Promise.all(batch.map((item, j) => fn(item, i + j)));
71
+ for (let j = 0; j < settled.length; j += 1) results[i + j] = settled[j];
72
+ }
73
+ return results;
74
+ }
17
75
 
18
76
  // src/utils/logger.ts
19
77
  var log = (msg, ...args) => {
@@ -28,15 +86,362 @@ var error = (msg, ...args) => {
28
86
 
29
87
  // src/catalog/constants.ts
30
88
  var CATALOG_REPO_URL = "https://raw.githubusercontent.com/wearehaustech/haus-workflow-catalog";
31
- var CATALOG_REF = process.env.HAUS_CATALOG_REF ?? "main";
89
+ var CATALOG_REF = process.env.HAUS_CATALOG_REF;
32
90
  var CATALOG_CACHE_SUBDIR = ".claude/haus/catalog-cache";
33
91
 
92
+ // library/catalog/validation-rules.json
93
+ var validation_rules_default = {
94
+ forbiddenTags: [
95
+ "python",
96
+ "django",
97
+ "go",
98
+ "rust",
99
+ "java",
100
+ "spring",
101
+ "kotlin",
102
+ "swift",
103
+ "android",
104
+ "flutter",
105
+ "dart",
106
+ "c++",
107
+ "perl",
108
+ "defi",
109
+ "trading"
110
+ ],
111
+ bannedAgentPhrases: ["autonomous", "swarm", "delegate", "orchestrat", "marketplace"],
112
+ requiredSkillFrontmatter: ["description"],
113
+ requiredAgentSections: ["## Use when", "## Do not use when", "## Verification"],
114
+ riskyInstallPatterns: [
115
+ { source: "\\bnpx\\s+-y\\b", flags: "i" },
116
+ { source: "\\bnpx\\s+--yes\\b", flags: "i" },
117
+ { source: "\\byarn\\s+dlx\\b", flags: "i" },
118
+ { source: "\\bpnpm\\s+dlx\\b", flags: "i" }
119
+ ],
120
+ allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
121
+ anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
122
+ httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
123
+ placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
124
+ allowedStacks: [
125
+ "haus",
126
+ "security",
127
+ "quality",
128
+ "frontend",
129
+ "backend",
130
+ "testing",
131
+ "review",
132
+ "workflow",
133
+ "reference-pack",
134
+ "core-skill",
135
+ "workflow-skill",
136
+ "stack-skill",
137
+ "review-skill",
138
+ "agent",
139
+ "hook",
140
+ "rule",
141
+ "react",
142
+ "typescript",
143
+ "php",
144
+ "csharp",
145
+ "vendure",
146
+ "vendure3",
147
+ "nestjs",
148
+ "graphql",
149
+ "nx21",
150
+ "turbo",
151
+ "nextjs",
152
+ "react19",
153
+ "typescript5",
154
+ "vite8",
155
+ "tanstack-query",
156
+ "tanstack-router",
157
+ "radix",
158
+ "radix-ui",
159
+ "shadcn",
160
+ "shadcn-ui",
161
+ "tailwind",
162
+ "tailwindcss",
163
+ "scss",
164
+ "scss-modules",
165
+ "vue",
166
+ "expressjs",
167
+ "soup-base",
168
+ "laravel",
169
+ "laravel-nova",
170
+ "wordpress",
171
+ "bedrock",
172
+ "elementor-pro",
173
+ "acf-pro",
174
+ "jetengine",
175
+ "dotnet",
176
+ "oidc",
177
+ "azure-ad",
178
+ "bankid",
179
+ "myid",
180
+ "cgi",
181
+ "crypto",
182
+ "collection2",
183
+ "postgresql",
184
+ "mariadb",
185
+ "mssql",
186
+ "elasticsearch",
187
+ "yarn4",
188
+ "pnpm89",
189
+ "playwright",
190
+ "testing-library",
191
+ "phpunit",
192
+ "storybook",
193
+ "wisest",
194
+ "vitest",
195
+ "jest",
196
+ "redis",
197
+ "sanity",
198
+ "strapi",
199
+ "prisma",
200
+ "cms",
201
+ "database",
202
+ "mysql",
203
+ "saml2",
204
+ "next-auth",
205
+ "auth",
206
+ "expo",
207
+ "react-native",
208
+ "mobile",
209
+ "i18next",
210
+ "i18n",
211
+ "bullmq",
212
+ "queue",
213
+ "sentry",
214
+ "observability",
215
+ "tooling",
216
+ "prettier",
217
+ "eslint",
218
+ "missing-prettier",
219
+ "missing-eslint",
220
+ "docker",
221
+ "pm2",
222
+ "deployer-php",
223
+ "stripe",
224
+ "qliro",
225
+ "supabase",
226
+ "payments"
227
+ ],
228
+ alwaysAllowedTags: [
229
+ "haus",
230
+ "security",
231
+ "quality",
232
+ "review",
233
+ "workflow",
234
+ "baseline",
235
+ "project-instructions"
236
+ ],
237
+ patternTagSuffixes: ["-patterns"]
238
+ };
239
+
240
+ // src/catalog/validation-rules.ts
241
+ var toRegExp = (r) => new RegExp(r.source, r.flags);
242
+ var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
243
+ var BANNED_AGENT_PHRASES = validation_rules_default.bannedAgentPhrases;
244
+ var REQUIRED_SKILL_FRONTMATTER = validation_rules_default.requiredSkillFrontmatter;
245
+ var REQUIRED_AGENT_SECTIONS = validation_rules_default.requiredAgentSections;
246
+ var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
247
+ var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
248
+ var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
249
+ var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
250
+ var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
251
+ var ALLOWED_STACKS = validation_rules_default.allowedStacks;
252
+ var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
253
+ var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
254
+ var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
255
+ function isTagAllowed(tag) {
256
+ const lower = tag.toLowerCase();
257
+ if (ALLOWED_SET.has(lower)) return true;
258
+ return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
259
+ }
260
+ function auditDisallowedTags(items) {
261
+ const failures = [];
262
+ for (const item of items) {
263
+ if (!item.id) continue;
264
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
265
+ if (!isTagAllowed(tag)) failures.push(`${item.id}: tag not in allowlist: "${tag}"`);
266
+ }
267
+ }
268
+ return failures;
269
+ }
270
+
271
+ // src/catalog/forbidden-content.ts
272
+ var PROSE_FORBIDDEN_TAGS = FORBIDDEN_TAGS.filter((t) => t.toLowerCase() !== "go");
273
+ function escapeRegExp(value) {
274
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
275
+ }
276
+ function extractUseWhenSection(text) {
277
+ const marker = "## Use when";
278
+ const idx = text.toLowerCase().indexOf(marker.toLowerCase());
279
+ if (idx < 0) return "";
280
+ const tail = text.slice(idx + marker.length);
281
+ const next = tail.search(/\n##\s+/);
282
+ return next < 0 ? tail : tail.slice(0, next);
283
+ }
284
+ function isYamlBlockScalarHeader(rest) {
285
+ return /^[>|][-+]?(\d+)?(?:\s+#.*)?$/.test(rest);
286
+ }
287
+ function extractFrontmatterValue(text, key) {
288
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
289
+ if (!m) return "";
290
+ const lines = m[1].split(/\r?\n/);
291
+ const keyRe = new RegExp(`^${escapeRegExp(key)}:[ \\t]*`);
292
+ const idx = lines.findIndex((l) => keyRe.test(l));
293
+ if (idx < 0) return "";
294
+ const rest = lines[idx].replace(keyRe, "").trim();
295
+ if (!rest) return "";
296
+ if (!isYamlBlockScalarHeader(rest)) {
297
+ return rest.replace(/^["']|["']$/g, "").trim();
298
+ }
299
+ const body = [];
300
+ for (let j = idx + 1; j < lines.length; j++) {
301
+ const line2 = lines[j];
302
+ if (/^[a-zA-Z_][\w.-]*:[ \t]/.test(line2)) break;
303
+ if (line2.trim() === "") continue;
304
+ if (/^\s+/.test(line2)) body.push(line2.trimStart());
305
+ else break;
306
+ }
307
+ return body.join(" ").replace(/\s+/g, " ").trim();
308
+ }
309
+ function extractFrontmatterDescription(text) {
310
+ return extractFrontmatterValue(text, "description");
311
+ }
312
+ function auditForbiddenTagsInText(text, label) {
313
+ const body = `${extractFrontmatterDescription(text)}
314
+ ${extractUseWhenSection(text)}`;
315
+ if (!body.trim()) return [];
316
+ const failures = [];
317
+ for (const word of PROSE_FORBIDDEN_TAGS) {
318
+ const re = new RegExp(`\\b${escapeRegExp(word)}\\b`, "i");
319
+ if (re.test(body)) failures.push(`${label}: forbidden stack/tag "${word}" in content`);
320
+ }
321
+ return failures;
322
+ }
323
+
324
+ // src/catalog/ingest-catalog.ts
325
+ function validateCatalogItem(item, content2) {
326
+ const label = item.id;
327
+ const lines = content2.split(/\r?\n/);
328
+ for (let i = 0; i < lines.length; i++) {
329
+ const line2 = lines[i] ?? "";
330
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
331
+ return { ok: false, reason: `${label}: risky install pattern at line ${i + 1}` };
332
+ }
333
+ if (ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
334
+ return { ok: false, reason: `${label}: disallowed npx at line ${i + 1}` };
335
+ }
336
+ }
337
+ const lower = content2.toLowerCase();
338
+ for (const phrase of BANNED_AGENT_PHRASES) {
339
+ if (lower.includes(phrase)) {
340
+ return { ok: false, reason: `${label}: banned phrase "${phrase}"` };
341
+ }
342
+ }
343
+ const tagFailures = auditForbiddenTagsInText(content2, label);
344
+ if (tagFailures.length > 0) {
345
+ return { ok: false, reason: tagFailures[0] };
346
+ }
347
+ return { ok: true };
348
+ }
349
+
350
+ // src/catalog/manifest-schema.ts
351
+ var POLLUTION_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
352
+ function isNonEmptyString(value) {
353
+ return typeof value === "string" && value.length > 0;
354
+ }
355
+ function safeParse(json) {
356
+ return JSON.parse(json, (key, value) => {
357
+ if (POLLUTION_KEYS.has(key)) return void 0;
358
+ return value;
359
+ });
360
+ }
361
+ function parseManifest(json) {
362
+ let data;
363
+ try {
364
+ data = safeParse(json);
365
+ } catch {
366
+ return { ok: false, error: "invalid JSON" };
367
+ }
368
+ if (!data || typeof data !== "object") {
369
+ return { ok: false, error: "manifest must be an object" };
370
+ }
371
+ const root = data;
372
+ if (!isNonEmptyString(root.version)) {
373
+ return { ok: false, error: "missing version" };
374
+ }
375
+ if (!Array.isArray(root.items)) {
376
+ return { ok: false, error: "missing items array" };
377
+ }
378
+ const items = [];
379
+ const seenIds = /* @__PURE__ */ new Set();
380
+ const seenPaths = /* @__PURE__ */ new Set();
381
+ for (let i = 0; i < root.items.length; i++) {
382
+ const raw = root.items[i];
383
+ if (!raw || typeof raw !== "object") {
384
+ return { ok: false, error: `item[${i}]: invalid entry` };
385
+ }
386
+ const item = raw;
387
+ if (!isNonEmptyString(item.id)) {
388
+ return { ok: false, error: `item[${i}]: missing id` };
389
+ }
390
+ if (!isNonEmptyString(item.type)) {
391
+ return { ok: false, error: `${item.id}: missing type` };
392
+ }
393
+ if (!isNonEmptyString(item.path)) {
394
+ return { ok: false, error: `${item.id}: missing path` };
395
+ }
396
+ if (seenIds.has(item.id)) {
397
+ return { ok: false, error: `${item.id}: duplicate id` };
398
+ }
399
+ seenIds.add(item.id);
400
+ const normPath = item.path.replace(/\\/g, "/");
401
+ if (seenPaths.has(normPath)) {
402
+ return { ok: false, error: `${item.id}: duplicate path "${normPath}"` };
403
+ }
404
+ seenPaths.add(normPath);
405
+ if (item.source === "curated") {
406
+ if (!isNonEmptyString(item.reviewStatus)) {
407
+ return { ok: false, error: `${item.id}: curated item missing reviewStatus` };
408
+ }
409
+ if (!isNonEmptyString(item.riskLevel)) {
410
+ return { ok: false, error: `${item.id}: curated item missing riskLevel` };
411
+ }
412
+ }
413
+ items.push(raw);
414
+ }
415
+ return { ok: true, manifest: { version: root.version, items } };
416
+ }
417
+
34
418
  // src/catalog/remote-catalog.ts
35
419
  function getCacheDir() {
36
- return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path.join(os.homedir(), CATALOG_CACHE_SUBDIR);
420
+ return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path2.join(os.homedir(), CATALOG_CACHE_SUBDIR);
421
+ }
422
+ var cachedCatalogRef;
423
+ function getResolvedCatalogRef() {
424
+ return cachedCatalogRef ?? process.env["HAUS_CATALOG_REF"] ?? "main";
425
+ }
426
+ function isCatalogRefResolved() {
427
+ return cachedCatalogRef !== void 0 || process.env["HAUS_CATALOG_REF"] !== void 0;
428
+ }
429
+ async function resolveCatalogRef(opts) {
430
+ const env = opts?.env ?? process.env;
431
+ if (env["HAUS_CATALOG_REF"]) return env["HAUS_CATALOG_REF"];
432
+ const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
433
+ const tag = await fetchLatest();
434
+ return tag ?? "main";
435
+ }
436
+ async function remoteBase() {
437
+ if (process.env["HAUS_CATALOG_REMOTE_BASE"]) {
438
+ return process.env["HAUS_CATALOG_REMOTE_BASE"];
439
+ }
440
+ if (cachedCatalogRef === void 0) {
441
+ cachedCatalogRef = await resolveCatalogRef();
442
+ }
443
+ return `${CATALOG_REPO_URL}/${cachedCatalogRef}`;
37
444
  }
38
- var REMOTE_BASE = process.env["HAUS_CATALOG_REMOTE_BASE"] ?? `${CATALOG_REPO_URL}/${CATALOG_REF}`;
39
- var REMOTE_MANIFEST_URL = `${REMOTE_BASE}/manifest.json`;
40
445
  async function fetchText(url) {
41
446
  try {
42
447
  const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
@@ -47,55 +452,59 @@ async function fetchText(url) {
47
452
  }
48
453
  }
49
454
  async function fetchRemoteManifest() {
50
- const text = await fetchText(REMOTE_MANIFEST_URL);
455
+ const base = await remoteBase();
456
+ const text = await fetchText(`${base}/manifest.json`);
51
457
  if (!text) return null;
52
- try {
53
- const data = JSON.parse(text);
54
- return data?.items?.length ? data.items : null;
55
- } catch {
458
+ const parsed = parseManifest(text);
459
+ if (!parsed.ok) {
460
+ warn(`Remote manifest failed schema validation: ${parsed.error}`);
56
461
  return null;
57
462
  }
463
+ if (!parsed.manifest.items.length) return null;
464
+ return parsed.manifest;
58
465
  }
59
466
  async function writeTextIfChanged(dest, text) {
60
- if (await fs.pathExists(dest)) {
61
- const local = await fs.readFile(dest, "utf8");
467
+ if (await fs2.pathExists(dest)) {
468
+ const local = await fs2.readFile(dest, "utf8");
62
469
  if (local === text) return "unchanged";
63
- await fs.writeFile(dest, text, "utf8");
470
+ await fs2.writeFile(dest, text, "utf8");
64
471
  return "updated";
65
472
  }
66
- await fs.ensureDir(path.dirname(dest));
67
- await fs.writeFile(dest, text, "utf8");
473
+ await fs2.ensureDir(path2.dirname(dest));
474
+ await fs2.writeFile(dest, text, "utf8");
68
475
  return "created";
69
476
  }
70
477
  var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
71
478
  async function readWorkflowTemplate(opts = {}) {
72
- const dest = path.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
73
- const text = await fetchText(`${REMOTE_BASE}/${WORKFLOW_TEMPLATE_REL}`);
479
+ const dest = path2.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
480
+ const base = await remoteBase();
481
+ const text = await fetchText(`${base}/${WORKFLOW_TEMPLATE_REL}`);
74
482
  if (text === null) {
75
- if (await fs.pathExists(dest)) return fs.readFile(dest, "utf8");
483
+ if (await fs2.pathExists(dest)) return fs2.readFile(dest, "utf8");
76
484
  return null;
77
485
  }
78
486
  if (!opts.dryRun) {
79
487
  await writeTextIfChanged(dest, text);
80
- } else if (await fs.pathExists(dest)) {
81
- return fs.readFile(dest, "utf8");
488
+ } else if (await fs2.pathExists(dest)) {
489
+ return fs2.readFile(dest, "utf8");
82
490
  }
83
491
  return text;
84
492
  }
85
493
  function isSafeCatalogPath(itemPath) {
86
- if (!itemPath || path.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
87
- const normalized = path.normalize(itemPath);
494
+ if (!itemPath || path2.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
495
+ const normalized = path2.normalize(itemPath);
88
496
  return !normalized.startsWith("..") && !normalized.includes("/..");
89
497
  }
90
498
  function safeJoin(base, itemPath) {
91
499
  if (!isSafeCatalogPath(itemPath)) return null;
92
- const resolved = path.resolve(base, itemPath);
93
- return resolved.startsWith(base + path.sep) || resolved === base ? resolved : null;
500
+ const resolved = path2.resolve(base, itemPath);
501
+ return resolved.startsWith(base + path2.sep) || resolved === base ? resolved : null;
94
502
  }
503
+ var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command"]);
95
504
  function isExternalReference(ref) {
96
505
  return /^[a-z][a-z0-9+.-]*:\/\//i.test(ref);
97
506
  }
98
- async function downloadSkillReferences(item, destDir) {
507
+ async function downloadSkillReferences(item, destDir, base) {
99
508
  for (const ref of item.references ?? []) {
100
509
  if (isExternalReference(ref)) continue;
101
510
  const refDest = safeJoin(destDir, ref);
@@ -103,7 +512,7 @@ async function downloadSkillReferences(item, destDir) {
103
512
  warn(`Skipping reference "${ref}" for ${item.id}: path traversal detected`);
104
513
  continue;
105
514
  }
106
- const text = await fetchText(`${REMOTE_BASE}/${item.path}/${ref}`);
515
+ const text = await fetchText(`${base}/${item.path}/${ref}`);
107
516
  if (text === null) {
108
517
  warn(`Failed to fetch reference "${ref}" for ${item.id}`);
109
518
  continue;
@@ -111,18 +520,79 @@ async function downloadSkillReferences(item, destDir) {
111
520
  await writeTextIfChanged(refDest, text);
112
521
  }
113
522
  }
523
+ async function syncOneItem(item, base) {
524
+ if (!KNOWN_ITEM_TYPES.has(item.type)) {
525
+ warn(
526
+ `Skipping ${item.id}: type "${item.type}" is unknown to this haus version \u2014 upgrade to use it`
527
+ );
528
+ return "failed";
529
+ }
530
+ if (!item.path) return "failed";
531
+ if (!isSafeCatalogPath(item.path)) {
532
+ warn(`Skipping ${item.id}: invalid path "${item.path}"`);
533
+ return "failed";
534
+ }
535
+ if (item.type === "skill") {
536
+ const destDir = safeJoin(getCacheDir(), item.path);
537
+ if (!destDir) {
538
+ warn(`Skipping ${item.id}: path traversal detected`);
539
+ return "failed";
540
+ }
541
+ const dest2 = path2.join(destDir, "SKILL.md");
542
+ const text2 = await fetchText(`${base}/${item.path}/SKILL.md`);
543
+ if (!text2) {
544
+ warn(`Failed to fetch content for ${item.id}`);
545
+ return "failed";
546
+ }
547
+ const verdict2 = validateCatalogItem(item, text2);
548
+ if (!verdict2.ok) {
549
+ warn(`Rejected ${item.id} at ingest: ${verdict2.reason}`);
550
+ return "failed";
551
+ }
552
+ try {
553
+ const outcome = await writeTextIfChanged(dest2, text2);
554
+ await downloadSkillReferences(item, destDir, base);
555
+ return outcome;
556
+ } catch (err) {
557
+ warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
558
+ return "failed";
559
+ }
560
+ }
561
+ const dest = safeJoin(getCacheDir(), item.path);
562
+ if (!dest) {
563
+ warn(`Skipping ${item.id}: path traversal detected`);
564
+ return "failed";
565
+ }
566
+ const text = await fetchText(`${base}/${item.path}`);
567
+ if (!text) {
568
+ warn(`Failed to fetch content for ${item.id}`);
569
+ return "failed";
570
+ }
571
+ const verdict = validateCatalogItem(item, text);
572
+ if (!verdict.ok) {
573
+ warn(`Rejected ${item.id} at ingest: ${verdict.reason}`);
574
+ return "failed";
575
+ }
576
+ try {
577
+ return await writeTextIfChanged(dest, text);
578
+ } catch (err) {
579
+ warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
580
+ return "failed";
581
+ }
582
+ }
114
583
  async function syncRemoteCatalog() {
115
- const items = await fetchRemoteManifest();
116
- if (!items) {
584
+ const manifest = await fetchRemoteManifest();
585
+ if (!manifest) {
117
586
  warn("Remote catalog fetch failed \u2014 using bundled catalog");
118
587
  return { newItems: [], refreshed: [], unchanged: 0, failed: [] };
119
588
  }
589
+ const { version, items } = manifest;
120
590
  const cacheDir = getCacheDir();
121
591
  try {
122
- await fs.ensureDir(cacheDir);
123
- await fs.writeFile(
124
- path.join(cacheDir, "manifest.json"),
125
- `${JSON.stringify({ items }, null, 2)}
592
+ await fs2.ensureDir(cacheDir);
593
+ await fs2.writeFile(
594
+ path2.join(cacheDir, "manifest.json"),
595
+ `${JSON.stringify({ version, items }, null, 2)}
126
596
  `,
127
597
  "utf8"
128
598
  );
@@ -136,63 +606,15 @@ async function syncRemoteCatalog() {
136
606
  const refreshed = [];
137
607
  let unchanged = 0;
138
608
  const failed = [];
139
- for (const item of items) {
140
- if (item.type !== "skill" && item.type !== "agent" && item.type !== "template" && item.type !== "command" || !item.path)
141
- continue;
142
- if (!isSafeCatalogPath(item.path)) {
143
- warn(`Skipping ${item.id}: invalid path "${item.path}"`);
144
- failed.push(item.id);
145
- continue;
146
- }
147
- if (item.type === "skill") {
148
- const destDir = safeJoin(getCacheDir(), item.path);
149
- if (!destDir) {
150
- warn(`Skipping ${item.id}: path traversal detected`);
151
- failed.push(item.id);
152
- continue;
153
- }
154
- const dest = path.join(destDir, "SKILL.md");
155
- const url = `${REMOTE_BASE}/${item.path}/SKILL.md`;
156
- const text = await fetchText(url);
157
- if (!text) {
158
- warn(`Failed to fetch content for ${item.id}`);
159
- failed.push(item.id);
160
- continue;
161
- }
162
- try {
163
- const outcome = await writeTextIfChanged(dest, text);
164
- await downloadSkillReferences(item, destDir);
165
- if (outcome === "created") newItems.push(item.id);
166
- else if (outcome === "updated") refreshed.push(item.id);
167
- else unchanged++;
168
- } catch (err) {
169
- warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
170
- failed.push(item.id);
171
- }
172
- } else {
173
- const dest = safeJoin(getCacheDir(), item.path);
174
- if (!dest) {
175
- warn(`Skipping ${item.id}: path traversal detected`);
176
- failed.push(item.id);
177
- continue;
178
- }
179
- const url = `${REMOTE_BASE}/${item.path}`;
180
- const text = await fetchText(url);
181
- if (!text) {
182
- warn(`Failed to fetch content for ${item.id}`);
183
- failed.push(item.id);
184
- continue;
185
- }
186
- try {
187
- const outcome = await writeTextIfChanged(dest, text);
188
- if (outcome === "created") newItems.push(item.id);
189
- else if (outcome === "updated") refreshed.push(item.id);
190
- else unchanged++;
191
- } catch (err) {
192
- warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
193
- failed.push(item.id);
194
- }
195
- }
609
+ const base = await remoteBase();
610
+ const outcomes = await mapWithConcurrency(items, (item) => syncOneItem(item, base), 8);
611
+ for (let i = 0; i < items.length; i++) {
612
+ const item = items[i];
613
+ const outcome = outcomes[i];
614
+ if (outcome === "created") newItems.push(item.id);
615
+ else if (outcome === "updated") refreshed.push(item.id);
616
+ else if (outcome === "unchanged") unchanged++;
617
+ else if (outcome === "failed") failed.push(item.id);
196
618
  }
197
619
  return { newItems, refreshed, unchanged, failed };
198
620
  }
@@ -213,7 +635,7 @@ async function fetchLatestCatalogTag() {
213
635
  }
214
636
  async function getCacheManifestAge() {
215
637
  try {
216
- const stat = await fs.stat(path.join(getCacheDir(), "manifest.json"));
638
+ const stat = await fs2.stat(path2.join(getCacheDir(), "manifest.json"));
217
639
  return Date.now() - stat.mtimeMs;
218
640
  } catch {
219
641
  return null;
@@ -237,64 +659,6 @@ function buildAllowRules() {
237
659
  import path4 from "path";
238
660
  import fs3 from "fs-extra";
239
661
 
240
- // src/utils/fs.ts
241
- import crypto from "crypto";
242
- import path2 from "path";
243
- import fg from "fast-glob";
244
- import fs2 from "fs-extra";
245
- async function readJson(file) {
246
- try {
247
- return await fs2.readJson(file);
248
- } catch {
249
- return void 0;
250
- }
251
- }
252
- async function writeJson(file, value) {
253
- await fs2.ensureDir(path2.dirname(file));
254
- await fs2.writeFile(file, `${JSON.stringify(value, null, 2)}
255
- `, "utf8");
256
- }
257
- async function pruneEmptyDir(dir) {
258
- try {
259
- const entries = await fs2.readdir(dir);
260
- if (entries.length === 0) await fs2.remove(dir);
261
- } catch {
262
- }
263
- }
264
- async function readText(file) {
265
- try {
266
- return await fs2.readFile(file, "utf8");
267
- } catch {
268
- return void 0;
269
- }
270
- }
271
- async function writeText(file, value) {
272
- await fs2.ensureDir(path2.dirname(file));
273
- await fs2.writeFile(file, value, "utf8");
274
- }
275
- async function listFiles(root, patterns) {
276
- const files = await fg(patterns, {
277
- cwd: root,
278
- dot: true,
279
- onlyFiles: true,
280
- ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"]
281
- });
282
- return files.sort((a, b) => a.localeCompare(b));
283
- }
284
- function hashText(value) {
285
- return `sha256-${crypto.createHash("sha256").update(value).digest("hex")}`;
286
- }
287
- async function mapWithConcurrency(items, fn, concurrency = 24) {
288
- const size = Number.isFinite(concurrency) ? Math.max(1, Math.floor(concurrency)) : 24;
289
- const results = new Array(items.length);
290
- for (let i = 0; i < items.length; i += size) {
291
- const batch = items.slice(i, i + size);
292
- const settled = await Promise.all(batch.map((item, j) => fn(item, i + j)));
293
- for (let j = 0; j < settled.length; j += 1) results[i + j] = settled[j];
294
- }
295
- return results;
296
- }
297
-
298
662
  // src/install/manifest.ts
299
663
  import os2 from "os";
300
664
  import path3 from "path";
@@ -1196,6 +1560,13 @@ ${templateContent}`;
1196
1560
  }
1197
1561
 
1198
1562
  // src/claude/write-claude-files.ts
1563
+ function targetDirForType(type) {
1564
+ if (type === "agent") return "agents";
1565
+ if (type === "template") return "templates";
1566
+ if (type === "command") return "commands";
1567
+ if (type === "skill") return "skills";
1568
+ return null;
1569
+ }
1199
1570
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
1200
1571
  const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
1201
1572
  mode: "fast",
@@ -1272,14 +1643,18 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
1272
1643
  const installedPathsByItem = /* @__PURE__ */ new Map();
1273
1644
  const installedIds = /* @__PURE__ */ new Set();
1274
1645
  const catalogItems = selectedIds !== void 0 ? rec.recommended.filter((r) => selectedIds.includes(r.id)) : rec.recommended;
1646
+ let curatedReviewStatusSkips = 0;
1275
1647
  for (const item of catalogItems) {
1276
1648
  const manifestItem = manifestById.get(item.id);
1277
1649
  if (!manifestItem?.path) continue;
1278
1650
  if (manifestItem.source === "curated") {
1279
1651
  if (manifestItem.reviewStatus !== "approved") {
1280
- warn(
1281
- `Skipping curated item ${item.id}: reviewStatus is not approved (${manifestItem.reviewStatus ?? "unset"})`
1282
- );
1652
+ curatedReviewStatusSkips++;
1653
+ if (curatedReviewStatusSkips === 1) {
1654
+ warn(
1655
+ `Skipping curated item ${item.id}: reviewStatus is not approved (${manifestItem.reviewStatus ?? "unset"})`
1656
+ );
1657
+ }
1283
1658
  continue;
1284
1659
  }
1285
1660
  if (manifestItem.riskLevel === "blocked") {
@@ -1288,7 +1663,13 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
1288
1663
  }
1289
1664
  }
1290
1665
  const sourcePath = catalogItemContentPath(contentRoot, manifestItem);
1291
- const target = item.type === "agent" ? "agents" : item.type === "template" ? "templates" : item.type === "command" ? "commands" : "skills";
1666
+ const target = targetDirForType(item.type);
1667
+ if (!target) {
1668
+ warn(
1669
+ `Skipping ${item.id}: type "${item.type}" is unknown to this haus version \u2014 upgrade the CLI to use it`
1670
+ );
1671
+ continue;
1672
+ }
1292
1673
  const destination = claudePath(root, target, path12.basename(sourcePath));
1293
1674
  if (await fs11.pathExists(sourcePath)) {
1294
1675
  if (dryRun) {
@@ -1310,9 +1691,19 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
1310
1691
  );
1311
1692
  }
1312
1693
  }
1694
+ if (curatedReviewStatusSkips > 1) {
1695
+ warn(
1696
+ `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
1697
+ );
1698
+ }
1313
1699
  await cleanupStaleCatalogItems(root, new Set(manifestById.keys()), dryRun);
1314
1700
  if (dryRun) return [...new Set(files)];
1315
1701
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
1702
+ const prevLock = await readJson(hausPath(root, "haus.lock.json"));
1703
+ const prevRefById = new Map(
1704
+ (prevLock ?? []).filter((e) => e.id && e.catalogRef).map((e) => [e.id, e.catalogRef])
1705
+ );
1706
+ const lockCatalogRef = (itemId) => isCatalogRefResolved() ? getResolvedCatalogRef() : prevRefById.get(itemId) ?? getResolvedCatalogRef();
1316
1707
  await writeManagedJson(
1317
1708
  root,
1318
1709
  hausPath(root, "selected-context.json"),
@@ -1334,7 +1725,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
1334
1725
  type: r.type,
1335
1726
  source: isCurated ? "curated" : "haus",
1336
1727
  version: hausVersion2,
1337
- catalogRef: CATALOG_REF,
1728
+ catalogRef: lockCatalogRef(r.id),
1338
1729
  hash: await hashInstalledPaths(root, relPaths),
1339
1730
  installMode: "copied",
1340
1731
  paths: relPaths
@@ -1437,9 +1828,14 @@ async function runApply(options) {
1437
1828
  const rec = await readJson(hausPath(root, "recommendation.json"));
1438
1829
  const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : rec?.recommended.length ?? 0;
1439
1830
  if (catalogItemCount > 0 && !await cacheHasItems()) {
1440
- warn(
1441
- isDryRun ? "Catalog cache is empty \u2014 `haus apply --write` will skip catalog items. Run `haus update` first." : "Catalog cache is empty \u2014 catalog items will be skipped. Run `haus update` first, or pass --allow-empty-cache to silence this warning."
1442
- );
1831
+ const message = "No catalog content found. Run `haus update` first.";
1832
+ if (isDryRun) {
1833
+ warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message}`);
1834
+ } else {
1835
+ error(message);
1836
+ process.exitCode = 1;
1837
+ return;
1838
+ }
1443
1839
  }
1444
1840
  }
1445
1841
  const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
@@ -1466,185 +1862,6 @@ async function refreshProjectApply(root) {
1466
1862
  return writeClaudeFiles(root, false, void 0, { refillConfig: false });
1467
1863
  }
1468
1864
 
1469
- // library/catalog/validation-rules.json
1470
- var validation_rules_default = {
1471
- forbiddenTags: [
1472
- "python",
1473
- "django",
1474
- "go",
1475
- "rust",
1476
- "java",
1477
- "spring",
1478
- "kotlin",
1479
- "swift",
1480
- "android",
1481
- "flutter",
1482
- "dart",
1483
- "c++",
1484
- "perl",
1485
- "defi",
1486
- "trading"
1487
- ],
1488
- bannedAgentPhrases: ["autonomous", "swarm", "delegate", "orchestrat", "marketplace"],
1489
- requiredSkillFrontmatter: ["description"],
1490
- requiredAgentSections: ["## Use when", "## Do not use when", "## Verification"],
1491
- riskyInstallPatterns: [
1492
- { source: "\\bnpx\\s+-y\\b", flags: "i" },
1493
- { source: "\\bnpx\\s+--yes\\b", flags: "i" },
1494
- { source: "\\byarn\\s+dlx\\b", flags: "i" },
1495
- { source: "\\bpnpm\\s+dlx\\b", flags: "i" }
1496
- ],
1497
- allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
1498
- anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
1499
- httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
1500
- placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
1501
- allowedStacks: [
1502
- "haus",
1503
- "security",
1504
- "quality",
1505
- "frontend",
1506
- "backend",
1507
- "testing",
1508
- "review",
1509
- "workflow",
1510
- "reference-pack",
1511
- "core-skill",
1512
- "workflow-skill",
1513
- "stack-skill",
1514
- "review-skill",
1515
- "agent",
1516
- "hook",
1517
- "rule",
1518
- "react",
1519
- "typescript",
1520
- "php",
1521
- "csharp",
1522
- "vendure",
1523
- "vendure3",
1524
- "nestjs",
1525
- "graphql",
1526
- "nx21",
1527
- "turbo",
1528
- "nextjs",
1529
- "react19",
1530
- "typescript5",
1531
- "vite8",
1532
- "tanstack-query",
1533
- "tanstack-router",
1534
- "radix",
1535
- "radix-ui",
1536
- "shadcn",
1537
- "shadcn-ui",
1538
- "tailwind",
1539
- "tailwindcss",
1540
- "scss",
1541
- "scss-modules",
1542
- "vue",
1543
- "expressjs",
1544
- "soup-base",
1545
- "laravel",
1546
- "laravel-nova",
1547
- "wordpress",
1548
- "bedrock",
1549
- "elementor-pro",
1550
- "acf-pro",
1551
- "jetengine",
1552
- "dotnet",
1553
- "oidc",
1554
- "azure-ad",
1555
- "bankid",
1556
- "myid",
1557
- "cgi",
1558
- "crypto",
1559
- "collection2",
1560
- "postgresql",
1561
- "mariadb",
1562
- "mssql",
1563
- "elasticsearch",
1564
- "yarn4",
1565
- "pnpm89",
1566
- "playwright",
1567
- "testing-library",
1568
- "phpunit",
1569
- "storybook",
1570
- "wisest",
1571
- "vitest",
1572
- "jest",
1573
- "redis",
1574
- "sanity",
1575
- "strapi",
1576
- "prisma",
1577
- "cms",
1578
- "database",
1579
- "mysql",
1580
- "saml2",
1581
- "next-auth",
1582
- "auth",
1583
- "expo",
1584
- "react-native",
1585
- "mobile",
1586
- "i18next",
1587
- "i18n",
1588
- "bullmq",
1589
- "queue",
1590
- "sentry",
1591
- "observability",
1592
- "tooling",
1593
- "prettier",
1594
- "eslint",
1595
- "missing-prettier",
1596
- "missing-eslint",
1597
- "docker",
1598
- "pm2",
1599
- "deployer-php",
1600
- "stripe",
1601
- "qliro",
1602
- "supabase",
1603
- "payments"
1604
- ],
1605
- alwaysAllowedTags: [
1606
- "haus",
1607
- "security",
1608
- "quality",
1609
- "review",
1610
- "workflow",
1611
- "baseline",
1612
- "project-instructions"
1613
- ],
1614
- patternTagSuffixes: ["-patterns"]
1615
- };
1616
-
1617
- // src/catalog/validation-rules.ts
1618
- var toRegExp = (r) => new RegExp(r.source, r.flags);
1619
- var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
1620
- var BANNED_AGENT_PHRASES = validation_rules_default.bannedAgentPhrases;
1621
- var REQUIRED_SKILL_FRONTMATTER = validation_rules_default.requiredSkillFrontmatter;
1622
- var REQUIRED_AGENT_SECTIONS = validation_rules_default.requiredAgentSections;
1623
- var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
1624
- var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
1625
- var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
1626
- var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
1627
- var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
1628
- var ALLOWED_STACKS = validation_rules_default.allowedStacks;
1629
- var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
1630
- var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
1631
- var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
1632
- function isTagAllowed(tag) {
1633
- const lower = tag.toLowerCase();
1634
- if (ALLOWED_SET.has(lower)) return true;
1635
- return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
1636
- }
1637
- function auditDisallowedTags(items) {
1638
- const failures = [];
1639
- for (const item of items) {
1640
- if (!item.id) continue;
1641
- for (const tag of Array.isArray(item.tags) ? item.tags : []) {
1642
- if (!isTagAllowed(tag)) failures.push(`${item.id}: tag not in allowlist: "${tag}"`);
1643
- }
1644
- }
1645
- return failures;
1646
- }
1647
-
1648
1865
  // src/commands/catalog-audit.ts
1649
1866
  function auditForbiddenTags(items) {
1650
1867
  const failures = [];
@@ -1758,6 +1975,17 @@ async function runConfig(key, action) {
1758
1975
  log(`${key} ${action}d`);
1759
1976
  }
1760
1977
 
1978
+ // src/recommender/token-estimate.ts
1979
+ var TOKENS_PER_ITEM = 320;
1980
+ function estimateContextTokens(selectedCount) {
1981
+ return selectedCount * TOKENS_PER_ITEM;
1982
+ }
1983
+ function tokenReductionPct(selected, skipped) {
1984
+ const total = selected + skipped;
1985
+ if (total === 0) return 0;
1986
+ return Math.max(0, Math.round(skipped / total * 100));
1987
+ }
1988
+
1761
1989
  // src/recommender/explain-recommendation.ts
1762
1990
  function normalizeRecommendation(input2) {
1763
1991
  const recommended = (input2.recommended ?? []).map((item) => {
@@ -1792,13 +2020,10 @@ function normalizeRecommendation(input2) {
1792
2020
  recommended,
1793
2021
  skipped,
1794
2022
  warnings: input2.warnings ?? [],
1795
- estimatedContextTokens: input2.estimatedContextTokens ?? recommended.length * 320,
2023
+ estimatedContextTokens: input2.estimatedContextTokens ?? estimateContextTokens(recommended.length),
1796
2024
  selectedRules: input2.selectedRules ?? recommended.length,
1797
2025
  skippedRules: input2.skippedRules ?? skipped.length,
1798
- estimatedTokenReductionPct: input2.estimatedTokenReductionPct ?? Math.max(
1799
- 0,
1800
- Math.round(skipped.length / Math.max(recommended.length + skipped.length, 1) * 100)
1801
- )
2026
+ estimatedTokenReductionPct: input2.estimatedTokenReductionPct ?? tokenReductionPct(recommended.length, skipped.length)
1802
2027
  };
1803
2028
  }
1804
2029
  function buildRecommendationExplanation(recommendation) {
@@ -3236,7 +3461,9 @@ async function recommend(root, context) {
3236
3461
  (t) => goals.includes(t) || goals.includes(t.replace(/-/g, " "))
3237
3462
  );
3238
3463
  if (goalMatch) push("goal-match", "guided goal match", `goal:${goalMatch}`);
3239
- if (item.tags.includes(context.packageManager) || item.tags.includes(`${context.packageManager}4`) || item.tags.includes(`${context.packageManager}89`)) {
3464
+ const pm = context.packageManager;
3465
+ const pmVersionedMatch = pm === "yarn" || pm === "pnpm" ? item.tags.includes(pm) || item.tags.includes(`${pm}4`) || item.tags.includes(`${pm}89`) : item.tags.includes(pm);
3466
+ if (pmVersionedMatch) {
3240
3467
  push(
3241
3468
  "package-manager-match",
3242
3469
  "package manager match",
@@ -3287,13 +3514,10 @@ async function recommend(root, context) {
3287
3514
  }
3288
3515
  recommended.sort((a, b) => a.id.localeCompare(b.id));
3289
3516
  skipped.sort((a, b) => a.id.localeCompare(b.id));
3290
- const estimatedContextTokens = recommended.length * 320;
3291
3517
  const selectedRules = recommended.length;
3292
3518
  const skippedRules = skipped.length;
3293
- const estimatedTokenReductionPct = Math.max(
3294
- 0,
3295
- Math.round(skippedRules / Math.max(selectedRules + skippedRules, 1) * 100)
3296
- );
3519
+ const estimatedContextTokens = estimateContextTokens(selectedRules);
3520
+ const estimatedTokenReductionPct = tokenReductionPct(selectedRules, skippedRules);
3297
3521
  return {
3298
3522
  mode: context.mode,
3299
3523
  recommended,
@@ -4167,61 +4391,6 @@ async function detectGlobalInstallDrift() {
4167
4391
  // src/commands/validate-catalog.ts
4168
4392
  import fs19 from "fs";
4169
4393
  import path27 from "path";
4170
-
4171
- // src/catalog/forbidden-content.ts
4172
- var PROSE_FORBIDDEN_TAGS = FORBIDDEN_TAGS.filter((t) => t.toLowerCase() !== "go");
4173
- function escapeRegExp(value) {
4174
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4175
- }
4176
- function extractUseWhenSection(text) {
4177
- const marker = "## Use when";
4178
- const idx = text.toLowerCase().indexOf(marker.toLowerCase());
4179
- if (idx < 0) return "";
4180
- const tail = text.slice(idx + marker.length);
4181
- const next = tail.search(/\n##\s+/);
4182
- return next < 0 ? tail : tail.slice(0, next);
4183
- }
4184
- function isYamlBlockScalarHeader(rest) {
4185
- return /^[>|][-+]?(\d+)?(?:\s+#.*)?$/.test(rest);
4186
- }
4187
- function extractFrontmatterValue(text, key) {
4188
- const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
4189
- if (!m) return "";
4190
- const lines = m[1].split(/\r?\n/);
4191
- const keyRe = new RegExp(`^${escapeRegExp(key)}:[ \\t]*`);
4192
- const idx = lines.findIndex((l) => keyRe.test(l));
4193
- if (idx < 0) return "";
4194
- const rest = lines[idx].replace(keyRe, "").trim();
4195
- if (!rest) return "";
4196
- if (!isYamlBlockScalarHeader(rest)) {
4197
- return rest.replace(/^["']|["']$/g, "").trim();
4198
- }
4199
- const body = [];
4200
- for (let j = idx + 1; j < lines.length; j++) {
4201
- const line2 = lines[j];
4202
- if (/^[a-zA-Z_][\w.-]*:[ \t]/.test(line2)) break;
4203
- if (line2.trim() === "") continue;
4204
- if (/^\s+/.test(line2)) body.push(line2.trimStart());
4205
- else break;
4206
- }
4207
- return body.join(" ").replace(/\s+/g, " ").trim();
4208
- }
4209
- function extractFrontmatterDescription(text) {
4210
- return extractFrontmatterValue(text, "description");
4211
- }
4212
- function auditForbiddenTagsInText(text, label) {
4213
- const body = `${extractFrontmatterDescription(text)}
4214
- ${extractUseWhenSection(text)}`;
4215
- if (!body.trim()) return [];
4216
- const failures = [];
4217
- for (const word of PROSE_FORBIDDEN_TAGS) {
4218
- const re = new RegExp(`\\b${escapeRegExp(word)}\\b`, "i");
4219
- if (re.test(body)) failures.push(`${label}: forbidden stack/tag "${word}" in content`);
4220
- }
4221
- return failures;
4222
- }
4223
-
4224
- // src/commands/validate-catalog.ts
4225
4394
  function auditForbiddenStacks(items) {
4226
4395
  const failures = [];
4227
4396
  for (const item of items) {
@@ -26,7 +26,9 @@ For each repo directory, run its setup **in that directory**, detecting what's n
26
26
  bash -lc 'export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; cd "<repo>" && nvm install && corepack enable && yarn install'
27
27
  ```
28
28
 
29
- Adjust per repo:
29
+ **Read the repo's own setup docs first — they win.** Before applying the file-heuristics below, look for the repo's canonical setup instructions: `docs/setup.md`, `CLAUDE.md`, `README.md` (or follow `docs/SUMMARY.md` to the setup page). If present, **follow them as authoritative** — they capture nested or non-standard builds a root file-scan can't see. Use the heuristics below only to fill gaps, or when the repo ships no setup doc. Example: a WordPress/Bedrock repo has **no root `package.json`** — its JS/theme build lives under `web/app/themes/<theme>` and is only described in the docs, so a root-only scan wrongly reports "no JS". When the docs point at a nested build, scan subdirectories for the relevant `package.json` / build script and run it.
30
+
31
+ Adjust per repo (gap-fill, or when no setup doc exists):
30
32
 
31
33
  1. **Node version.** If `.nvmrc` (or `engines.node` in `package.json`) is present, select it with `nvm install` (reads `.nvmrc`, installs the version if missing, then switches to it). If the user uses `fnm` instead, `fnm use --install-if-missing`. If neither is available, tell the user the required version and continue on the current node.
32
34
  2. **JS dependencies.** Enable the pinned package manager with `corepack enable`, then install based on what's present:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haus-tech/haus-workflow",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Haus AI workflow CLI for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {