@haus-tech/haus-workflow 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { readFileSync as readFileSync3 } from "fs";
5
- import path28 from "path";
5
+ import path29 from "path";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/commands/apply.ts
@@ -53,6 +53,18 @@ async function fetchRemoteManifest() {
53
53
  return null;
54
54
  }
55
55
  }
56
+ var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
57
+ async function readWorkflowTemplate(opts = {}) {
58
+ const dest = path.join(CACHE_DIR, WORKFLOW_TEMPLATE_REL);
59
+ if (await fs.pathExists(dest)) return fs.readFile(dest, "utf8");
60
+ const text = await fetchText(`${REMOTE_BASE}/${WORKFLOW_TEMPLATE_REL}`);
61
+ if (text === null) return null;
62
+ if (!opts.dryRun) {
63
+ await fs.ensureDir(path.dirname(dest));
64
+ await fs.writeFile(dest, text, "utf8");
65
+ }
66
+ return text;
67
+ }
56
68
  function isSafeCatalogPath(itemPath) {
57
69
  if (!itemPath || path.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
58
70
  const normalized = path.normalize(itemPath);
@@ -80,7 +92,8 @@ async function syncRemoteCatalog() {
80
92
  let unchanged = 0;
81
93
  const failed = [];
82
94
  for (const item of items) {
83
- if (item.type !== "skill" && item.type !== "agent" || !item.path) continue;
95
+ if (item.type !== "skill" && item.type !== "agent" && item.type !== "template" || !item.path)
96
+ continue;
84
97
  if (!isSafeCatalogPath(item.path)) {
85
98
  warn(`Skipping ${item.id}: invalid path "${item.path}"`);
86
99
  failed.push(item.id);
@@ -159,7 +172,7 @@ async function getCacheManifestAge() {
159
172
 
160
173
  // src/claude/write-claude-files.ts
161
174
  import path10 from "path";
162
- import fs9 from "fs-extra";
175
+ import fs10 from "fs-extra";
163
176
 
164
177
  // src/update/hash-installed.ts
165
178
  import path3 from "path";
@@ -254,10 +267,10 @@ function summarizeDiff(diffText) {
254
267
  const lines = diffText.split("\n");
255
268
  let additions = 0;
256
269
  let deletions = 0;
257
- for (const line of lines) {
258
- if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
259
- if (line.startsWith("+")) additions += 1;
260
- if (line.startsWith("-")) deletions += 1;
270
+ for (const line2 of lines) {
271
+ if (line2.startsWith("+++ ") || line2.startsWith("--- ")) continue;
272
+ if (line2.startsWith("+")) additions += 1;
273
+ if (line2.startsWith("-")) deletions += 1;
261
274
  }
262
275
  return { additions, deletions };
263
276
  }
@@ -313,8 +326,7 @@ import path5 from "path";
313
326
  var CONFIG_PATH = ".haus-workflow/config.json";
314
327
  var DEFAULT_HOOKS_CONFIG = {
315
328
  hooks: {
316
- context: { enabled: false },
317
- memoryInject: { enabled: false }
329
+ context: { enabled: false }
318
330
  }
319
331
  };
320
332
  async function isHookEnabled(root, key) {
@@ -322,15 +334,103 @@ async function isHookEnabled(root, key) {
322
334
  return cfg?.hooks?.[key]?.enabled === true;
323
335
  }
324
336
 
337
+ // src/security/dangerous-commands.ts
338
+ var DANGEROUS_COMMANDS = [
339
+ "rm -rf",
340
+ "sudo",
341
+ "chmod -R 777",
342
+ "chown -R",
343
+ "git push --force",
344
+ "git reset --hard",
345
+ "docker system prune",
346
+ "drop database",
347
+ "truncate table",
348
+ "php artisan migrate --force",
349
+ "npm publish",
350
+ "yarn npm publish",
351
+ "pnpm publish"
352
+ ];
353
+
354
+ // src/security/sensitive-paths.ts
355
+ var SENSITIVE_PATHS = [
356
+ ".env",
357
+ ".env.*",
358
+ "*.pem",
359
+ "*.key",
360
+ "*.p12",
361
+ "*.pfx",
362
+ "id_rsa",
363
+ "id_ed25519",
364
+ "*.sql",
365
+ "*.dump",
366
+ "*.backup",
367
+ "*.bak",
368
+ "storage/logs",
369
+ "wp-content/uploads",
370
+ "uploads",
371
+ "customer-data",
372
+ "exports",
373
+ "secrets",
374
+ "certs"
375
+ ];
376
+ var SENSITIVE_PATH_REGEXES = [
377
+ /^\.env(\.|$)/,
378
+ /(^|\/)\.env(\.|$)/,
379
+ /\.pem$/,
380
+ /\.key$/,
381
+ /\.p12$/,
382
+ /\.pfx$/,
383
+ /\.sql$/,
384
+ /\.dump$/,
385
+ /customer-data/,
386
+ /exports/,
387
+ /certs/,
388
+ /secrets/,
389
+ /(^|\/)storage\/logs(\/|$)/,
390
+ /(^|\/)wp-content\/uploads(\/|$)/,
391
+ /(^|\/)uploads(\/|$)/
392
+ ];
393
+ var SENSITIVE_ITEM_KEYWORDS = [
394
+ ".env",
395
+ "secrets",
396
+ "certs",
397
+ "customer-data",
398
+ "exports",
399
+ ".pem",
400
+ ".key"
401
+ ];
402
+
403
+ // src/security/deny-rules.ts
404
+ var SENSITIVE_DIRS = /* @__PURE__ */ new Set([
405
+ "storage/logs",
406
+ "wp-content/uploads",
407
+ "uploads",
408
+ "customer-data",
409
+ "exports",
410
+ "secrets",
411
+ "certs"
412
+ ]);
413
+ var FILE_TOOLS = ["Read", "Edit", "Write"];
414
+ function buildDenyRules() {
415
+ const rules = [];
416
+ for (const command of DANGEROUS_COMMANDS) {
417
+ rules.push(`Bash(${command}:*)`);
418
+ }
419
+ for (const path30 of SENSITIVE_PATHS) {
420
+ const pattern = SENSITIVE_DIRS.has(path30) ? `${path30}/**` : path30;
421
+ for (const tool of FILE_TOOLS) {
422
+ rules.push(`${tool}(${pattern})`);
423
+ }
424
+ }
425
+ return [...new Set(rules)];
426
+ }
427
+
325
428
  // src/claude/load-hooks.ts
326
429
  var CANONICAL_HOOKS = {
327
430
  hooks: {
328
431
  UserPromptSubmit: [
329
432
  {
330
- hooks: [
331
- { type: "command", command: "haus context --from-hook || true" },
332
- { type: "command", command: "haus memory inject --from-hook || true" }
333
- ]
433
+ hooks: [{ type: "command", command: "haus context --from-hook || true" }]
334
434
  }
335
435
  ],
336
436
  PreToolUse: [
@@ -347,12 +447,11 @@ var CANONICAL_HOOKS = {
347
447
  };
348
448
  var STABLE_HOOK_IDS = {
349
449
  "haus context --from-hook || true": "haus.context-hook",
350
- "haus memory inject --from-hook || true": "haus.memory-hook",
351
450
  "haus guard file-access --from-hook || true": "haus.guard-file",
352
451
  "haus guard bash --from-hook || true": "haus.guard-bash"
353
452
  };
354
453
  async function loadClaudeHooksSettings() {
355
- return CANONICAL_HOOKS;
454
+ return { ...CANONICAL_HOOKS, permissions: { deny: buildDenyRules() } };
356
455
  }
357
456
  function flattenRecommendedHooks(settings) {
358
457
  const out = [];
@@ -438,9 +537,11 @@ function renderProjectFacts(ctx, rec, pkgVersion) {
438
537
  const repoName = ctx.repoName ?? path6.basename(ctx.root ?? "unknown");
439
538
  return `${header}
440
539
 
441
- # Project facts
540
+ # What haus found in this project
442
541
 
443
- > Auto-generated by \`haus apply\`. Do not edit \u2014 changes will be overwritten on next apply.
542
+ > This is a plain summary of your project that haus wrote automatically, so Claude
543
+ > always has the basics to hand. haus rewrites it on every \`haus apply\`, so don't
544
+ > edit it by hand \u2014 your changes would be replaced next time.
444
545
 
445
546
  **Repo:** ${repoName}
446
547
  **Package manager:** ${ctx.packageManager ?? "unknown"}
@@ -472,7 +573,9 @@ async function writeProjectFacts(root, pkgVersion, dryRun) {
472
573
  dependencies: [],
473
574
  securityRisks: [],
474
575
  crossRepoHints: [],
475
- warnings: []
576
+ warnings: [],
577
+ detectionStatus: "unknown",
578
+ unsupportedSignals: []
476
579
  };
477
580
  const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
478
581
  mode: "fast",
@@ -564,84 +667,189 @@ async function writeRootClaudeMd(root, dryRun) {
564
667
  }
565
668
 
566
669
  // src/claude/write-workflow-config.ts
670
+ import path9 from "path";
671
+ import fs8 from "fs-extra";
672
+
673
+ // src/claude/derive-workflow-config.ts
567
674
  import path8 from "path";
568
675
  import fs7 from "fs-extra";
569
- function buildWorkflowConfig(ctx) {
676
+ var VALIDATION_LIBS = [
677
+ "zod",
678
+ "valibot",
679
+ "yup",
680
+ "joi",
681
+ "@hapi/joi",
682
+ "class-validator",
683
+ "superstruct",
684
+ "ajv"
685
+ ];
686
+ function binCmd(pm, bin, args) {
687
+ const tail = args ? ` ${args}` : "";
688
+ if (pm === "yarn") return `yarn ${bin}${tail}`;
689
+ if (pm === "pnpm") return `pnpm exec ${bin}${tail}`;
690
+ return `npx --no-install ${bin}${tail}`;
691
+ }
692
+ async function deriveWorkflowConfig(root, ctx) {
570
693
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
571
- const testCmd = pm + " test";
572
- const auditCmd = pm + " audit";
573
- return "# Project workflow configuration\n\n> Project-specific values for the workflow standard in WORKFLOW.md.\n> Edit freely \u2014 this file is project-owned and will not be overwritten by haus.\n\n## Source-of-truth documents\n- Spec: <!-- fill in path, e.g. docs/SPEC.md -->\n- Design: <!-- fill in path, e.g. docs/DESIGN.md -->\n- UX flows: <!-- fill in path, e.g. docs/UX.md -->\n\n## Commands\n- Test (unit + integration): `" + testCmd + "`\n- Test (E2E): <!-- fill in command -->\n- Type check: <!-- fill in command, e.g. tsc --noEmit -->\n- Lint: <!-- fill in command, e.g. npm run lint -->\n- Lint fix: <!-- fill in command, e.g. npm run lint -- --fix -->\n- Format check: <!-- fill in command, e.g. prettier --check . -->\n- Security audit: `" + auditCmd + "`\n\n## Validation library\n<!-- fill in, e.g. zod, yup, joi -->\n\n## Highest-stakes logic\n<!-- fill in domain areas requiring TDD-only treatment, e.g. payment flows, auth, medical data -->\n\n## Pre-commit tool\n<!-- fill in, e.g. lefthook, husky -->\n";
694
+ const pkg = await readJson(path8.join(root, "package.json"));
695
+ const scripts = pkg?.scripts ?? {};
696
+ const deps = new Set(ctx.dependencies);
697
+ const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
698
+ const script = (name) => scripts[name] ? `${pm} run ${name}` : null;
699
+ const firstScript = (...names) => {
700
+ for (const n of names) if (scripts[n]) return `${pm} run ${n}`;
701
+ return null;
702
+ };
703
+ const hasDep = (name) => deps.has(name);
704
+ const exists = (rel) => fs7.pathExistsSync(path8.join(root, rel));
705
+ const hasTypeScript = hasDep("typescript") || exists("tsconfig.json");
706
+ const hasEslint = hasDep("eslint");
707
+ const hasPrettier = hasDep("prettier");
708
+ const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
709
+ const hasCypress = hasDep("cypress");
710
+ const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
711
+ return {
712
+ test: script("test") ?? `${pm} test`,
713
+ testE2E: firstScript("test:e2e", "e2e", "test:integration") ?? (hasPlaywright ? binCmd(pm, "playwright", "test") : null) ?? (hasCypress ? binCmd(pm, "cypress", "run") : null),
714
+ typecheck: firstScript("typecheck", "type-check", "tsc") ?? (hasTypeScript ? binCmd(pm, "tsc", "--noEmit") : null),
715
+ lint: script("lint") ?? (hasEslint ? binCmd(pm, "eslint", ".") : null),
716
+ lintFix: firstScript("lint:fix", "lint-fix") ?? (scripts.lint ? `${pm} run lint -- --fix` : hasEslint ? binCmd(pm, "eslint", ". --fix") : null),
717
+ formatCheck: firstScript("format:check", "format-check", "prettier:check") ?? (hasPrettier ? binCmd(pm, "prettier", "--check .") : null),
718
+ securityAudit: `${pm} audit`,
719
+ validationLibrary: VALIDATION_LIBS.find((lib) => deps.has(lib)) ?? null,
720
+ preCommitTool,
721
+ specPath: exists("docs/SPEC.md") ? "docs/SPEC.md" : null,
722
+ designPath: exists("docs/DESIGN.md") ? "docs/DESIGN.md" : null,
723
+ uxPath: exists("docs/UX.md") ? "docs/UX.md" : null
724
+ };
725
+ }
726
+
727
+ // src/claude/write-workflow-config.ts
728
+ function fields(v) {
729
+ return [
730
+ { prefix: "- Spec: ", value: v.specPath, hint: "path, e.g. docs/SPEC.md" },
731
+ { prefix: "- Design: ", value: v.designPath, hint: "path, e.g. docs/DESIGN.md" },
732
+ { prefix: "- UX flows: ", value: v.uxPath, hint: "path, e.g. docs/UX.md" },
733
+ { prefix: "- Test (unit + integration): ", value: v.test, hint: "command", code: true },
734
+ { prefix: "- Test (E2E): ", value: v.testE2E, hint: "command, e.g. playwright test", code: true },
735
+ { prefix: "- Type check: ", value: v.typecheck, hint: "command, e.g. tsc --noEmit", code: true },
736
+ { prefix: "- Lint: ", value: v.lint, hint: "command, e.g. eslint .", code: true },
737
+ { prefix: "- Lint fix: ", value: v.lintFix, hint: "command, e.g. eslint . --fix", code: true },
738
+ { prefix: "- Format check: ", value: v.formatCheck, hint: "command, e.g. prettier --check .", code: true },
739
+ { prefix: "- Security audit: ", value: v.securityAudit, hint: "command", code: true },
740
+ { prefix: "- Library: ", value: v.validationLibrary, hint: "e.g. zod, yup, joi" },
741
+ { prefix: "- Tool: ", value: v.preCommitTool, hint: "e.g. lefthook, husky" }
742
+ ];
574
743
  }
575
- async function writeWorkflowConfig(root, dryRun) {
744
+ function renderValue(f) {
745
+ if (f.value === null) return `<!-- fill in ${f.hint} -->`;
746
+ return f.code ? `\`${f.value}\`` : f.value;
747
+ }
748
+ function line(f) {
749
+ return `${f.prefix}${renderValue(f)}`;
750
+ }
751
+ function buildWorkflowConfig(v) {
752
+ const f = fields(v);
753
+ const byPrefix = (p) => line(f.find((x) => x.prefix === p));
754
+ return "# How this project works (commands & conventions)\n\n> The everyday commands and conventions for this project \u2014 the build, test, and\n> lint commands, where docs live, and so on. This file is yours to edit and haus\n> will not overwrite it. haus fills in what it can detect on first setup;\n> `haus apply --refill-config` fills any still-blank fields without touching\n> anything you've edited.\n\n## Source-of-truth documents\n" + byPrefix("- Spec: ") + "\n" + byPrefix("- Design: ") + "\n" + byPrefix("- UX flows: ") + "\n\n## Commands\n" + byPrefix("- Test (unit + integration): ") + "\n" + byPrefix("- Test (E2E): ") + "\n" + byPrefix("- Type check: ") + "\n" + byPrefix("- Lint: ") + "\n" + byPrefix("- Lint fix: ") + "\n" + byPrefix("- Format check: ") + "\n" + byPrefix("- Security audit: ") + "\n\n## Validation library\n" + byPrefix("- Library: ") + "\n\n## Highest-stakes logic\n<!-- fill in domain areas requiring TDD-only treatment, e.g. payment flows, auth, medical data -->\n\n## Pre-commit tool\n" + byPrefix("- Tool: ") + "\n";
755
+ }
756
+ function refillContent(existing, v) {
757
+ const f = fields(v);
758
+ return existing.split("\n").map((ln) => {
759
+ const field = f.find((x) => ln.startsWith(x.prefix));
760
+ if (!field || field.value === null) return ln;
761
+ const rest = ln.slice(field.prefix.length).trim();
762
+ return rest.startsWith("<!-- fill in") ? line(field) : ln;
763
+ }).join("\n");
764
+ }
765
+ var FALLBACK_CONTEXT = {
766
+ mode: "fast",
767
+ generatedAt: "",
768
+ root: "",
769
+ repoName: "",
770
+ packageManager: "unknown",
771
+ repoRoles: [],
772
+ confidence: 0,
773
+ detectedStacks: {},
774
+ dependencies: [],
775
+ securityRisks: [],
776
+ crossRepoHints: [],
777
+ warnings: [],
778
+ detectionStatus: "unknown",
779
+ unsupportedSignals: []
780
+ };
781
+ async function writeWorkflowConfig(root, dryRun, opts = {}) {
576
782
  const destPath = hausPath(root, "workflow-config.md");
577
783
  const printable = displayPath(root, destPath);
578
- if (await fs7.pathExists(destPath)) {
784
+ const exists = await fs8.pathExists(destPath);
785
+ if (exists && !opts.refill) {
579
786
  if (dryRun) log(printable + ": exists (project-owned, skipping)");
580
787
  return null;
581
788
  }
582
789
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
583
- mode: "fast",
584
- generatedAt: "",
790
+ ...FALLBACK_CONTEXT,
585
791
  root,
586
- repoName: path8.basename(root),
587
- packageManager: "unknown",
588
- repoRoles: [],
589
- confidence: 0,
590
- detectedStacks: {},
591
- dependencies: [],
592
- securityRisks: [],
593
- crossRepoHints: [],
594
- warnings: []
792
+ repoName: path9.basename(root)
595
793
  };
596
- const content = buildWorkflowConfig(ctx);
794
+ const values = await deriveWorkflowConfig(root, ctx);
795
+ if (exists) {
796
+ const current = await fs8.readFile(destPath, "utf8");
797
+ const refilled = refillContent(current, values);
798
+ if (refilled === current) {
799
+ if (dryRun) log(printable + ": no blank fields to refill");
800
+ return null;
801
+ }
802
+ if (dryRun) {
803
+ log(printable + ": would refill blank fields");
804
+ return destPath;
805
+ }
806
+ await writeText(destPath, refilled);
807
+ return destPath;
808
+ }
597
809
  if (dryRun) {
598
810
  log(printable + ": would create");
599
811
  return destPath;
600
812
  }
601
- await writeText(destPath, content);
813
+ await writeText(destPath, buildWorkflowConfig(values));
602
814
  return destPath;
603
815
  }
604
816
 
605
817
  // src/claude/write-workflow.ts
606
- import path9 from "path";
607
- import fs8 from "fs-extra";
818
+ import fs9 from "fs-extra";
608
819
 
609
820
  // src/claude/managed-template.ts
610
- function normaliseLF(content) {
611
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
821
+ function normaliseLF(content2) {
822
+ return content2.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
612
823
  }
613
- function parseHausManagedHeader(line) {
614
- const match = line.match(/<!-- HAUS-MANAGED id=([\w.:-]+)/);
824
+ function parseHausManagedHeader(line2) {
825
+ const match = line2.match(/<!-- HAUS-MANAGED id=([\w.:-]+)/);
615
826
  if (!match) return null;
616
- const hashMatch = line.match(/hash=(sha256-[a-f0-9]+)/);
827
+ const hashMatch = line2.match(/hash=(sha256-[a-f0-9]+)/);
617
828
  return { id: match[1], hash: hashMatch?.[1] };
618
829
  }
619
830
 
620
831
  // src/claude/write-workflow.ts
621
832
  var STABLE_ID2 = "template.workflow";
622
833
  var SCHEMA_VERSION2 = "1";
623
- var TEMPLATE_REL = "library/global/templates/agentic-workflow-standard.md";
624
- var CATALOG_CACHE_TEMPLATE = path9.join(CACHE_DIR, "templates/agentic-workflow-standard.md");
625
834
  function makeWorkflowHeader(pkgVersion, contentHash) {
626
835
  return `<!-- HAUS-MANAGED id=${STABLE_ID2} v=${SCHEMA_VERSION2} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
627
836
  }
628
837
  async function writeWorkflow(root, pkgVersion, dryRun) {
629
- const cachePath = CATALOG_CACHE_TEMPLATE;
630
- const packagePath = path9.join(packageRoot(), TEMPLATE_REL);
631
- const templatePath = await fs8.pathExists(cachePath) ? cachePath : packagePath;
632
- if (!await fs8.pathExists(templatePath)) {
633
- warn(`Workflow template not found \u2014 run \`haus update\` to fetch from catalog`);
838
+ const templateContent = await readWorkflowTemplate({ dryRun });
839
+ if (templateContent === null) {
840
+ warn(
841
+ `Workflow template could not be fetched from the catalog \u2014 check your network, then re-run \`haus apply --write\` (or \`haus update\`)`
842
+ );
634
843
  return null;
635
844
  }
636
- const templateContent = await fs8.readFile(templatePath, "utf8");
637
845
  const contentHash = hashText(normaliseLF(templateContent));
638
846
  const header = makeWorkflowHeader(pkgVersion, contentHash);
639
847
  const next = `${header}
640
848
  ${templateContent}`;
641
849
  const destPath = hausPath(root, "WORKFLOW.md");
642
850
  const printable = displayPath(root, destPath);
643
- if (await fs8.pathExists(destPath)) {
644
- const existing = await fs8.readFile(destPath, "utf8");
851
+ if (await fs9.pathExists(destPath)) {
852
+ const existing = await fs9.readFile(destPath, "utf8");
645
853
  const firstLine = existing.split("\n")[0] ?? "";
646
854
  const parsed = parseHausManagedHeader(firstLine);
647
855
  if (!parsed) {
@@ -663,7 +871,7 @@ ${templateContent}`;
663
871
  }
664
872
  }
665
873
  if (dryRun) {
666
- const prev = await fs8.pathExists(destPath) ? await fs8.readFile(destPath, "utf8") : "";
874
+ const prev = await fs9.pathExists(destPath) ? await fs9.readFile(destPath, "utf8") : "";
667
875
  if (!prev) {
668
876
  log(createUnifiedDiff(printable, "", next));
669
877
  } else {
@@ -678,7 +886,7 @@ ${templateContent}`;
678
886
  }
679
887
 
680
888
  // src/claude/write-claude-files.ts
681
- async function writeClaudeFiles(root, dryRun, selectedIds) {
889
+ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
682
890
  const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
683
891
  mode: "fast",
684
892
  recommended: [],
@@ -700,7 +908,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds) {
700
908
  ];
701
909
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
702
910
  const workflowPath = await writeWorkflow(root, hausVersion, dryRun);
703
- const workflowConfigPath = await writeWorkflowConfig(root, dryRun);
911
+ const workflowConfigPath = await writeWorkflowConfig(root, dryRun, {
912
+ refill: opts.refillConfig
913
+ });
704
914
  const projectFactsPath = await writeProjectFacts(root, hausVersion, dryRun);
705
915
  const p6Files = [
706
916
  rootClaudeMdPath,
@@ -718,7 +928,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds) {
718
928
  await writeManagedJson(root, claudePath(root, "settings.json"), hookSettings, dryRun);
719
929
  if (!dryRun) await assertPostApplySettingsMatchCanonical(root, hookSettings);
720
930
  const configPath = hausPath(root, "config.json");
721
- if (!await fs9.pathExists(configPath)) {
931
+ if (!await fs10.pathExists(configPath)) {
722
932
  await writeManagedJson(root, configPath, DEFAULT_HOOKS_CONFIG, dryRun);
723
933
  }
724
934
  await writeManagedText(
@@ -736,7 +946,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds) {
736
946
  await writeManagedText(
737
947
  root,
738
948
  claudePath(root, "rules", "haus.md"),
739
- "- Keep context minimal.\n- Follow project conventions.\n",
949
+ "- Keep context minimal.\n- Follow project conventions.\n\n## Driving haus\nWhen the user asks to set up, configure, check, or fix the project, run `haus setup-project` or `haus doctor` and narrate results in plain language \u2014 never make them use a terminal or read JSON. The `/haus-setup`, `/haus-doctor`, and `/haus-fix` commands do the same.\n",
740
950
  dryRun
741
951
  );
742
952
  await writeManagedText(
@@ -774,18 +984,18 @@ async function writeClaudeFiles(root, dryRun, selectedIds) {
774
984
  }
775
985
  const cachedItem = cacheManifestById.get(item.id);
776
986
  const cachePath = cachedItem?.path ? path10.join(CACHE_DIR, cachedItem.path) : null;
777
- const sourcePath = cachePath && await fs9.pathExists(cachePath) ? cachePath : path10.join(manifestDir, manifestItem.path);
987
+ const sourcePath = cachePath && await fs10.pathExists(cachePath) ? cachePath : path10.join(manifestDir, manifestItem.path);
778
988
  const target = item.type === "agent" ? "agents" : item.type === "template" ? "templates" : "skills";
779
989
  const destination = claudePath(root, target, path10.basename(sourcePath));
780
- if (await fs9.pathExists(sourcePath)) {
990
+ if (await fs10.pathExists(sourcePath)) {
781
991
  if (dryRun) {
782
- const exists = await fs9.pathExists(destination);
992
+ const exists = await fs10.pathExists(destination);
783
993
  log(
784
994
  `${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
785
995
  );
786
996
  } else {
787
- await fs9.ensureDir(path10.dirname(destination));
788
- await fs9.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
997
+ await fs10.ensureDir(path10.dirname(destination));
998
+ await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
789
999
  }
790
1000
  files.push(destination);
791
1001
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -840,7 +1050,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds) {
840
1050
  return [...new Set(files)];
841
1051
  }
842
1052
  async function writeManagedText(root, filePath, nextText, dryRun) {
843
- const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
1053
+ const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
844
1054
  const printable = displayPath(root, filePath);
845
1055
  if (dryRun) {
846
1056
  if (!prev) {
@@ -924,7 +1134,9 @@ async function runApply(options) {
924
1134
  }
925
1135
  }
926
1136
  }
927
- const files = await writeClaudeFiles(root, isDryRun, selectedIds);
1137
+ const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
1138
+ refillConfig: options.refillConfig
1139
+ });
928
1140
  if (isDryRun) {
929
1141
  log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
930
1142
  } else {
@@ -991,8 +1203,7 @@ async function runCatalogAudit() {
991
1203
  import path13 from "path";
992
1204
  var CONFIG_PATH2 = ".haus-workflow/config.json";
993
1205
  var HOOK_ALIASES = {
994
- "hook.context": "context",
995
- "hook.memory": "memoryInject"
1206
+ "hook.context": "context"
996
1207
  };
997
1208
  async function runConfig(key, action) {
998
1209
  const hookKey = HOOK_ALIASES[key];
@@ -1045,7 +1256,8 @@ function normalizeRecommendation(input2) {
1045
1256
  finalScore: item.score ?? 0
1046
1257
  },
1047
1258
  tags: item.tags,
1048
- ecosystem: item.ecosystem
1259
+ ecosystem: item.ecosystem,
1260
+ tokenEstimate: item.tokenEstimate
1049
1261
  };
1050
1262
  });
1051
1263
  const skipped = (input2.skipped ?? []).map((item) => ({
@@ -1105,46 +1317,7 @@ function buildRecommendationExplanation(recommendation) {
1105
1317
  };
1106
1318
  }
1107
1319
 
1108
- // src/recommender/task-intent.ts
1109
- function pickTaskRelevantRules(recommendation, task, taskIntents = /* @__PURE__ */ new Set()) {
1110
- const recommended = recommendation?.recommended ?? [];
1111
- if (!task) return recommended;
1112
- if (taskIntents.size > 0) {
1113
- const intentMatches = recommended.filter((rule) => {
1114
- if (rule.selectionMode === "baseline") return false;
1115
- const ruleIntents = computeRuleIntents(rule);
1116
- if (ruleIntents.size === 0) return false;
1117
- for (const ti of taskIntents) {
1118
- if (ruleIntents.has(ti)) return true;
1119
- }
1120
- return false;
1121
- });
1122
- if (intentMatches.length > 0) return intentMatches;
1123
- }
1124
- const tokens = task.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
1125
- const tokenMatches = recommended.filter((rule) => {
1126
- if (rule.selectionMode === "baseline") return false;
1127
- const corpus = [
1128
- rule.id,
1129
- rule.ecosystem ?? "",
1130
- ...rule.tags ?? [],
1131
- rule.reason ?? "",
1132
- ...rule.reasons.map((r) => r.message)
1133
- ].join(" ").toLowerCase();
1134
- return tokens.some((token) => corpus.includes(token));
1135
- });
1136
- if (tokenMatches.length > 0) return tokenMatches;
1137
- const taskWantsTesting = taskIntents.has("testing");
1138
- const cappedMediumOrHigh = recommended.filter((rule) => {
1139
- if (rule.selectionMode === "baseline") return false;
1140
- if (rule.confidenceLevel === "low") return false;
1141
- if (taskWantsTesting) return true;
1142
- const ruleIntents = computeRuleIntents(rule);
1143
- const isTestingOnly = ruleIntents.size > 0 && [...ruleIntents].every((i) => i === "testing");
1144
- return !isTestingOnly;
1145
- });
1146
- return cappedMediumOrHigh.slice(0, 8);
1147
- }
1320
+ // src/recommender/task-classification.ts
1148
1321
  var ALL_INTENTS = [
1149
1322
  "backend",
1150
1323
  "frontend",
@@ -1358,9 +1531,76 @@ function computeRuleIntents(rule) {
1358
1531
  return intents;
1359
1532
  }
1360
1533
 
1534
+ // src/recommender/rule-selection.ts
1535
+ var DEFAULT_CONTEXT_TOKEN_BUDGET = 12e3;
1536
+ function pickTaskRelevantRules(recommendation, task, taskIntents = /* @__PURE__ */ new Set(), opts = {}) {
1537
+ const recommended = recommendation?.recommended ?? [];
1538
+ return applyTokenBudget(selectRules(recommended, task, taskIntents), opts.tokenBudget);
1539
+ }
1540
+ function applyTokenBudget(rules, budget) {
1541
+ if (!budget || budget <= 0) return rules;
1542
+ const total = rules.reduce((sum, r) => sum + (r.tokenEstimate ?? 0), 0);
1543
+ if (total <= budget) return rules;
1544
+ const keep = /* @__PURE__ */ new Set();
1545
+ let used = 0;
1546
+ for (const r of rules) {
1547
+ if (r.selectionMode === "baseline") {
1548
+ keep.add(r.id);
1549
+ used += r.tokenEstimate ?? 0;
1550
+ }
1551
+ }
1552
+ const matched = rules.filter((r) => r.selectionMode !== "baseline").sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
1553
+ for (const r of matched) {
1554
+ const est = r.tokenEstimate ?? 0;
1555
+ if (used + est <= budget) {
1556
+ keep.add(r.id);
1557
+ used += est;
1558
+ }
1559
+ }
1560
+ return rules.filter((r) => keep.has(r.id));
1561
+ }
1562
+ function selectRules(recommended, task, taskIntents) {
1563
+ if (!task) return recommended;
1564
+ if (taskIntents.size > 0) {
1565
+ const intentMatches = recommended.filter((rule) => {
1566
+ if (rule.selectionMode === "baseline") return false;
1567
+ const ruleIntents = computeRuleIntents(rule);
1568
+ if (ruleIntents.size === 0) return false;
1569
+ for (const ti of taskIntents) {
1570
+ if (ruleIntents.has(ti)) return true;
1571
+ }
1572
+ return false;
1573
+ });
1574
+ if (intentMatches.length > 0) return intentMatches;
1575
+ }
1576
+ const tokens = task.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
1577
+ const tokenMatches = recommended.filter((rule) => {
1578
+ if (rule.selectionMode === "baseline") return false;
1579
+ const corpus = [
1580
+ rule.id,
1581
+ rule.ecosystem ?? "",
1582
+ ...rule.tags ?? [],
1583
+ rule.reason ?? "",
1584
+ ...rule.reasons.map((r) => r.message)
1585
+ ].join(" ").toLowerCase();
1586
+ return tokens.some((token) => corpus.includes(token));
1587
+ });
1588
+ if (tokenMatches.length > 0) return tokenMatches;
1589
+ const taskWantsTesting = taskIntents.has("testing");
1590
+ const cappedMediumOrHigh = recommended.filter((rule) => {
1591
+ if (rule.selectionMode === "baseline") return false;
1592
+ if (rule.confidenceLevel === "low") return false;
1593
+ if (taskWantsTesting) return true;
1594
+ const ruleIntents = computeRuleIntents(rule);
1595
+ const isTestingOnly = ruleIntents.size > 0 && [...ruleIntents].every((i) => i === "testing");
1596
+ return !isTestingOnly;
1597
+ });
1598
+ return cappedMediumOrHigh.slice(0, 8);
1599
+ }
1600
+
1361
1601
  // src/scanner/scan-project.ts
1362
- import { readFile } from "fs/promises";
1363
- import path15 from "path";
1602
+ import { readFile as readFile2 } from "fs/promises";
1603
+ import path17 from "path";
1364
1604
 
1365
1605
  // src/utils/audit-checks.ts
1366
1606
  function isRecord(v) {
@@ -1388,7 +1628,7 @@ function compareVersions(a, b) {
1388
1628
 
1389
1629
  // src/scanner/detect-package-manager.ts
1390
1630
  import path14 from "path";
1391
- import fs10 from "fs-extra";
1631
+ import fs11 from "fs-extra";
1392
1632
  function detectPackageManager(root, packageManagerField) {
1393
1633
  const field = String(packageManagerField ?? "").trim();
1394
1634
  if (field.startsWith("yarn@")) {
@@ -1406,77 +1646,394 @@ function detectPackageManager(root, packageManagerField) {
1406
1646
  if (satisfiesVersion(version, ">=9")) return "npm";
1407
1647
  return "unknown";
1408
1648
  }
1409
- if (fs10.existsSync(path14.join(root, "yarn.lock"))) return "yarn";
1410
- if (fs10.existsSync(path14.join(root, "pnpm-lock.yaml"))) return "pnpm";
1411
- if (fs10.existsSync(path14.join(root, "package-lock.json"))) return "npm";
1649
+ if (fs11.existsSync(path14.join(root, "yarn.lock"))) return "yarn";
1650
+ if (fs11.existsSync(path14.join(root, "pnpm-lock.yaml"))) return "pnpm";
1651
+ if (fs11.existsSync(path14.join(root, "package-lock.json"))) return "npm";
1412
1652
  return "unknown";
1413
1653
  }
1414
1654
 
1415
- // src/scanner/scan-project.ts
1416
- var SAFE_FILES = [
1417
- "package.json",
1418
- "yarn.lock",
1419
- "pnpm-lock.yaml",
1420
- "composer.json",
1421
- "composer.lock",
1422
- "nx.json",
1423
- "turbo.json",
1424
- "tsconfig.json",
1425
- "vite.config.*",
1426
- "next.config.*",
1427
- "tailwind.config.*",
1428
- "components.json",
1429
- "playwright.config.*",
1430
- "phpunit.xml",
1431
- "artisan",
1432
- "routes/*.php",
1433
- "app/Providers/*.php",
1434
- "schema.graphql",
1435
- "**/*.graphql",
1436
- "**/vendure-config.*",
1437
- "**/*module.ts",
1438
- "web/app/**",
1439
- "wp-content/plugins/**",
1440
- "wp-content/themes/**",
1441
- "wp-content/mu-plugins/**",
1442
- "wp-content/acf-json/**",
1443
- ".storybook/**",
1444
- ".env.example",
1445
- "wp-config.php",
1446
- "**/*.csproj",
1447
- "**/*.sln",
1448
- "docker-compose.*",
1449
- "Dockerfile"
1450
- ];
1451
- var SENSITIVE = [
1452
- /^\.env(\.|$)/,
1453
- /(^|\/)\.env(\.|$)/,
1454
- /\.pem$/,
1455
- /\.key$/,
1456
- /\.p12$/,
1457
- /\.pfx$/,
1458
- /\.sql$/,
1459
- /\.dump$/,
1460
- /customer-data/,
1461
- /exports/,
1462
- /certs/,
1463
- /secrets/,
1464
- /(^|\/)storage\/logs(\/|$)/,
1465
- /(^|\/)wp-content\/uploads(\/|$)/,
1466
- /(^|\/)uploads(\/|$)/
1655
+ // src/scanner/detection-registry.ts
1656
+ var dep = (value) => ({ kind: "dep", value });
1657
+ var depPrefix = (value) => ({ kind: "depPrefix", value });
1658
+ var depAbsent = (value) => ({ kind: "depAbsent", value });
1659
+ var fileEndsWith = (value) => ({ kind: "file", value, mode: "endsWith" });
1660
+ var fileIncludes = (value) => ({ kind: "file", value, mode: "includes" });
1661
+ var fileEquals = (value) => ({ kind: "file", value, mode: "equals" });
1662
+ var fileStartsWith = (value) => ({ kind: "file", value, mode: "startsWith" });
1663
+ var content = (value) => ({ kind: "content", value });
1664
+ var STACK_BUCKETS = [
1665
+ "backend",
1666
+ "frontend",
1667
+ "databases",
1668
+ "testing",
1669
+ "auth",
1670
+ "tooling",
1671
+ "packageManagers"
1467
1672
  ];
1673
+ function matchSignal(sig, ctx) {
1674
+ switch (sig.kind) {
1675
+ case "dep":
1676
+ return ctx.deps.has(sig.value);
1677
+ case "depPrefix":
1678
+ for (const d of ctx.deps) if (d.startsWith(sig.value)) return true;
1679
+ return false;
1680
+ case "depAbsent":
1681
+ return !ctx.deps.has(sig.value);
1682
+ case "content":
1683
+ return ctx.contentBlob.includes(sig.value);
1684
+ case "file":
1685
+ return ctx.files.some((f) => {
1686
+ switch (sig.mode) {
1687
+ case "endsWith":
1688
+ return f.endsWith(sig.value);
1689
+ case "includes":
1690
+ return f.includes(sig.value);
1691
+ case "equals":
1692
+ return f === sig.value;
1693
+ case "startsWith":
1694
+ return f.startsWith(sig.value);
1695
+ }
1696
+ });
1697
+ }
1698
+ }
1699
+ function matchRule(rule, ctx) {
1700
+ if (rule.all) return rule.all.every((s) => matchSignal(s, ctx));
1701
+ if (rule.any) return rule.any.some((s) => matchSignal(s, ctx));
1702
+ return false;
1703
+ }
1704
+ var ROLE_RULES = [
1705
+ { role: "next-app", any: [dep("next"), fileIncludes("next.config.")] },
1706
+ { role: "react-app", any: [dep("react")] },
1707
+ { role: "vite-app", any: [dep("vite"), fileIncludes("vite.config.")] },
1708
+ { role: "react-router-app", all: [dep("react-router"), dep("@react-router/node")] },
1709
+ { role: "sanity-studio", any: [dep("sanity")] },
1710
+ { role: "strapi-app", any: [dep("@strapi/strapi"), depPrefix("@strapi/")] },
1711
+ { role: "expo-app", any: [dep("expo")] },
1712
+ { role: "vendure-app", any: [dep("@vendure/core")] },
1713
+ { role: "vendure-plugin", any: [depPrefix("@haus/vendure-"), fileIncludes("vendure-config")] },
1714
+ { role: "nestjs-api", any: [dep("@nestjs/core")] },
1715
+ { role: "graphql-api", any: [dep("graphql"), dep("@nestjs/graphql")] },
1716
+ { role: "nx-monorepo", any: [fileEndsWith("nx.json")] },
1717
+ { role: "turbo-monorepo", any: [fileEndsWith("turbo.json")] },
1718
+ { role: "laravel-app", any: [fileEndsWith("artisan"), dep("laravel/framework")] },
1719
+ { role: "laravel-nova-app", any: [dep("laravel/nova")] },
1720
+ { role: "dotnet-service", any: [fileEndsWith(".csproj"), fileEndsWith(".sln")] },
1721
+ { role: "express-service", any: [dep("express")] }
1722
+ ];
1723
+ var STACK_RULES = [
1724
+ { stack: ["frontend", "nextjs"], any: [dep("next")] },
1725
+ { stack: ["frontend", "react19"], any: [dep("react")] },
1726
+ { stack: ["frontend", "vue"], any: [dep("vue")] },
1727
+ { stack: ["frontend", "vite8"], any: [dep("vite")] },
1728
+ { stack: ["frontend", "react-router-v7"], all: [dep("react-router"), dep("@react-router/node")] },
1729
+ { stack: ["frontend", "tailwindcss"], any: [dep("tailwindcss"), fileIncludes("tailwind.config.")] },
1730
+ {
1731
+ stack: ["frontend", "shadcn"],
1732
+ all: [fileEndsWith("components.json"), dep("class-variance-authority")]
1733
+ },
1734
+ { stack: ["tooling", "typescript5"], any: [dep("typescript")] },
1735
+ { stack: ["backend", "sanity"], any: [dep("sanity"), dep("next-sanity"), dep("@sanity/client")] },
1736
+ { stack: ["backend", "strapi"], any: [dep("@strapi/strapi"), depPrefix("@strapi/")] },
1737
+ { stack: ["backend", "prisma"], any: [dep("prisma"), dep("@prisma/client")] },
1738
+ { stack: ["frontend", "expo"], any: [dep("expo")] },
1739
+ { stack: ["frontend", "react-native"], any: [dep("react-native")] },
1740
+ { stack: ["tooling", "i18next"], any: [dep("i18next"), dep("react-i18next")] },
1741
+ { stack: ["tooling", "bullmq"], any: [dep("bullmq")] },
1742
+ { stack: ["tooling", "docker"], any: [fileEquals("Dockerfile"), fileStartsWith("docker-compose")] },
1743
+ { stack: ["tooling", "pm2"], any: [dep("pm2"), fileIncludes("ecosystem.config")] },
1744
+ { stack: ["tooling", "sentry"], any: [depPrefix("@sentry/")] },
1745
+ { stack: ["tooling", "deployer-php"], any: [dep("deployer/deployer")] },
1746
+ { stack: ["tooling", "missing-prettier"], any: [depAbsent("prettier")] },
1747
+ { stack: ["tooling", "missing-eslint"], any: [depAbsent("eslint")] },
1748
+ {
1749
+ stack: ["tooling", "stripe"],
1750
+ any: [dep("@stripe/stripe-js"), dep("@stripe/react-stripe-js")]
1751
+ },
1752
+ { stack: ["tooling", "qliro"], any: [dep("@haus-tech/qliro-plugin")] },
1753
+ {
1754
+ stack: ["databases", "supabase"],
1755
+ any: [dep("@supabase/supabase-js"), depPrefix("@supabase/")]
1756
+ },
1757
+ { stack: ["backend", "vendure3"], any: [dep("@vendure/core")] },
1758
+ { stack: ["backend", "nestjs"], any: [dep("@nestjs/core")] },
1759
+ { stack: ["backend", "nestjs"], any: [content("NestFactory")] },
1760
+ { stack: ["backend", "vendure3"], any: [content("@VendurePlugin")] },
1761
+ { stack: ["backend", "graphql"], any: [dep("graphql"), dep("@nestjs/graphql")] },
1762
+ { stack: ["backend", "graphql"], any: [fileEndsWith(".graphql"), fileEndsWith("schema.graphql")] },
1763
+ { stack: ["backend", "laravel"], any: [dep("laravel/framework")] },
1764
+ { stack: ["backend", "laravel"], any: [fileIncludes("app/Providers/"), fileIncludes("routes/")] },
1765
+ { stack: ["backend", "wordpress"], any: [fileEndsWith("wp-config.php"), dep("roots/wordpress")] },
1766
+ {
1767
+ stack: ["backend", "elementor"],
1768
+ any: [
1769
+ dep("wpackagist-plugin/elementor"),
1770
+ dep("wearehaus/elementor-pro"),
1771
+ dep("wpackagist-theme/hello-elementor")
1772
+ ]
1773
+ },
1774
+ {
1775
+ stack: ["backend", "acf-pro"],
1776
+ any: [
1777
+ dep("wearehaus/advanced-custom-fields-pro"),
1778
+ dep("wpackagist-plugin/advanced-custom-fields")
1779
+ ]
1780
+ },
1781
+ { stack: ["backend", "jetengine"], any: [dep("wearehaus/jet-engine")] },
1782
+ { stack: ["backend", "jetsmartfilters"], any: [dep("wearehaus/jet-smart-filters")] },
1783
+ { stack: ["backend", "gravityforms"], any: [dep("wearehaus/gravityforms")] },
1784
+ { stack: ["backend", "dotnet"], any: [fileEndsWith(".csproj"), fileEndsWith(".sln")] },
1785
+ { stack: ["testing", "playwright"], any: [dep("@playwright/test")] },
1786
+ { stack: ["testing", "storybook"], any: [fileIncludes(".storybook")] },
1787
+ { stack: ["testing", "testing-library"], any: [depPrefix("@testing-library/")] },
1788
+ { stack: ["testing", "phpunit"], any: [fileEndsWith("phpunit.xml")] },
1789
+ { stack: ["testing", "storybook"], any: [depPrefix("@storybook/")] },
1790
+ { stack: ["testing", "vitest"], any: [dep("vitest")] },
1791
+ { stack: ["testing", "jest"], any: [dep("jest"), dep("jest-environment-jsdom")] },
1792
+ { stack: ["databases", "postgresql"], any: [dep("pg")] },
1793
+ { stack: ["databases", "mariadb"], any: [dep("mariadb"), dep("mysql2")] },
1794
+ { stack: ["databases", "mysql"], any: [dep("mysql"), dep("mysql2")] },
1795
+ { stack: ["databases", "mssql"], any: [dep("mssql")] },
1796
+ { stack: ["databases", "elasticsearch"], any: [dep("@elastic/elasticsearch")] },
1797
+ { stack: ["databases", "redis"], any: [dep("predis/predis"), dep("ioredis"), dep("redis")] },
1798
+ { stack: ["auth", "oidc"], any: [content("openid")] },
1799
+ { stack: ["auth", "azure-ad"], any: [content("AZURE_AD")] },
1800
+ { stack: ["auth", "bankid"], any: [content("BANKID")] },
1801
+ {
1802
+ stack: ["auth", "saml2"],
1803
+ any: [dep("24slides/laravel-saml2"), dep("aacotroneo/laravel-saml2")]
1804
+ },
1805
+ { stack: ["auth", "next-auth"], any: [dep("next-auth"), dep("@auth/core")] }
1806
+ ];
1807
+ function runDetection(ctx, rules = STACK_RULES) {
1808
+ const roles = [];
1809
+ for (const rule of ROLE_RULES) {
1810
+ if (rule.role && matchRule(rule, ctx) && !roles.includes(rule.role)) roles.push(rule.role);
1811
+ }
1812
+ const stacks = {};
1813
+ for (const bucket of STACK_BUCKETS) stacks[bucket] = [];
1814
+ for (const rule of rules) {
1815
+ if (!rule.stack || !matchRule(rule, ctx)) continue;
1816
+ const [bucket, name] = rule.stack;
1817
+ stacks[bucket] ??= [];
1818
+ if (!stacks[bucket].includes(name)) stacks[bucket].push(name);
1819
+ }
1820
+ return { roles, stacks };
1821
+ }
1822
+
1823
+ // src/scanner/detection.ts
1824
+ import path15 from "path";
1825
+ var UNSUPPORTED_MARKERS = {
1826
+ "requirements.txt": "python",
1827
+ "pyproject.toml": "python",
1828
+ "go.mod": "go",
1829
+ "Cargo.toml": "rust",
1830
+ "pom.xml": "java",
1831
+ "build.gradle": "java",
1832
+ "build.gradle.kts": "java",
1833
+ Gemfile: "ruby"
1834
+ };
1835
+ var WEAK_STACK_SIGNALS = /* @__PURE__ */ new Set(["missing-prettier", "missing-eslint"]);
1836
+ function computeDetectionStatus(roles, stacks, unsupportedSignals) {
1837
+ const hasRealStack = Object.entries(stacks).some(
1838
+ ([bucket, names]) => bucket !== "packageManagers" && names.some((n) => !WEAK_STACK_SIGNALS.has(n))
1839
+ );
1840
+ const hasRealSignal = roles.length > 0 || hasRealStack;
1841
+ if (!hasRealSignal) return "unknown";
1842
+ return unsupportedSignals.length > 0 ? "partial" : "supported";
1843
+ }
1468
1844
  function blocked(rel) {
1469
- return SENSITIVE.some((x) => x.test(rel));
1845
+ return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
1470
1846
  }
1847
+ function dependencySet(pkg, composer) {
1848
+ const depNames = /* @__PURE__ */ new Set();
1849
+ const pushObj = (obj) => {
1850
+ if (!isRecord(obj)) return;
1851
+ for (const key of Object.keys(obj)) depNames.add(key);
1852
+ };
1853
+ pushObj(pkg?.dependencies);
1854
+ pushObj(pkg?.devDependencies);
1855
+ pushObj(composer?.require);
1856
+ pushObj(composer?.["require-dev"]);
1857
+ return [...depNames].sort();
1858
+ }
1859
+ function finalizeRoles(registryRoles, deps, files) {
1860
+ const roles = new Set(registryRoles);
1861
+ const hasWpConfig = files.some((f) => f.endsWith("wp-config.php"));
1862
+ const hasBedrockLayout = files.some((f) => f.includes("web/app")) || deps.includes("roots/wordpress");
1863
+ if (hasWpConfig && hasBedrockLayout) {
1864
+ roles.add("wordpress-bedrock-site");
1865
+ roles.add("wordpress-site");
1866
+ } else if (hasWpConfig) {
1867
+ roles.add("wordpress-vanilla-site");
1868
+ roles.add("wordpress-site");
1869
+ } else if (deps.includes("roots/wordpress")) {
1870
+ roles.add("wordpress-bedrock-site");
1871
+ roles.add("wordpress-site");
1872
+ }
1873
+ return [...roles].sort();
1874
+ }
1875
+ function collectUnsupportedSignals(files) {
1876
+ return [
1877
+ ...new Set(
1878
+ files.map((f) => UNSUPPORTED_MARKERS[path15.basename(f)]).filter((s) => Boolean(s))
1879
+ )
1880
+ ].sort();
1881
+ }
1882
+
1883
+ // src/scanner/render.ts
1884
+ import { readFile } from "fs/promises";
1885
+ import path16 from "path";
1886
+
1887
+ // src/scanner/role-labels.ts
1888
+ var ROLE_LABELS = {
1889
+ "next-app": "a Next.js app",
1890
+ "react-app": "a React app",
1891
+ "vite-app": "a Vite app",
1892
+ "react-router-app": "a React Router app",
1893
+ "sanity-studio": "a Sanity Studio",
1894
+ "strapi-app": "a Strapi app",
1895
+ "expo-app": "an Expo app",
1896
+ "vendure-app": "a Vendure server",
1897
+ "vendure-plugin": "a Vendure plugin",
1898
+ "nestjs-api": "a NestJS API",
1899
+ "graphql-api": "a GraphQL API",
1900
+ "nx-monorepo": "an Nx monorepo",
1901
+ "turbo-monorepo": "a Turborepo monorepo",
1902
+ "laravel-app": "a Laravel app",
1903
+ "laravel-nova-app": "a Laravel Nova app",
1904
+ "dotnet-service": "a .NET service",
1905
+ "express-service": "an Express service",
1906
+ "wordpress-bedrock-site": "a WordPress (Bedrock) site",
1907
+ "wordpress-vanilla-site": "a WordPress site",
1908
+ "wordpress-site": "a WordPress site"
1909
+ };
1910
+ function article(word) {
1911
+ return /^[aeiou]/i.test(word) ? "an" : "a";
1912
+ }
1913
+ function friendlyRole(role) {
1914
+ const known = ROLE_LABELS[role];
1915
+ if (known) return known;
1916
+ const words = role.replace(/[-_]+/g, " ").trim();
1917
+ return words ? `${article(words)} ${words}` : "a project";
1918
+ }
1919
+ function joinRoles(labels) {
1920
+ if (labels.length === 0) return "";
1921
+ if (labels.length === 1) return labels[0];
1922
+ return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`;
1923
+ }
1924
+ function describeRepo(context) {
1925
+ const labels = context.repoRoles.map(friendlyRole);
1926
+ const roleText = joinRoles(labels);
1927
+ if (context.detectionStatus === "unknown") {
1928
+ const markers = context.unsupportedSignals.join(", ");
1929
+ const detail = markers ? ` (I see ${markers})` : "";
1930
+ return `I couldn't fully recognise this stack${detail}, so I'll apply the general workflow and security guidance rather than framework-specific help.`;
1931
+ }
1932
+ const base = roleText ? `This looks like ${roleText}, using ${context.packageManager}.` : `I recognised this project's tooling (${context.packageManager}) but not a specific framework.`;
1933
+ if (context.detectionStatus === "partial" && context.unsupportedSignals.length > 0) {
1934
+ return `${base} I also see ${context.unsupportedSignals.join(", ")}, which haus doesn't fully support \u2014 guidance covers the recognised parts.`;
1935
+ }
1936
+ return base;
1937
+ }
1938
+
1939
+ // src/scanner/render.ts
1940
+ async function buildContentBlob(root, files) {
1941
+ const candidates = files.filter(
1942
+ (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
1943
+ );
1944
+ const slice = candidates.slice(0, 300);
1945
+ const CHUNK = 24;
1946
+ const parts = [];
1947
+ for (let i = 0; i < slice.length; i += CHUNK) {
1948
+ const batch = await Promise.all(
1949
+ slice.slice(i, i + CHUNK).map(async (rel) => {
1950
+ try {
1951
+ return await readFile(path16.join(root, rel), "utf8");
1952
+ } catch {
1953
+ return "";
1954
+ }
1955
+ })
1956
+ );
1957
+ parts.push(...batch);
1958
+ }
1959
+ return parts.join("\n");
1960
+ }
1961
+ function computeConfidence(roles, stacks) {
1962
+ const stackCount = Object.values(stacks).reduce((sum, arr) => sum + arr.length, 0);
1963
+ if (roles.length === 0) return 0.15;
1964
+ return Math.min(0.99, Number((0.4 + roles.length * 0.08 + stackCount * 0.02).toFixed(2)));
1965
+ }
1966
+ function renderSummary(context) {
1967
+ return `# Repo summary
1968
+
1969
+ ${describeRepo(context)}
1970
+
1971
+ - Repo: ${context.repoName}
1972
+ - Package manager: ${context.packageManager}
1973
+ - Roles: ${context.repoRoles.join(", ") || "unknown"}
1974
+ - Generated: ${context.generatedAt}
1975
+ `;
1976
+ }
1977
+
1978
+ // src/scanner/scan-project.ts
1979
+ var SAFE_FILES = [
1980
+ "package.json",
1981
+ "yarn.lock",
1982
+ "pnpm-lock.yaml",
1983
+ "composer.json",
1984
+ "composer.lock",
1985
+ "nx.json",
1986
+ "turbo.json",
1987
+ "tsconfig.json",
1988
+ "vite.config.*",
1989
+ "next.config.*",
1990
+ "tailwind.config.*",
1991
+ "components.json",
1992
+ "playwright.config.*",
1993
+ "phpunit.xml",
1994
+ "artisan",
1995
+ "routes/*.php",
1996
+ "app/Providers/*.php",
1997
+ "schema.graphql",
1998
+ "**/*.graphql",
1999
+ "**/vendure-config.*",
2000
+ "**/*module.ts",
2001
+ "web/app/**",
2002
+ "wp-content/plugins/**",
2003
+ "wp-content/themes/**",
2004
+ "wp-content/mu-plugins/**",
2005
+ "wp-content/acf-json/**",
2006
+ ".storybook/**",
2007
+ ".env.example",
2008
+ "wp-config.php",
2009
+ "**/*.csproj",
2010
+ "**/*.sln",
2011
+ "docker-compose.*",
2012
+ "Dockerfile",
2013
+ // Unsupported-ecosystem markers — matched by PRESENCE only (never content-read; none
2014
+ // match the content-blob extensions). Drive detectionStatus / unsupportedSignals.
2015
+ "requirements.txt",
2016
+ "pyproject.toml",
2017
+ "go.mod",
2018
+ "Cargo.toml",
2019
+ "pom.xml",
2020
+ "build.gradle",
2021
+ "build.gradle.kts",
2022
+ "Gemfile"
2023
+ ];
1471
2024
  async function scanProject(root, mode = "fast") {
1472
- const pkg = await readJson(path15.join(root, "package.json"));
1473
- const composer = await readJson(path15.join(root, "composer.json"));
2025
+ const pkg = await readJson(path17.join(root, "package.json"));
2026
+ const composer = await readJson(path17.join(root, "composer.json"));
1474
2027
  const files = await listFiles(root, SAFE_FILES);
1475
2028
  const safeFiles = files.filter((f) => !blocked(f));
1476
2029
  const deps = dependencySet(pkg, composer);
1477
2030
  const packageManager = detectPackageManager(root, String(pkg?.packageManager ?? ""));
1478
- const roles = detectRoles(deps, safeFiles);
1479
- const stacks = await detectStacks(root, deps, safeFiles, packageManager);
2031
+ const contentBlob = await buildContentBlob(root, safeFiles);
2032
+ const detection = runDetection({ deps: new Set(deps), files: safeFiles, contentBlob });
2033
+ const roles = finalizeRoles(detection.roles, deps, safeFiles);
2034
+ const stacks = detection.stacks;
2035
+ if (packageManager === "yarn") stacks.packageManagers.push("yarn4");
2036
+ if (packageManager === "pnpm") stacks.packageManagers.push("pnpm89");
1480
2037
  const warnings = [];
1481
2038
  const securityRisks = [];
1482
2039
  const crossRepoHints = [];
@@ -1494,11 +2051,13 @@ async function scanProject(root, mode = "fast") {
1494
2051
  if (!safeFiles.some((f) => f.endsWith(".env.example"))) securityRisks.push("Missing env template");
1495
2052
  if (safeFiles.some((f) => f.includes("wp-content/uploads")))
1496
2053
  securityRisks.push("Uploads directory present");
2054
+ const unsupportedSignals = collectUnsupportedSignals(safeFiles);
2055
+ const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
1497
2056
  const context = {
1498
2057
  mode,
1499
2058
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1500
2059
  root,
1501
- repoName: String(pkg?.name ?? path15.basename(root)),
2060
+ repoName: String(pkg?.name ?? path17.basename(root)),
1502
2061
  packageManager,
1503
2062
  repoRoles: roles,
1504
2063
  confidence: computeConfidence(roles, stacks),
@@ -1506,7 +2065,9 @@ async function scanProject(root, mode = "fast") {
1506
2065
  dependencies: deps,
1507
2066
  securityRisks,
1508
2067
  crossRepoHints,
1509
- warnings
2068
+ warnings,
2069
+ detectionStatus,
2070
+ unsupportedSignals
1510
2071
  };
1511
2072
  const dependencyMap = {
1512
2073
  node: deps.filter((d) => !d.includes("/")),
@@ -1515,7 +2076,7 @@ async function scanProject(root, mode = "fast") {
1515
2076
  const scanHashes = Object.fromEntries(
1516
2077
  await Promise.all(
1517
2078
  safeFiles.map(
1518
- async (f) => [f, hashText(await readFile(path15.join(root, f), "utf8"))]
2079
+ async (f) => [f, hashText(await readFile2(path17.join(root, f), "utf8"))]
1519
2080
  )
1520
2081
  )
1521
2082
  );
@@ -1526,184 +2087,6 @@ async function scanProject(root, mode = "fast") {
1526
2087
  await writeText(hausPath(root, "repo-summary.md"), repoSummary);
1527
2088
  return { ...context, dependencyMap, scanHashes, repoSummary };
1528
2089
  }
1529
- function dependencySet(pkg, composer) {
1530
- const out = /* @__PURE__ */ new Set();
1531
- const pushObj = (obj) => {
1532
- if (!isRecord(obj)) return;
1533
- for (const key of Object.keys(obj)) out.add(key);
1534
- };
1535
- pushObj(pkg?.dependencies);
1536
- pushObj(pkg?.devDependencies);
1537
- pushObj(composer?.require);
1538
- pushObj(composer?.["require-dev"]);
1539
- return [...out].sort();
1540
- }
1541
- function detectRoles(deps, files) {
1542
- const roles = /* @__PURE__ */ new Set();
1543
- if (deps.includes("next") || files.some((f) => f.includes("next.config."))) roles.add("next-app");
1544
- if (deps.includes("react")) roles.add("react-app");
1545
- if (deps.includes("vite") || files.some((f) => f.includes("vite.config."))) roles.add("vite-app");
1546
- if (deps.includes("react-router") && deps.includes("@react-router/node"))
1547
- roles.add("react-router-app");
1548
- if (deps.includes("sanity")) roles.add("sanity-studio");
1549
- if (deps.includes("@strapi/strapi") || deps.some((d) => d.startsWith("@strapi/")))
1550
- roles.add("strapi-app");
1551
- if (deps.includes("expo")) roles.add("expo-app");
1552
- if (deps.includes("@vendure/core")) roles.add("vendure-app");
1553
- if (deps.some((d) => d.startsWith("@haus/vendure-")) || files.some((f) => f.includes("vendure-config")))
1554
- roles.add("vendure-plugin");
1555
- if (deps.includes("@nestjs/core")) roles.add("nestjs-api");
1556
- if (deps.includes("graphql") || deps.includes("@nestjs/graphql")) roles.add("graphql-api");
1557
- if (files.some((f) => f.endsWith("nx.json"))) roles.add("nx-monorepo");
1558
- if (files.some((f) => f.endsWith("turbo.json"))) roles.add("turbo-monorepo");
1559
- if (files.some((f) => f.endsWith("artisan")) || deps.includes("laravel/framework"))
1560
- roles.add("laravel-app");
1561
- if (deps.includes("laravel/nova")) roles.add("laravel-nova-app");
1562
- const hasWpConfig = files.some((f) => f.endsWith("wp-config.php"));
1563
- const hasBedrockLayout = files.some((f) => f.includes("web/app")) || deps.includes("roots/wordpress");
1564
- if (hasWpConfig && hasBedrockLayout) {
1565
- roles.add("wordpress-bedrock-site");
1566
- roles.add("wordpress-site");
1567
- } else if (hasWpConfig) {
1568
- roles.add("wordpress-vanilla-site");
1569
- roles.add("wordpress-site");
1570
- } else if (deps.includes("roots/wordpress")) {
1571
- roles.add("wordpress-bedrock-site");
1572
- roles.add("wordpress-site");
1573
- }
1574
- if (files.some((f) => f.endsWith(".csproj") || f.endsWith(".sln"))) roles.add("dotnet-service");
1575
- if (deps.includes("express")) roles.add("express-service");
1576
- return [...roles].sort();
1577
- }
1578
- async function detectStacks(root, deps, files, packageManager) {
1579
- const out = {
1580
- backend: [],
1581
- frontend: [],
1582
- databases: [],
1583
- testing: [],
1584
- auth: [],
1585
- tooling: [],
1586
- packageManagers: []
1587
- };
1588
- const add = (k, v) => {
1589
- out[k] ??= [];
1590
- if (!out[k].includes(v)) out[k].push(v);
1591
- };
1592
- if (deps.includes("next")) add("frontend", "nextjs");
1593
- if (deps.includes("react")) add("frontend", "react19");
1594
- if (deps.includes("vue")) add("frontend", "vue");
1595
- if (deps.includes("vite")) add("frontend", "vite8");
1596
- if (deps.includes("react-router") && deps.includes("@react-router/node"))
1597
- add("frontend", "react-router-v7");
1598
- if (deps.includes("tailwindcss") || files.some((f) => f.includes("tailwind.config."))) {
1599
- add("frontend", "tailwindcss");
1600
- }
1601
- if (files.some((f) => f.endsWith("components.json")) && deps.includes("class-variance-authority")) {
1602
- add("frontend", "shadcn");
1603
- }
1604
- if (deps.includes("typescript")) add("tooling", "typescript5");
1605
- if (deps.includes("sanity") || deps.includes("next-sanity") || deps.includes("@sanity/client")) {
1606
- add("backend", "sanity");
1607
- }
1608
- if (deps.includes("@strapi/strapi") || deps.some((d) => d.startsWith("@strapi/"))) {
1609
- add("backend", "strapi");
1610
- }
1611
- if (deps.includes("prisma") || deps.includes("@prisma/client")) add("backend", "prisma");
1612
- if (deps.includes("expo")) add("frontend", "expo");
1613
- if (deps.includes("react-native")) add("frontend", "react-native");
1614
- if (deps.includes("i18next") || deps.includes("react-i18next")) add("tooling", "i18next");
1615
- if (deps.includes("bullmq")) add("tooling", "bullmq");
1616
- if (files.some((f) => f === "Dockerfile" || f.startsWith("docker-compose")))
1617
- add("tooling", "docker");
1618
- if (deps.includes("pm2") || files.some((f) => f.includes("ecosystem.config")))
1619
- add("tooling", "pm2");
1620
- if (deps.some((d) => d.startsWith("@sentry/"))) add("tooling", "sentry");
1621
- if (deps.includes("deployer/deployer")) add("tooling", "deployer-php");
1622
- if (!deps.includes("prettier")) add("tooling", "missing-prettier");
1623
- if (!deps.includes("eslint")) add("tooling", "missing-eslint");
1624
- if (deps.includes("@stripe/stripe-js") || deps.includes("@stripe/react-stripe-js")) {
1625
- add("tooling", "stripe");
1626
- }
1627
- if (deps.includes("@haus-tech/qliro-plugin")) add("tooling", "qliro");
1628
- if (deps.includes("@supabase/supabase-js") || deps.some((d) => d.startsWith("@supabase/"))) {
1629
- add("databases", "supabase");
1630
- }
1631
- if (deps.includes("@vendure/core")) add("backend", "vendure3");
1632
- if (deps.includes("@nestjs/core")) add("backend", "nestjs");
1633
- if (await hasNeedle(root, files, "NestFactory")) add("backend", "nestjs");
1634
- if (await hasNeedle(root, files, "@VendurePlugin")) add("backend", "vendure3");
1635
- if (deps.includes("graphql") || deps.includes("@nestjs/graphql")) add("backend", "graphql");
1636
- if (files.some((f) => f.endsWith(".graphql") || f.endsWith("schema.graphql")))
1637
- add("backend", "graphql");
1638
- if (deps.includes("laravel/framework")) add("backend", "laravel");
1639
- if (files.some((f) => f.includes("app/Providers/") || f.includes("routes/")))
1640
- add("backend", "laravel");
1641
- if (files.some((f) => f.endsWith("wp-config.php")) || deps.includes("roots/wordpress"))
1642
- add("backend", "wordpress");
1643
- if (deps.includes("wpackagist-plugin/elementor") || deps.includes("wearehaus/elementor-pro") || deps.includes("wpackagist-theme/hello-elementor")) {
1644
- add("backend", "elementor");
1645
- }
1646
- if (deps.includes("wearehaus/advanced-custom-fields-pro") || deps.includes("wpackagist-plugin/advanced-custom-fields")) {
1647
- add("backend", "acf-pro");
1648
- }
1649
- if (deps.includes("wearehaus/jet-engine")) add("backend", "jetengine");
1650
- if (deps.includes("wearehaus/jet-smart-filters")) add("backend", "jetsmartfilters");
1651
- if (deps.includes("wearehaus/gravityforms")) add("backend", "gravityforms");
1652
- if (files.some((f) => f.endsWith(".csproj") || f.endsWith(".sln"))) add("backend", "dotnet");
1653
- if (deps.includes("@playwright/test")) add("testing", "playwright");
1654
- if (files.some((f) => f.includes(".storybook"))) add("testing", "storybook");
1655
- if (deps.some((d) => d.startsWith("@testing-library/"))) add("testing", "testing-library");
1656
- if (files.some((f) => f.endsWith("phpunit.xml"))) add("testing", "phpunit");
1657
- if (deps.some((d) => d.startsWith("@storybook/"))) add("testing", "storybook");
1658
- if (deps.includes("vitest")) add("testing", "vitest");
1659
- if (deps.includes("jest") || deps.includes("jest-environment-jsdom")) add("testing", "jest");
1660
- if (deps.includes("pg")) add("databases", "postgresql");
1661
- if (deps.includes("mariadb") || deps.includes("mysql2")) add("databases", "mariadb");
1662
- if (deps.includes("mysql") || deps.includes("mysql2")) add("databases", "mysql");
1663
- if (deps.includes("mssql")) add("databases", "mssql");
1664
- if (deps.includes("@elastic/elasticsearch")) add("databases", "elasticsearch");
1665
- if (deps.includes("predis/predis") || deps.includes("ioredis") || deps.includes("redis")) {
1666
- add("databases", "redis");
1667
- }
1668
- if (await hasNeedle(root, files, "openid")) add("auth", "oidc");
1669
- if (await hasNeedle(root, files, "AZURE_AD")) add("auth", "azure-ad");
1670
- if (await hasNeedle(root, files, "BANKID")) add("auth", "bankid");
1671
- if (deps.includes("24slides/laravel-saml2") || deps.includes("aacotroneo/laravel-saml2")) {
1672
- add("auth", "saml2");
1673
- }
1674
- if (deps.includes("next-auth") || deps.includes("@auth/core")) add("auth", "next-auth");
1675
- if (packageManager === "yarn") add("packageManagers", "yarn4");
1676
- if (packageManager === "pnpm") add("packageManagers", "pnpm89");
1677
- return out;
1678
- }
1679
- async function hasNeedle(root, files, needle) {
1680
- const candidates = files.filter(
1681
- (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
1682
- );
1683
- for (const rel of candidates.slice(0, 300)) {
1684
- try {
1685
- const content = await readFile(path15.join(root, rel), "utf8");
1686
- if (content.includes(needle)) return true;
1687
- } catch {
1688
- continue;
1689
- }
1690
- }
1691
- return false;
1692
- }
1693
- function computeConfidence(roles, stacks) {
1694
- const stackCount = Object.values(stacks).reduce((sum, arr) => sum + arr.length, 0);
1695
- if (roles.length === 0) return 0.15;
1696
- return Math.min(0.99, Number((0.4 + roles.length * 0.08 + stackCount * 0.02).toFixed(2)));
1697
- }
1698
- function renderSummary(context) {
1699
- return `# Repo summary
1700
-
1701
- - Repo: ${context.repoName}
1702
- - Package manager: ${context.packageManager}
1703
- - Roles: ${context.repoRoles.join(", ") || "unknown"}
1704
- - Generated: ${context.generatedAt}
1705
- `;
1706
- }
1707
2090
 
1708
2091
  // src/scanner/read-context.ts
1709
2092
  async function readContextOrScan(root) {
@@ -1727,7 +2110,9 @@ async function runContext(options) {
1727
2110
  (recommendationRaw?.recommended ?? []).map((item) => [item.id, item.scoreBreakdown])
1728
2111
  );
1729
2112
  const taskIntents = options.task ? classifyTaskIntents(options.task) : /* @__PURE__ */ new Set();
1730
- const selected = pickTaskRelevantRules(recommendation, options.task, taskIntents);
2113
+ const selected = pickTaskRelevantRules(recommendation, options.task, taskIntents, {
2114
+ tokenBudget: DEFAULT_CONTEXT_TOKEN_BUDGET
2115
+ });
1731
2116
  const payload = {
1732
2117
  task: options.task ?? "not provided",
1733
2118
  taskIntents: [...taskIntents].sort(),
@@ -1775,8 +2160,8 @@ async function runContext(options) {
1775
2160
  }
1776
2161
 
1777
2162
  // src/commands/doctor.ts
1778
- import path16 from "path";
1779
- import fs11 from "fs-extra";
2163
+ import path18 from "path";
2164
+ import fs12 from "fs-extra";
1780
2165
 
1781
2166
  // src/update/npm-version.ts
1782
2167
  var NPM_PACKAGE_NAME = "@haus-tech/haus-workflow";
@@ -1823,114 +2208,200 @@ async function runDoctor(options) {
1823
2208
  const recommendation = await readJson(
1824
2209
  hausPath(root, "recommendation.json")
1825
2210
  );
1826
- log("Haus Doctor");
1827
- log(`Repo: ${context.repoName}`);
1828
- log(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
1829
- log(`Package manager: ${context.packageManager}`);
1830
- log(`Recommended items: ${recommendation?.recommended?.length ?? 0}`);
2211
+ const detail = [];
2212
+ const attention = [];
2213
+ const ok = (text) => detail.push({ stream: "log", text });
2214
+ const flag = (text, sentence, fix) => {
2215
+ detail.push({ stream: "warn", text });
2216
+ attention.push({ sentence, fix });
2217
+ };
2218
+ ok(`Repo: ${context.repoName}`);
2219
+ ok(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
2220
+ ok(`Package manager: ${context.packageManager}`);
2221
+ ok(`Recommended items: ${recommendation?.recommended?.length ?? 0}`);
1831
2222
  const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation?.warnings ?? []])];
1832
2223
  for (const warning of warningLines) {
1833
- log(`- WARN: ${warning}`);
2224
+ ok(`- WARN: ${warning}`);
1834
2225
  }
1835
2226
  const hooks = await verifyProjectSettingsHooksContract(root);
1836
2227
  if (hooks.skipped) {
1837
- log(`- HOOKS: (skipped) ${hooks.message}`);
2228
+ ok(`- HOOKS: (skipped) ${hooks.message}`);
1838
2229
  } else if (!hooks.ok) {
1839
- log(`- HOOKS FAIL: ${hooks.message}`);
2230
+ flag(
2231
+ `- HOOKS FAIL: ${hooks.message}`,
2232
+ "The Claude Code hooks don't match what haus expects",
2233
+ "haus apply --write"
2234
+ );
1840
2235
  process.exitCode = 1;
1841
2236
  } else {
1842
- log(`- HOOKS OK: ${hooks.message}`);
2237
+ ok(`- HOOKS OK: ${hooks.message}`);
1843
2238
  }
1844
- const gatedHooks = ["context", "memoryInject"];
2239
+ const gatedHooks = ["context"];
1845
2240
  for (const key of gatedHooks) {
1846
2241
  const enabled = await isHookEnabled(root, key);
1847
- log(`- HOOK ${key}: ${enabled ? "enabled" : "disabled (default)"}`);
2242
+ ok(`- HOOK ${key}: ${enabled ? "enabled" : "disabled (default)"}`);
1848
2243
  }
1849
- const rootClaudeMdPath = path16.join(root, "CLAUDE.md");
2244
+ const rootClaudeMdPath = path18.join(root, "CLAUDE.md");
1850
2245
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
1851
2246
  if (!rootClaudeMdContent) {
1852
- warn("- CLAUDE.md: missing (run `haus apply --write` to create)");
2247
+ flag(
2248
+ "- CLAUDE.md: missing (run `haus apply --write` to create)",
2249
+ "Your project's CLAUDE.md is missing, so haus guidance never loads",
2250
+ "haus apply --write"
2251
+ );
1853
2252
  } else if (!rootClaudeMdContent.includes(BLOCK_BEGIN)) {
1854
- warn("- CLAUDE.md: haus import block missing (run `haus apply --write` to add)");
2253
+ flag(
2254
+ "- CLAUDE.md: haus import block missing (run `haus apply --write` to add)",
2255
+ "The haus import block is missing from CLAUDE.md, so its guidance never loads",
2256
+ "haus apply --write"
2257
+ );
1855
2258
  } else {
1856
- log("- CLAUDE.md: import block present");
2259
+ const beginIdx = rootClaudeMdContent.indexOf(BLOCK_BEGIN);
2260
+ const endIdx = rootClaudeMdContent.indexOf(BLOCK_END, beginIdx + BLOCK_BEGIN.length);
2261
+ if (endIdx < 0) {
2262
+ flag(
2263
+ "- CLAUDE.md: haus import block is not closed (run `haus apply --write` to repair)",
2264
+ "The haus import block in CLAUDE.md is broken, so its guidance may not load",
2265
+ "haus apply --write"
2266
+ );
2267
+ } else {
2268
+ ok("- CLAUDE.md: import block present");
2269
+ const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
2270
+ const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
2271
+ for (const target of importTargets) {
2272
+ if (!await fs12.pathExists(hausPath(root, target))) {
2273
+ flag(
2274
+ `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
2275
+ `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
2276
+ "haus apply --write"
2277
+ );
2278
+ }
2279
+ }
2280
+ }
1857
2281
  }
1858
2282
  const workflowPath = hausPath(root, "WORKFLOW.md");
1859
- const workflowExists = await fs11.pathExists(workflowPath);
2283
+ const workflowExists = await fs12.pathExists(workflowPath);
1860
2284
  if (!workflowExists) {
1861
- warn("- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)");
2285
+ flag(
2286
+ "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
2287
+ "The workflow standard file is missing",
2288
+ "haus apply --write"
2289
+ );
1862
2290
  } else {
1863
2291
  const workflowContent = await readText(workflowPath);
1864
2292
  const firstLine = workflowContent?.split("\n")[0] ?? "";
1865
2293
  if (!firstLine.includes("HAUS-MANAGED")) {
1866
- log("- .haus-workflow/WORKFLOW.md: OK (user-owned)");
2294
+ ok("- .haus-workflow/WORKFLOW.md: OK (user-owned)");
1867
2295
  } else {
1868
2296
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
1869
- const cachePath = path16.join(CACHE_DIR, "templates/agentic-workflow-standard.md");
1870
- const bundledPath = path16.join(
2297
+ const cachePath = path18.join(CACHE_DIR, "templates/agentic-workflow-standard.md");
2298
+ const bundledPath = path18.join(
1871
2299
  packageRoot(),
1872
2300
  "library",
1873
2301
  "global",
1874
2302
  "templates",
1875
2303
  "agentic-workflow-standard.md"
1876
2304
  );
1877
- const templatePath = await fs11.pathExists(cachePath) ? cachePath : bundledPath;
2305
+ const templatePath = await fs12.pathExists(cachePath) ? cachePath : bundledPath;
1878
2306
  const templateContent = await readText(templatePath);
1879
2307
  if (storedHashMatch && templateContent) {
1880
2308
  const currentHash = hashText(normaliseLF(templateContent));
1881
2309
  if (storedHashMatch[1] !== currentHash) {
1882
- warn("- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)");
2310
+ flag(
2311
+ "- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
2312
+ "The workflow standard is out of date",
2313
+ "haus apply --write"
2314
+ );
1883
2315
  } else {
1884
- log("- .haus-workflow/WORKFLOW.md: OK");
2316
+ ok("- .haus-workflow/WORKFLOW.md: OK");
1885
2317
  }
1886
2318
  } else {
1887
- log("- .haus-workflow/WORKFLOW.md: OK");
2319
+ ok("- .haus-workflow/WORKFLOW.md: OK");
1888
2320
  }
1889
2321
  }
1890
2322
  }
1891
2323
  const workflowConfigPath = hausPath(root, "workflow-config.md");
1892
- const workflowConfigExists = await fs11.pathExists(workflowConfigPath);
2324
+ const workflowConfigExists = await fs12.pathExists(workflowConfigPath);
1893
2325
  if (!workflowConfigExists) {
1894
- warn("- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)");
2326
+ flag(
2327
+ "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
2328
+ "The workflow config file is missing",
2329
+ "haus apply --write"
2330
+ );
1895
2331
  } else {
1896
- log("- .haus-workflow/workflow-config.md: OK (project-owned)");
2332
+ const cfg = await fs12.readFile(workflowConfigPath, "utf8");
2333
+ const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
2334
+ if (unfilled > 0) {
2335
+ flag(
2336
+ `- .haus-workflow/workflow-config.md: ${unfilled} field(s) still unfilled (run \`haus apply --refill-config\` to auto-fill detectable ones)`,
2337
+ `${unfilled} workflow-config field(s) are still blank`,
2338
+ "haus apply --refill-config"
2339
+ );
2340
+ } else {
2341
+ ok("- .haus-workflow/workflow-config.md: OK (project-owned)");
2342
+ }
1897
2343
  }
1898
2344
  const projectMdPath = hausPath(root, "project.md");
1899
- const projectMdExists = await fs11.pathExists(projectMdPath);
2345
+ const projectMdExists = await fs12.pathExists(projectMdPath);
1900
2346
  if (!projectMdExists) {
1901
- warn("- .haus-workflow/project.md: missing (run `haus apply --write`)");
2347
+ flag(
2348
+ "- .haus-workflow/project.md: missing (run `haus apply --write`)",
2349
+ "The project facts file is missing",
2350
+ "haus apply --write"
2351
+ );
1902
2352
  } else {
1903
2353
  const projectMdContent = await readText(projectMdPath);
1904
2354
  const hasHeader = projectMdContent?.split("\n")[0]?.includes("HAUS-MANAGED") ?? false;
1905
2355
  if (!hasHeader) {
1906
- warn("- .haus-workflow/project.md: no HAUS-MANAGED header (user-owned)");
2356
+ ok("- .haus-workflow/project.md: no HAUS-MANAGED header (user-owned)");
1907
2357
  } else {
1908
- log("- .haus-workflow/project.md: OK");
2358
+ ok("- .haus-workflow/project.md: OK");
1909
2359
  }
1910
2360
  }
1911
2361
  const cacheAgeMs = await getCacheManifestAge();
1912
2362
  if (cacheAgeMs === null) {
1913
- warn("- CATALOG CACHE: absent (run `haus update` to populate)");
2363
+ flag(
2364
+ "- CATALOG CACHE: absent (run `haus update` to populate)",
2365
+ "The catalog cache hasn't been downloaded yet",
2366
+ "haus update"
2367
+ );
1914
2368
  } else {
1915
2369
  const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
1916
2370
  if (cacheAgeDays >= 7) {
1917
- warn(`- CATALOG CACHE: stale (${cacheAgeDays}d old \u2014 run \`haus update\`)`);
2371
+ flag(
2372
+ `- CATALOG CACHE: stale (${cacheAgeDays}d old \u2014 run \`haus update\`)`,
2373
+ `The catalog cache is ${cacheAgeDays} days old`,
2374
+ "haus update"
2375
+ );
1918
2376
  } else {
1919
- log(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
2377
+ ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
1920
2378
  }
1921
2379
  }
1922
- const pkgJson = await readJson(path16.join(packageRoot(), "package.json"));
2380
+ const pkgJson = await readJson(path18.join(packageRoot(), "package.json"));
1923
2381
  const currentVersion = pkgJson?.version ?? "0.0.0";
1924
2382
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
1925
2383
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
1926
- warn(
1927
- `- CLI UPDATE: ${currentVersion} \u2192 ${npmStatus.latest} available (run: npm install -g ${NPM_PACKAGE_NAME})`
2384
+ flag(
2385
+ `- CLI UPDATE: ${currentVersion} \u2192 ${npmStatus.latest} available (run: npm install -g ${NPM_PACKAGE_NAME})`,
2386
+ `A newer haus (${npmStatus.latest}) is available`,
2387
+ `npm install -g ${NPM_PACKAGE_NAME}`
1928
2388
  );
1929
2389
  process.exitCode = 1;
1930
2390
  } else if (npmStatus.latest !== null) {
1931
- log(`- CLI: ${currentVersion} (up to date)`);
2391
+ ok(`- CLI: ${currentVersion} (up to date)`);
1932
2392
  } else {
1933
- log(`- CLI: ${currentVersion} (version check unavailable)`);
2393
+ ok(`- CLI: ${currentVersion} (version check unavailable)`);
2394
+ }
2395
+ if (attention.length === 0) {
2396
+ log("\u2705 Your project is set up and healthy.");
2397
+ } else {
2398
+ log(`\u26A0\uFE0F ${attention.length} thing(s) need attention:`);
2399
+ for (const a of attention) log(` \u2022 ${a.sentence} \u2014 fix: ${a.fix}`);
2400
+ }
2401
+ log("Haus Doctor");
2402
+ for (const line2 of detail) {
2403
+ if (line2.stream === "warn") warn(line2.text);
2404
+ else log(line2.text);
1934
2405
  }
1935
2406
  }
1936
2407
 
@@ -1989,57 +2460,17 @@ async function runExplainRecommendation(options) {
1989
2460
  // src/commands/guard.ts
1990
2461
  import { readFileSync as readFileSync2 } from "fs";
1991
2462
 
1992
- // src/security/dangerous-commands.ts
1993
- var DANGEROUS_COMMANDS = [
1994
- "rm -rf",
1995
- "sudo",
1996
- "chmod -R 777",
1997
- "chown -R",
1998
- "git push --force",
1999
- "git reset --hard",
2000
- "docker system prune",
2001
- "drop database",
2002
- "truncate table",
2003
- "php artisan migrate --force",
2004
- "npm publish",
2005
- "yarn npm publish",
2006
- "pnpm publish"
2007
- ];
2008
-
2009
2463
  // src/security/guard-bash.ts
2010
2464
  function guardBash(command) {
2011
2465
  const matched = DANGEROUS_COMMANDS.find((token) => command.includes(token));
2012
- if (matched) return `Blocked dangerous command: ${command}`;
2466
+ if (matched) return `I didn't run that \u2014 it can permanently change or delete things: ${command}`;
2013
2467
  return void 0;
2014
2468
  }
2015
2469
 
2016
- // src/security/sensitive-paths.ts
2017
- var SENSITIVE_PATHS = [
2018
- ".env",
2019
- ".env.*",
2020
- "*.pem",
2021
- "*.key",
2022
- "*.p12",
2023
- "*.pfx",
2024
- "id_rsa",
2025
- "id_ed25519",
2026
- "*.sql",
2027
- "*.dump",
2028
- "*.backup",
2029
- "*.bak",
2030
- "storage/logs",
2031
- "wp-content/uploads",
2032
- "uploads",
2033
- "customer-data",
2034
- "exports",
2035
- "secrets",
2036
- "certs"
2037
- ];
2038
-
2039
2470
  // src/security/guard-file-access.ts
2040
2471
  function guardFileAccess(candidate) {
2041
2472
  const matched = SENSITIVE_PATHS.find((token) => candidate.includes(token.replace("*", "")));
2042
- if (matched) return `Blocked sensitive path: ${candidate}`;
2473
+ if (matched) return `I didn't open ${candidate} \u2014 it looks like it holds secrets or sensitive data`;
2043
2474
  return void 0;
2044
2475
  }
2045
2476
 
@@ -2071,23 +2502,136 @@ async function runGuard(kind, _options) {
2071
2502
  const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
2072
2503
  if (kind === "file-access") {
2073
2504
  const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
2074
- if (guardFileAccess(candidate)) {
2075
- deny(`Blocked sensitive path: ${candidate}`);
2505
+ const reason2 = guardFileAccess(candidate);
2506
+ if (reason2) {
2507
+ deny(reason2);
2076
2508
  process.exitCode = 1;
2077
2509
  return;
2078
2510
  }
2079
2511
  return;
2080
2512
  }
2081
2513
  const command = String(toolInput.command ?? "");
2082
- if (guardBash(command) || DANGEROUS_COMMANDS.some((token) => command.includes(token))) {
2083
- deny(`Blocked dangerous command: ${command}`);
2514
+ const reason = guardBash(command);
2515
+ if (reason) {
2516
+ deny(reason);
2084
2517
  process.exitCode = 1;
2085
2518
  }
2086
2519
  }
2087
2520
 
2088
2521
  // src/commands/init.ts
2089
- import path17 from "path";
2090
- import fs12 from "fs-extra";
2522
+ import path19 from "path";
2523
+ import fs13 from "fs-extra";
2524
+
2525
+ // src/recommender/ecosystem.ts
2526
+ var ECOSYSTEM_GROUPS = {
2527
+ laravel: ["laravel-app", "laravel-nova-app"],
2528
+ wordpress: ["wordpress-site", "wordpress-bedrock-site", "wordpress-vanilla-site"],
2529
+ vendure: ["vendure-app", "vendure-plugin"],
2530
+ nestjs: ["nestjs-api"],
2531
+ nextjs: ["next-app"],
2532
+ react: ["react-app", "next-app", "design-system"],
2533
+ vue: ["vue-app"],
2534
+ dotnet: ["dotnet-service"],
2535
+ nx: ["nx-monorepo"],
2536
+ turbo: ["turbo-monorepo"]
2537
+ };
2538
+ var ECOSYSTEM_PRIMARY_BACKENDS = /* @__PURE__ */ new Set([
2539
+ "laravel",
2540
+ "wordpress",
2541
+ "vendure",
2542
+ "nestjs",
2543
+ "dotnet"
2544
+ ]);
2545
+ var ECOSYSTEM_COMPATIBLE_BACKENDS = {
2546
+ vendure: /* @__PURE__ */ new Set(["vendure", "nestjs"]),
2547
+ nestjs: /* @__PURE__ */ new Set(["nestjs"]),
2548
+ laravel: /* @__PURE__ */ new Set(["laravel"]),
2549
+ wordpress: /* @__PURE__ */ new Set(["wordpress"]),
2550
+ dotnet: /* @__PURE__ */ new Set(["dotnet"])
2551
+ };
2552
+ function inferRepoEcosystems(roles) {
2553
+ const ecosystems = /* @__PURE__ */ new Set();
2554
+ for (const [eco, roleList] of Object.entries(ECOSYSTEM_GROUPS)) {
2555
+ if (roleList.some((r) => roles.includes(r))) ecosystems.add(eco);
2556
+ }
2557
+ return [...ecosystems];
2558
+ }
2559
+ function pickDominantBackend(ecosystems) {
2560
+ for (const eco of ecosystems) {
2561
+ if (ECOSYSTEM_PRIMARY_BACKENDS.has(eco)) return eco;
2562
+ }
2563
+ return void 0;
2564
+ }
2565
+ function isBackendEcosystem(eco) {
2566
+ return ECOSYSTEM_PRIMARY_BACKENDS.has(eco);
2567
+ }
2568
+
2569
+ // src/recommender/policies.ts
2570
+ var UNSUPPORTED = [
2571
+ "python",
2572
+ "django",
2573
+ "go",
2574
+ "rust",
2575
+ "java",
2576
+ "spring",
2577
+ "kotlin",
2578
+ "swift",
2579
+ "android",
2580
+ "flutter",
2581
+ "dart",
2582
+ "c++",
2583
+ "perl",
2584
+ "defi",
2585
+ "trading"
2586
+ ];
2587
+ function matchRequiresAny(clauses, ctx) {
2588
+ for (const clause of clauses) {
2589
+ if ("stack" in clause) {
2590
+ if (ctx.stackSet.has(clause.stack.toLowerCase())) {
2591
+ return { matched: true, signal: `stack:${clause.stack}` };
2592
+ }
2593
+ } else if ("dependency" in clause) {
2594
+ if (ctx.depSet.has(clause.dependency.toLowerCase())) {
2595
+ return { matched: true, signal: `dependency:${clause.dependency}` };
2596
+ }
2597
+ } else if ("packageNamePattern" in clause) {
2598
+ const pattern = clause.packageNamePattern.toLowerCase();
2599
+ const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
2600
+ for (const dep2 of ctx.depSet) {
2601
+ if (pattern.endsWith("*") ? dep2.startsWith(prefix) : dep2 === pattern) {
2602
+ return {
2603
+ matched: true,
2604
+ signal: `packageNamePattern:${clause.packageNamePattern}`
2605
+ };
2606
+ }
2607
+ }
2608
+ } else if ("role" in clause) {
2609
+ if (ctx.roleSet.has(clause.role.toLowerCase())) {
2610
+ return { matched: true, signal: `role:${clause.role}` };
2611
+ }
2612
+ }
2613
+ }
2614
+ return { matched: false };
2615
+ }
2616
+ function describeRequiresAny(clauses) {
2617
+ return clauses.map((c) => {
2618
+ if ("stack" in c) return `stack=${c.stack}`;
2619
+ if ("dependency" in c) return `dependency=${c.dependency}`;
2620
+ if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
2621
+ if ("role" in c) return `role=${c.role}`;
2622
+ return "unknown";
2623
+ }).join(" | ");
2624
+ }
2625
+ function mergeRecommendationWarnings(context) {
2626
+ const markers = context.unsupportedSignals?.join(", ");
2627
+ const statusLines = context.detectionStatus === "unknown" ? [
2628
+ markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support. Only stack-agnostic workflow and security guidance is applied.` : "Stack not recognised \u2014 no supported framework detected. Only stack-agnostic workflow and security guidance is applied."
2629
+ ] : context.detectionStatus === "partial" && markers ? [
2630
+ `Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
2631
+ ] : [];
2632
+ const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
2633
+ return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
2634
+ }
2091
2635
 
2092
2636
  // src/utils/exec.ts
2093
2637
  import { execa } from "execa";
@@ -2110,49 +2654,43 @@ async function runCommand(command, args = [], options = {}) {
2110
2654
  throw new Error(`Failed to run command: ${command} ${args.join(" ")} (${message})`);
2111
2655
  }
2112
2656
  }
2113
- async function runGit(args, options = {}) {
2114
- return runCommand("git", args, options);
2115
- }
2657
+ async function runGit(args, options = {}) {
2658
+ return runCommand("git", args, options);
2659
+ }
2660
+
2661
+ // src/recommender/scoring.ts
2662
+ function computeConfidenceLevel(args) {
2663
+ const { isDefaultBaseline, reasons, hasEcosystemConflict, score } = args;
2664
+ const positiveCodes = new Set(reasons.map((r) => r.code));
2665
+ positiveCodes.delete("default-baseline");
2666
+ const distinctSignals = positiveCodes.size;
2667
+ const strongCount = (positiveCodes.has("repo-role-match") ? 1 : 0) + (positiveCodes.has("stack-match") ? 1 : 0) + (positiveCodes.has("requires-any-match") ? 1 : 0);
2668
+ if (hasEcosystemConflict) return "low";
2669
+ if (isDefaultBaseline && distinctSignals === 0) return "medium";
2670
+ if (strongCount >= 2 && score >= 70) return "high";
2671
+ if (strongCount >= 1 && distinctSignals >= 2 && score >= 50) return "medium";
2672
+ if (distinctSignals === 1) return "low";
2673
+ return distinctSignals >= 2 ? "medium" : "low";
2674
+ }
2675
+ function confidenceLevelToNumber(level, score) {
2676
+ const base = level === "high" ? 0.85 : level === "medium" ? 0.6 : 0.3;
2677
+ const bonus = Math.min(0.1, Math.max(0, score - 40) / 1e3);
2678
+ return Number(Math.min(0.99, base + bonus).toFixed(2));
2679
+ }
2680
+ async function readChangedFiles(root) {
2681
+ if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
2682
+ try {
2683
+ const result = await runGit(["diff", "--name-only"], { cwd: root });
2684
+ if (result.exitCode !== 0) {
2685
+ return [];
2686
+ }
2687
+ return result.stdout.split("\n").map((x) => x.trim()).filter(Boolean).sort();
2688
+ } catch {
2689
+ return [];
2690
+ }
2691
+ }
2116
2692
 
2117
2693
  // src/recommender/recommend.ts
2118
- var UNSUPPORTED = [
2119
- "python",
2120
- "django",
2121
- "go",
2122
- "rust",
2123
- "java",
2124
- "spring",
2125
- "kotlin",
2126
- "swift",
2127
- "android",
2128
- "flutter",
2129
- "dart",
2130
- "c++",
2131
- "perl",
2132
- "defi",
2133
- "trading"
2134
- ];
2135
- var SENSITIVE2 = [".env", "secrets", "certs", "customer-data", "exports", ".pem", ".key"];
2136
- var ECOSYSTEM_GROUPS = {
2137
- laravel: ["laravel-app", "laravel-nova-app"],
2138
- wordpress: ["wordpress-site", "wordpress-bedrock-site", "wordpress-vanilla-site"],
2139
- vendure: ["vendure-app", "vendure-plugin"],
2140
- nestjs: ["nestjs-api"],
2141
- nextjs: ["next-app"],
2142
- react: ["react-app", "next-app", "design-system"],
2143
- vue: ["vue-app"],
2144
- dotnet: ["dotnet-service"],
2145
- nx: ["nx-monorepo"],
2146
- turbo: ["turbo-monorepo"]
2147
- };
2148
- var ECOSYSTEM_PRIMARY_BACKENDS = /* @__PURE__ */ new Set(["laravel", "wordpress", "vendure", "nestjs", "dotnet"]);
2149
- var ECOSYSTEM_COMPATIBLE_BACKENDS = {
2150
- vendure: /* @__PURE__ */ new Set(["vendure", "nestjs"]),
2151
- nestjs: /* @__PURE__ */ new Set(["nestjs"]),
2152
- laravel: /* @__PURE__ */ new Set(["laravel"]),
2153
- wordpress: /* @__PURE__ */ new Set(["wordpress"]),
2154
- dotnet: /* @__PURE__ */ new Set(["dotnet"])
2155
- };
2156
2694
  async function recommend(root, context) {
2157
2695
  const items = await loadCatalog(root);
2158
2696
  const setupAnswers = await readJson(hausPath(root, "setup-answers.json")) ?? {};
@@ -2171,8 +2709,8 @@ async function recommend(root, context) {
2171
2709
  const changedFiles = await readChangedFiles(root);
2172
2710
  const securityRiskCount = context.securityRisks?.length ?? 0;
2173
2711
  for (const item of items) {
2174
- const blob = `${item.id} ${item.tags.join(" ")}`.toLowerCase();
2175
- if (UNSUPPORTED.some((x) => blob.includes(x))) {
2712
+ const itemSearchText = `${item.id} ${item.tags.join(" ")}`.toLowerCase();
2713
+ if (UNSUPPORTED.some((x) => itemSearchText.includes(x))) {
2176
2714
  skipped.push({
2177
2715
  id: item.id,
2178
2716
  reason: "Unsupported stack policy",
@@ -2334,7 +2872,7 @@ async function recommend(root, context) {
2334
2872
  );
2335
2873
  }
2336
2874
  }
2337
- if (SENSITIVE2.some((x) => blob.includes(x))) {
2875
+ if (SENSITIVE_ITEM_KEYWORDS.some((x) => itemSearchText.includes(x))) {
2338
2876
  pushSkipReason("sensitive-policy", "Sensitive content policy block", 100);
2339
2877
  }
2340
2878
  const trust = sourceTrust.get(item.source);
@@ -2389,7 +2927,8 @@ async function recommend(root, context) {
2389
2927
  finalScore: score
2390
2928
  },
2391
2929
  tags: item.tags,
2392
- ecosystem: item.ecosystem
2930
+ ecosystem: item.ecosystem,
2931
+ tokenEstimate: item.tokenEstimate
2393
2932
  });
2394
2933
  } else {
2395
2934
  if (skipReasons.length === 0) {
@@ -2430,94 +2969,6 @@ function buildStackSet(context) {
2430
2969
  )
2431
2970
  );
2432
2971
  }
2433
- function inferRepoEcosystems(roles) {
2434
- const ecosystems = /* @__PURE__ */ new Set();
2435
- for (const [eco, roleList] of Object.entries(ECOSYSTEM_GROUPS)) {
2436
- if (roleList.some((r) => roles.includes(r))) ecosystems.add(eco);
2437
- }
2438
- return [...ecosystems];
2439
- }
2440
- function pickDominantBackend(ecosystems) {
2441
- for (const eco of ecosystems) {
2442
- if (ECOSYSTEM_PRIMARY_BACKENDS.has(eco)) return eco;
2443
- }
2444
- return void 0;
2445
- }
2446
- function isBackendEcosystem(eco) {
2447
- return ECOSYSTEM_PRIMARY_BACKENDS.has(eco);
2448
- }
2449
- function matchRequiresAny(clauses, ctx) {
2450
- for (const clause of clauses) {
2451
- if ("stack" in clause) {
2452
- if (ctx.stackSet.has(clause.stack.toLowerCase())) {
2453
- return { matched: true, signal: `stack:${clause.stack}` };
2454
- }
2455
- } else if ("dependency" in clause) {
2456
- if (ctx.depSet.has(clause.dependency.toLowerCase())) {
2457
- return { matched: true, signal: `dependency:${clause.dependency}` };
2458
- }
2459
- } else if ("packageNamePattern" in clause) {
2460
- const pattern = clause.packageNamePattern.toLowerCase();
2461
- const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
2462
- for (const dep of ctx.depSet) {
2463
- if (pattern.endsWith("*") ? dep.startsWith(prefix) : dep === pattern) {
2464
- return {
2465
- matched: true,
2466
- signal: `packageNamePattern:${clause.packageNamePattern}`
2467
- };
2468
- }
2469
- }
2470
- } else if ("role" in clause) {
2471
- if (ctx.roleSet.has(clause.role.toLowerCase())) {
2472
- return { matched: true, signal: `role:${clause.role}` };
2473
- }
2474
- }
2475
- }
2476
- return { matched: false };
2477
- }
2478
- function describeRequiresAny(clauses) {
2479
- return clauses.map((c) => {
2480
- if ("stack" in c) return `stack=${c.stack}`;
2481
- if ("dependency" in c) return `dependency=${c.dependency}`;
2482
- if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
2483
- if ("role" in c) return `role=${c.role}`;
2484
- return "unknown";
2485
- }).join(" | ");
2486
- }
2487
- function computeConfidenceLevel(args) {
2488
- const { isDefaultBaseline, reasons, hasEcosystemConflict, score } = args;
2489
- const positiveCodes = new Set(reasons.map((r) => r.code));
2490
- positiveCodes.delete("default-baseline");
2491
- const distinctSignals = positiveCodes.size;
2492
- const strongCount = (positiveCodes.has("repo-role-match") ? 1 : 0) + (positiveCodes.has("stack-match") ? 1 : 0) + (positiveCodes.has("requires-any-match") ? 1 : 0);
2493
- if (hasEcosystemConflict) return "low";
2494
- if (isDefaultBaseline && distinctSignals === 0) return "medium";
2495
- if (strongCount >= 2 && score >= 70) return "high";
2496
- if (strongCount >= 1 && distinctSignals >= 2 && score >= 50) return "medium";
2497
- if (distinctSignals === 1) return "low";
2498
- return distinctSignals >= 2 ? "medium" : "low";
2499
- }
2500
- function confidenceLevelToNumber(level, score) {
2501
- const base = level === "high" ? 0.85 : level === "medium" ? 0.6 : 0.3;
2502
- const bonus = Math.min(0.1, Math.max(0, score - 40) / 1e3);
2503
- return Number(Math.min(0.99, base + bonus).toFixed(2));
2504
- }
2505
- function mergeRecommendationWarnings(context) {
2506
- const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
2507
- return [.../* @__PURE__ */ new Set([...context.warnings, ...riskLines])];
2508
- }
2509
- async function readChangedFiles(root) {
2510
- if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
2511
- try {
2512
- const result = await runGit(["diff", "--name-only"], { cwd: root });
2513
- if (result.exitCode !== 0) {
2514
- return [];
2515
- }
2516
- return result.stdout.split("\n").map((x) => x.trim()).filter(Boolean).sort();
2517
- } catch {
2518
- return [];
2519
- }
2520
- }
2521
2972
 
2522
2973
  // src/utils/prompts.ts
2523
2974
  import { stdin as input, stdout as output } from "process";
@@ -2565,8 +3016,13 @@ async function runSetupProject(options) {
2565
3016
  merged[question] = existing[question] ?? "pending-user-answer";
2566
3017
  continue;
2567
3018
  }
3019
+ const prefilled = existing[question];
3020
+ if (prefilled && prefilled !== "pending-user-answer" && prefilled !== "no-answer") {
3021
+ merged[question] = prefilled;
3022
+ continue;
3023
+ }
2568
3024
  const answer = await ask(question);
2569
- merged[question] = answer || existing[question] || "no-answer";
3025
+ merged[question] = answer || prefilled || "no-answer";
2570
3026
  }
2571
3027
  await writeJson(hausPath(root, "setup-answers.json"), merged);
2572
3028
  }
@@ -2630,8 +3086,8 @@ async function runSetupProject(options) {
2630
3086
  // src/commands/init.ts
2631
3087
  async function runInit(options) {
2632
3088
  const root = process.cwd();
2633
- const hausDir = path17.join(root, ".haus-workflow");
2634
- const alreadyInit = await fs12.pathExists(hausDir);
3089
+ const hausDir = path19.join(root, ".haus-workflow");
3090
+ const alreadyInit = await fs13.pathExists(hausDir);
2635
3091
  if (alreadyInit) {
2636
3092
  log("Haus AI already initialized in this project.");
2637
3093
  log("Run `haus setup-project` to reconfigure.");
@@ -2643,8 +3099,21 @@ async function runInit(options) {
2643
3099
 
2644
3100
  // src/install/apply.ts
2645
3101
  import crypto2 from "crypto";
2646
- import path20 from "path";
2647
- import fs14 from "fs-extra";
3102
+ import path22 from "path";
3103
+ import fs15 from "fs-extra";
3104
+
3105
+ // src/install/allow-rules.ts
3106
+ var ALLOWED_SUBCOMMANDS = [
3107
+ "setup-project",
3108
+ "apply",
3109
+ "doctor",
3110
+ "scan",
3111
+ "context",
3112
+ "recommend"
3113
+ ];
3114
+ function buildAllowRules() {
3115
+ return [...new Set(ALLOWED_SUBCOMMANDS.map((sub) => `Bash(haus ${sub}:*)`))];
3116
+ }
2648
3117
 
2649
3118
  // src/install/header.ts
2650
3119
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -2656,35 +3125,35 @@ function parseAttrs(raw) {
2656
3125
  if (!idMatch || !vMatch || !srcMatch) return void 0;
2657
3126
  return { stableId: idMatch[1], schemaVersion: vMatch[1], source: srcMatch[1] };
2658
3127
  }
2659
- function parseMarkdownHeader(content) {
2660
- const firstLine = content.split("\n")[0] ?? "";
3128
+ function parseMarkdownHeader(content2) {
3129
+ const firstLine = content2.split("\n")[0] ?? "";
2661
3130
  if (!firstLine.startsWith(MD_PREFIX)) return void 0;
2662
3131
  return parseAttrs(firstLine);
2663
3132
  }
2664
3133
  function buildMarkdownHeader(h) {
2665
3134
  return `${MD_PREFIX} id=${h.stableId} v=${h.schemaVersion} source=${h.source}${MD_SUFFIX}`;
2666
3135
  }
2667
- function stampMarkdown(content, h) {
3136
+ function stampMarkdown(content2, h) {
2668
3137
  const header = buildMarkdownHeader(h);
2669
- const existing = parseMarkdownHeader(content);
3138
+ const existing = parseMarkdownHeader(content2);
2670
3139
  if (existing) {
2671
- const rest = content.slice(content.indexOf("\n") + 1);
3140
+ const rest = content2.slice(content2.indexOf("\n") + 1);
2672
3141
  return `${header}
2673
3142
  ${rest}`;
2674
3143
  }
2675
3144
  return `${header}
2676
- ${content}`;
3145
+ ${content2}`;
2677
3146
  }
2678
3147
 
2679
3148
  // src/install/manifest.ts
2680
3149
  import os4 from "os";
2681
- import path18 from "path";
3150
+ import path20 from "path";
2682
3151
  var MANIFEST_SCHEMA = "haus-install-manifest/1";
2683
3152
  function globalClaudeDir() {
2684
- return path18.join(os4.homedir(), ".claude");
3153
+ return path20.join(os4.homedir(), ".claude");
2685
3154
  }
2686
3155
  function hausManifestPath() {
2687
- return path18.join(globalClaudeDir(), "haus", "install-manifest.json");
3156
+ return path20.join(globalClaudeDir(), "haus", "install-manifest.json");
2688
3157
  }
2689
3158
  async function readManifest() {
2690
3159
  return readJson(hausManifestPath());
@@ -2703,10 +3172,10 @@ function buildManifest(source, files, hooks) {
2703
3172
  }
2704
3173
 
2705
3174
  // src/install/settings-merge.ts
2706
- import path19 from "path";
2707
- import fs13 from "fs-extra";
3175
+ import path21 from "path";
3176
+ import fs14 from "fs-extra";
2708
3177
  function settingsJsonPath() {
2709
- return path19.join(globalClaudeDir(), "settings.json");
3178
+ return path21.join(globalClaudeDir(), "settings.json");
2710
3179
  }
2711
3180
  async function readSettings() {
2712
3181
  const parsed = await readJson(settingsJsonPath());
@@ -2738,10 +3207,95 @@ function mergeHooks(settings, fragments) {
2738
3207
  }
2739
3208
  updated._haus = {
2740
3209
  hooks: [...existing, ...addedIds],
2741
- hookCommands: [...existingCommands, ...addedCommands]
3210
+ hookCommands: [...existingCommands, ...addedCommands],
3211
+ // Preserve deny/allow tracking so hook, deny, and allow merges are order-independent.
3212
+ ...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
3213
+ ...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
2742
3214
  };
2743
3215
  return { settings: updated, addedIds };
2744
3216
  }
3217
+ function mergeDenyRules(settings, rules) {
3218
+ const existingDeny = settings.permissions?.deny ?? [];
3219
+ const seen = new Set(existingDeny);
3220
+ const trackedDeny = settings._haus?.denyRules ?? [];
3221
+ const addedRules = [];
3222
+ for (const rule of rules) {
3223
+ if (seen.has(rule)) continue;
3224
+ seen.add(rule);
3225
+ addedRules.push(rule);
3226
+ }
3227
+ const updated = { ...settings };
3228
+ updated.permissions = {
3229
+ ...settings.permissions ?? {},
3230
+ deny: [...existingDeny, ...addedRules]
3231
+ };
3232
+ updated._haus = {
3233
+ hooks: settings._haus?.hooks ?? [],
3234
+ ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
3235
+ denyRules: [...trackedDeny, ...addedRules],
3236
+ ...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
3237
+ };
3238
+ return { settings: updated, addedRules };
3239
+ }
3240
+ function mergeAllowRules(settings, rules) {
3241
+ const existingAllow = settings.permissions?.allow ?? [];
3242
+ const seen = new Set(existingAllow);
3243
+ const trackedAllow = settings._haus?.allowRules ?? [];
3244
+ const addedRules = [];
3245
+ for (const rule of rules) {
3246
+ if (seen.has(rule)) continue;
3247
+ seen.add(rule);
3248
+ addedRules.push(rule);
3249
+ }
3250
+ const updated = { ...settings };
3251
+ updated.permissions = {
3252
+ ...settings.permissions ?? {},
3253
+ allow: [...existingAllow, ...addedRules]
3254
+ };
3255
+ updated._haus = {
3256
+ hooks: settings._haus?.hooks ?? [],
3257
+ ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
3258
+ ...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
3259
+ allowRules: [...trackedAllow, ...addedRules]
3260
+ };
3261
+ return { settings: updated, addedRules };
3262
+ }
3263
+ function stripHausAllow(settings) {
3264
+ const prevHaus = settings._haus;
3265
+ if (!prevHaus?.allowRules || prevHaus.allowRules.length === 0) return settings;
3266
+ const ownedSet = new Set(prevHaus.allowRules);
3267
+ const updated = { ...settings };
3268
+ const remainingAllow = (settings.permissions?.allow ?? []).filter((rule) => !ownedSet.has(rule));
3269
+ const permissions = { ...settings.permissions ?? {} };
3270
+ if (remainingAllow.length > 0) permissions.allow = remainingAllow;
3271
+ else delete permissions.allow;
3272
+ if (Object.keys(permissions).length > 0) updated.permissions = permissions;
3273
+ else delete updated.permissions;
3274
+ const haus = { ...prevHaus };
3275
+ delete haus.allowRules;
3276
+ const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0;
3277
+ if (stillTracking) updated._haus = haus;
3278
+ else delete updated._haus;
3279
+ return updated;
3280
+ }
3281
+ function stripHausDeny(settings) {
3282
+ const prevHaus = settings._haus;
3283
+ if (!prevHaus?.denyRules || prevHaus.denyRules.length === 0) return settings;
3284
+ const ownedSet = new Set(prevHaus.denyRules);
3285
+ const updated = { ...settings };
3286
+ const remainingDeny = (settings.permissions?.deny ?? []).filter((rule) => !ownedSet.has(rule));
3287
+ const permissions = { ...settings.permissions ?? {} };
3288
+ if (remainingDeny.length > 0) permissions.deny = remainingDeny;
3289
+ else delete permissions.deny;
3290
+ if (Object.keys(permissions).length > 0) updated.permissions = permissions;
3291
+ else delete updated.permissions;
3292
+ const haus = { ...prevHaus };
3293
+ delete haus.denyRules;
3294
+ const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0;
3295
+ if (stillTracking) updated._haus = haus;
3296
+ else delete updated._haus;
3297
+ return updated;
3298
+ }
2745
3299
  function stripHausHooks(settings) {
2746
3300
  if (!settings._haus) return settings;
2747
3301
  const ownedCommands = new Set(settings._haus.hookCommands ?? []);
@@ -2762,7 +3316,7 @@ function stripHausHooks(settings) {
2762
3316
  async function loadHooksFragment(fragmentPath) {
2763
3317
  let raw;
2764
3318
  try {
2765
- raw = await fs13.readJson(fragmentPath);
3319
+ raw = await fs14.readJson(fragmentPath);
2766
3320
  } catch {
2767
3321
  return [];
2768
3322
  }
@@ -2772,45 +3326,45 @@ async function loadHooksFragment(fragmentPath) {
2772
3326
 
2773
3327
  // src/install/apply.ts
2774
3328
  var SCHEMA_VERSION3 = "1";
2775
- function hashContent(content) {
2776
- return `sha256-${crypto2.createHash("sha256").update(content).digest("hex")}`;
3329
+ function hashContent(content2) {
3330
+ return `sha256-${crypto2.createHash("sha256").update(content2).digest("hex")}`;
2777
3331
  }
2778
3332
  function sourceVersion() {
2779
3333
  try {
2780
- const pkgPath = path20.join(packageRoot(), "package.json");
2781
- const pkg = JSON.parse(fs14.readFileSync(pkgPath, "utf8"));
3334
+ const pkgPath = path22.join(packageRoot(), "package.json");
3335
+ const pkg = JSON.parse(fs15.readFileSync(pkgPath, "utf8"));
2782
3336
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
2783
3337
  } catch {
2784
3338
  return "haus@0.0.0";
2785
3339
  }
2786
3340
  }
2787
3341
  function globalSrcDir() {
2788
- return path20.join(packageRoot(), "library", "global");
3342
+ return path22.join(packageRoot(), "library", "global");
2789
3343
  }
2790
3344
  function collectSourceFiles(srcDir, claudeDir) {
2791
3345
  const entries = [];
2792
- const skillsDir = path20.join(srcDir, "skills");
2793
- if (fs14.pathExistsSync(skillsDir)) {
2794
- for (const skillName of fs14.readdirSync(skillsDir)) {
2795
- const skillFile = path20.join(skillsDir, skillName, "SKILL.md");
2796
- if (fs14.pathExistsSync(skillFile)) {
3346
+ const skillsDir = path22.join(srcDir, "skills");
3347
+ if (fs15.pathExistsSync(skillsDir)) {
3348
+ for (const skillName of fs15.readdirSync(skillsDir)) {
3349
+ const skillFile = path22.join(skillsDir, skillName, "SKILL.md");
3350
+ if (fs15.pathExistsSync(skillFile)) {
2797
3351
  entries.push({
2798
3352
  stableId: `skill.${skillName}`,
2799
- srcRelPath: path20.join("library", "global", "skills", skillName, "SKILL.md"),
2800
- destPath: path20.join(claudeDir, "skills", skillName, "SKILL.md")
3353
+ srcRelPath: path22.join("library", "global", "skills", skillName, "SKILL.md"),
3354
+ destPath: path22.join(claudeDir, "skills", skillName, "SKILL.md")
2801
3355
  });
2802
3356
  }
2803
3357
  }
2804
3358
  }
2805
- const agentsDir = path20.join(srcDir, "agents");
2806
- if (fs14.pathExistsSync(agentsDir)) {
2807
- for (const agentFile of fs14.readdirSync(agentsDir)) {
2808
- if (!agentFile.endsWith(".md")) continue;
2809
- const agentName = agentFile.replace(/\.md$/, "");
3359
+ const commandsDir = path22.join(srcDir, "commands");
3360
+ if (fs15.pathExistsSync(commandsDir)) {
3361
+ for (const fileName of fs15.readdirSync(commandsDir)) {
3362
+ if (!fileName.endsWith(".md")) continue;
3363
+ const commandName = fileName.slice(0, -".md".length);
2810
3364
  entries.push({
2811
- stableId: `agent.${agentName}`,
2812
- srcRelPath: path20.join("library", "global", "agents", agentFile),
2813
- destPath: path20.join(claudeDir, "agents", agentFile)
3365
+ stableId: `command.${commandName}`,
3366
+ srcRelPath: path22.join("library", "global", "commands", fileName),
3367
+ destPath: path22.join(claudeDir, "commands", fileName)
2814
3368
  });
2815
3369
  }
2816
3370
  }
@@ -2834,7 +3388,7 @@ async function applyInstall(options = {}) {
2834
3388
  };
2835
3389
  const manifestFiles = [];
2836
3390
  for (const entry of sourceFiles) {
2837
- const srcPath = path20.join(packageRoot(), entry.srcRelPath);
3391
+ const srcPath = path22.join(packageRoot(), entry.srcRelPath);
2838
3392
  const rawContent = await readText(srcPath);
2839
3393
  if (rawContent === void 0) {
2840
3394
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -2854,7 +3408,7 @@ async function applyInstall(options = {}) {
2854
3408
  }
2855
3409
  continue;
2856
3410
  }
2857
- const destExists = fs14.pathExistsSync(entry.destPath);
3411
+ const destExists = fs15.pathExistsSync(entry.destPath);
2858
3412
  if (destExists) {
2859
3413
  const currentContent = await readText(entry.destPath);
2860
3414
  if (currentContent !== void 0) {
@@ -2890,22 +3444,24 @@ async function applyInstall(options = {}) {
2890
3444
  schemaVersion: SCHEMA_VERSION3
2891
3445
  });
2892
3446
  }
2893
- const fragmentPath = path20.join(srcDir, "settings-fragments", "hooks.json");
3447
+ const fragmentPath = path22.join(srcDir, "settings-fragments", "hooks.json");
2894
3448
  const fragments = await loadHooksFragment(fragmentPath);
2895
3449
  const settings = await readSettings();
2896
- const { settings: mergedSettings, addedIds } = mergeHooks(settings, fragments);
3450
+ const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
3451
+ const { settings: deniedSettings } = mergeDenyRules(hookSettings, buildDenyRules());
3452
+ const { settings: mergedSettings } = mergeAllowRules(deniedSettings, buildAllowRules());
2897
3453
  result.hookIds = addedIds;
2898
3454
  if (!check && existingManifest) {
2899
3455
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
2900
3456
  for (const entry of existingManifest.files) {
2901
3457
  if (currentDestPaths.has(entry.destPath)) continue;
2902
- if (!fs14.pathExistsSync(entry.destPath)) continue;
2903
- const content = await readText(entry.destPath);
2904
- if (!content) continue;
2905
- const hasHeader = parseMarkdownHeader(content) !== void 0;
2906
- const currentHash = hashContent(content);
3458
+ if (!fs15.pathExistsSync(entry.destPath)) continue;
3459
+ const content2 = await readText(entry.destPath);
3460
+ if (!content2) continue;
3461
+ const hasHeader = parseMarkdownHeader(content2) !== void 0;
3462
+ const currentHash = hashContent(content2);
2907
3463
  if (hasHeader && currentHash === entry.hash) {
2908
- if (!dryRun) await fs14.remove(entry.destPath);
3464
+ if (!dryRun) await fs15.remove(entry.destPath);
2909
3465
  result.deleted.push(entry.destPath);
2910
3466
  } else {
2911
3467
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -2957,14 +3513,27 @@ async function runInstall(options) {
2957
3513
  force: options.force,
2958
3514
  check: options.check
2959
3515
  });
2960
- printApplyResult(result, options.dryRun ?? false);
3516
+ if (!options.postinstall) printApplyResult(result, options.dryRun ?? false);
2961
3517
  if (options.check && result.drift) {
2962
3518
  process.exitCode = 1;
2963
3519
  } else if (!options.check && !options.dryRun) {
2964
3520
  const total = result.created.length + result.updated.length;
2965
- log(
2966
- `haus install complete (${total} file(s) written, ${result.hookIds.length} hook(s) added)`
2967
- );
3521
+ if (options.postinstall) {
3522
+ log("haus configured Claude Code for you:");
3523
+ const parts = [];
3524
+ if (result.created.length) parts.push(`${result.created.length} file(s) added`);
3525
+ if (result.updated.length) parts.push(`${result.updated.length} file(s) updated`);
3526
+ log(
3527
+ parts.length ? ` \u2022 ${parts.join(", ")} in ~/.claude (skills, slash commands)` : " \u2022 already up to date \u2014 no files changed"
3528
+ );
3529
+ log(` \u2022 ensured hooks + security rules are present in ~/.claude/settings.json`);
3530
+ log("Undo any time with: haus uninstall");
3531
+ log("Disable this on install: HAUS_NO_POSTINSTALL=1");
3532
+ } else {
3533
+ log(
3534
+ `haus install complete (${total} file(s) written, ${result.hookIds.length} hook(s) added)`
3535
+ );
3536
+ }
2968
3537
  }
2969
3538
  } catch (err) {
2970
3539
  error(`haus install failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -2972,79 +3541,6 @@ async function runInstall(options) {
2972
3541
  }
2973
3542
  }
2974
3543
 
2975
- // src/memory/memory-store.ts
2976
- var FILES = [
2977
- "project-learnings.md",
2978
- "decisions.md",
2979
- "recurring-issues.md",
2980
- "client-context.md"
2981
- ];
2982
- async function ensureMemory(root) {
2983
- await Promise.all(
2984
- FILES.map(async (name) => {
2985
- const file = hausPath(root, "memory", name);
2986
- const current = await readText(file);
2987
- if (!current) await writeText(file, `# ${name}
2988
- `);
2989
- })
2990
- );
2991
- const indexFile = hausPath(root, "memory", "index.json");
2992
- const index = await readJson(indexFile);
2993
- if (!index) await writeJson(indexFile, { files: [...FILES] });
2994
- }
2995
- async function readMemory(root) {
2996
- await ensureMemory(root);
2997
- const blocks = await Promise.all(FILES.map((name) => readText(hausPath(root, "memory", name))));
2998
- return blocks.filter(Boolean).join("\n");
2999
- }
3000
- async function appendLearning(root, line) {
3001
- await ensureMemory(root);
3002
- const file = hausPath(root, "memory", "project-learnings.md");
3003
- const current = await readText(file) ?? "# project-learnings.md\n";
3004
- await writeText(file, `${current}
3005
- - ${line}
3006
- `);
3007
- }
3008
-
3009
- // src/memory/redact-memory.ts
3010
- function redactMemory(text) {
3011
- return text.replace(/(api[_-]?key|token|secret|password)\s*[:=]\s*\S+/gi, "$1=[REDACTED]").replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, "[REDACTED-KEY]");
3012
- }
3013
-
3014
- // src/commands/memory.ts
3015
- async function runMemory(subcommand, options) {
3016
- const root = process.cwd();
3017
- if (subcommand === "inject" && options.fromHook && !await isHookEnabled(root, "memoryInject")) {
3018
- return;
3019
- }
3020
- await ensureMemory(root);
3021
- if (subcommand === "status") {
3022
- log("Memory ready at .haus-workflow/memory");
3023
- return;
3024
- }
3025
- if (subcommand === "add") {
3026
- if (!options.text) throw new Error("memory add requires text");
3027
- await appendLearning(root, redactMemory(options.text));
3028
- log("Memory added");
3029
- return;
3030
- }
3031
- if (subcommand === "inject") {
3032
- const text = redactMemory(await readMemory(root));
3033
- if (!text.trim()) {
3034
- log("No relevant Haus memory found.");
3035
- return;
3036
- }
3037
- const compact = `Task: ${options.task ?? "n/a"}
3038
- ${text}`.slice(
3039
- 0,
3040
- options.fromHook ? 1200 : 4e3
3041
- );
3042
- log(compact);
3043
- return;
3044
- }
3045
- log("Promotion proposal: review memory and move stable rules into .claude/rules manually.");
3046
- }
3047
-
3048
3544
  // src/commands/recommend.ts
3049
3545
  async function runRecommend(options) {
3050
3546
  const root = process.cwd();
@@ -3088,20 +3584,20 @@ async function runScan(options) {
3088
3584
  }
3089
3585
 
3090
3586
  // src/commands/undo.ts
3091
- import path21 from "path";
3092
- import fs15 from "fs-extra";
3587
+ import path23 from "path";
3588
+ import fs16 from "fs-extra";
3093
3589
  var CLAUDE_DIR = ".claude";
3094
3590
  async function runUndo(options) {
3095
3591
  const root = process.cwd();
3096
- const targets = [path21.join(root, CLAUDE_DIR), path21.join(root, HAUS_DIR)];
3097
- const existing = targets.filter((p) => fs15.existsSync(p));
3592
+ const targets = [path23.join(root, CLAUDE_DIR), path23.join(root, HAUS_DIR)];
3593
+ const existing = targets.filter((p) => fs16.existsSync(p));
3098
3594
  if (existing.length === 0) {
3099
3595
  log("Nothing to remove: no .claude/ or .haus-workflow/ in this directory.");
3100
3596
  return;
3101
3597
  }
3102
3598
  if (!options.yes) {
3103
3599
  const ok = await confirm(
3104
- `Remove ${existing.map((p) => path21.relative(root, p)).join(" and ")}? This cannot be undone.`
3600
+ `Remove ${existing.map((p) => path23.relative(root, p)).join(" and ")}? This cannot be undone.`
3105
3601
  );
3106
3602
  if (!ok) {
3107
3603
  log("Cancelled.");
@@ -3109,15 +3605,15 @@ async function runUndo(options) {
3109
3605
  }
3110
3606
  }
3111
3607
  for (const p of existing) {
3112
- await fs15.remove(p);
3113
- log(`Removed ${path21.relative(root, p)}`);
3608
+ await fs16.remove(p);
3609
+ log(`Removed ${path23.relative(root, p)}`);
3114
3610
  }
3115
3611
  }
3116
3612
 
3117
3613
  // src/install/uninstall.ts
3118
3614
  import crypto3 from "crypto";
3119
- import path22 from "path";
3120
- import fs16 from "fs-extra";
3615
+ import path24 from "path";
3616
+ import fs17 from "fs-extra";
3121
3617
  async function runUninstall(options = {}) {
3122
3618
  const { force = false } = options;
3123
3619
  const manifest = await readManifest();
@@ -3127,17 +3623,17 @@ async function runUninstall(options = {}) {
3127
3623
  return result;
3128
3624
  }
3129
3625
  for (const entry of manifest.files) {
3130
- const exists = fs16.pathExistsSync(entry.destPath);
3626
+ const exists = fs17.pathExistsSync(entry.destPath);
3131
3627
  if (!exists) continue;
3132
- const content = await readText(entry.destPath);
3133
- if (content === void 0) continue;
3134
- const header = parseMarkdownHeader(content);
3628
+ const content2 = await readText(entry.destPath);
3629
+ if (content2 === void 0) continue;
3630
+ const header = parseMarkdownHeader(content2);
3135
3631
  if (!header) {
3136
3632
  warn(`Skipping user-owned file (no HAUS-MANAGED header): ${entry.destPath}`);
3137
3633
  result.skipped.push(entry.destPath);
3138
3634
  continue;
3139
3635
  }
3140
- const currentHash = `sha256-${crypto3.createHash("sha256").update(content).digest("hex")}`;
3636
+ const currentHash = `sha256-${crypto3.createHash("sha256").update(content2).digest("hex")}`;
3141
3637
  if (currentHash !== entry.hash && !force) {
3142
3638
  warn(
3143
3639
  `Skipping user-edited haus file (hash mismatch): ${entry.destPath} \u2014 use --force to delete`
@@ -3145,22 +3641,22 @@ async function runUninstall(options = {}) {
3145
3641
  result.skipped.push(entry.destPath);
3146
3642
  continue;
3147
3643
  }
3148
- await fs16.remove(entry.destPath);
3149
- await pruneEmptyDir(path22.dirname(entry.destPath));
3644
+ await fs17.remove(entry.destPath);
3645
+ await pruneEmptyDir(path24.dirname(entry.destPath));
3150
3646
  result.deleted.push(entry.destPath);
3151
3647
  }
3152
3648
  const settings = await readSettings();
3153
- const stripped = stripHausHooks(settings);
3649
+ const stripped = stripHausHooks(stripHausAllow(stripHausDeny(settings)));
3154
3650
  await writeSettings(stripped);
3155
3651
  result.hooksStripped = true;
3156
- const hausDir = path22.join(globalClaudeDir(), "haus");
3652
+ const hausDir = path24.join(globalClaudeDir(), "haus");
3157
3653
  const manifestPath = hausManifestPath();
3158
- if (fs16.pathExistsSync(manifestPath)) {
3159
- await fs16.remove(manifestPath);
3654
+ if (fs17.pathExistsSync(manifestPath)) {
3655
+ await fs17.remove(manifestPath);
3160
3656
  }
3161
- if (fs16.pathExistsSync(hausDir)) {
3162
- const remaining = await fs16.readdir(hausDir);
3163
- if (remaining.length === 0) await fs16.remove(hausDir);
3657
+ if (fs17.pathExistsSync(hausDir)) {
3658
+ const remaining = await fs17.readdir(hausDir);
3659
+ if (remaining.length === 0) await fs17.remove(hausDir);
3164
3660
  }
3165
3661
  return result;
3166
3662
  }
@@ -3179,8 +3675,8 @@ function printUninstallResult(result) {
3179
3675
  }
3180
3676
  async function pruneEmptyDir(dir) {
3181
3677
  try {
3182
- const entries = await fs16.readdir(dir);
3183
- if (entries.length === 0) await fs16.remove(dir);
3678
+ const entries = await fs17.readdir(dir);
3679
+ if (entries.length === 0) await fs17.remove(dir);
3184
3680
  } catch {
3185
3681
  }
3186
3682
  }
@@ -3198,7 +3694,7 @@ async function runUninstallCommand(options) {
3198
3694
  }
3199
3695
 
3200
3696
  // src/commands/update.ts
3201
- import path24 from "path";
3697
+ import path26 from "path";
3202
3698
 
3203
3699
  // src/update/diff-generated-files.ts
3204
3700
  function diffGeneratedFiles() {
@@ -3224,8 +3720,8 @@ function summarizeLockDiff(before, after) {
3224
3720
  }
3225
3721
 
3226
3722
  // src/update/lockfile.ts
3227
- import { mkdir, readFile as readFile2, copyFile } from "fs/promises";
3228
- import path23 from "path";
3723
+ import { mkdir, readFile as readFile3, copyFile } from "fs/promises";
3724
+ import path25 from "path";
3229
3725
  async function checkLock(root) {
3230
3726
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
3231
3727
  const hasValidVersions = lock.every(
@@ -3238,7 +3734,7 @@ async function applyLock(root) {
3238
3734
  const lockPath = hausPath(root, "haus.lock.json");
3239
3735
  let before = "[]";
3240
3736
  try {
3241
- before = await readFile2(lockPath, "utf8");
3737
+ before = await readFile3(lockPath, "utf8");
3242
3738
  } catch {
3243
3739
  before = "[]";
3244
3740
  }
@@ -3246,7 +3742,7 @@ async function applyLock(root) {
3246
3742
  try {
3247
3743
  const backupDir = hausPath(root, "backups");
3248
3744
  await mkdir(backupDir, { recursive: true });
3249
- await copyFile(lockPath, path23.join(backupDir, `haus.lock.${Date.now()}.json`));
3745
+ await copyFile(lockPath, path25.join(backupDir, `haus.lock.${Date.now()}.json`));
3250
3746
  } catch {
3251
3747
  }
3252
3748
  const enriched = await Promise.all(
@@ -3268,7 +3764,7 @@ function diffLock(before, after) {
3268
3764
  }
3269
3765
  async function hasLocalOverrides(root) {
3270
3766
  try {
3271
- await readFile2(path23.join(root, ".claude", "settings.json"), "utf8");
3767
+ await readFile3(path25.join(root, ".claude", "settings.json"), "utf8");
3272
3768
  return true;
3273
3769
  } catch {
3274
3770
  return false;
@@ -3280,7 +3776,7 @@ var NPM_PACKAGE_NAME2 = "@haus-tech/haus-workflow";
3280
3776
  async function runUpdate(options) {
3281
3777
  const root = process.cwd();
3282
3778
  if (options.check) {
3283
- const pkgJson2 = await readJson(path24.join(packageRoot(), "package.json"));
3779
+ const pkgJson2 = await readJson(path26.join(packageRoot(), "package.json"));
3284
3780
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
3285
3781
  const [status, npmVersion, latestCatalogTag] = await Promise.all([
3286
3782
  checkLock(root),
@@ -3307,7 +3803,7 @@ async function runUpdate(options) {
3307
3803
  if (!status.ok) process.exitCode = 1;
3308
3804
  return;
3309
3805
  }
3310
- const pkgJson = await readJson(path24.join(packageRoot(), "package.json"));
3806
+ const pkgJson = await readJson(path26.join(packageRoot(), "package.json"));
3311
3807
  const currentVersion = pkgJson?.version ?? "0.0.0";
3312
3808
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
3313
3809
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -3337,49 +3833,187 @@ async function runUpdate(options) {
3337
3833
  }
3338
3834
 
3339
3835
  // src/commands/validate-catalog.ts
3340
- import fs17 from "fs";
3341
- import path26 from "path";
3836
+ import fs18 from "fs";
3837
+ import path27 from "path";
3342
3838
 
3343
- // src/catalog/allowed-stacks.ts
3344
- import path25 from "path";
3345
- async function readAllowedStacks(root) {
3346
- const data = await readJson(
3347
- path25.join(root, "library", "catalog", "allowed-stacks.json")
3348
- );
3349
- return data?.stacks ?? [];
3350
- }
3839
+ // library/catalog/validation-rules.json
3840
+ var validation_rules_default = {
3841
+ forbiddenTags: [
3842
+ "python",
3843
+ "django",
3844
+ "go",
3845
+ "rust",
3846
+ "java",
3847
+ "spring",
3848
+ "kotlin",
3849
+ "swift",
3850
+ "android",
3851
+ "flutter",
3852
+ "dart",
3853
+ "c++",
3854
+ "perl",
3855
+ "defi",
3856
+ "trading"
3857
+ ],
3858
+ bannedAgentPhrases: ["autonomous", "swarm", "delegate", "orchestrat", "marketplace"],
3859
+ requiredSkillSections: ["## Use when", "## Do not use when"],
3860
+ requiredAgentSections: ["## Use when", "## Do not use when", "## Verification"],
3861
+ riskyInstallPatterns: [
3862
+ { source: "\\bnpx\\s+-y\\b", flags: "i" },
3863
+ { source: "\\bnpx\\s+--yes\\b", flags: "i" },
3864
+ { source: "\\byarn\\s+dlx\\b", flags: "i" },
3865
+ { source: "\\bpnpm\\s+dlx\\b", flags: "i" }
3866
+ ],
3867
+ allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
3868
+ anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
3869
+ httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
3870
+ placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
3871
+ allowedStacks: [
3872
+ "haus",
3873
+ "security",
3874
+ "quality",
3875
+ "frontend",
3876
+ "backend",
3877
+ "testing",
3878
+ "review",
3879
+ "workflow",
3880
+ "reference-pack",
3881
+ "core-skill",
3882
+ "workflow-skill",
3883
+ "stack-skill",
3884
+ "review-skill",
3885
+ "agent",
3886
+ "hook",
3887
+ "rule",
3888
+ "react",
3889
+ "typescript",
3890
+ "php",
3891
+ "csharp",
3892
+ "vendure",
3893
+ "vendure3",
3894
+ "nestjs",
3895
+ "graphql",
3896
+ "nx21",
3897
+ "turbo",
3898
+ "nextjs",
3899
+ "react19",
3900
+ "typescript5",
3901
+ "vite8",
3902
+ "tanstack-query",
3903
+ "tanstack-router",
3904
+ "radix",
3905
+ "radix-ui",
3906
+ "shadcn",
3907
+ "shadcn-ui",
3908
+ "tailwind",
3909
+ "tailwindcss",
3910
+ "scss",
3911
+ "scss-modules",
3912
+ "vue",
3913
+ "expressjs",
3914
+ "soup-base",
3915
+ "laravel",
3916
+ "laravel-nova",
3917
+ "wordpress",
3918
+ "bedrock",
3919
+ "elementor-pro",
3920
+ "acf-pro",
3921
+ "jetengine",
3922
+ "dotnet",
3923
+ "oidc",
3924
+ "azure-ad",
3925
+ "bankid",
3926
+ "myid",
3927
+ "cgi",
3928
+ "crypto",
3929
+ "collection2",
3930
+ "postgresql",
3931
+ "mariadb",
3932
+ "mssql",
3933
+ "elasticsearch",
3934
+ "yarn4",
3935
+ "pnpm89",
3936
+ "playwright",
3937
+ "testing-library",
3938
+ "phpunit",
3939
+ "storybook",
3940
+ "wisest",
3941
+ "vitest",
3942
+ "jest",
3943
+ "redis",
3944
+ "sanity",
3945
+ "strapi",
3946
+ "prisma",
3947
+ "cms",
3948
+ "database",
3949
+ "mysql",
3950
+ "saml2",
3951
+ "next-auth",
3952
+ "auth",
3953
+ "expo",
3954
+ "react-native",
3955
+ "mobile",
3956
+ "i18next",
3957
+ "i18n",
3958
+ "bullmq",
3959
+ "queue",
3960
+ "sentry",
3961
+ "observability",
3962
+ "tooling",
3963
+ "prettier",
3964
+ "eslint",
3965
+ "missing-prettier",
3966
+ "missing-eslint",
3967
+ "docker",
3968
+ "pm2",
3969
+ "deployer-php",
3970
+ "stripe",
3971
+ "qliro",
3972
+ "supabase",
3973
+ "payments"
3974
+ ],
3975
+ alwaysAllowedTags: [
3976
+ "haus",
3977
+ "security",
3978
+ "quality",
3979
+ "review",
3980
+ "workflow",
3981
+ "baseline",
3982
+ "project-instructions"
3983
+ ],
3984
+ patternTagSuffixes: ["-patterns"]
3985
+ };
3351
3986
 
3352
3987
  // src/catalog/validation-rules.ts
3353
- var FORBIDDEN_TAGS = [
3354
- "python",
3355
- "django",
3356
- "go",
3357
- "rust",
3358
- "java",
3359
- "spring",
3360
- "kotlin",
3361
- "swift",
3362
- "android",
3363
- "flutter",
3364
- "dart",
3365
- "c++",
3366
- "perl",
3367
- "defi",
3368
- "trading"
3369
- ];
3370
- var BANNED_AGENT_PHRASES = ["autonomous", "swarm", "delegate", "orchestrat", "marketplace"];
3371
- var REQUIRED_SKILL_SECTIONS = ["## Use when", "## Do not use when"];
3372
- var REQUIRED_AGENT_SECTIONS = ["## Use when", "## Do not use when", "## Verification"];
3373
- var RISKY_INSTALL_PATTERNS = [
3374
- /\bnpx\s+-y\b/i,
3375
- /\bnpx\s+--yes\b/i,
3376
- /\byarn\s+dlx\b/i,
3377
- /\bpnpm\s+dlx\b/i
3378
- ];
3379
- var ALLOWED_NPX_PATTERN = /\bnpx\s+tsx\b/i;
3380
- var ANY_NPX_PATTERN = /\bnpx\s+\S+/i;
3381
- var HTTP_URL_PATTERN = /^http:\/\//i;
3382
- var PLACEHOLDER_PATTERN = /\bTODO\b|\bPLACEHOLDER\b/i;
3988
+ var toRegExp = (r) => new RegExp(r.source, r.flags);
3989
+ var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
3990
+ var BANNED_AGENT_PHRASES = validation_rules_default.bannedAgentPhrases;
3991
+ var REQUIRED_SKILL_SECTIONS = validation_rules_default.requiredSkillSections;
3992
+ var REQUIRED_AGENT_SECTIONS = validation_rules_default.requiredAgentSections;
3993
+ var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
3994
+ var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
3995
+ var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
3996
+ var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
3997
+ var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
3998
+ var ALLOWED_STACKS = validation_rules_default.allowedStacks;
3999
+ var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
4000
+ var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
4001
+ var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
4002
+ function isTagAllowed(tag) {
4003
+ const lower = tag.toLowerCase();
4004
+ if (ALLOWED_SET.has(lower)) return true;
4005
+ return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
4006
+ }
4007
+ function auditDisallowedTags(items) {
4008
+ const failures = [];
4009
+ for (const item of items) {
4010
+ if (!item.id) continue;
4011
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
4012
+ if (!isTagAllowed(tag)) failures.push(`${item.id}: tag not in allowlist: "${tag}"`);
4013
+ }
4014
+ }
4015
+ return failures;
4016
+ }
3383
4017
 
3384
4018
  // src/commands/validate-catalog.ts
3385
4019
  function auditForbiddenStacks(items) {
@@ -3449,23 +4083,23 @@ function auditShippedFiles(manifestDir, items) {
3449
4083
  const failures = [];
3450
4084
  for (const item of items) {
3451
4085
  if (!item.path) continue;
3452
- const absPath = path26.join(manifestDir, item.path);
4086
+ const absPath = path27.join(manifestDir, item.path);
3453
4087
  if (item.type === "skill") {
3454
- const skillMd = path26.join(absPath, "SKILL.md");
3455
- if (!fs17.existsSync(skillMd)) {
3456
- failures.push(`${item.id}: missing ${path26.relative(manifestDir, skillMd)}`);
4088
+ const skillMd = path27.join(absPath, "SKILL.md");
4089
+ if (!fs18.existsSync(skillMd)) {
4090
+ failures.push(`${item.id}: missing ${path27.relative(manifestDir, skillMd)}`);
3457
4091
  continue;
3458
4092
  }
3459
- const text = fs17.readFileSync(skillMd, "utf8");
4093
+ const text = fs18.readFileSync(skillMd, "utf8");
3460
4094
  for (const section of REQUIRED_SKILL_SECTIONS) {
3461
4095
  if (!text.includes(section)) failures.push(`${item.id}: SKILL.md missing ${section}`);
3462
4096
  }
3463
4097
  } else if (item.type === "agent") {
3464
- if (!fs17.existsSync(absPath)) {
4098
+ if (!fs18.existsSync(absPath)) {
3465
4099
  failures.push(`${item.id}: missing agent file ${item.path}`);
3466
4100
  continue;
3467
4101
  }
3468
- const text = fs17.readFileSync(absPath, "utf8");
4102
+ const text = fs18.readFileSync(absPath, "utf8");
3469
4103
  if (!text.startsWith("---")) failures.push(`${item.id}: agent file missing YAML frontmatter`);
3470
4104
  for (const section of REQUIRED_AGENT_SECTIONS) {
3471
4105
  if (!text.includes(section)) failures.push(`${item.id}: agent file missing ${section}`);
@@ -3476,7 +4110,7 @@ function auditShippedFiles(manifestDir, items) {
3476
4110
  failures.push(`${item.id}: agent file contains disallowed phrase "${phrase}"`);
3477
4111
  }
3478
4112
  } else if (item.type === "template") {
3479
- if (!fs17.existsSync(absPath)) {
4113
+ if (!fs18.existsSync(absPath)) {
3480
4114
  failures.push(`${item.id}: missing template file ${item.path}`);
3481
4115
  }
3482
4116
  }
@@ -3487,21 +4121,21 @@ function auditMarkdownContent(manifestDir) {
3487
4121
  const failures = [];
3488
4122
  const dirs = ["skills", "agents"];
3489
4123
  for (const dir of dirs) {
3490
- const abs = path26.join(manifestDir, dir);
3491
- if (!fs17.existsSync(abs)) continue;
4124
+ const abs = path27.join(manifestDir, dir);
4125
+ if (!fs18.existsSync(abs)) continue;
3492
4126
  walkMd(abs, (file) => {
3493
- const text = fs17.readFileSync(file, "utf8");
3494
- const rel = path26.relative(manifestDir, file);
4127
+ const text = fs18.readFileSync(file, "utf8");
4128
+ const rel = path27.relative(manifestDir, file);
3495
4129
  const lines = text.split(/\r?\n/);
3496
4130
  for (let i = 0; i < lines.length; i++) {
3497
- const line = lines[i] ?? "";
3498
- if (PLACEHOLDER_PATTERN.test(line)) {
4131
+ const line2 = lines[i] ?? "";
4132
+ if (PLACEHOLDER_PATTERN.test(line2)) {
3499
4133
  failures.push(`${rel}:${i + 1}: TODO or placeholder in shipped content`);
3500
4134
  }
3501
- if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line))) {
4135
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
3502
4136
  failures.push(`${rel}:${i + 1}: risky install pattern`);
3503
4137
  }
3504
- if (ANY_NPX_PATTERN.test(line) && !ALLOWED_NPX_PATTERN.test(line)) {
4138
+ if (ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
3505
4139
  failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
3506
4140
  }
3507
4141
  }
@@ -3510,8 +4144,8 @@ function auditMarkdownContent(manifestDir) {
3510
4144
  return failures;
3511
4145
  }
3512
4146
  function walkMd(dir, fn) {
3513
- for (const entry of fs17.readdirSync(dir, { withFileTypes: true })) {
3514
- const full = path26.join(dir, entry.name);
4147
+ for (const entry of fs18.readdirSync(dir, { withFileTypes: true })) {
4148
+ const full = path27.join(dir, entry.name);
3515
4149
  if (entry.isDirectory()) walkMd(full, fn);
3516
4150
  else if (entry.name.endsWith(".md")) fn(full);
3517
4151
  }
@@ -3522,8 +4156,8 @@ async function runValidateCatalog(manifestPath) {
3522
4156
  process.exitCode = 1;
3523
4157
  return;
3524
4158
  }
3525
- const abs = path26.resolve(process.cwd(), manifestPath);
3526
- const manifestDir = path26.dirname(abs);
4159
+ const abs = path27.resolve(process.cwd(), manifestPath);
4160
+ const manifestDir = path27.dirname(abs);
3527
4161
  const data = await readJson(abs);
3528
4162
  if (!data?.items) {
3529
4163
  error(`Could not read catalog manifest at ${abs}`);
@@ -3535,17 +4169,7 @@ async function runValidateCatalog(manifestPath) {
3535
4169
  const stackFailures = auditForbiddenStacks(items);
3536
4170
  const fileFailures = auditShippedFiles(manifestDir, items);
3537
4171
  const contentFailures = auditMarkdownContent(manifestDir);
3538
- const allowed = new Set((await readAllowedStacks(packageRoot())).map((x) => x.toLowerCase()));
3539
- const tagFailures = [];
3540
- if (allowed.size > 0) {
3541
- for (const item of items) {
3542
- for (const tag of Array.isArray(item.tags) ? item.tags : []) {
3543
- if (!allowed.has(tag.toLowerCase()) && !tag.includes("-patterns") && tag !== "haus" && tag !== "security" && tag !== "quality" && tag !== "review" && tag !== "workflow" && tag !== "baseline" && tag !== "project-instructions") {
3544
- tagFailures.push(`${item.id}: tag not in allowlist: "${tag}"`);
3545
- }
3546
- }
3547
- }
3548
- }
4172
+ const tagFailures = auditDisallowedTags(items);
3549
4173
  const allFailures = [
3550
4174
  ...structureFailures,
3551
4175
  ...stackFailures,
@@ -3562,7 +4186,7 @@ async function runValidateCatalog(manifestPath) {
3562
4186
  }
3563
4187
 
3564
4188
  // src/commands/workspace.ts
3565
- import path27 from "path";
4189
+ import path28 from "path";
3566
4190
  import YAML from "yaml";
3567
4191
  async function runWorkspace(action) {
3568
4192
  if (action === "init") {
@@ -3595,7 +4219,7 @@ relationships: []
3595
4219
  const summaries = [];
3596
4220
  const ownership = {};
3597
4221
  for (const repo of repos) {
3598
- const repoRoot = path27.resolve(process.cwd(), repo.path);
4222
+ const repoRoot = path28.resolve(process.cwd(), repo.path);
3599
4223
  const result = await scanProject(repoRoot, "fast");
3600
4224
  summaries.push({
3601
4225
  name: repo.name,
@@ -3604,9 +4228,9 @@ relationships: []
3604
4228
  packageManager: result.packageManager,
3605
4229
  deps: result.dependencies
3606
4230
  });
3607
- for (const dep of result.dependencies) {
3608
- ownership[dep] ??= [];
3609
- ownership[dep].push(repo.name);
4231
+ for (const dep2 of result.dependencies) {
4232
+ ownership[dep2] ??= [];
4233
+ ownership[dep2].push(repo.name);
3610
4234
  }
3611
4235
  }
3612
4236
  await writeJson(".haus-workflow/workspace-summary.json", {
@@ -3631,7 +4255,7 @@ ${summaries.map(
3631
4255
  // src/cli.ts
3632
4256
  function cliVersion() {
3633
4257
  try {
3634
- const pkgPath = path28.join(packageRoot(), "package.json");
4258
+ const pkgPath = path29.join(packageRoot(), "package.json");
3635
4259
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
3636
4260
  return pkg.version ?? "0.0.0";
3637
4261
  } catch {
@@ -3641,7 +4265,7 @@ function cliVersion() {
3641
4265
  var program = new Command();
3642
4266
  function validateRuntimeNodeVersion() {
3643
4267
  try {
3644
- const pkgPath = path28.join(packageRoot(), "package.json");
4268
+ const pkgPath = path29.join(packageRoot(), "package.json");
3645
4269
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
3646
4270
  const requiredRange = pkg.engines?.node;
3647
4271
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -3662,6 +4286,9 @@ program.command("doctor").option("--hooks", "Verify .claude/settings.json matche
3662
4286
  program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
3663
4287
  "--allow-empty-cache",
3664
4288
  "Apply core files only when catalog cache is empty (skip catalog items without error)"
4289
+ ).option(
4290
+ "--refill-config",
4291
+ "Fill still-blank fields in an existing workflow-config.md without touching edited ones"
3665
4292
  ).action(runApply);
3666
4293
  program.command("undo").option("-y, --yes", "Skip confirmation").action(runUndo);
3667
4294
  program.command("explain-recommendation").option("--json").action(runExplainRecommendation);
@@ -3671,20 +4298,15 @@ program.command("refresh").action(runRefresh);
3671
4298
  program.command("catalog-audit").action(runCatalogAudit);
3672
4299
  program.command("validate-catalog").argument("[manifest]").action(runValidateCatalog);
3673
4300
  program.command("update").option("--check").action(runUpdate);
3674
- program.command("install").option("--dry-run").option("--force").option("--check", "Exit non-zero if any HAUS-MANAGED file is out of date").action(runInstall);
4301
+ 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);
3675
4302
  program.command("uninstall").option("--force").action(runUninstallCommand);
3676
- var memory = program.command("memory");
3677
- memory.command("status").action(() => runMemory("status", {}));
3678
- memory.command("add <text>").action((text) => runMemory("add", { text }));
3679
- memory.command("inject").option("--task <task>").option("--from-hook").action((opts) => runMemory("inject", opts));
3680
- memory.command("promote").action(() => runMemory("promote", {}));
3681
4303
  var guard = program.command("guard");
3682
4304
  guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
3683
4305
  guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
3684
4306
  var config = program.command("config");
3685
- config.command("enable <key>").description("Enable a hook (hook.context, hook.memory)").action((key) => runConfig(key, "enable"));
3686
- config.command("disable <key>").description("Disable a hook (hook.context, hook.memory)").action((key) => runConfig(key, "disable"));
3687
- config.command("status <key>").description("Show current state of a hook (hook.context, hook.memory)").action((key) => runConfig(key, "status"));
4307
+ config.command("enable <key>").description("Enable a hook (hook.context)").action((key) => runConfig(key, "enable"));
4308
+ config.command("disable <key>").description("Disable a hook (hook.context)").action((key) => runConfig(key, "disable"));
4309
+ config.command("status <key>").description("Show current state of a hook (hook.context)").action((key) => runConfig(key, "status"));
3688
4310
  var workspace = program.command("workspace");
3689
4311
  workspace.command("init").action(() => runWorkspace("init"));
3690
4312
  workspace.command("scan").action(() => runWorkspace("scan"));