@haus-tech/haus-workflow 0.28.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { readFileSync as readFileSync4 } from "node:fs";
5
- import path35 from "node:path";
5
+ import path36 from "node:path";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/commands/apply.ts
@@ -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] ?? "";
@@ -637,7 +659,7 @@ function safeJoin(base, itemPath) {
637
659
  const resolved = path2.resolve(base, itemPath);
638
660
  return resolved.startsWith(base + path2.sep) || resolved === base ? resolved : null;
639
661
  }
640
- var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command"]);
662
+ var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
641
663
  function isMarkdownPath(rel) {
642
664
  return rel.toLowerCase().endsWith(".md");
643
665
  }
@@ -782,12 +804,13 @@ async function writeFetchedDirectory(destDir, fetched) {
782
804
  }
783
805
  }
784
806
  async function syncDirectoryFromPrefix(item, catalogPrefix, destDir, base, opts) {
785
- const relFiles = await listFilesUnderCatalogPrefix(catalogPrefix, base);
807
+ const relFiles = opts.relFiles ?? await listFilesUnderCatalogPrefix(catalogPrefix, base);
786
808
  if (!relFiles) {
787
809
  warn(`Failed to list files for ${item.id}`);
788
810
  return "failed";
789
811
  }
790
- if (!relFiles.includes("SKILL.md") && catalogPrefix !== SUPERPOWERS_SHARED_CATALOG_REL) {
812
+ const requireSkillMd = opts.requireSkillMd ?? true;
813
+ if (requireSkillMd && !relFiles.includes("SKILL.md") && catalogPrefix !== SUPERPOWERS_SHARED_CATALOG_REL) {
791
814
  warn(`Failed to fetch content for ${item.id}: missing SKILL.md`);
792
815
  return "failed";
793
816
  }
@@ -802,7 +825,12 @@ async function syncDirectoryFromPrefix(item, catalogPrefix, destDir, base, opts)
802
825
  for (const file of fetched) {
803
826
  if (file.kind !== "text" || !isMarkdownPath(file.rel)) continue;
804
827
  const verdict = validateCatalogItem(
805
- { 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
+ },
806
834
  file.body
807
835
  );
808
836
  if (!verdict.ok) {
@@ -833,6 +861,42 @@ async function syncSkillDirectory(item, base) {
833
861
  return "failed";
834
862
  }
835
863
  }
864
+ async function syncConfigItem(item, base) {
865
+ const dest = safeJoin(getCacheDir(), item.path);
866
+ if (!dest) {
867
+ warn(`Skipping ${item.id}: path traversal detected`);
868
+ return "failed";
869
+ }
870
+ const relFiles = await listFilesUnderCatalogPrefix(item.path, base);
871
+ if (relFiles && relFiles.length > 0) {
872
+ try {
873
+ return await syncDirectoryFromPrefix(item, item.path, dest, base, {
874
+ validateMarkdown: false,
875
+ requireSkillMd: false,
876
+ relFiles
877
+ });
878
+ } catch (err) {
879
+ warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
880
+ return "failed";
881
+ }
882
+ }
883
+ const text = await fetchText(`${base}/${item.path}`);
884
+ if (text === null) {
885
+ warn(`Failed to fetch content for ${item.id}`);
886
+ return "failed";
887
+ }
888
+ const verdict = validateCatalogItem(item, text);
889
+ if (!verdict.ok) {
890
+ warn(`Rejected ${item.id} at ingest: ${verdict.reason}`);
891
+ return "failed";
892
+ }
893
+ try {
894
+ return await writeTextIfChanged(dest, text);
895
+ } catch (err) {
896
+ warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
897
+ return "failed";
898
+ }
899
+ }
836
900
  async function syncSuperpowersShared(base) {
837
901
  const relFiles = await listFilesUnderCatalogPrefix(SUPERPOWERS_SHARED_CATALOG_REL, base);
838
902
  if (!relFiles || relFiles.length === 0) return "skipped";
@@ -866,6 +930,9 @@ async function syncOneItem(item, base) {
866
930
  if (item.type === "skill") {
867
931
  return syncSkillDirectory(item, base);
868
932
  }
933
+ if (item.type === "config") {
934
+ return syncConfigItem(item, base);
935
+ }
869
936
  const dest = safeJoin(getCacheDir(), item.path);
870
937
  if (!dest) {
871
938
  warn(`Skipping ${item.id}: path traversal detected`);
@@ -1593,8 +1660,8 @@ function buildDenyRules() {
1593
1660
  for (const command of DENY_COMMANDS) {
1594
1661
  rules.push(`Bash(${command}:*)`);
1595
1662
  }
1596
- for (const path36 of DENY_PATHS) {
1597
- const pattern = DENY_DIRS.has(path36) ? `${path36}/**` : path36;
1663
+ for (const path37 of DENY_PATHS) {
1664
+ const pattern = DENY_DIRS.has(path37) ? `${path37}/**` : path37;
1598
1665
  for (const tool of FILE_TOOLS) {
1599
1666
  rules.push(`${tool}(${pattern})`);
1600
1667
  }
@@ -2202,6 +2269,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2202
2269
  continue;
2203
2270
  }
2204
2271
  }
2272
+ if (item.type === "config") continue;
2205
2273
  const sourcePath = catalogItemContentPath(contentRoot, manifestItem);
2206
2274
  const target = targetDirForType(item.type);
2207
2275
  if (!target) {
@@ -2250,7 +2318,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2250
2318
  `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
2251
2319
  );
2252
2320
  }
2253
- await cleanupStaleCatalogItems(root, new Set(manifestById.keys()), dryRun);
2321
+ await cleanupStaleCatalogItems(root, manifestById, dryRun);
2254
2322
  if (dryRun) return [...new Set(files)];
2255
2323
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
2256
2324
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
@@ -2287,11 +2355,15 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2287
2355
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
2288
2356
  return [...new Set(files)];
2289
2357
  }
2290
- async function cleanupStaleCatalogItems(root, knownIds, dryRun) {
2358
+ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
2291
2359
  const prevLock = await readJson(hausPath(root, "haus.lock.json"));
2292
2360
  if (!prevLock?.length) return;
2293
2361
  for (const entry of prevLock) {
2294
- 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;
2295
2367
  const relPaths = entry.paths ?? [];
2296
2368
  if (relPaths.length === 0) continue;
2297
2369
  const existing = [];
@@ -2299,28 +2371,29 @@ async function cleanupStaleCatalogItems(root, knownIds, dryRun) {
2299
2371
  if (await fs12.pathExists(path12.join(root, rel))) existing.push(rel);
2300
2372
  }
2301
2373
  if (existing.length === 0) continue;
2374
+ const pruneReason = deprecated ? "deprecated" : "stale";
2302
2375
  if (entry.hash === void 0) {
2303
2376
  warn(
2304
- `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(", ")}`
2305
2378
  );
2306
2379
  continue;
2307
2380
  }
2308
2381
  const currentHash = await hashInstalledPaths(root, relPaths);
2309
2382
  if (currentHash !== entry.hash) {
2310
2383
  warn(
2311
- `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(", ")}`
2312
2385
  );
2313
2386
  continue;
2314
2387
  }
2315
2388
  for (const rel of existing) {
2316
2389
  const abs = path12.join(root, rel);
2317
2390
  if (dryRun) {
2318
- log(`[dry-run] would remove stale ${displayPath(root, abs)} (${entry.id})`);
2391
+ log(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2319
2392
  continue;
2320
2393
  }
2321
2394
  await fs12.remove(abs);
2322
2395
  await pruneEmptyDir(path12.dirname(abs));
2323
- log(`Removed stale ${displayPath(root, abs)} (${entry.id})`);
2396
+ log(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2324
2397
  }
2325
2398
  }
2326
2399
  }
@@ -2330,6 +2403,9 @@ async function cacheHasItems() {
2330
2403
  const data = await readJson(path13.join(getCacheDir(), "manifest.json"));
2331
2404
  return Array.isArray(data?.items) && data.items.length > 0;
2332
2405
  }
2406
+ function installableRecommendedItems(recommended) {
2407
+ return recommended.filter((item) => item.install !== false);
2408
+ }
2333
2409
  async function runApply(options) {
2334
2410
  if (!options.dryRun && !options.write) {
2335
2411
  log("Use --dry-run or --write");
@@ -2348,28 +2424,31 @@ async function runApply(options) {
2348
2424
  if (!rec) {
2349
2425
  log("No recommendation.json found \u2014 run `haus recommend` first. Writing core files only.");
2350
2426
  selectedIds = [];
2351
- } else if (rec.recommended.length === 0) {
2352
- log("Recommendation contains no catalog items. Writing core files only.");
2353
- selectedIds = [];
2354
2427
  } else {
2355
- const items = rec.recommended;
2356
- const choices = items.map((item) => ({
2357
- name: `${item.id} [${item.selectionMode}] \u2014 ${item.reason}`,
2358
- value: item.id,
2359
- checked: true
2360
- }));
2361
- const chosen = await checkbox({
2362
- message: "Select catalog items to apply (space to toggle, enter to confirm):",
2363
- choices,
2364
- pageSize: Math.min(20, items.length + 2)
2365
- });
2366
- selectedIds = chosen;
2367
- log(`Selected ${selectedIds.length} of ${items.length} catalog items.`);
2428
+ const items = installableRecommendedItems(rec.recommended);
2429
+ if (items.length === 0) {
2430
+ log("Recommendation contains no catalog items. Writing core files only.");
2431
+ selectedIds = [];
2432
+ } else {
2433
+ const choices = items.map((item) => ({
2434
+ name: `${item.id} [${item.selectionMode}] \u2014 ${item.reason}`,
2435
+ value: item.id,
2436
+ checked: true
2437
+ }));
2438
+ const chosen = await checkbox({
2439
+ message: "Select catalog items to apply (space to toggle, enter to confirm):",
2440
+ choices,
2441
+ pageSize: Math.min(20, items.length + 2)
2442
+ });
2443
+ selectedIds = chosen;
2444
+ log(`Selected ${selectedIds.length} of ${items.length} catalog items.`);
2445
+ }
2368
2446
  }
2369
2447
  }
2370
2448
  if (!options.allowEmptyCache && !process.env["HAUS_FIXTURE_CATALOG"]) {
2371
2449
  const rec = await readJson(hausPath(root, "recommendation.json"));
2372
- const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : rec?.recommended.length ?? 0;
2450
+ const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
2451
+ const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : installableRecommended.length;
2373
2452
  if (catalogItemCount > 0 && !await cacheHasItems()) {
2374
2453
  const message = "No catalog content found. Run `haus update` first.";
2375
2454
  if (isDryRun) {
@@ -2664,7 +2743,7 @@ var STACK_RULES = [
2664
2743
  { stack: ["tooling", "missing-eslint"], any: [depAbsent("eslint")] },
2665
2744
  {
2666
2745
  stack: ["tooling", "stripe"],
2667
- any: [dep("@stripe/stripe-js"), dep("@stripe/react-stripe-js")]
2746
+ any: [dep("stripe"), dep("@stripe/stripe-js"), dep("@stripe/react-stripe-js")]
2668
2747
  },
2669
2748
  { stack: ["tooling", "qliro"], any: [dep("@haus-tech/qliro-plugin")] },
2670
2749
  {
@@ -3558,6 +3637,19 @@ async function recommend(root, context) {
3558
3637
  );
3559
3638
  continue;
3560
3639
  }
3640
+ if (item.id === "haus.ecc-database-reviewer" && !roleSet.has("database")) {
3641
+ skip(item.id, "required-role-missing", "Required role missing: database", "role:database");
3642
+ continue;
3643
+ }
3644
+ 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"))) {
3645
+ skip(
3646
+ item.id,
3647
+ "co-install-react-reviewer",
3648
+ "Skip typescript-reviewer on React/Next stacks (use react-reviewer)",
3649
+ "stack:react|nextjs|role:react-app|next-app"
3650
+ );
3651
+ continue;
3652
+ }
3561
3653
  const isDefaultBaseline = item.default === true;
3562
3654
  const reasons = [];
3563
3655
  const push = (code, message, signal) => reasons.push({ code, message, signal });
@@ -3602,13 +3694,22 @@ async function recommend(root, context) {
3602
3694
  }
3603
3695
  const hasEvidence = reasons.some((r) => r.code !== "default-baseline");
3604
3696
  if (isDefaultBaseline || hasEvidence) {
3697
+ const isConfig = item.type === "config";
3698
+ const entryReasons = isConfig ? [
3699
+ ...reasons,
3700
+ {
3701
+ code: "config-scaffold",
3702
+ message: "Install with `haus scaffold`",
3703
+ signal: "type:config"
3704
+ }
3705
+ ] : reasons;
3605
3706
  recommended.push({
3606
3707
  id: item.id,
3607
3708
  type: item.type,
3608
- reason: reasons.length ? reasons.map((x) => x.message).join(", ") : "eligible",
3609
- reasons,
3709
+ reason: entryReasons.length ? entryReasons.map((x) => x.message).join(", ") : "eligible",
3710
+ reasons: entryReasons,
3610
3711
  selectionMode: isDefaultBaseline && !hasEvidence ? "baseline" : "matched",
3611
- install: true,
3712
+ install: !isConfig,
3612
3713
  tags: item.tags,
3613
3714
  ecosystem: item.ecosystem,
3614
3715
  tokenEstimate: item.tokenEstimate
@@ -3617,9 +3718,11 @@ async function recommend(root, context) {
3617
3718
  skip(item.id, "no-role-stack-match", "No role/stack match");
3618
3719
  }
3619
3720
  }
3721
+ applyCoInstallSuppression(recommended, skipped);
3620
3722
  recommended.sort((a, b) => a.id.localeCompare(b.id));
3621
3723
  skipped.sort((a, b) => a.id.localeCompare(b.id));
3622
- const selectedRules = recommended.length;
3724
+ const installableRecommended = recommended.filter((item) => item.install);
3725
+ const selectedRules = installableRecommended.length;
3623
3726
  const skippedRules = skipped.length;
3624
3727
  const estimatedContextTokens = estimateContextTokens(selectedRules);
3625
3728
  const estimatedTokenReductionPct = tokenReductionPct(selectedRules, skippedRules);
@@ -3640,6 +3743,78 @@ function buildStackSet(context) {
3640
3743
  )
3641
3744
  );
3642
3745
  }
3746
+ function applyCoInstallSuppression(recommended, skipped) {
3747
+ const recommendedIds = new Set(recommended.map((r) => r.id));
3748
+ const rules = [
3749
+ {
3750
+ suppress: "haus.sentry-sentry-workflow",
3751
+ whenAnyRecommended: [
3752
+ "haus.sentry-sentry-php-sdk",
3753
+ "haus.sentry-sentry-nextjs-sdk",
3754
+ "haus.sentry-sentry-node-sdk",
3755
+ "haus.sentry-sentry-nestjs-sdk"
3756
+ ],
3757
+ code: "co-install-sentry-sdk",
3758
+ message: "Skip sentry-workflow when a stack-specific Sentry SDK skill is selected",
3759
+ signal: "co-install:sentry-sdk"
3760
+ },
3761
+ {
3762
+ suppress: "haus.ecc-e2e-runner",
3763
+ whenAnyRecommended: ["haus.ecc-e2e-testing"],
3764
+ code: "co-install-e2e-skill",
3765
+ message: "Skip e2e-runner when e2e-testing skill covers patterns",
3766
+ signal: "co-install:e2e-testing"
3767
+ },
3768
+ {
3769
+ suppress: "haus.oh-my-claudecode-test-engineer",
3770
+ whenAnyRecommended: ["haus.ecc-e2e-testing", "haus.ecc-e2e-runner"],
3771
+ code: "co-install-e2e-coverage",
3772
+ message: "Skip oh-my test-engineer when ECC e2e skill or agent is selected",
3773
+ signal: "co-install:e2e-coverage"
3774
+ },
3775
+ {
3776
+ suppress: "haus.wshobson-js-testing-patterns",
3777
+ whenAnyRecommended: ["haus.ecc-react-testing"],
3778
+ code: "co-install-react-testing",
3779
+ message: "Skip js-testing-patterns when ecc-react-testing covers component tests",
3780
+ signal: "co-install:react-testing"
3781
+ },
3782
+ {
3783
+ suppress: "haus.superpowers-test-driven-development",
3784
+ whenAnyRecommended: ["haus.wshobson-js-testing-patterns", "haus.ecc-react-testing"],
3785
+ code: "co-install-stack-testing",
3786
+ message: "Skip TDD superpower when a stack testing skill is selected",
3787
+ signal: "co-install:stack-testing"
3788
+ },
3789
+ {
3790
+ suppress: "haus.superpowers-specifying-gates",
3791
+ whenAnyRecommended: ["haus.superpowers-checking-gates"],
3792
+ code: "co-install-gate-workflow",
3793
+ message: "Skip specifying-gates when checking-gates is selected",
3794
+ signal: "co-install:checking-gates"
3795
+ },
3796
+ {
3797
+ suppress: "haus.ecc-redis-patterns",
3798
+ whenAnyRecommended: ["haus.redis-redis-connections"],
3799
+ code: "co-install-redis-official",
3800
+ message: "Skip ecc-redis-patterns when official redis-connections skill is selected",
3801
+ signal: "co-install:redis-connections"
3802
+ }
3803
+ ];
3804
+ for (const rule of rules) {
3805
+ if (!recommendedIds.has(rule.suppress)) continue;
3806
+ if (!rule.whenAnyRecommended.some((id) => recommendedIds.has(id))) continue;
3807
+ const idx = recommended.findIndex((r) => r.id === rule.suppress);
3808
+ if (idx === -1) continue;
3809
+ const [removed] = recommended.splice(idx, 1);
3810
+ recommendedIds.delete(rule.suppress);
3811
+ skipped.push({
3812
+ id: removed.id,
3813
+ reason: rule.message,
3814
+ skipReasons: [{ code: rule.code, message: rule.message, signal: rule.signal }]
3815
+ });
3816
+ }
3817
+ }
3643
3818
 
3644
3819
  // src/commands/setup-core.ts
3645
3820
  async function runSetupCore(root, opts) {
@@ -4123,6 +4298,144 @@ async function runRefresh() {
4123
4298
  log(`Recommended items: ${recommendation.recommended.length}`);
4124
4299
  }
4125
4300
 
4301
+ // src/install/scaffold.ts
4302
+ import path22 from "node:path";
4303
+ import fs17 from "fs-extra";
4304
+ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
4305
+ const scaffolded = [];
4306
+ const skipped = [];
4307
+ const skippedExisting = [];
4308
+ const record = (name, outcome) => {
4309
+ if (outcome === "scaffolded") {
4310
+ scaffolded.push(name);
4311
+ return;
4312
+ }
4313
+ skipped.push(name);
4314
+ if (outcome === "skipped-exists") skippedExisting.push(name);
4315
+ };
4316
+ const realRoot = await fs17.realpath(catalogRoot).catch(() => null);
4317
+ for (const item of items) {
4318
+ if (item.type !== "config") continue;
4319
+ const sourcePath = path22.resolve(catalogRoot, item.path);
4320
+ const rel = path22.relative(catalogRoot, sourcePath);
4321
+ const escapes = rel === "" || path22.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
4322
+ if (escapes) {
4323
+ warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
4324
+ continue;
4325
+ }
4326
+ const realSource = await fs17.realpath(sourcePath).catch(() => null);
4327
+ if (!realRoot || !realSource) {
4328
+ warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4329
+ continue;
4330
+ }
4331
+ const realRel = path22.relative(realRoot, realSource);
4332
+ if (realRel !== "" && (path22.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
4333
+ warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
4334
+ continue;
4335
+ }
4336
+ const stat = await fs17.lstat(sourcePath).catch(() => null);
4337
+ if (!stat) {
4338
+ warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
4339
+ continue;
4340
+ }
4341
+ if (stat.isSymbolicLink()) {
4342
+ warn(`Skipping ${item.id}: source path is a symlink`);
4343
+ continue;
4344
+ }
4345
+ if (stat.isFile()) {
4346
+ const filename = path22.basename(sourcePath);
4347
+ const dest = path22.join(projectRoot, filename);
4348
+ record(filename, await scaffoldFile(sourcePath, dest, item.id, opts));
4349
+ } else if (stat.isDirectory()) {
4350
+ const entries = await fs17.readdir(sourcePath);
4351
+ for (const entry of entries) {
4352
+ const src = path22.join(sourcePath, entry);
4353
+ const dest = path22.join(projectRoot, entry);
4354
+ record(entry, await scaffoldFile(src, dest, item.id, opts));
4355
+ }
4356
+ }
4357
+ }
4358
+ return { scaffolded, skipped, skippedExisting };
4359
+ }
4360
+ async function scaffoldFile(src, dest, itemId, opts) {
4361
+ const srcStat = await fs17.lstat(src).catch(() => null);
4362
+ if (!srcStat) {
4363
+ warn(`Skipping ${path22.basename(src)}: source not found`);
4364
+ return "skipped-refused";
4365
+ }
4366
+ if (srcStat.isSymbolicLink()) {
4367
+ warn(`Skipping ${path22.basename(src)}: source is a symlink`);
4368
+ return "skipped-refused";
4369
+ }
4370
+ const exists = await fs17.pathExists(dest);
4371
+ if (exists && !opts.force) {
4372
+ warn(`Skipping ${path22.basename(dest)}: already exists (use --force to overwrite)`);
4373
+ return "skipped-exists";
4374
+ }
4375
+ if (opts.dryRun) {
4376
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path22.basename(dest)} (${itemId})`);
4377
+ return "scaffolded";
4378
+ }
4379
+ if (exists && opts.force) {
4380
+ await fs17.remove(dest);
4381
+ }
4382
+ await fs17.ensureDir(path22.dirname(dest));
4383
+ await fs17.copy(src, dest, {
4384
+ overwrite: true,
4385
+ dereference: false,
4386
+ // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
4387
+ filter: (s) => {
4388
+ try {
4389
+ return !fs17.lstatSync(s).isSymbolicLink();
4390
+ } catch {
4391
+ return false;
4392
+ }
4393
+ }
4394
+ });
4395
+ log(`${exists ? "Overwrote" : "Created"} ${path22.basename(dest)} (${itemId})`);
4396
+ return "scaffolded";
4397
+ }
4398
+
4399
+ // src/commands/scaffold.ts
4400
+ async function runScaffold(ids, options) {
4401
+ const { items: allItems, contentRoot } = await loadCatalogContext(options.root);
4402
+ const configItems = allItems.filter((item) => {
4403
+ if (item.type !== "config") return false;
4404
+ if (item.reviewStatus && item.reviewStatus !== "approved") return false;
4405
+ if (item.riskLevel === "blocked") return false;
4406
+ if (ids.length > 0 && !ids.includes(item.id)) return false;
4407
+ return true;
4408
+ });
4409
+ if (configItems.length === 0) {
4410
+ if (ids.length > 0) {
4411
+ log(`No config items found for: ${ids.join(", ")}`);
4412
+ } else {
4413
+ log("No config items available in catalog.");
4414
+ }
4415
+ return;
4416
+ }
4417
+ log(`Scaffolding ${configItems.length} config item(s)\u2026`);
4418
+ const result = await scaffoldConfigItems(options.root, contentRoot, configItems, {
4419
+ force: options.force,
4420
+ dryRun: options.dryRun
4421
+ });
4422
+ if (result.scaffolded.length > 0) {
4423
+ if (options.dryRun) {
4424
+ log(`[dry-run] would scaffold: ${result.scaffolded.join(", ")} (no files written)`);
4425
+ } else {
4426
+ log(`\u2713 Scaffolded: ${result.scaffolded.join(", ")}`);
4427
+ }
4428
+ }
4429
+ if (result.skipped.length > 0) {
4430
+ log(`Skipped: ${result.skipped.join(", ")}`);
4431
+ if (result.skippedExisting.length > 0) {
4432
+ log(
4433
+ `Already present (re-run with --force to overwrite): ${result.skippedExisting.join(", ")}`
4434
+ );
4435
+ }
4436
+ }
4437
+ }
4438
+
4126
4439
  // src/commands/scan.ts
4127
4440
  async function runScan(options) {
4128
4441
  const result = await scanProject(process.cwd());
@@ -4136,8 +4449,8 @@ async function runScan(options) {
4136
4449
  }
4137
4450
 
4138
4451
  // src/commands/undo.ts
4139
- import path22 from "node:path";
4140
- import fs17 from "fs-extra";
4452
+ import path23 from "node:path";
4453
+ import fs18 from "fs-extra";
4141
4454
 
4142
4455
  // src/claude/managed-paths.ts
4143
4456
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md", "commands/haus-doctor.md"];
@@ -4154,74 +4467,74 @@ async function collectManagedPaths(root) {
4154
4467
  const lock = await readJson(hausPath(root, "haus.lock.json"));
4155
4468
  for (const row of lock ?? []) {
4156
4469
  for (const rel of row.paths ?? []) {
4157
- paths.add(path22.resolve(root, rel));
4470
+ paths.add(path23.resolve(root, rel));
4158
4471
  }
4159
4472
  }
4160
4473
  const existing = [];
4161
4474
  for (const abs of paths) {
4162
- if (await fs17.pathExists(abs)) existing.push(abs);
4475
+ if (await fs18.pathExists(abs)) existing.push(abs);
4163
4476
  }
4164
4477
  return existing;
4165
4478
  }
4166
4479
  async function settingsHasHausContent(root) {
4167
4480
  const settingsPath = claudePath(root, "settings.json");
4168
- if (!await fs17.pathExists(settingsPath)) return false;
4481
+ if (!await fs18.pathExists(settingsPath)) return false;
4169
4482
  const settings = await readProjectSettings(root);
4170
4483
  return settings._haus != null;
4171
4484
  }
4172
4485
  async function claudeMdHasHausBlock(root) {
4173
- const filePath = path22.join(root, "CLAUDE.md");
4174
- if (!await fs17.pathExists(filePath)) return false;
4175
- const text = await fs17.readFile(filePath, "utf8");
4486
+ const filePath = path23.join(root, "CLAUDE.md");
4487
+ if (!await fs18.pathExists(filePath)) return false;
4488
+ const text = await fs18.readFile(filePath, "utf8");
4176
4489
  return text.includes(BLOCK_BEGIN);
4177
4490
  }
4178
4491
  async function stripProjectSettings(root) {
4179
4492
  const settingsPath = claudePath(root, "settings.json");
4180
- if (!await fs17.pathExists(settingsPath)) return false;
4493
+ if (!await fs18.pathExists(settingsPath)) return false;
4181
4494
  let settings = await readProjectSettings(root);
4182
4495
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4183
4496
  const hasContent = Object.keys(settings).length > 0;
4184
4497
  if (hasContent) {
4185
4498
  await writeProjectSettings(root, settings);
4186
- log(`Stripped haus rules from ${path22.relative(root, settingsPath)} (user settings preserved).`);
4499
+ log(`Stripped haus rules from ${path23.relative(root, settingsPath)} (user settings preserved).`);
4187
4500
  return true;
4188
4501
  }
4189
- await fs17.remove(settingsPath);
4190
- log(`Removed ${path22.relative(root, settingsPath)} (no user-owned settings remained).`);
4502
+ await fs18.remove(settingsPath);
4503
+ log(`Removed ${path23.relative(root, settingsPath)} (no user-owned settings remained).`);
4191
4504
  return true;
4192
4505
  }
4193
4506
  async function stripRootClaudeMd(root) {
4194
- const filePath = path22.join(root, "CLAUDE.md");
4195
- if (!await fs17.pathExists(filePath)) return false;
4196
- const prev = await fs17.readFile(filePath, "utf8");
4507
+ const filePath = path23.join(root, "CLAUDE.md");
4508
+ if (!await fs18.pathExists(filePath)) return false;
4509
+ const prev = await fs18.readFile(filePath, "utf8");
4197
4510
  if (!prev.includes(BLOCK_BEGIN)) return false;
4198
4511
  const next = stripHausBlock(prev);
4199
4512
  if (next.length === 0) {
4200
- await fs17.remove(filePath);
4513
+ await fs18.remove(filePath);
4201
4514
  log("Removed CLAUDE.md (only contained haus import block).");
4202
4515
  } else {
4203
- await fs17.writeFile(filePath, next, "utf8");
4516
+ await fs18.writeFile(filePath, next, "utf8");
4204
4517
  log("Removed haus import block from CLAUDE.md (user content preserved).");
4205
4518
  }
4206
4519
  return true;
4207
4520
  }
4208
4521
  async function pruneDirIfEmpty(dir) {
4209
- if (!await fs17.pathExists(dir)) return;
4210
- const entries = await fs17.readdir(dir);
4211
- if (entries.length === 0) await fs17.remove(dir);
4522
+ if (!await fs18.pathExists(dir)) return;
4523
+ const entries = await fs18.readdir(dir);
4524
+ if (entries.length === 0) await fs18.remove(dir);
4212
4525
  }
4213
4526
  async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
4214
4527
  if (managedAbsPaths.length === 0) return;
4215
4528
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4216
4529
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
4217
4530
  for (const abs of managedAbsPaths) {
4218
- if (!await fs17.pathExists(abs)) continue;
4219
- const rel = path22.relative(root, abs);
4220
- const backupPath = path22.join(backupRoot, rel);
4221
- await fs17.ensureDir(path22.dirname(backupPath));
4222
- await fs17.copy(abs, backupPath);
4531
+ if (!await fs18.pathExists(abs)) continue;
4532
+ const rel = path23.relative(root, abs);
4533
+ const backupPath = path23.join(backupRoot, rel);
4534
+ await fs18.ensureDir(path23.dirname(backupPath));
4535
+ await fs18.copy(abs, backupPath);
4223
4536
  }
4224
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path22.relative(root, backupRoot)}.`);
4537
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path23.relative(root, backupRoot)}.`);
4225
4538
  }
4226
4539
  async function runUndo(options) {
4227
4540
  const root = process.cwd();
@@ -4232,7 +4545,7 @@ async function runUndo(options) {
4232
4545
  log("Nothing to remove: no haus-managed files found in this directory.");
4233
4546
  return;
4234
4547
  }
4235
- const relTargets = managed.map((p) => path22.relative(root, p));
4548
+ const relTargets = managed.map((p) => path23.relative(root, p));
4236
4549
  const summaryParts = [...relTargets];
4237
4550
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
4238
4551
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -4249,9 +4562,9 @@ User-owned .claude/ files will be preserved.`
4249
4562
  }
4250
4563
  await backupManagedFilesBeforeUndo(root, managed);
4251
4564
  for (const abs of managed) {
4252
- if (!await fs17.pathExists(abs)) continue;
4253
- await fs17.remove(abs);
4254
- log(`Removed ${path22.relative(root, abs)}`);
4565
+ if (!await fs18.pathExists(abs)) continue;
4566
+ await fs18.remove(abs);
4567
+ log(`Removed ${path23.relative(root, abs)}`);
4255
4568
  }
4256
4569
  if (stripSettings) await stripProjectSettings(root);
4257
4570
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -4262,8 +4575,8 @@ User-owned .claude/ files will be preserved.`
4262
4575
 
4263
4576
  // src/install/uninstall.ts
4264
4577
  import crypto3 from "node:crypto";
4265
- import path23 from "node:path";
4266
- import fs18 from "fs-extra";
4578
+ import path24 from "node:path";
4579
+ import fs19 from "fs-extra";
4267
4580
  async function runUninstall(options = {}) {
4268
4581
  const { force = false } = options;
4269
4582
  const manifest = await readManifest();
@@ -4273,7 +4586,7 @@ async function runUninstall(options = {}) {
4273
4586
  return result;
4274
4587
  }
4275
4588
  for (const entry of manifest.files) {
4276
- const exists = fs18.pathExistsSync(entry.destPath);
4589
+ const exists = fs19.pathExistsSync(entry.destPath);
4277
4590
  if (!exists) continue;
4278
4591
  const content2 = await readText(entry.destPath);
4279
4592
  if (content2 === void 0) continue;
@@ -4291,22 +4604,22 @@ async function runUninstall(options = {}) {
4291
4604
  result.skipped.push(entry.destPath);
4292
4605
  continue;
4293
4606
  }
4294
- await fs18.remove(entry.destPath);
4295
- await pruneEmptyDir(path23.dirname(entry.destPath));
4607
+ await fs19.remove(entry.destPath);
4608
+ await pruneEmptyDir(path24.dirname(entry.destPath));
4296
4609
  result.deleted.push(entry.destPath);
4297
4610
  }
4298
4611
  const settings = await readSettings();
4299
4612
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
4300
4613
  await writeSettings(stripped);
4301
4614
  result.hooksStripped = true;
4302
- const hausDir = path23.join(globalClaudeDir(), "haus");
4615
+ const hausDir = path24.join(globalClaudeDir(), "haus");
4303
4616
  const manifestPath2 = hausManifestPath();
4304
- if (fs18.pathExistsSync(manifestPath2)) {
4305
- await fs18.remove(manifestPath2);
4617
+ if (fs19.pathExistsSync(manifestPath2)) {
4618
+ await fs19.remove(manifestPath2);
4306
4619
  }
4307
- if (fs18.pathExistsSync(hausDir)) {
4308
- const remaining = await fs18.readdir(hausDir);
4309
- if (remaining.length === 0) await fs18.remove(hausDir);
4620
+ if (fs19.pathExistsSync(hausDir)) {
4621
+ const remaining = await fs19.readdir(hausDir);
4622
+ if (remaining.length === 0) await fs19.remove(hausDir);
4310
4623
  }
4311
4624
  return result;
4312
4625
  }
@@ -4337,12 +4650,12 @@ async function runUninstallCommand(options) {
4337
4650
  }
4338
4651
 
4339
4652
  // src/commands/update.ts
4340
- import path25 from "node:path";
4653
+ import path26 from "node:path";
4341
4654
 
4342
4655
  // src/claude/refresh-project.ts
4343
- import fs19 from "fs-extra";
4656
+ import fs20 from "fs-extra";
4344
4657
  async function isHausProject(root) {
4345
- if (await fs19.pathExists(hausPath(root, "recommendation.json"))) return true;
4658
+ if (await fs20.pathExists(hausPath(root, "recommendation.json"))) return true;
4346
4659
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
4347
4660
  if (result.status === "ok") return result.value._haus != null;
4348
4661
  return false;
@@ -4377,7 +4690,7 @@ function summarizeLockDiff(before, after) {
4377
4690
 
4378
4691
  // src/update/lockfile.ts
4379
4692
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
4380
- import path24 from "node:path";
4693
+ import path25 from "node:path";
4381
4694
  async function checkLock(root) {
4382
4695
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
4383
4696
  const hasValidVersions = lock.every(
@@ -4408,7 +4721,7 @@ async function applyLock(root) {
4408
4721
  try {
4409
4722
  const backupDir = hausPath(root, "backups");
4410
4723
  await mkdir(backupDir, { recursive: true });
4411
- await copyFile(lockPath, path24.join(backupDir, `haus.lock.${Date.now()}.json`));
4724
+ await copyFile(lockPath, path25.join(backupDir, `haus.lock.${Date.now()}.json`));
4412
4725
  } catch {
4413
4726
  }
4414
4727
  const enriched = await Promise.all(
@@ -4430,7 +4743,7 @@ function diffLock(before, after) {
4430
4743
  }
4431
4744
  async function hasLocalOverrides(root) {
4432
4745
  try {
4433
- await readFile2(path24.join(root, ".claude", "settings.json"), "utf8");
4746
+ await readFile2(path25.join(root, ".claude", "settings.json"), "utf8");
4434
4747
  return true;
4435
4748
  } catch {
4436
4749
  return false;
@@ -4442,7 +4755,7 @@ var NPM_PACKAGE_NAME2 = "@haus-tech/haus-workflow";
4442
4755
  async function runUpdate(options) {
4443
4756
  const root = process.cwd();
4444
4757
  if (options.check) {
4445
- const pkgJson2 = await readJson(path25.join(packageRoot(), "package.json"));
4758
+ const pkgJson2 = await readJson(path26.join(packageRoot(), "package.json"));
4446
4759
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
4447
4760
  const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
4448
4761
  checkLock(root),
@@ -4472,7 +4785,7 @@ async function runUpdate(options) {
4472
4785
  if (status.driftCount > 0) process.exitCode = 1;
4473
4786
  return;
4474
4787
  }
4475
- const pkgJson = await readJson(path25.join(packageRoot(), "package.json"));
4788
+ const pkgJson = await readJson(path26.join(packageRoot(), "package.json"));
4476
4789
  const currentVersion = pkgJson?.version ?? "0.0.0";
4477
4790
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4478
4791
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -4549,16 +4862,16 @@ async function detectGlobalInstallDrift() {
4549
4862
  }
4550
4863
 
4551
4864
  // src/commands/validate-catalog.ts
4552
- import path27 from "node:path";
4865
+ import path28 from "node:path";
4553
4866
 
4554
4867
  // src/catalog/validate-core.ts
4555
- import fs20 from "node:fs";
4556
- import path26 from "node:path";
4868
+ import fs21 from "node:fs";
4869
+ import path27 from "node:path";
4557
4870
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
4558
4871
  var MANIFEST_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
4559
4872
  function isSafeCatalogPath2(itemPath) {
4560
- if (!itemPath || path26.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
4561
- const normalized = path26.normalize(itemPath);
4873
+ if (!itemPath || path27.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
4874
+ const normalized = path27.normalize(itemPath);
4562
4875
  return !normalized.startsWith("..") && !normalized.includes("/..");
4563
4876
  }
4564
4877
  function auditForbiddenStacks(items) {
@@ -4606,7 +4919,7 @@ function auditManifestStructure(manifestVersion, items) {
4606
4919
  } else {
4607
4920
  seenIds.set(item.id, i);
4608
4921
  }
4609
- if (item.type === "skill" || item.type === "agent" || item.type === "template" || item.type === "command") {
4922
+ if (item.type === "skill" || item.type === "agent" || item.type === "template" || item.type === "command" || item.type === "config") {
4610
4923
  if (!item.path) {
4611
4924
  failures.push(`${item.id}: missing path`);
4612
4925
  } else if (!isSafeCatalogPath2(item.path)) {
@@ -4637,8 +4950,8 @@ function checkRequiredFrontmatter(text, label) {
4637
4950
  return failures;
4638
4951
  }
4639
4952
  function auditTemplateContent(manifestDir, absPath, itemId) {
4640
- const rel = path26.relative(manifestDir, absPath);
4641
- const text = fs20.readFileSync(absPath, "utf8");
4953
+ const rel = path27.relative(manifestDir, absPath);
4954
+ const text = fs21.readFileSync(absPath, "utf8");
4642
4955
  const failures = [];
4643
4956
  const lines = text.split(/\r?\n/);
4644
4957
  for (let i = 0; i < lines.length; i++) {
@@ -4658,42 +4971,46 @@ function auditShippedFiles(manifestDir, items) {
4658
4971
  const failures = [];
4659
4972
  for (const item of items) {
4660
4973
  if (!item.path || !isSafeCatalogPath2(item.path)) continue;
4661
- const absPath = path26.join(manifestDir, item.path);
4974
+ const absPath = path27.join(manifestDir, item.path);
4662
4975
  if (item.type === "skill") {
4663
- const skillMd = path26.join(absPath, "SKILL.md");
4664
- if (!fs20.existsSync(skillMd)) {
4665
- failures.push(`${item.id}: missing ${path26.relative(manifestDir, skillMd)}`);
4976
+ const skillMd = path27.join(absPath, "SKILL.md");
4977
+ if (!fs21.existsSync(skillMd)) {
4978
+ failures.push(`${item.id}: missing ${path27.relative(manifestDir, skillMd)}`);
4666
4979
  continue;
4667
4980
  }
4668
- const text = fs20.readFileSync(skillMd, "utf8");
4981
+ const text = fs21.readFileSync(skillMd, "utf8");
4669
4982
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
4670
4983
  failures.push(
4671
- ...auditForbiddenTagsInText(text, `${item.id}: ${path26.relative(manifestDir, skillMd)}`)
4984
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path27.relative(manifestDir, skillMd)}`)
4672
4985
  );
4673
4986
  } else if (item.type === "agent") {
4674
- if (!fs20.existsSync(absPath)) {
4987
+ if (!fs21.existsSync(absPath)) {
4675
4988
  failures.push(`${item.id}: missing agent file ${item.path}`);
4676
4989
  continue;
4677
4990
  }
4678
- const text = fs20.readFileSync(absPath, "utf8");
4679
- const rel = path26.relative(manifestDir, absPath);
4991
+ const text = fs21.readFileSync(absPath, "utf8");
4992
+ const rel = path27.relative(manifestDir, absPath);
4680
4993
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
4681
4994
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
4682
4995
  } else if (item.type === "template") {
4683
- if (!fs20.existsSync(absPath)) {
4996
+ if (!fs21.existsSync(absPath)) {
4684
4997
  failures.push(`${item.id}: missing template file ${item.path}`);
4685
4998
  continue;
4686
4999
  }
4687
5000
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
4688
5001
  } else if (item.type === "command") {
4689
- if (!fs20.existsSync(absPath)) {
5002
+ if (!fs21.existsSync(absPath)) {
4690
5003
  failures.push(`${item.id}: missing command file ${item.path}`);
4691
5004
  continue;
4692
5005
  }
4693
- const text = fs20.readFileSync(absPath, "utf8");
4694
- const rel = path26.relative(manifestDir, absPath);
5006
+ const text = fs21.readFileSync(absPath, "utf8");
5007
+ const rel = path27.relative(manifestDir, absPath);
4695
5008
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
4696
5009
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
5010
+ } else if (item.type === "config") {
5011
+ if (!fs21.existsSync(absPath)) {
5012
+ failures.push(`${item.id}: missing config path ${item.path}`);
5013
+ }
4697
5014
  }
4698
5015
  }
4699
5016
  return failures;
@@ -4705,21 +5022,25 @@ var DIR_ITEM_TYPE = {
4705
5022
  commands: "command"
4706
5023
  };
4707
5024
  function walkMd(dir, fn) {
4708
- for (const entry of fs20.readdirSync(dir, { withFileTypes: true })) {
4709
- const full = path26.join(dir, entry.name);
5025
+ for (const entry of fs21.readdirSync(dir, { withFileTypes: true })) {
5026
+ const full = path27.join(dir, entry.name);
4710
5027
  if (entry.isDirectory()) walkMd(full, fn);
4711
5028
  else if (entry.name.endsWith(".md")) fn(full);
4712
5029
  }
4713
5030
  }
4714
- function auditMarkdownContent(manifestDir) {
5031
+ function auditMarkdownContent(manifestDir, items) {
4715
5032
  const failures = [];
5033
+ const pathSourceMap = buildItemPathSourceMap(items);
4716
5034
  for (const dir of ["skills", "agents", "templates", "commands"]) {
4717
- const abs = path26.join(manifestDir, dir);
4718
- if (!fs20.existsSync(abs)) continue;
4719
- const checkNonTsxNpx = !NPX_TSX_ONLY_EXEMPT_TYPES.includes(DIR_ITEM_TYPE[dir] ?? "");
5035
+ const abs = path27.join(manifestDir, dir);
5036
+ if (!fs21.existsSync(abs)) continue;
5037
+ const dirType = DIR_ITEM_TYPE[dir] ?? "";
4720
5038
  walkMd(abs, (file) => {
4721
- const text = fs20.readFileSync(file, "utf8");
4722
- const rel = path26.relative(manifestDir, file);
5039
+ const text = fs21.readFileSync(file, "utf8");
5040
+ const rel = path27.relative(manifestDir, file);
5041
+ const norm = rel.replace(/\\/g, "/");
5042
+ const source = resolveMarkdownItemSource(norm, pathSourceMap);
5043
+ const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
4723
5044
  const lines = text.split(/\r?\n/);
4724
5045
  for (let i = 0; i < lines.length; i++) {
4725
5046
  const line2 = lines[i] ?? "";
@@ -4729,7 +5050,7 @@ function auditMarkdownContent(manifestDir) {
4729
5050
  failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
4730
5051
  }
4731
5052
  }
4732
- if (!rel.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
5053
+ if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
4733
5054
  });
4734
5055
  }
4735
5056
  return failures;
@@ -4739,7 +5060,7 @@ function validateCatalogData(manifestDir, manifestVersion, items) {
4739
5060
  ...auditManifestStructure(manifestVersion, items),
4740
5061
  ...auditForbiddenStacks(items),
4741
5062
  ...auditShippedFiles(manifestDir, items),
4742
- ...auditMarkdownContent(manifestDir),
5063
+ ...auditMarkdownContent(manifestDir, items),
4743
5064
  ...auditDisallowedTags(items)
4744
5065
  ];
4745
5066
  return { ok: failures.length === 0, failures, items };
@@ -4752,8 +5073,8 @@ async function runValidateCatalog(manifestPath2) {
4752
5073
  process.exitCode = 1;
4753
5074
  return;
4754
5075
  }
4755
- const abs = path27.resolve(process.cwd(), manifestPath2);
4756
- const manifestDir = path27.dirname(abs);
5076
+ const abs = path28.resolve(process.cwd(), manifestPath2);
5077
+ const manifestDir = path28.dirname(abs);
4757
5078
  const data = await readJson(abs);
4758
5079
  if (!data?.items) {
4759
5080
  error(`Could not read catalog manifest at ${abs}`);
@@ -4771,7 +5092,7 @@ async function runValidateCatalog(manifestPath2) {
4771
5092
 
4772
5093
  // src/commands/workspace.ts
4773
5094
  import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
4774
- import path34 from "node:path";
5095
+ import path35 from "node:path";
4775
5096
 
4776
5097
  // src/commands/workspace/aggregate.ts
4777
5098
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -4825,7 +5146,7 @@ ${summaries.map(
4825
5146
  }
4826
5147
 
4827
5148
  // src/commands/workspace/config.ts
4828
- import path28 from "node:path";
5149
+ import path29 from "node:path";
4829
5150
  import YAML from "yaml";
4830
5151
  var WORKSPACE_FILE = "haus.workspace.yaml";
4831
5152
  function parseWorkspaceConfig(text) {
@@ -4848,11 +5169,11 @@ function parseWorkspaceConfig(text) {
4848
5169
  };
4849
5170
  }
4850
5171
  async function readWorkspaceConfig(workspaceRoot) {
4851
- return parseWorkspaceConfig(await readText(path28.join(workspaceRoot, WORKSPACE_FILE)));
5172
+ return parseWorkspaceConfig(await readText(path29.join(workspaceRoot, WORKSPACE_FILE)));
4852
5173
  }
4853
5174
 
4854
5175
  // src/commands/workspace/discover.ts
4855
- import path29 from "node:path";
5176
+ import path30 from "node:path";
4856
5177
  import fg4 from "fast-glob";
4857
5178
  import YAML2 from "yaml";
4858
5179
  var DEFAULT_MAX_DEPTH = 3;
@@ -4880,8 +5201,8 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
4880
5201
  const gitDirs = /* @__PURE__ */ new Set();
4881
5202
  const manifestDirs = /* @__PURE__ */ new Set();
4882
5203
  for (const match of matches) {
4883
- const base = path29.posix.basename(match);
4884
- const dir = path29.posix.dirname(match);
5204
+ const base = path30.posix.basename(match);
5205
+ const dir = path30.posix.dirname(match);
4885
5206
  const owner = dir === "." ? "." : dir;
4886
5207
  if (base === ".git") gitDirs.add(owner);
4887
5208
  else manifestDirs.add(owner);
@@ -4897,9 +5218,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
4897
5218
  }
4898
5219
  repoRoots.sort((a, b) => a.localeCompare(b));
4899
5220
  return mapWithConcurrency(repoRoots, async (relDir) => {
4900
- const absDir = path29.resolve(workspaceRoot, relDir);
4901
- const pkg = await readJson(path29.join(absDir, "package.json"));
4902
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path29.basename(relDir === "." ? workspaceRoot : absDir);
5221
+ const absDir = path30.resolve(workspaceRoot, relDir);
5222
+ const pkg = await readJson(path30.join(absDir, "package.json"));
5223
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path30.basename(relDir === "." ? workspaceRoot : absDir);
4903
5224
  let role = "auto";
4904
5225
  try {
4905
5226
  const scan = await scanProject(absDir);
@@ -4942,7 +5263,7 @@ function renderWorkspaceYaml(config) {
4942
5263
  });
4943
5264
  }
4944
5265
  async function runDiscover(workspaceRoot, opts = {}) {
4945
- const yamlPath = path29.join(workspaceRoot, "haus.workspace.yaml");
5266
+ const yamlPath = path30.join(workspaceRoot, "haus.workspace.yaml");
4946
5267
  const existingText = await readText(yamlPath);
4947
5268
  const existing = parseWorkspaceConfig(existingText);
4948
5269
  if (existingText && !existing) {
@@ -4976,18 +5297,18 @@ async function runDiscover(workspaceRoot, opts = {}) {
4976
5297
 
4977
5298
  // src/commands/workspace/doctor.ts
4978
5299
  import { existsSync as existsSync3 } from "node:fs";
4979
- import path31 from "node:path";
5300
+ import path32 from "node:path";
4980
5301
 
4981
5302
  // src/commands/workspace/manifest.ts
4982
5303
  import { readFileSync as readFileSync3 } from "node:fs";
4983
- import path30 from "node:path";
5304
+ import path31 from "node:path";
4984
5305
  var MANIFEST_FILE = "workspace.manifest.json";
4985
5306
  function manifestPath(workspaceRoot) {
4986
5307
  return hausPath(workspaceRoot, MANIFEST_FILE);
4987
5308
  }
4988
5309
  function hausVersion() {
4989
5310
  try {
4990
- const pkg = JSON.parse(readFileSync3(path30.join(packageRoot(), "package.json"), "utf8"));
5311
+ const pkg = JSON.parse(readFileSync3(path31.join(packageRoot(), "package.json"), "utf8"));
4991
5312
  return pkg.version ?? "0.0.0";
4992
5313
  } catch {
4993
5314
  return "0.0.0";
@@ -5053,7 +5374,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
5053
5374
  }
5054
5375
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
5055
5376
  for (const repo of config.repos) {
5056
- const repoRoot = path31.resolve(workspaceRoot, repo.path);
5377
+ const repoRoot = path32.resolve(workspaceRoot, repo.path);
5057
5378
  const entry = manifestByName.get(repo.name);
5058
5379
  if (!entry) {
5059
5380
  flag({
@@ -5127,11 +5448,11 @@ function emit(args) {
5127
5448
 
5128
5449
  // src/commands/workspace/setup.ts
5129
5450
  import { existsSync as existsSync4, statSync } from "node:fs";
5130
- import path33 from "node:path";
5451
+ import path34 from "node:path";
5131
5452
 
5132
5453
  // src/claude/write-workspace-claude-md.ts
5133
- import path32 from "node:path";
5134
- import fs21 from "fs-extra";
5454
+ import path33 from "node:path";
5455
+ import fs22 from "fs-extra";
5135
5456
  function buildWorkspaceImportBlock(client, members) {
5136
5457
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
5137
5458
  const body = [
@@ -5149,8 +5470,8 @@ ${BLOCK_END}`;
5149
5470
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5150
5471
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
5151
5472
  const dryRun = opts.dryRun ?? false;
5152
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path32.join(workspaceRoot, "CLAUDE.md");
5153
- const prev = await fs21.pathExists(filePath) ? await fs21.readFile(filePath, "utf8") : "";
5473
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path33.join(workspaceRoot, "CLAUDE.md");
5474
+ const prev = await fs22.pathExists(filePath) ? await fs22.readFile(filePath, "utf8") : "";
5154
5475
  const next = opts.collision ? `${block}
5155
5476
  ` : injectHausBlock(prev, block);
5156
5477
  const printable = displayPath(workspaceRoot, filePath);
@@ -5175,20 +5496,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
5175
5496
 
5176
5497
  // src/commands/workspace/setup.ts
5177
5498
  function resolveWorkspaceRoot(start = process.cwd()) {
5178
- let dir = path33.resolve(start);
5499
+ let dir = path34.resolve(start);
5179
5500
  for (; ; ) {
5180
- if (existsSync4(path33.join(dir, WORKSPACE_FILE))) return dir;
5181
- const parent = path33.dirname(dir);
5182
- if (parent === dir) return path33.resolve(start);
5501
+ if (existsSync4(path34.join(dir, WORKSPACE_FILE))) return dir;
5502
+ const parent = path34.dirname(dir);
5503
+ if (parent === dir) return path34.resolve(start);
5183
5504
  dir = parent;
5184
5505
  }
5185
5506
  }
5186
5507
  function isRootRepo(workspaceRoot, repoPath) {
5187
- return path33.resolve(workspaceRoot, repoPath) === path33.resolve(workspaceRoot);
5508
+ return path34.resolve(workspaceRoot, repoPath) === path34.resolve(workspaceRoot);
5188
5509
  }
5189
5510
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
5190
5511
  const apply = options.write ?? false;
5191
- const configText = await readText(path33.join(workspaceRoot, WORKSPACE_FILE));
5512
+ const configText = await readText(path34.join(workspaceRoot, WORKSPACE_FILE));
5192
5513
  if (!configText) {
5193
5514
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5194
5515
  process.exitCode = 1;
@@ -5205,7 +5526,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5205
5526
  const statuses = [];
5206
5527
  const aggregateInputs = [];
5207
5528
  for (const repo of repos) {
5208
- const repoRoot = path33.resolve(workspaceRoot, repo.path);
5529
+ const repoRoot = path34.resolve(workspaceRoot, repo.path);
5209
5530
  log(`
5210
5531
  \u2192 ${repo.name} (${repo.path})`);
5211
5532
  try {
@@ -5273,7 +5594,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
5273
5594
  const status = statusByName.get(repo.name);
5274
5595
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
5275
5596
  if (status?.status === "ok") {
5276
- const lock = await checkLock(path33.resolve(workspaceRoot, repo.path));
5597
+ const lock = await checkLock(path34.resolve(workspaceRoot, repo.path));
5277
5598
  manifestRepos.push({
5278
5599
  name: repo.name,
5279
5600
  path: repo.path,
@@ -5353,7 +5674,7 @@ relationships: []
5353
5674
  log("Workspace initialized.");
5354
5675
  }
5355
5676
  async function scanWorkspace(workspaceRoot, opts) {
5356
- const configText = await readText(path34.join(workspaceRoot, WORKSPACE_FILE));
5677
+ const configText = await readText(path35.join(workspaceRoot, WORKSPACE_FILE));
5357
5678
  if (!configText) {
5358
5679
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
5359
5680
  process.exitCode = 1;
@@ -5374,7 +5695,7 @@ async function scanWorkspace(workspaceRoot, opts) {
5374
5695
  }
5375
5696
  const inputs = [];
5376
5697
  for (const repo of config.repos) {
5377
- const repoRoot = path34.resolve(workspaceRoot, repo.path);
5698
+ const repoRoot = path35.resolve(workspaceRoot, repo.path);
5378
5699
  if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
5379
5700
  throw new Error(`Repo path is not a directory: ${repo.path}`);
5380
5701
  }
@@ -5424,7 +5745,7 @@ async function runWorkspace(action, options = {}) {
5424
5745
  // src/cli.ts
5425
5746
  function cliVersion() {
5426
5747
  try {
5427
- const pkgPath = path35.join(packageRoot(), "package.json");
5748
+ const pkgPath = path36.join(packageRoot(), "package.json");
5428
5749
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5429
5750
  return pkg.version ?? "0.0.0";
5430
5751
  } catch {
@@ -5434,7 +5755,7 @@ function cliVersion() {
5434
5755
  var program = new Command();
5435
5756
  function validateRuntimeNodeVersion() {
5436
5757
  try {
5437
- const pkgPath = path35.join(packageRoot(), "package.json");
5758
+ const pkgPath = path36.join(packageRoot(), "package.json");
5438
5759
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
5439
5760
  const requiredRange = pkg.engines?.node;
5440
5761
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -5470,6 +5791,9 @@ program.command("catalog-audit").action(runCatalogAudit);
5470
5791
  program.command("validate-catalog").argument("[manifest]").action(runValidateCatalog);
5471
5792
  program.command("update").option("--check").action(runUpdate);
5472
5793
  program.command("install").option("--dry-run").option("--force").option("--check", "Exit non-zero if any HAUS-MANAGED file is out of date").option("--postinstall", "Run by the npm postinstall hook; prints a plain-language change notice").action(runInstall);
5794
+ program.command("scaffold [ids...]").description(
5795
+ "Copy catalog config items (ESLint, Prettier) into the project root. Pass item IDs to scaffold specific items, or omit to scaffold all available config items."
5796
+ ).option("--force", "Overwrite existing files").option("--dry-run", "Preview what would be written without changing anything").option("--root <path>", "Project root (defaults to cwd)", process.cwd()).action(runScaffold);
5473
5797
  program.command("uninstall").option("--force").action(runUninstallCommand);
5474
5798
  var guard = program.command("guard");
5475
5799
  guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));