@haus-tech/haus-workflow 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/cli.js +264 -132
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.16.0](https://github.com/WeAreHausTech/haus-workflow/compare/v0.15.0...v0.16.0) (2026-06-05)
4
+
5
+ ### Features
6
+
7
+ - support catalog contexts and refresh-aware sync ([1ab1d6c](https://github.com/WeAreHausTech/haus-workflow/commit/1ab1d6c1ca91a412fc4e7269e1616050c4397101))
8
+
9
+ ### Bug Fixes
10
+
11
+ - Add forbidden-content, sources-report, and drift ([e7cbd07](https://github.com/WeAreHausTech/haus-workflow/commit/e7cbd076f7f8d8a21555c5e0cdba058b9788ee29))
12
+
3
13
  ## [0.15.0](https://github.com/WeAreHausTech/haus-workflow/compare/v0.14.0...v0.15.0) (2026-06-05)
4
14
 
5
15
  ### Features
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import path34 from "path";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/commands/apply.ts
9
- import path12 from "path";
9
+ import path13 from "path";
10
10
  import checkbox from "@inquirer/checkbox";
11
11
  import fs11 from "fs-extra";
12
12
 
@@ -32,7 +32,9 @@ var CATALOG_REF = process.env.HAUS_CATALOG_REF ?? "main";
32
32
  var CATALOG_CACHE_SUBDIR = ".claude/haus/catalog-cache";
33
33
 
34
34
  // src/catalog/remote-catalog.ts
35
- var CACHE_DIR = process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path.join(os.homedir(), CATALOG_CACHE_SUBDIR);
35
+ function getCacheDir() {
36
+ return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path.join(os.homedir(), CATALOG_CACHE_SUBDIR);
37
+ }
36
38
  var REMOTE_BASE = process.env["HAUS_CATALOG_REMOTE_BASE"] ?? `${CATALOG_REPO_URL}/${CATALOG_REF}`;
37
39
  var REMOTE_MANIFEST_URL = `${REMOTE_BASE}/manifest.json`;
38
40
  async function fetchText(url) {
@@ -54,15 +56,29 @@ async function fetchRemoteManifest() {
54
56
  return null;
55
57
  }
56
58
  }
59
+ async function writeTextIfChanged(dest, text) {
60
+ if (await fs.pathExists(dest)) {
61
+ const local = await fs.readFile(dest, "utf8");
62
+ if (local === text) return "unchanged";
63
+ await fs.writeFile(dest, text, "utf8");
64
+ return "updated";
65
+ }
66
+ await fs.ensureDir(path.dirname(dest));
67
+ await fs.writeFile(dest, text, "utf8");
68
+ return "created";
69
+ }
57
70
  var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
58
71
  async function readWorkflowTemplate(opts = {}) {
59
- const dest = path.join(CACHE_DIR, WORKFLOW_TEMPLATE_REL);
60
- if (await fs.pathExists(dest)) return fs.readFile(dest, "utf8");
72
+ const dest = path.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
61
73
  const text = await fetchText(`${REMOTE_BASE}/${WORKFLOW_TEMPLATE_REL}`);
62
- if (text === null) return null;
74
+ if (text === null) {
75
+ if (await fs.pathExists(dest)) return fs.readFile(dest, "utf8");
76
+ return null;
77
+ }
63
78
  if (!opts.dryRun) {
64
- await fs.ensureDir(path.dirname(dest));
65
- await fs.writeFile(dest, text, "utf8");
79
+ await writeTextIfChanged(dest, text);
80
+ } else if (await fs.pathExists(dest)) {
81
+ return fs.readFile(dest, "utf8");
66
82
  }
67
83
  return text;
68
84
  }
@@ -87,37 +103,37 @@ async function downloadSkillReferences(item, destDir) {
87
103
  warn(`Skipping reference "${ref}" for ${item.id}: path traversal detected`);
88
104
  continue;
89
105
  }
90
- if (await fs.pathExists(refDest)) continue;
91
106
  const text = await fetchText(`${REMOTE_BASE}/${item.path}/${ref}`);
92
107
  if (text === null) {
93
108
  warn(`Failed to fetch reference "${ref}" for ${item.id}`);
94
109
  continue;
95
110
  }
96
- await fs.ensureDir(path.dirname(refDest));
97
- await fs.writeFile(refDest, text, "utf8");
111
+ await writeTextIfChanged(refDest, text);
98
112
  }
99
113
  }
100
114
  async function syncRemoteCatalog() {
101
115
  const items = await fetchRemoteManifest();
102
116
  if (!items) {
103
117
  warn("Remote catalog fetch failed \u2014 using bundled catalog");
104
- return { newItems: [], unchanged: 0, failed: [] };
118
+ return { newItems: [], refreshed: [], unchanged: 0, failed: [] };
105
119
  }
120
+ const cacheDir = getCacheDir();
106
121
  try {
107
- await fs.ensureDir(CACHE_DIR);
122
+ await fs.ensureDir(cacheDir);
108
123
  await fs.writeFile(
109
- path.join(CACHE_DIR, "manifest.json"),
124
+ path.join(cacheDir, "manifest.json"),
110
125
  `${JSON.stringify({ items }, null, 2)}
111
126
  `,
112
127
  "utf8"
113
128
  );
114
129
  } catch (err) {
115
130
  warn(
116
- `Catalog cache not writable (${CACHE_DIR}) \u2014 skipping cache sync: ${err instanceof Error ? err.message : String(err)}`
131
+ `Catalog cache not writable (${cacheDir}) \u2014 skipping cache sync: ${err instanceof Error ? err.message : String(err)}`
117
132
  );
118
- return { newItems: [], unchanged: 0, failed: [] };
133
+ return { newItems: [], refreshed: [], unchanged: 0, failed: [] };
119
134
  }
120
135
  const newItems = [];
136
+ const refreshed = [];
121
137
  let unchanged = 0;
122
138
  const failed = [];
123
139
  for (const item of items) {
@@ -129,18 +145,13 @@ async function syncRemoteCatalog() {
129
145
  continue;
130
146
  }
131
147
  if (item.type === "skill") {
132
- const destDir = safeJoin(CACHE_DIR, item.path);
148
+ const destDir = safeJoin(getCacheDir(), item.path);
133
149
  if (!destDir) {
134
150
  warn(`Skipping ${item.id}: path traversal detected`);
135
151
  failed.push(item.id);
136
152
  continue;
137
153
  }
138
154
  const dest = path.join(destDir, "SKILL.md");
139
- if (await fs.pathExists(dest)) {
140
- await downloadSkillReferences(item, destDir);
141
- unchanged++;
142
- continue;
143
- }
144
155
  const url = `${REMOTE_BASE}/${item.path}/SKILL.md`;
145
156
  const text = await fetchText(url);
146
157
  if (!text) {
@@ -149,25 +160,22 @@ async function syncRemoteCatalog() {
149
160
  continue;
150
161
  }
151
162
  try {
152
- await fs.ensureDir(path.dirname(dest));
153
- await fs.writeFile(dest, text, "utf8");
163
+ const outcome = await writeTextIfChanged(dest, text);
154
164
  await downloadSkillReferences(item, destDir);
155
- newItems.push(item.id);
165
+ if (outcome === "created") newItems.push(item.id);
166
+ else if (outcome === "updated") refreshed.push(item.id);
167
+ else unchanged++;
156
168
  } catch (err) {
157
169
  warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
158
170
  failed.push(item.id);
159
171
  }
160
172
  } else {
161
- const dest = safeJoin(CACHE_DIR, item.path);
173
+ const dest = safeJoin(getCacheDir(), item.path);
162
174
  if (!dest) {
163
175
  warn(`Skipping ${item.id}: path traversal detected`);
164
176
  failed.push(item.id);
165
177
  continue;
166
178
  }
167
- if (await fs.pathExists(dest)) {
168
- unchanged++;
169
- continue;
170
- }
171
179
  const url = `${REMOTE_BASE}/${item.path}`;
172
180
  const text = await fetchText(url);
173
181
  if (!text) {
@@ -176,16 +184,17 @@ async function syncRemoteCatalog() {
176
184
  continue;
177
185
  }
178
186
  try {
179
- await fs.ensureDir(path.dirname(dest));
180
- await fs.writeFile(dest, text, "utf8");
181
- newItems.push(item.id);
187
+ const outcome = await writeTextIfChanged(dest, text);
188
+ if (outcome === "created") newItems.push(item.id);
189
+ else if (outcome === "updated") refreshed.push(item.id);
190
+ else unchanged++;
182
191
  } catch (err) {
183
192
  warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
184
193
  failed.push(item.id);
185
194
  }
186
195
  }
187
196
  }
188
- return { newItems, unchanged, failed };
197
+ return { newItems, refreshed, unchanged, failed };
189
198
  }
190
199
  var CATALOG_TAGS_API_URL = "https://api.github.com/repos/WeAreHausTech/haus-workflow-catalog/tags";
191
200
  async function fetchLatestCatalogTag() {
@@ -204,7 +213,7 @@ async function fetchLatestCatalogTag() {
204
213
  }
205
214
  async function getCacheManifestAge() {
206
215
  try {
207
- const stat = await fs.stat(path.join(CACHE_DIR, "manifest.json"));
216
+ const stat = await fs.stat(path.join(getCacheDir(), "manifest.json"));
208
217
  return Date.now() - stat.mtimeMs;
209
218
  } catch {
210
219
  return null;
@@ -599,21 +608,21 @@ var PROJECT_HOOK_FRAGMENTS = [
599
608
  id: "haus.context-hook",
600
609
  gate: "keep",
601
610
  event: "UserPromptSubmit",
602
- command: "haus context --from-hook || true"
611
+ command: "haus context --from-hook"
603
612
  },
604
613
  {
605
614
  id: "haus.guard-file",
606
615
  gate: "keep",
607
616
  event: "PreToolUse",
608
617
  matcher: "Read|Edit|Write",
609
- command: "haus guard file-access --from-hook || true"
618
+ command: "haus guard file-access --from-hook"
610
619
  },
611
620
  {
612
621
  id: "haus.guard-bash",
613
622
  gate: "keep",
614
623
  event: "PreToolUse",
615
624
  matcher: "Bash",
616
- command: "haus guard bash --from-hook || true"
625
+ command: "haus guard bash --from-hook"
617
626
  }
618
627
  ];
619
628
  async function readProjectSettings(root) {
@@ -637,11 +646,54 @@ async function applyProjectSettingsMerge(root) {
637
646
  }
638
647
 
639
648
  // src/claude/write-claude-files.ts
640
- import path11 from "path";
649
+ import path12 from "path";
641
650
  import fs10 from "fs-extra";
642
651
 
643
- // src/update/hash-installed.ts
652
+ // src/catalog/load-catalog.ts
644
653
  import path6 from "path";
654
+ async function loadCatalogContext(root) {
655
+ const envPath = process.env["HAUS_FIXTURE_CATALOG"];
656
+ if (envPath) {
657
+ const data2 = await readJson(envPath);
658
+ return {
659
+ items: data2?.items ?? [],
660
+ contentRoot: path6.dirname(envPath),
661
+ source: "fixture"
662
+ };
663
+ }
664
+ const cacheDir = getCacheDir();
665
+ const cacheManifestPath = path6.join(cacheDir, "manifest.json");
666
+ const cacheData = await readJson(cacheManifestPath);
667
+ if (cacheData?.items?.length) {
668
+ return { items: cacheData.items, contentRoot: cacheDir, source: "cache" };
669
+ }
670
+ const localManifest = path6.join(root, "library/catalog/manifest.json");
671
+ const localData = await readJson(localManifest);
672
+ if (localData?.items?.length) {
673
+ return {
674
+ items: localData.items,
675
+ contentRoot: path6.dirname(localManifest),
676
+ source: "local"
677
+ };
678
+ }
679
+ const packageManifest = path6.join(packageRoot(), "library/catalog/manifest.json");
680
+ const data = await readJson(packageManifest);
681
+ return {
682
+ items: data?.items ?? [],
683
+ contentRoot: path6.dirname(packageManifest),
684
+ source: "bundled"
685
+ };
686
+ }
687
+ async function loadCatalog(root) {
688
+ const ctx = await loadCatalogContext(root);
689
+ return ctx.items;
690
+ }
691
+ function catalogItemContentPath(contentRoot, item) {
692
+ return path6.join(contentRoot, item.path);
693
+ }
694
+
695
+ // src/update/hash-installed.ts
696
+ import path7 from "path";
645
697
  import fg2 from "fast-glob";
646
698
  import fs4 from "fs-extra";
647
699
  var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
@@ -652,7 +704,7 @@ async function hashInstalledPaths(root, relPaths) {
652
704
  const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
653
705
  const fileDigests = [];
654
706
  for (const rel of normalized) {
655
- const abs = path6.join(root, rel);
707
+ const abs = path7.join(root, rel);
656
708
  if (!await fs4.pathExists(abs)) continue;
657
709
  const stat = await fs4.stat(abs);
658
710
  if (stat.isFile()) {
@@ -663,8 +715,8 @@ async function hashInstalledPaths(root, relPaths) {
663
715
  if (!stat.isDirectory()) continue;
664
716
  const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
665
717
  for (const sub of inner.sort()) {
666
- const relFile = path6.join(rel, sub).replace(/\\/g, "/");
667
- const absFile = path6.join(abs, sub);
718
+ const relFile = path7.join(rel, sub).replace(/\\/g, "/");
719
+ const absFile = path7.join(abs, sub);
668
720
  const body = await fs4.readFile(absFile, "utf8");
669
721
  fileDigests.push({ rel: relFile, digest: hashText(body) });
670
722
  }
@@ -699,7 +751,7 @@ function summarizeDiff(diffText) {
699
751
  }
700
752
 
701
753
  // src/claude/load-hooks-config.ts
702
- import path7 from "path";
754
+ import path8 from "path";
703
755
  var CONFIG_PATH = ".haus-workflow/config.json";
704
756
  var DEFAULT_HOOKS_CONFIG = {
705
757
  hooks: {
@@ -707,7 +759,7 @@ var DEFAULT_HOOKS_CONFIG = {
707
759
  }
708
760
  };
709
761
  async function isHookEnabled(root, key) {
710
- const cfg = await readJson(path7.join(root, CONFIG_PATH));
762
+ const cfg = await readJson(path8.join(root, CONFIG_PATH));
711
763
  return cfg?.hooks?.[key]?.enabled === true;
712
764
  }
713
765
 
@@ -719,25 +771,25 @@ var CANONICAL_HOOKS = {
719
771
  hooks: {
720
772
  UserPromptSubmit: [
721
773
  {
722
- hooks: [{ type: "command", command: "haus context --from-hook || true" }]
774
+ hooks: [{ type: "command", command: "haus context --from-hook" }]
723
775
  }
724
776
  ],
725
777
  PreToolUse: [
726
778
  {
727
779
  matcher: "Read|Edit|Write",
728
- hooks: [{ type: "command", command: "haus guard file-access --from-hook || true" }]
780
+ hooks: [{ type: "command", command: "haus guard file-access --from-hook" }]
729
781
  },
730
782
  {
731
783
  matcher: "Bash",
732
- hooks: [{ type: "command", command: "haus guard bash --from-hook || true" }]
784
+ hooks: [{ type: "command", command: "haus guard bash --from-hook" }]
733
785
  }
734
786
  ]
735
787
  }
736
788
  };
737
789
  var STABLE_HOOK_IDS = {
738
- "haus context --from-hook || true": "haus.context-hook",
739
- "haus guard file-access --from-hook || true": "haus.guard-file",
740
- "haus guard bash --from-hook || true": "haus.guard-bash"
790
+ "haus context --from-hook": "haus.context-hook",
791
+ "haus guard file-access --from-hook": "haus.guard-file",
792
+ "haus guard bash --from-hook": "haus.guard-bash"
741
793
  };
742
794
  async function loadClaudeHooksSettings() {
743
795
  return { ...CANONICAL_HOOKS, permissions: { deny: buildDenyRules() } };
@@ -833,7 +885,7 @@ async function verifyProjectSettingsHooksContract(root) {
833
885
  }
834
886
 
835
887
  // src/claude/write-root-claude-md.ts
836
- import path8 from "path";
888
+ import path9 from "path";
837
889
  import fs6 from "fs-extra";
838
890
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
839
891
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
@@ -873,7 +925,7 @@ ${block}
873
925
  `;
874
926
  }
875
927
  async function writeRootClaudeMd(root, dryRun) {
876
- const filePath = path8.join(root, "CLAUDE.md");
928
+ const filePath = path9.join(root, "CLAUDE.md");
877
929
  const block = buildImportBlock();
878
930
  const prev = await fs6.pathExists(filePath) ? await fs6.readFile(filePath, "utf8") : "";
879
931
  const next = injectHausBlock(prev, block);
@@ -898,11 +950,11 @@ async function writeRootClaudeMd(root, dryRun) {
898
950
  }
899
951
 
900
952
  // src/claude/write-workflow-config.ts
901
- import path10 from "path";
953
+ import path11 from "path";
902
954
  import fs8 from "fs-extra";
903
955
 
904
956
  // src/claude/derive-workflow-config.ts
905
- import path9 from "path";
957
+ import path10 from "path";
906
958
  import fs7 from "fs-extra";
907
959
  function binCmd(pm, bin, args) {
908
960
  const tail = args ? ` ${args}` : "";
@@ -912,7 +964,7 @@ function binCmd(pm, bin, args) {
912
964
  }
913
965
  async function deriveWorkflowConfig(root, ctx) {
914
966
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
915
- const pkg = await readJson(path9.join(root, "package.json"));
967
+ const pkg = await readJson(path10.join(root, "package.json"));
916
968
  const scripts = pkg?.scripts ?? {};
917
969
  const deps = new Set(ctx.dependencies);
918
970
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -922,7 +974,7 @@ async function deriveWorkflowConfig(root, ctx) {
922
974
  return null;
923
975
  };
924
976
  const hasDep = (name) => deps.has(name);
925
- const exists = (rel) => fs7.pathExistsSync(path9.join(root, rel));
977
+ const exists = (rel) => fs7.pathExistsSync(path10.join(root, rel));
926
978
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
927
979
  const hasCypress = hasDep("cypress");
928
980
  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;
@@ -999,7 +1051,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
999
1051
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
1000
1052
  ...FALLBACK_CONTEXT,
1001
1053
  root,
1002
- repoName: path10.basename(root)
1054
+ repoName: path11.basename(root)
1003
1055
  };
1004
1056
  const values = await deriveWorkflowConfig(root, ctx);
1005
1057
  if (exists) {
@@ -1108,7 +1160,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
1108
1160
  estimatedTokenReductionPct: 0
1109
1161
  };
1110
1162
  const pkgRoot = packageRoot();
1111
- const hausVersion2 = (await readJson(path11.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
1163
+ const hausVersion2 = (await readJson(path12.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
1112
1164
  const coreFiles = [
1113
1165
  claudePath(root, "settings.json"),
1114
1166
  claudePath(root, "rules", "haus.md"),
@@ -1167,15 +1219,8 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
1167
1219
  "- Never read secrets.\n- Block dangerous shell commands.\n",
1168
1220
  dryRun
1169
1221
  );
1170
- const fixtureManifestPath = process.env["HAUS_FIXTURE_CATALOG"];
1171
- const manifestPath2 = fixtureManifestPath ?? path11.join(pkgRoot, "library", "catalog", "manifest.json");
1172
- const manifestDir = path11.dirname(manifestPath2);
1173
- const manifest = await readJson(manifestPath2) ?? { items: [] };
1174
- const manifestById = new Map((manifest.items ?? []).map((item) => [item.id, item]));
1175
- const cacheManifest = await readJson(
1176
- path11.join(CACHE_DIR, "manifest.json")
1177
- );
1178
- const cacheManifestById = new Map((cacheManifest?.items ?? []).map((item) => [item.id, item]));
1222
+ const { items: manifestItems, contentRoot } = await loadCatalogContext(root);
1223
+ const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
1179
1224
  const installedPathsByItem = /* @__PURE__ */ new Map();
1180
1225
  const installedIds = /* @__PURE__ */ new Set();
1181
1226
  const catalogItems = selectedIds !== void 0 ? rec.recommended.filter((r) => selectedIds.includes(r.id)) : rec.recommended;
@@ -1194,11 +1239,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
1194
1239
  continue;
1195
1240
  }
1196
1241
  }
1197
- const cachedItem = cacheManifestById.get(item.id);
1198
- const cachePath = cachedItem?.path ? path11.join(CACHE_DIR, cachedItem.path) : null;
1199
- const sourcePath = cachePath && await fs10.pathExists(cachePath) ? cachePath : path11.join(manifestDir, manifestItem.path);
1242
+ const sourcePath = catalogItemContentPath(contentRoot, manifestItem);
1200
1243
  const target = item.type === "agent" ? "agents" : item.type === "template" ? "templates" : "skills";
1201
- const destination = claudePath(root, target, path11.basename(sourcePath));
1244
+ const destination = claudePath(root, target, path12.basename(sourcePath));
1202
1245
  if (await fs10.pathExists(sourcePath)) {
1203
1246
  if (dryRun) {
1204
1247
  const exists = await fs10.pathExists(destination);
@@ -1206,12 +1249,12 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
1206
1249
  `${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
1207
1250
  );
1208
1251
  } else {
1209
- await fs10.ensureDir(path11.dirname(destination));
1252
+ await fs10.ensureDir(path12.dirname(destination));
1210
1253
  await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
1211
1254
  }
1212
1255
  files.push(destination);
1213
1256
  const current = installedPathsByItem.get(item.id) ?? [];
1214
- installedPathsByItem.set(item.id, [...current, path11.relative(root, destination)]);
1257
+ installedPathsByItem.set(item.id, [...current, path12.relative(root, destination)]);
1215
1258
  installedIds.add(item.id);
1216
1259
  } else {
1217
1260
  warn(
@@ -1289,7 +1332,7 @@ async function writeManagedJson(root, filePath, value, dryRun) {
1289
1332
 
1290
1333
  // src/commands/apply.ts
1291
1334
  async function cacheHasItems() {
1292
- const data = await readJson(path12.join(CACHE_DIR, "manifest.json"));
1335
+ const data = await readJson(path13.join(getCacheDir(), "manifest.json"));
1293
1336
  return Array.isArray(data?.items) && data.items.length > 0;
1294
1337
  }
1295
1338
  async function runApply(options) {
@@ -1333,17 +1376,9 @@ async function runApply(options) {
1333
1376
  const rec = await readJson(hausPath(root, "recommendation.json"));
1334
1377
  const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : rec?.recommended.length ?? 0;
1335
1378
  if (catalogItemCount > 0 && !await cacheHasItems()) {
1336
- if (isDryRun) {
1337
- warn(
1338
- "Catalog cache is empty \u2014 `haus apply --write` will skip catalog items. Run `haus update` first."
1339
- );
1340
- } else {
1341
- error(
1342
- "Catalog cache is empty \u2014 cannot install catalog items. Run `haus update` first, or pass --allow-empty-cache to apply core files only."
1343
- );
1344
- process.exitCode = 1;
1345
- return;
1346
- }
1379
+ warn(
1380
+ isDryRun ? "Catalog cache is empty \u2014 `haus apply --write` will skip catalog items. Run `haus update` first." : "Catalog cache is empty \u2014 catalog items will be skipped. Run `haus update` first, or pass --allow-empty-cache to silence this warning."
1381
+ );
1347
1382
  }
1348
1383
  }
1349
1384
  const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
@@ -1369,26 +1404,6 @@ async function refreshProjectApply(root) {
1369
1404
  return writeClaudeFiles(root, false, void 0, { refillConfig: false });
1370
1405
  }
1371
1406
 
1372
- // src/catalog/load-catalog.ts
1373
- import os4 from "os";
1374
- import path13 from "path";
1375
- var CACHE_MANIFEST = path13.join(os4.homedir(), CATALOG_CACHE_SUBDIR, "manifest.json");
1376
- async function loadCatalog(root) {
1377
- const envPath = process.env["HAUS_FIXTURE_CATALOG"];
1378
- if (envPath) {
1379
- const data2 = await readJson(envPath);
1380
- return data2?.items ?? [];
1381
- }
1382
- const cacheData = await readJson(CACHE_MANIFEST);
1383
- if (cacheData?.items?.length) return cacheData.items;
1384
- const localManifest = path13.join(root, "library/catalog/manifest.json");
1385
- const localData = await readJson(localManifest);
1386
- if (localData?.items?.length) return localData.items;
1387
- const packageManifest = path13.join(packageRoot(), "library/catalog/manifest.json");
1388
- const data = await readJson(packageManifest);
1389
- return data?.items ?? [];
1390
- }
1391
-
1392
1407
  // library/catalog/validation-rules.json
1393
1408
  var validation_rules_default = {
1394
1409
  forbiddenTags: [
@@ -2342,6 +2357,38 @@ ${describeRepo(context)}
2342
2357
  `;
2343
2358
  }
2344
2359
 
2360
+ // src/scanner/write-sources-report.ts
2361
+ function buildSourcesReport(items) {
2362
+ const statusBySource = /* @__PURE__ */ new Map();
2363
+ for (const item of items) {
2364
+ const src = item.source?.trim();
2365
+ if (!src || src === "haus") continue;
2366
+ if (src === "curated") {
2367
+ if (item.reviewStatus === "approved" && item.riskLevel !== "blocked") {
2368
+ statusBySource.set("curated", "approved");
2369
+ } else if (!statusBySource.has("curated")) {
2370
+ statusBySource.set("curated", "candidate");
2371
+ }
2372
+ continue;
2373
+ }
2374
+ if (item.reviewStatus === "approved" && item.riskLevel !== "blocked") {
2375
+ statusBySource.set(src, "approved");
2376
+ } else if (!statusBySource.has(src)) {
2377
+ statusBySource.set(src, "candidate");
2378
+ }
2379
+ }
2380
+ return {
2381
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2382
+ items: [...statusBySource.entries()].map(([id, status]) => ({ id, status })).sort((a, b) => a.id.localeCompare(b.id))
2383
+ };
2384
+ }
2385
+ async function writeSourcesReport(root) {
2386
+ const items = await loadCatalog(root);
2387
+ const report = buildSourcesReport(items);
2388
+ await writeJson(hausPath(root, "sources-report.json"), report);
2389
+ return report;
2390
+ }
2391
+
2345
2392
  // src/scanner/scan-project.ts
2346
2393
  var SAFE_FILES = [
2347
2394
  "package.json",
@@ -2450,6 +2497,7 @@ async function scanProject(root, mode = "fast") {
2450
2497
  await writeJson(hausPath(root, "dependency-map.json"), dependencyMap);
2451
2498
  await writeJson(hausPath(root, "scan-hashes.json"), scanHashes);
2452
2499
  await writeText(hausPath(root, "repo-summary.md"), repoSummary);
2500
+ await writeSourcesReport(root);
2453
2501
  return { ...context, dependencyMap, scanHashes, repoSummary };
2454
2502
  }
2455
2503
 
@@ -2461,6 +2509,18 @@ async function readContextOrScan(root) {
2461
2509
  return scan;
2462
2510
  }
2463
2511
 
2512
+ // src/security/secret-patterns.ts
2513
+ var SECRET_PATTERNS = [
2514
+ /api[_-]?key\s*[:=]\s*\S+/i,
2515
+ /token\s*[:=]\s*\S+/i,
2516
+ /password\s*[:=]\s*\S+/i
2517
+ ];
2518
+
2519
+ // src/security/redact-sensitive.ts
2520
+ function redactSensitive(input2) {
2521
+ return SECRET_PATTERNS.reduce((acc, pattern) => acc.replace(pattern, "[REDACTED]"), input2);
2522
+ }
2523
+
2464
2524
  // src/commands/context.ts
2465
2525
  async function runContext(options) {
2466
2526
  const root = process.cwd();
@@ -2509,7 +2569,7 @@ async function runContext(options) {
2509
2569
  }),
2510
2570
  summary
2511
2571
  ];
2512
- const text = lines.join("\n");
2572
+ const text = redactSensitive(lines.join("\n"));
2513
2573
  log(options.fromHook ? text.slice(0, 3e3) : text);
2514
2574
  }
2515
2575
 
@@ -2648,7 +2708,7 @@ async function runDoctor(options) {
2648
2708
  ok("- .haus-workflow/WORKFLOW.md: OK (user-owned)");
2649
2709
  } else {
2650
2710
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
2651
- const cachePath = path19.join(CACHE_DIR, "templates/agentic-workflow-standard.md");
2711
+ const cachePath = path19.join(getCacheDir(), "templates/agentic-workflow-standard.md");
2652
2712
  const bundledPath = path19.join(
2653
2713
  packageRoot(),
2654
2714
  "library",
@@ -2723,7 +2783,9 @@ async function runDoctor(options) {
2723
2783
  `A newer haus (${npmStatus.latest}) is available`,
2724
2784
  `npm install -g ${NPM_PACKAGE_NAME}`
2725
2785
  );
2726
- process.exitCode = 1;
2786
+ if (!process.env["HAUS_FIXTURE_CATALOG"]) {
2787
+ process.exitCode = 1;
2788
+ }
2727
2789
  } else if (npmStatus.latest !== null) {
2728
2790
  ok(`- CLI: ${currentVersion} (up to date)`);
2729
2791
  } else {
@@ -2916,23 +2978,7 @@ async function readChangedFiles(root) {
2916
2978
  }
2917
2979
 
2918
2980
  // src/recommender/policies.ts
2919
- var UNSUPPORTED = [
2920
- "python",
2921
- "django",
2922
- "go",
2923
- "rust",
2924
- "java",
2925
- "spring",
2926
- "kotlin",
2927
- "swift",
2928
- "android",
2929
- "flutter",
2930
- "dart",
2931
- "c++",
2932
- "perl",
2933
- "defi",
2934
- "trading"
2935
- ];
2981
+ var UNSUPPORTED = FORBIDDEN_TAGS;
2936
2982
  function matchRequiresAny(clauses, ctx) {
2937
2983
  for (const clause of clauses) {
2938
2984
  if ("stack" in clause) {
@@ -3218,6 +3264,15 @@ async function runSetupCore(root, opts) {
3218
3264
  return baseResult;
3219
3265
  }
3220
3266
  }
3267
+ if (!dryRun && !process.env["HAUS_FIXTURE_CATALOG"]) {
3268
+ log("Syncing remote catalog...");
3269
+ const sync = await syncRemoteCatalog();
3270
+ if (sync.newItems.length > 0) {
3271
+ log(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
3272
+ } else if (sync.refreshed.length > 0) {
3273
+ log(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
3274
+ }
3275
+ }
3221
3276
  const files = await writeClaudeFiles(root, dryRun ?? false);
3222
3277
  log("Applied files:");
3223
3278
  files.forEach((f) => log(`- ${displayPath(root, f)}`));
@@ -3570,10 +3625,14 @@ async function runRecommend(options) {
3570
3625
 
3571
3626
  // src/commands/refresh.ts
3572
3627
  async function runRefresh() {
3573
- const result = await scanProject(process.cwd(), "fast");
3574
- log("Haus scan complete");
3575
- log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
3576
- log(`Package manager: ${result.packageManager}`);
3628
+ const root = process.cwd();
3629
+ const context = await scanProject(root, "fast");
3630
+ const recommendation = await recommend(root, context);
3631
+ await writeJson(hausPath(root, "recommendation.json"), recommendation);
3632
+ log("Haus refresh complete");
3633
+ log(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
3634
+ log(`Package manager: ${context.packageManager}`);
3635
+ log(`Recommended items: ${recommendation.recommended.length}`);
3577
3636
  }
3578
3637
 
3579
3638
  // src/commands/scan.ts
@@ -3827,7 +3886,17 @@ async function checkLock(root) {
3827
3886
  (item) => !item.version || normalizeVersion(item.version) !== null
3828
3887
  );
3829
3888
  const catalogRef = lock[0]?.catalogRef ?? null;
3830
- return { ok: lock.length > 0 && hasValidVersions, count: lock.length, catalogRef };
3889
+ const drift = [];
3890
+ for (const item of lock) {
3891
+ if (!item.hash) continue;
3892
+ const paths = Array.isArray(item.paths) ? item.paths.map(String) : [];
3893
+ const actual = await hashInstalledPaths(root, paths);
3894
+ if (item.hash !== actual) {
3895
+ drift.push({ id: item.id, expected: item.hash, actual });
3896
+ }
3897
+ }
3898
+ const ok = lock.length > 0 && hasValidVersions && drift.length === 0;
3899
+ return { ok, count: lock.length, catalogRef, drift, driftCount: drift.length };
3831
3900
  }
3832
3901
  async function applyLock(root) {
3833
3902
  const lockPath = hausPath(root, "haus.lock.json");
@@ -3902,6 +3971,7 @@ async function runUpdate(options) {
3902
3971
  )
3903
3972
  );
3904
3973
  if (!status.ok) process.exitCode = 1;
3974
+ if (status.driftCount > 0) process.exitCode = 1;
3905
3975
  return;
3906
3976
  }
3907
3977
  const pkgJson = await readJson(path25.join(packageRoot(), "package.json"));
@@ -3924,7 +3994,12 @@ async function runUpdate(options) {
3924
3994
  if (sync.newItems.length > 0) {
3925
3995
  log(`Catalog updated: ${sync.newItems.length} new item(s): ${sync.newItems.join(", ")}`);
3926
3996
  log("Run `haus recommend && haus apply --write` to install new skills.");
3927
- } else if (sync.unchanged > 0) {
3997
+ }
3998
+ if (sync.refreshed.length > 0) {
3999
+ log(`Catalog refreshed: ${sync.refreshed.length} updated item(s): ${sync.refreshed.join(", ")}`);
4000
+ log("Run `haus apply --write` to install refreshed skill content.");
4001
+ }
4002
+ if (sync.newItems.length === 0 && sync.refreshed.length === 0 && sync.unchanged > 0) {
3928
4003
  log(`Catalog up to date (${sync.unchanged} item(s) unchanged).`);
3929
4004
  }
3930
4005
  if (sync.failed.length > 0) {
@@ -3978,6 +4053,32 @@ async function detectGlobalInstallDrift() {
3978
4053
  // src/commands/validate-catalog.ts
3979
4054
  import fs18 from "fs";
3980
4055
  import path26 from "path";
4056
+
4057
+ // src/catalog/forbidden-content.ts
4058
+ var PROSE_FORBIDDEN_TAGS = FORBIDDEN_TAGS.filter((t) => t.toLowerCase() !== "go");
4059
+ function escapeRegExp(value) {
4060
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4061
+ }
4062
+ function extractUseWhenSection(text) {
4063
+ const marker = "## Use when";
4064
+ const idx = text.toLowerCase().indexOf(marker.toLowerCase());
4065
+ if (idx < 0) return "";
4066
+ const tail = text.slice(idx + marker.length);
4067
+ const next = tail.search(/\n##\s+/);
4068
+ return next < 0 ? tail : tail.slice(0, next);
4069
+ }
4070
+ function auditForbiddenTagsInText(text, label) {
4071
+ const body = extractUseWhenSection(text);
4072
+ if (!body.trim()) return [];
4073
+ const failures = [];
4074
+ for (const word of PROSE_FORBIDDEN_TAGS) {
4075
+ const re = new RegExp(`\\b${escapeRegExp(word)}\\b`, "i");
4076
+ if (re.test(body)) failures.push(`${label}: forbidden stack/tag "${word}" in content`);
4077
+ }
4078
+ return failures;
4079
+ }
4080
+
4081
+ // src/commands/validate-catalog.ts
3981
4082
  function auditForbiddenStacks(items) {
3982
4083
  const failures = [];
3983
4084
  for (const item of items) {
@@ -4056,6 +4157,9 @@ function auditShippedFiles(manifestDir, items) {
4056
4157
  for (const section of REQUIRED_SKILL_SECTIONS) {
4057
4158
  if (!text.includes(section)) failures.push(`${item.id}: SKILL.md missing ${section}`);
4058
4159
  }
4160
+ failures.push(
4161
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path26.relative(manifestDir, skillMd)}`)
4162
+ );
4059
4163
  } else if (item.type === "agent") {
4060
4164
  if (!fs18.existsSync(absPath)) {
4061
4165
  failures.push(`${item.id}: missing agent file ${item.path}`);
@@ -4071,17 +4175,42 @@ function auditShippedFiles(manifestDir, items) {
4071
4175
  if (lower.includes(phrase))
4072
4176
  failures.push(`${item.id}: agent file contains disallowed phrase "${phrase}"`);
4073
4177
  }
4178
+ failures.push(
4179
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path26.relative(manifestDir, absPath)}`)
4180
+ );
4074
4181
  } else if (item.type === "template") {
4075
4182
  if (!fs18.existsSync(absPath)) {
4076
4183
  failures.push(`${item.id}: missing template file ${item.path}`);
4184
+ continue;
4077
4185
  }
4186
+ failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
4187
+ }
4188
+ }
4189
+ return failures;
4190
+ }
4191
+ function auditTemplateContent(manifestDir, absPath, itemId) {
4192
+ const rel = path26.relative(manifestDir, absPath);
4193
+ const text = fs18.readFileSync(absPath, "utf8");
4194
+ const failures = [];
4195
+ const lines = text.split(/\r?\n/);
4196
+ for (let i = 0; i < lines.length; i++) {
4197
+ const line2 = lines[i] ?? "";
4198
+ if (PLACEHOLDER_PATTERN.test(line2)) {
4199
+ failures.push(`${rel}:${i + 1}: TODO or placeholder in shipped content`);
4200
+ }
4201
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
4202
+ failures.push(`${rel}:${i + 1}: risky install pattern`);
4203
+ }
4204
+ if (ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
4205
+ failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
4078
4206
  }
4079
4207
  }
4208
+ failures.push(...auditForbiddenTagsInText(text, `${itemId}: ${rel}`));
4080
4209
  return failures;
4081
4210
  }
4082
4211
  function auditMarkdownContent(manifestDir) {
4083
4212
  const failures = [];
4084
- const dirs = ["skills", "agents"];
4213
+ const dirs = ["skills", "agents", "templates"];
4085
4214
  for (const dir of dirs) {
4086
4215
  const abs = path26.join(manifestDir, dir);
4087
4216
  if (!fs18.existsSync(abs)) continue;
@@ -4101,6 +4230,9 @@ function auditMarkdownContent(manifestDir) {
4101
4230
  failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
4102
4231
  }
4103
4232
  }
4233
+ if (!rel.includes("/references/")) {
4234
+ failures.push(...auditForbiddenTagsInText(text, rel));
4235
+ }
4104
4236
  });
4105
4237
  }
4106
4238
  return failures;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haus-tech/haus-workflow",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Haus AI workflow CLI for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {