@haus-tech/haus-workflow 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6,8 +6,9 @@ import path34 from "path";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/commands/apply.ts
9
- import path10 from "path";
9
+ import path12 from "path";
10
10
  import checkbox from "@inquirer/checkbox";
11
+ import fs11 from "fs-extra";
11
12
 
12
13
  // src/catalog/remote-catalog.ts
13
14
  import os from "os";
@@ -102,13 +103,20 @@ async function syncRemoteCatalog() {
102
103
  warn("Remote catalog fetch failed \u2014 using bundled catalog");
103
104
  return { newItems: [], unchanged: 0, failed: [] };
104
105
  }
105
- await fs.ensureDir(CACHE_DIR);
106
- await fs.writeFile(
107
- path.join(CACHE_DIR, "manifest.json"),
108
- `${JSON.stringify({ items }, null, 2)}
106
+ try {
107
+ await fs.ensureDir(CACHE_DIR);
108
+ await fs.writeFile(
109
+ path.join(CACHE_DIR, "manifest.json"),
110
+ `${JSON.stringify({ items }, null, 2)}
109
111
  `,
110
- "utf8"
111
- );
112
+ "utf8"
113
+ );
114
+ } catch (err) {
115
+ warn(
116
+ `Catalog cache not writable (${CACHE_DIR}) \u2014 skipping cache sync: ${err instanceof Error ? err.message : String(err)}`
117
+ );
118
+ return { newItems: [], unchanged: 0, failed: [] };
119
+ }
112
120
  const newItems = [];
113
121
  let unchanged = 0;
114
122
  const failed = [];
@@ -140,10 +148,15 @@ async function syncRemoteCatalog() {
140
148
  failed.push(item.id);
141
149
  continue;
142
150
  }
143
- await fs.ensureDir(path.dirname(dest));
144
- await fs.writeFile(dest, text, "utf8");
145
- await downloadSkillReferences(item, destDir);
146
- newItems.push(item.id);
151
+ try {
152
+ await fs.ensureDir(path.dirname(dest));
153
+ await fs.writeFile(dest, text, "utf8");
154
+ await downloadSkillReferences(item, destDir);
155
+ newItems.push(item.id);
156
+ } catch (err) {
157
+ warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
158
+ failed.push(item.id);
159
+ }
147
160
  } else {
148
161
  const dest = safeJoin(CACHE_DIR, item.path);
149
162
  if (!dest) {
@@ -162,9 +175,14 @@ async function syncRemoteCatalog() {
162
175
  failed.push(item.id);
163
176
  continue;
164
177
  }
165
- await fs.ensureDir(path.dirname(dest));
166
- await fs.writeFile(dest, text, "utf8");
167
- newItems.push(item.id);
178
+ try {
179
+ await fs.ensureDir(path.dirname(dest));
180
+ await fs.writeFile(dest, text, "utf8");
181
+ newItems.push(item.id);
182
+ } catch (err) {
183
+ warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
184
+ failed.push(item.id);
185
+ }
168
186
  }
169
187
  }
170
188
  return { newItems, unchanged, failed };
@@ -193,13 +211,21 @@ async function getCacheManifestAge() {
193
211
  }
194
212
  }
195
213
 
196
- // src/claude/write-claude-files.ts
197
- import path9 from "path";
198
- import fs9 from "fs-extra";
214
+ // src/install/allow-rules.ts
215
+ var ALLOWED_SUBCOMMANDS = [
216
+ "setup-project",
217
+ "apply",
218
+ "doctor",
219
+ "scan",
220
+ "context",
221
+ "recommend"
222
+ ];
223
+ function buildAllowRules() {
224
+ return [...new Set(ALLOWED_SUBCOMMANDS.map((sub) => `Bash(haus ${sub}:*)`))];
225
+ }
199
226
 
200
- // src/update/hash-installed.ts
201
- import path3 from "path";
202
- import fg2 from "fast-glob";
227
+ // src/install/settings-merge.ts
228
+ import path4 from "path";
203
229
  import fs3 from "fs-extra";
204
230
 
205
231
  // src/utils/fs.ts
@@ -253,118 +279,181 @@ async function mapWithConcurrency(items, fn, concurrency = 24) {
253
279
  return results;
254
280
  }
255
281
 
256
- // src/update/hash-installed.ts
257
- var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
258
- async function hashInstalledPaths(root, relPaths) {
259
- if (relPaths.length === 0) {
260
- return hashText(EMPTY_LOCK_PATHS_TOKEN);
261
- }
262
- const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
263
- const fileDigests = [];
264
- for (const rel of normalized) {
265
- const abs = path3.join(root, rel);
266
- if (!await fs3.pathExists(abs)) continue;
267
- const stat = await fs3.stat(abs);
268
- if (stat.isFile()) {
269
- const body = await fs3.readFile(abs, "utf8");
270
- fileDigests.push({ rel, digest: hashText(body) });
271
- continue;
272
- }
273
- if (!stat.isDirectory()) continue;
274
- const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
275
- for (const sub of inner.sort()) {
276
- const relFile = path3.join(rel, sub).replace(/\\/g, "/");
277
- const absFile = path3.join(abs, sub);
278
- const body = await fs3.readFile(absFile, "utf8");
279
- fileDigests.push({ rel: relFile, digest: hashText(body) });
280
- }
281
- }
282
- if (fileDigests.length === 0) {
283
- return hashText(`${EMPTY_LOCK_PATHS_TOKEN}|${normalized.join("|")}`);
284
- }
285
- fileDigests.sort((a, b) => a.rel.localeCompare(b.rel));
286
- return hashText(fileDigests.map((f) => `${f.rel}=${f.digest}`).join("|"));
282
+ // src/install/manifest.ts
283
+ import os2 from "os";
284
+ import path3 from "path";
285
+ var MANIFEST_SCHEMA = "haus-install-manifest/1";
286
+ function globalClaudeDir() {
287
+ return path3.join(os2.homedir(), ".claude");
287
288
  }
288
-
289
- // src/utils/diff.ts
290
- import { createTwoFilesPatch } from "diff";
291
- function hasTextChanged(before, after) {
292
- return before !== after;
289
+ function hausManifestPath() {
290
+ return path3.join(globalClaudeDir(), "haus", "install-manifest.json");
293
291
  }
294
- function createUnifiedDiff(filePath, before, after) {
295
- return createTwoFilesPatch(filePath, filePath, before, after, "before", "after", {
296
- context: 3
297
- });
292
+ async function readManifest() {
293
+ return readJson(hausManifestPath());
298
294
  }
299
- function summarizeDiff(diffText) {
300
- const lines = diffText.split("\n");
301
- let additions = 0;
302
- let deletions = 0;
303
- for (const line2 of lines) {
304
- if (line2.startsWith("+++ ") || line2.startsWith("--- ")) continue;
305
- if (line2.startsWith("+")) additions += 1;
306
- if (line2.startsWith("-")) deletions += 1;
307
- }
308
- return { additions, deletions };
295
+ async function writeManifest(manifest) {
296
+ await writeJson(hausManifestPath(), manifest);
297
+ }
298
+ function buildManifest(source, files, hooks) {
299
+ return {
300
+ _schema: MANIFEST_SCHEMA,
301
+ source,
302
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
303
+ files,
304
+ hooks
305
+ };
309
306
  }
310
307
 
311
- // src/utils/paths.ts
312
- import { existsSync, readFileSync } from "fs";
313
- import os2 from "os";
314
- import path4 from "path";
315
- import { fileURLToPath } from "url";
316
- var HAUS_DIR = ".haus-workflow";
317
- function hausPath(root, ...parts) {
318
- return path4.join(root, HAUS_DIR, ...parts);
308
+ // src/install/settings-merge.ts
309
+ function settingsJsonPath() {
310
+ return path4.join(globalClaudeDir(), "settings.json");
319
311
  }
320
- function claudePath(root, ...parts) {
321
- return path4.join(root, ".claude", ...parts);
312
+ async function readSettings() {
313
+ const parsed = await readJson(settingsJsonPath());
314
+ return parsed ?? {};
322
315
  }
323
- function displayPath(root, targetPath) {
324
- const rel = path4.relative(root, targetPath).replace(/\\/g, "/");
325
- if (rel && !rel.startsWith("../") && rel !== "..") {
326
- return rel.startsWith("./") ? rel : `./${rel}`;
316
+ async function writeSettings(settings) {
317
+ await writeJson(settingsJsonPath(), settings);
318
+ }
319
+ function mergeHooks(settings, fragments) {
320
+ const existing = settings._haus?.hooks ?? [];
321
+ const existingCommands = settings._haus?.hookCommands ?? [];
322
+ const existingSet = new Set(existing);
323
+ const updated = { ...settings };
324
+ updated.hooks = { ...settings.hooks ?? {} };
325
+ const addedIds = [];
326
+ const addedCommands = [];
327
+ for (const fragment of fragments) {
328
+ if (fragment.gate !== "keep") continue;
329
+ if (existingSet.has(fragment.id)) continue;
330
+ const event = fragment.event;
331
+ if (!updated.hooks[event]) updated.hooks[event] = [];
332
+ const entry = {
333
+ hooks: [{ type: "command", command: fragment.command }]
334
+ };
335
+ if (fragment.matcher) entry.matcher = fragment.matcher;
336
+ updated.hooks[event] = [...updated.hooks[event] ?? [], entry];
337
+ addedIds.push(fragment.id);
338
+ addedCommands.push(fragment.command);
327
339
  }
328
- const home = os2.homedir();
329
- const normalized = targetPath.replace(/\\/g, "/");
330
- if (home && home.trim().length > 0) {
331
- const homeRel = path4.relative(home, targetPath).replace(/\\/g, "/");
332
- if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
333
- return `~/${homeRel}`;
334
- }
340
+ updated._haus = {
341
+ hooks: [...existing, ...addedIds],
342
+ hookCommands: [...existingCommands, ...addedCommands],
343
+ // Preserve deny/allow tracking so hook, deny, and allow merges are order-independent.
344
+ ...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
345
+ ...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
346
+ };
347
+ return { settings: updated, addedIds };
348
+ }
349
+ function mergeDenyRules(settings, rules) {
350
+ const existingDeny = settings.permissions?.deny ?? [];
351
+ const seen = new Set(existingDeny);
352
+ const trackedDeny = settings._haus?.denyRules ?? [];
353
+ const addedRules = [];
354
+ for (const rule of rules) {
355
+ if (seen.has(rule)) continue;
356
+ seen.add(rule);
357
+ addedRules.push(rule);
335
358
  }
336
- return normalized;
359
+ const updated = { ...settings };
360
+ updated.permissions = {
361
+ ...settings.permissions ?? {},
362
+ deny: [...existingDeny, ...addedRules]
363
+ };
364
+ updated._haus = {
365
+ hooks: settings._haus?.hooks ?? [],
366
+ ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
367
+ denyRules: [...trackedDeny, ...addedRules],
368
+ ...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
369
+ };
370
+ return { settings: updated, addedRules };
337
371
  }
338
- function packageRoot() {
339
- let dir = path4.dirname(fileURLToPath(import.meta.url));
340
- for (let i = 0; i < 12; i++) {
341
- const pkgPath = path4.join(dir, "package.json");
342
- if (existsSync(pkgPath)) {
343
- try {
344
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
345
- if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
346
- } catch {
347
- }
348
- }
349
- const parent = path4.dirname(dir);
350
- if (parent === dir) break;
351
- dir = parent;
372
+ function mergeAllowRules(settings, rules) {
373
+ const existingAllow = settings.permissions?.allow ?? [];
374
+ const seen = new Set(existingAllow);
375
+ const trackedAllow = settings._haus?.allowRules ?? [];
376
+ const addedRules = [];
377
+ for (const rule of rules) {
378
+ if (seen.has(rule)) continue;
379
+ seen.add(rule);
380
+ addedRules.push(rule);
352
381
  }
353
- const file = fileURLToPath(import.meta.url);
354
- return path4.resolve(path4.dirname(file), "../..");
382
+ const updated = { ...settings };
383
+ updated.permissions = {
384
+ ...settings.permissions ?? {},
385
+ allow: [...existingAllow, ...addedRules]
386
+ };
387
+ updated._haus = {
388
+ hooks: settings._haus?.hooks ?? [],
389
+ ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
390
+ ...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
391
+ allowRules: [...trackedAllow, ...addedRules]
392
+ };
393
+ return { settings: updated, addedRules };
355
394
  }
356
-
357
- // src/claude/load-hooks-config.ts
358
- import path5 from "path";
359
- var CONFIG_PATH = ".haus-workflow/config.json";
360
- var DEFAULT_HOOKS_CONFIG = {
361
- hooks: {
362
- context: { enabled: false }
395
+ function stripHausAllow(settings) {
396
+ const prevHaus = settings._haus;
397
+ if (!prevHaus?.allowRules || prevHaus.allowRules.length === 0) return settings;
398
+ const ownedSet = new Set(prevHaus.allowRules);
399
+ const updated = { ...settings };
400
+ const remainingAllow = (settings.permissions?.allow ?? []).filter((rule) => !ownedSet.has(rule));
401
+ const permissions = { ...settings.permissions ?? {} };
402
+ if (remainingAllow.length > 0) permissions.allow = remainingAllow;
403
+ else delete permissions.allow;
404
+ if (Object.keys(permissions).length > 0) updated.permissions = permissions;
405
+ else delete updated.permissions;
406
+ const haus = { ...prevHaus };
407
+ delete haus.allowRules;
408
+ const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0;
409
+ if (stillTracking) updated._haus = haus;
410
+ else delete updated._haus;
411
+ return updated;
412
+ }
413
+ function stripHausDeny(settings) {
414
+ const prevHaus = settings._haus;
415
+ if (!prevHaus?.denyRules || prevHaus.denyRules.length === 0) return settings;
416
+ const ownedSet = new Set(prevHaus.denyRules);
417
+ const updated = { ...settings };
418
+ const remainingDeny = (settings.permissions?.deny ?? []).filter((rule) => !ownedSet.has(rule));
419
+ const permissions = { ...settings.permissions ?? {} };
420
+ if (remainingDeny.length > 0) permissions.deny = remainingDeny;
421
+ else delete permissions.deny;
422
+ if (Object.keys(permissions).length > 0) updated.permissions = permissions;
423
+ else delete updated.permissions;
424
+ const haus = { ...prevHaus };
425
+ delete haus.denyRules;
426
+ const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0;
427
+ if (stillTracking) updated._haus = haus;
428
+ else delete updated._haus;
429
+ return updated;
430
+ }
431
+ function stripHausHooks(settings) {
432
+ if (!settings._haus) return settings;
433
+ const ownedCommands = new Set(settings._haus.hookCommands ?? []);
434
+ const usePrefix = ownedCommands.size === 0;
435
+ const updated = { ...settings };
436
+ updated.hooks = {};
437
+ for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
438
+ const kept = entries.filter((entry) => {
439
+ const cmd = entry.hooks[0]?.command ?? "";
440
+ return usePrefix ? !cmd.startsWith("haus ") : !ownedCommands.has(cmd);
441
+ });
442
+ if (kept.length > 0) updated.hooks[event] = kept;
363
443
  }
364
- };
365
- async function isHookEnabled(root, key) {
366
- const cfg = await readJson(path5.join(root, CONFIG_PATH));
367
- return cfg?.hooks?.[key]?.enabled === true;
444
+ const { _haus: _, ...rest } = updated;
445
+ void _;
446
+ return rest;
447
+ }
448
+ async function loadHooksFragment(fragmentPath) {
449
+ let raw;
450
+ try {
451
+ raw = await fs3.readJson(fragmentPath);
452
+ } catch {
453
+ return [];
454
+ }
455
+ const data = raw;
456
+ return Array.isArray(data?.hooks) ? data.hooks : [];
368
457
  }
369
458
 
370
459
  // src/security/dangerous-commands.ts
@@ -458,6 +547,173 @@ function buildDenyRules() {
458
547
  return [...new Set(rules)];
459
548
  }
460
549
 
550
+ // src/utils/paths.ts
551
+ import { existsSync, readFileSync } from "fs";
552
+ import os3 from "os";
553
+ import path5 from "path";
554
+ import { fileURLToPath } from "url";
555
+ var HAUS_DIR = ".haus-workflow";
556
+ function hausPath(root, ...parts) {
557
+ return path5.join(root, HAUS_DIR, ...parts);
558
+ }
559
+ function claudePath(root, ...parts) {
560
+ return path5.join(root, ".claude", ...parts);
561
+ }
562
+ function displayPath(root, targetPath) {
563
+ const rel = path5.relative(root, targetPath).replace(/\\/g, "/");
564
+ if (rel && !rel.startsWith("../") && rel !== "..") {
565
+ return rel.startsWith("./") ? rel : `./${rel}`;
566
+ }
567
+ const home = os3.homedir();
568
+ const normalized = targetPath.replace(/\\/g, "/");
569
+ if (home && home.trim().length > 0) {
570
+ const homeRel = path5.relative(home, targetPath).replace(/\\/g, "/");
571
+ if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
572
+ return `~/${homeRel}`;
573
+ }
574
+ }
575
+ return normalized;
576
+ }
577
+ function packageRoot() {
578
+ let dir = path5.dirname(fileURLToPath(import.meta.url));
579
+ for (let i = 0; i < 12; i++) {
580
+ const pkgPath = path5.join(dir, "package.json");
581
+ if (existsSync(pkgPath)) {
582
+ try {
583
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
584
+ if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
585
+ } catch {
586
+ }
587
+ }
588
+ const parent = path5.dirname(dir);
589
+ if (parent === dir) break;
590
+ dir = parent;
591
+ }
592
+ const file = fileURLToPath(import.meta.url);
593
+ return path5.resolve(path5.dirname(file), "../..");
594
+ }
595
+
596
+ // src/claude/merge-project-settings.ts
597
+ var PROJECT_HOOK_FRAGMENTS = [
598
+ {
599
+ id: "haus.context-hook",
600
+ gate: "keep",
601
+ event: "UserPromptSubmit",
602
+ command: "haus context --from-hook || true"
603
+ },
604
+ {
605
+ id: "haus.guard-file",
606
+ gate: "keep",
607
+ event: "PreToolUse",
608
+ matcher: "Read|Edit|Write",
609
+ command: "haus guard file-access --from-hook || true"
610
+ },
611
+ {
612
+ id: "haus.guard-bash",
613
+ gate: "keep",
614
+ event: "PreToolUse",
615
+ matcher: "Bash",
616
+ command: "haus guard bash --from-hook || true"
617
+ }
618
+ ];
619
+ async function readProjectSettings(root) {
620
+ const parsed = await readJson(claudePath(root, "settings.json"));
621
+ return parsed ?? {};
622
+ }
623
+ async function writeProjectSettings(root, settings) {
624
+ await writeJson(claudePath(root, "settings.json"), settings);
625
+ }
626
+ async function mergeProjectSettings(root) {
627
+ const base = await readProjectSettings(root);
628
+ const { settings: withHooks } = mergeHooks(base, PROJECT_HOOK_FRAGMENTS);
629
+ const { settings: withDeny } = mergeDenyRules(withHooks, buildDenyRules());
630
+ const { settings: merged } = mergeAllowRules(withDeny, buildAllowRules());
631
+ return merged;
632
+ }
633
+ async function applyProjectSettingsMerge(root) {
634
+ const merged = await mergeProjectSettings(root);
635
+ await writeProjectSettings(root, merged);
636
+ return merged;
637
+ }
638
+
639
+ // src/claude/write-claude-files.ts
640
+ import path11 from "path";
641
+ import fs10 from "fs-extra";
642
+
643
+ // src/update/hash-installed.ts
644
+ import path6 from "path";
645
+ import fg2 from "fast-glob";
646
+ import fs4 from "fs-extra";
647
+ var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
648
+ async function hashInstalledPaths(root, relPaths) {
649
+ if (relPaths.length === 0) {
650
+ return hashText(EMPTY_LOCK_PATHS_TOKEN);
651
+ }
652
+ const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
653
+ const fileDigests = [];
654
+ for (const rel of normalized) {
655
+ const abs = path6.join(root, rel);
656
+ if (!await fs4.pathExists(abs)) continue;
657
+ const stat = await fs4.stat(abs);
658
+ if (stat.isFile()) {
659
+ const body = await fs4.readFile(abs, "utf8");
660
+ fileDigests.push({ rel, digest: hashText(body) });
661
+ continue;
662
+ }
663
+ if (!stat.isDirectory()) continue;
664
+ const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
665
+ for (const sub of inner.sort()) {
666
+ const relFile = path6.join(rel, sub).replace(/\\/g, "/");
667
+ const absFile = path6.join(abs, sub);
668
+ const body = await fs4.readFile(absFile, "utf8");
669
+ fileDigests.push({ rel: relFile, digest: hashText(body) });
670
+ }
671
+ }
672
+ if (fileDigests.length === 0) {
673
+ return hashText(`${EMPTY_LOCK_PATHS_TOKEN}|${normalized.join("|")}`);
674
+ }
675
+ fileDigests.sort((a, b) => a.rel.localeCompare(b.rel));
676
+ return hashText(fileDigests.map((f) => `${f.rel}=${f.digest}`).join("|"));
677
+ }
678
+
679
+ // src/utils/diff.ts
680
+ import { createTwoFilesPatch } from "diff";
681
+ function hasTextChanged(before, after) {
682
+ return before !== after;
683
+ }
684
+ function createUnifiedDiff(filePath, before, after) {
685
+ return createTwoFilesPatch(filePath, filePath, before, after, "before", "after", {
686
+ context: 3
687
+ });
688
+ }
689
+ function summarizeDiff(diffText) {
690
+ const lines = diffText.split("\n");
691
+ let additions = 0;
692
+ let deletions = 0;
693
+ for (const line2 of lines) {
694
+ if (line2.startsWith("+++ ") || line2.startsWith("--- ")) continue;
695
+ if (line2.startsWith("+")) additions += 1;
696
+ if (line2.startsWith("-")) deletions += 1;
697
+ }
698
+ return { additions, deletions };
699
+ }
700
+
701
+ // src/claude/load-hooks-config.ts
702
+ import path7 from "path";
703
+ var CONFIG_PATH = ".haus-workflow/config.json";
704
+ var DEFAULT_HOOKS_CONFIG = {
705
+ hooks: {
706
+ context: { enabled: false }
707
+ }
708
+ };
709
+ async function isHookEnabled(root, key) {
710
+ const cfg = await readJson(path7.join(root, CONFIG_PATH));
711
+ return cfg?.hooks?.[key]?.enabled === true;
712
+ }
713
+
714
+ // src/claude/verify-hooks-contract.ts
715
+ import fs5 from "fs-extra";
716
+
461
717
  // src/claude/load-hooks.ts
462
718
  var CANONICAL_HOOKS = {
463
719
  hooks: {
@@ -505,24 +761,52 @@ function flattenRecommendedHooks(settings) {
505
761
  }
506
762
 
507
763
  // src/claude/verify-hooks-contract.ts
508
- import { isDeepStrictEqual } from "util";
509
- import fs4 from "fs-extra";
510
- async function assertPostApplySettingsMatchCanonical(root, canonical) {
764
+ function collectHookCommands(settings) {
765
+ const cmds = [];
766
+ for (const entries of Object.values(settings.hooks ?? {})) {
767
+ for (const entry of entries) {
768
+ for (const h of entry.hooks ?? []) {
769
+ if (h.command) cmds.push(h.command);
770
+ }
771
+ }
772
+ }
773
+ return cmds;
774
+ }
775
+ function hausHookContractSatisfied(project, canonical) {
776
+ const present = new Set(collectHookCommands(project));
777
+ for (const block of canonical.hooks.UserPromptSubmit) {
778
+ for (const h of block.hooks) {
779
+ if (!present.has(h.command)) return false;
780
+ }
781
+ }
782
+ for (const block of canonical.hooks.PreToolUse) {
783
+ for (const h of block.hooks) {
784
+ if (!present.has(h.command)) return false;
785
+ }
786
+ }
787
+ const denySet = new Set(project.permissions?.deny ?? []);
788
+ for (const rule of canonical.permissions?.deny ?? []) {
789
+ if (!denySet.has(rule)) return false;
790
+ }
791
+ return true;
792
+ }
793
+ async function assertPostApplySettingsHausContract(root) {
794
+ const canonical = await loadClaudeHooksSettings();
511
795
  const written = await readJson(claudePath(root, "settings.json"));
512
796
  if (written == null || typeof written !== "object") {
513
797
  throw new Error(
514
798
  "haus: post-apply self-check failed: .claude/settings.json missing or unreadable"
515
799
  );
516
800
  }
517
- if (!isDeepStrictEqual(canonical, written)) {
801
+ if (!hausHookContractSatisfied(written, canonical)) {
518
802
  throw new Error(
519
- "haus: post-apply self-check failed: .claude/settings.json does not match canonical hook contract"
803
+ "haus: post-apply self-check failed: .claude/settings.json missing required haus hooks or deny rules"
520
804
  );
521
805
  }
522
806
  }
523
807
  async function verifyProjectSettingsHooksContract(root) {
524
808
  const settingsPath = claudePath(root, "settings.json");
525
- if (!await fs4.pathExists(settingsPath)) {
809
+ if (!await fs5.pathExists(settingsPath)) {
526
810
  return {
527
811
  ok: true,
528
812
  skipped: true,
@@ -539,18 +823,18 @@ async function verifyProjectSettingsHooksContract(root) {
539
823
  if (project == null || typeof project !== "object") {
540
824
  return { ok: false, message: ".claude/settings.json is unreadable." };
541
825
  }
542
- if (!isDeepStrictEqual(canonical, project)) {
826
+ if (!hausHookContractSatisfied(project, canonical)) {
543
827
  return {
544
828
  ok: false,
545
- message: ".claude/settings.json drifts from canonical hook config (regenerate with `haus apply --write`)."
829
+ message: ".claude/settings.json missing required haus hooks or deny rules (regenerate with `haus apply --write`)."
546
830
  };
547
831
  }
548
- return { ok: true, message: "settings.json matches canonical hook contract." };
832
+ return { ok: true, message: "settings.json carries required haus hook contract." };
549
833
  }
550
834
 
551
835
  // src/claude/write-root-claude-md.ts
552
- import path6 from "path";
553
- import fs5 from "fs-extra";
836
+ import path8 from "path";
837
+ import fs6 from "fs-extra";
554
838
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
555
839
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
556
840
  var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
@@ -560,6 +844,16 @@ function buildImportBlock() {
560
844
  ${IMPORT_CONTENT}
561
845
  ${BLOCK_END}`;
562
846
  }
847
+ function stripHausBlock(existing) {
848
+ const beginIdx = existing.indexOf(BLOCK_BEGIN);
849
+ const endIdx = existing.indexOf(BLOCK_END);
850
+ if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx) return existing;
851
+ const before = existing.slice(0, beginIdx);
852
+ const after = existing.slice(endIdx + BLOCK_END.length);
853
+ const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
854
+ return merged.length > 0 ? `${merged}
855
+ ` : "";
856
+ }
563
857
  function injectHausBlock(existing, block) {
564
858
  const beginIdx = existing.indexOf(BLOCK_BEGIN);
565
859
  const endIdx = existing.indexOf(BLOCK_END);
@@ -579,9 +873,9 @@ ${block}
579
873
  `;
580
874
  }
581
875
  async function writeRootClaudeMd(root, dryRun) {
582
- const filePath = path6.join(root, "CLAUDE.md");
876
+ const filePath = path8.join(root, "CLAUDE.md");
583
877
  const block = buildImportBlock();
584
- const prev = await fs5.pathExists(filePath) ? await fs5.readFile(filePath, "utf8") : "";
878
+ const prev = await fs6.pathExists(filePath) ? await fs6.readFile(filePath, "utf8") : "";
585
879
  const next = injectHausBlock(prev, block);
586
880
  const printable = displayPath(root, filePath);
587
881
  if (dryRun) {
@@ -604,12 +898,12 @@ async function writeRootClaudeMd(root, dryRun) {
604
898
  }
605
899
 
606
900
  // src/claude/write-workflow-config.ts
607
- import path8 from "path";
608
- import fs7 from "fs-extra";
901
+ import path10 from "path";
902
+ import fs8 from "fs-extra";
609
903
 
610
904
  // src/claude/derive-workflow-config.ts
611
- import path7 from "path";
612
- import fs6 from "fs-extra";
905
+ import path9 from "path";
906
+ import fs7 from "fs-extra";
613
907
  function binCmd(pm, bin, args) {
614
908
  const tail = args ? ` ${args}` : "";
615
909
  if (pm === "yarn") return `yarn ${bin}${tail}`;
@@ -618,7 +912,7 @@ function binCmd(pm, bin, args) {
618
912
  }
619
913
  async function deriveWorkflowConfig(root, ctx) {
620
914
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
621
- const pkg = await readJson(path7.join(root, "package.json"));
915
+ const pkg = await readJson(path9.join(root, "package.json"));
622
916
  const scripts = pkg?.scripts ?? {};
623
917
  const deps = new Set(ctx.dependencies);
624
918
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -628,7 +922,7 @@ async function deriveWorkflowConfig(root, ctx) {
628
922
  return null;
629
923
  };
630
924
  const hasDep = (name) => deps.has(name);
631
- const exists = (rel) => fs6.pathExistsSync(path7.join(root, rel));
925
+ const exists = (rel) => fs7.pathExistsSync(path9.join(root, rel));
632
926
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
633
927
  const hasCypress = hasDep("cypress");
634
928
  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;
@@ -697,7 +991,7 @@ var FALLBACK_CONTEXT = {
697
991
  async function writeWorkflowConfig(root, dryRun, opts = {}) {
698
992
  const destPath = hausPath(root, "workflow-config.md");
699
993
  const printable = displayPath(root, destPath);
700
- const exists = await fs7.pathExists(destPath);
994
+ const exists = await fs8.pathExists(destPath);
701
995
  if (exists && !opts.refill) {
702
996
  if (dryRun) log(printable + ": exists (project-owned, skipping)");
703
997
  return null;
@@ -705,11 +999,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
705
999
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
706
1000
  ...FALLBACK_CONTEXT,
707
1001
  root,
708
- repoName: path8.basename(root)
1002
+ repoName: path10.basename(root)
709
1003
  };
710
1004
  const values = await deriveWorkflowConfig(root, ctx);
711
1005
  if (exists) {
712
- const current = await fs7.readFile(destPath, "utf8");
1006
+ const current = await fs8.readFile(destPath, "utf8");
713
1007
  const refilled = refillContent(current, values);
714
1008
  if (refilled === current) {
715
1009
  if (dryRun) log(printable + ": no blank fields to refill");
@@ -731,7 +1025,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
731
1025
  }
732
1026
 
733
1027
  // src/claude/write-workflow.ts
734
- import fs8 from "fs-extra";
1028
+ import fs9 from "fs-extra";
735
1029
 
736
1030
  // src/claude/managed-template.ts
737
1031
  function normaliseLF(content2) {
@@ -764,8 +1058,8 @@ async function writeWorkflow(root, pkgVersion, dryRun) {
764
1058
  ${templateContent}`;
765
1059
  const destPath = hausPath(root, "WORKFLOW.md");
766
1060
  const printable = displayPath(root, destPath);
767
- if (await fs8.pathExists(destPath)) {
768
- const existing = await fs8.readFile(destPath, "utf8");
1061
+ if (await fs9.pathExists(destPath)) {
1062
+ const existing = await fs9.readFile(destPath, "utf8");
769
1063
  const firstLine = existing.split("\n")[0] ?? "";
770
1064
  const parsed = parseHausManagedHeader(firstLine);
771
1065
  if (!parsed) {
@@ -787,7 +1081,7 @@ ${templateContent}`;
787
1081
  }
788
1082
  }
789
1083
  if (dryRun) {
790
- const prev = await fs8.pathExists(destPath) ? await fs8.readFile(destPath, "utf8") : "";
1084
+ const prev = await fs9.pathExists(destPath) ? await fs9.readFile(destPath, "utf8") : "";
791
1085
  if (!prev) {
792
1086
  log(createUnifiedDiff(printable, "", next));
793
1087
  } else {
@@ -814,7 +1108,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
814
1108
  estimatedTokenReductionPct: 0
815
1109
  };
816
1110
  const pkgRoot = packageRoot();
817
- const hausVersion2 = (await readJson(path9.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
1111
+ const hausVersion2 = (await readJson(path11.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
818
1112
  const coreFiles = [
819
1113
  claudePath(root, "settings.json"),
820
1114
  claudePath(root, "rules", "haus.md"),
@@ -838,11 +1132,15 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
838
1132
  hausPath(root, "selected-context.json"),
839
1133
  hausPath(root, "haus.lock.json")
840
1134
  ];
841
- const hookSettings = await loadClaudeHooksSettings();
842
- await writeManagedJson(root, claudePath(root, "settings.json"), hookSettings, dryRun);
843
- if (!dryRun) await assertPostApplySettingsMatchCanonical(root, hookSettings);
1135
+ if (dryRun) {
1136
+ const mergedSettings = await mergeProjectSettings(root);
1137
+ await writeManagedJson(root, claudePath(root, "settings.json"), mergedSettings, true);
1138
+ } else {
1139
+ await applyProjectSettingsMerge(root);
1140
+ await assertPostApplySettingsHausContract(root);
1141
+ }
844
1142
  const configPath = hausPath(root, "config.json");
845
- if (!await fs9.pathExists(configPath)) {
1143
+ if (!await fs10.pathExists(configPath)) {
846
1144
  await writeManagedJson(root, configPath, DEFAULT_HOOKS_CONFIG, dryRun);
847
1145
  }
848
1146
  await writeManagedText(
@@ -870,12 +1168,12 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
870
1168
  dryRun
871
1169
  );
872
1170
  const fixtureManifestPath = process.env["HAUS_FIXTURE_CATALOG"];
873
- const manifestPath2 = fixtureManifestPath ?? path9.join(pkgRoot, "library", "catalog", "manifest.json");
874
- const manifestDir = path9.dirname(manifestPath2);
1171
+ const manifestPath2 = fixtureManifestPath ?? path11.join(pkgRoot, "library", "catalog", "manifest.json");
1172
+ const manifestDir = path11.dirname(manifestPath2);
875
1173
  const manifest = await readJson(manifestPath2) ?? { items: [] };
876
1174
  const manifestById = new Map((manifest.items ?? []).map((item) => [item.id, item]));
877
1175
  const cacheManifest = await readJson(
878
- path9.join(CACHE_DIR, "manifest.json")
1176
+ path11.join(CACHE_DIR, "manifest.json")
879
1177
  );
880
1178
  const cacheManifestById = new Map((cacheManifest?.items ?? []).map((item) => [item.id, item]));
881
1179
  const installedPathsByItem = /* @__PURE__ */ new Map();
@@ -897,23 +1195,23 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
897
1195
  }
898
1196
  }
899
1197
  const cachedItem = cacheManifestById.get(item.id);
900
- const cachePath = cachedItem?.path ? path9.join(CACHE_DIR, cachedItem.path) : null;
901
- const sourcePath = cachePath && await fs9.pathExists(cachePath) ? cachePath : path9.join(manifestDir, manifestItem.path);
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);
902
1200
  const target = item.type === "agent" ? "agents" : item.type === "template" ? "templates" : "skills";
903
- const destination = claudePath(root, target, path9.basename(sourcePath));
904
- if (await fs9.pathExists(sourcePath)) {
1201
+ const destination = claudePath(root, target, path11.basename(sourcePath));
1202
+ if (await fs10.pathExists(sourcePath)) {
905
1203
  if (dryRun) {
906
- const exists = await fs9.pathExists(destination);
1204
+ const exists = await fs10.pathExists(destination);
907
1205
  log(
908
1206
  `${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
909
1207
  );
910
1208
  } else {
911
- await fs9.ensureDir(path9.dirname(destination));
912
- await fs9.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
1209
+ await fs10.ensureDir(path11.dirname(destination));
1210
+ await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
913
1211
  }
914
1212
  files.push(destination);
915
1213
  const current = installedPathsByItem.get(item.id) ?? [];
916
- installedPathsByItem.set(item.id, [...current, path9.relative(root, destination)]);
1214
+ installedPathsByItem.set(item.id, [...current, path11.relative(root, destination)]);
917
1215
  installedIds.add(item.id);
918
1216
  } else {
919
1217
  warn(
@@ -964,7 +1262,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
964
1262
  return [...new Set(files)];
965
1263
  }
966
1264
  async function writeManagedText(root, filePath, nextText, dryRun) {
967
- const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
1265
+ const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
968
1266
  const printable = displayPath(root, filePath);
969
1267
  if (dryRun) {
970
1268
  if (!prev) {
@@ -991,7 +1289,7 @@ async function writeManagedJson(root, filePath, value, dryRun) {
991
1289
 
992
1290
  // src/commands/apply.ts
993
1291
  async function cacheHasItems() {
994
- const data = await readJson(path10.join(CACHE_DIR, "manifest.json"));
1292
+ const data = await readJson(path12.join(CACHE_DIR, "manifest.json"));
995
1293
  return Array.isArray(data?.items) && data.items.length > 0;
996
1294
  }
997
1295
  async function runApply(options) {
@@ -1058,11 +1356,23 @@ async function runApply(options) {
1058
1356
  files.forEach((f) => log(`- ${displayPath(root, f)}`));
1059
1357
  }
1060
1358
  }
1359
+ async function isHausProject(root) {
1360
+ if (await fs11.pathExists(hausPath(root, "recommendation.json"))) return true;
1361
+ if (await fs11.pathExists(claudePath(root, "settings.json"))) {
1362
+ const settings = await readProjectSettings(root);
1363
+ if (settings._haus != null) return true;
1364
+ }
1365
+ return false;
1366
+ }
1367
+ async function refreshProjectApply(root) {
1368
+ if (!await isHausProject(root)) return [];
1369
+ return writeClaudeFiles(root, false, void 0, { refillConfig: false });
1370
+ }
1061
1371
 
1062
1372
  // src/catalog/load-catalog.ts
1063
- import os3 from "os";
1064
- import path11 from "path";
1065
- var CACHE_MANIFEST = path11.join(os3.homedir(), CATALOG_CACHE_SUBDIR, "manifest.json");
1373
+ import os4 from "os";
1374
+ import path13 from "path";
1375
+ var CACHE_MANIFEST = path13.join(os4.homedir(), CATALOG_CACHE_SUBDIR, "manifest.json");
1066
1376
  async function loadCatalog(root) {
1067
1377
  const envPath = process.env["HAUS_FIXTURE_CATALOG"];
1068
1378
  if (envPath) {
@@ -1071,10 +1381,10 @@ async function loadCatalog(root) {
1071
1381
  }
1072
1382
  const cacheData = await readJson(CACHE_MANIFEST);
1073
1383
  if (cacheData?.items?.length) return cacheData.items;
1074
- const localManifest = path11.join(root, "library/catalog/manifest.json");
1384
+ const localManifest = path13.join(root, "library/catalog/manifest.json");
1075
1385
  const localData = await readJson(localManifest);
1076
1386
  if (localData?.items?.length) return localData.items;
1077
- const packageManifest = path11.join(packageRoot(), "library/catalog/manifest.json");
1387
+ const packageManifest = path13.join(packageRoot(), "library/catalog/manifest.json");
1078
1388
  const data = await readJson(packageManifest);
1079
1389
  return data?.items ?? [];
1080
1390
  }
@@ -1284,7 +1594,7 @@ async function runCatalogAudit() {
1284
1594
  }
1285
1595
 
1286
1596
  // src/commands/config.ts
1287
- import path12 from "path";
1597
+ import path14 from "path";
1288
1598
  var CONFIG_PATH2 = ".haus-workflow/config.json";
1289
1599
  var HOOK_ALIASES = {
1290
1600
  "hook.context": "context"
@@ -1297,7 +1607,7 @@ async function runConfig(key, action) {
1297
1607
  );
1298
1608
  }
1299
1609
  const root = process.cwd();
1300
- const configPath = path12.join(root, CONFIG_PATH2);
1610
+ const configPath = path14.join(root, CONFIG_PATH2);
1301
1611
  const existing = await readJson(configPath);
1302
1612
  const cfg = existing ?? structuredClone(DEFAULT_HOOKS_CONFIG);
1303
1613
  cfg.hooks ??= {};
@@ -1669,7 +1979,7 @@ function selectRules(recommended, task, taskIntents) {
1669
1979
 
1670
1980
  // src/scanner/scan-project.ts
1671
1981
  import { readFile as readFile2 } from "fs/promises";
1672
- import path16 from "path";
1982
+ import path18 from "path";
1673
1983
 
1674
1984
  // src/utils/audit-checks.ts
1675
1985
  function isRecord(v) {
@@ -1696,8 +2006,8 @@ function compareVersions(a, b) {
1696
2006
  }
1697
2007
 
1698
2008
  // src/scanner/detect-package-manager.ts
1699
- import path13 from "path";
1700
- import fs10 from "fs-extra";
2009
+ import path15 from "path";
2010
+ import fs12 from "fs-extra";
1701
2011
  function detectPackageManager(root, packageManagerField) {
1702
2012
  const field = String(packageManagerField ?? "").trim();
1703
2013
  if (field.startsWith("yarn@")) {
@@ -1715,9 +2025,9 @@ function detectPackageManager(root, packageManagerField) {
1715
2025
  if (satisfiesVersion(version, ">=9")) return "npm";
1716
2026
  return "unknown";
1717
2027
  }
1718
- if (fs10.existsSync(path13.join(root, "yarn.lock"))) return "yarn";
1719
- if (fs10.existsSync(path13.join(root, "pnpm-lock.yaml"))) return "pnpm";
1720
- if (fs10.existsSync(path13.join(root, "package-lock.json"))) return "npm";
2028
+ if (fs12.existsSync(path15.join(root, "yarn.lock"))) return "yarn";
2029
+ if (fs12.existsSync(path15.join(root, "pnpm-lock.yaml"))) return "pnpm";
2030
+ if (fs12.existsSync(path15.join(root, "package-lock.json"))) return "npm";
1721
2031
  return "unknown";
1722
2032
  }
1723
2033
 
@@ -1890,7 +2200,7 @@ function runDetection(ctx, rules = STACK_RULES) {
1890
2200
  }
1891
2201
 
1892
2202
  // src/scanner/detection.ts
1893
- import path14 from "path";
2203
+ import path16 from "path";
1894
2204
  var UNSUPPORTED_MARKERS = {
1895
2205
  "requirements.txt": "python",
1896
2206
  "pyproject.toml": "python",
@@ -1944,14 +2254,14 @@ function finalizeRoles(registryRoles, deps, files) {
1944
2254
  function collectUnsupportedSignals(files) {
1945
2255
  return [
1946
2256
  ...new Set(
1947
- files.map((f) => UNSUPPORTED_MARKERS[path14.basename(f)]).filter((s) => Boolean(s))
2257
+ files.map((f) => UNSUPPORTED_MARKERS[path16.basename(f)]).filter((s) => Boolean(s))
1948
2258
  )
1949
2259
  ].sort();
1950
2260
  }
1951
2261
 
1952
2262
  // src/scanner/render.ts
1953
2263
  import { readFile } from "fs/promises";
1954
- import path15 from "path";
2264
+ import path17 from "path";
1955
2265
 
1956
2266
  // src/scanner/role-labels.ts
1957
2267
  var ROLE_LABELS = {
@@ -2013,7 +2323,7 @@ async function buildContentBlob(root, files) {
2013
2323
  const slice = candidates.slice(0, 300);
2014
2324
  const parts = await mapWithConcurrency(slice, async (rel) => {
2015
2325
  try {
2016
- return await readFile(path15.join(root, rel), "utf8");
2326
+ return await readFile(path17.join(root, rel), "utf8");
2017
2327
  } catch {
2018
2328
  return "";
2019
2329
  }
@@ -2079,8 +2389,8 @@ var SAFE_FILES = [
2079
2389
  "Gemfile"
2080
2390
  ];
2081
2391
  async function scanProject(root, mode = "fast") {
2082
- const pkg = await readJson(path16.join(root, "package.json"));
2083
- const composer = await readJson(path16.join(root, "composer.json"));
2392
+ const pkg = await readJson(path18.join(root, "package.json"));
2393
+ const composer = await readJson(path18.join(root, "composer.json"));
2084
2394
  const files = await listFiles(root, SAFE_FILES);
2085
2395
  const safeFiles = files.filter((f) => !blocked(f));
2086
2396
  const deps = dependencySet(pkg, composer);
@@ -2114,7 +2424,7 @@ async function scanProject(root, mode = "fast") {
2114
2424
  mode,
2115
2425
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2116
2426
  root,
2117
- repoName: String(pkg?.name ?? path16.basename(root)),
2427
+ repoName: String(pkg?.name ?? path18.basename(root)),
2118
2428
  packageManager,
2119
2429
  repoRoles: roles,
2120
2430
  detectedStacks: stacks,
@@ -2132,7 +2442,7 @@ async function scanProject(root, mode = "fast") {
2132
2442
  const scanHashes = Object.fromEntries(
2133
2443
  await mapWithConcurrency(
2134
2444
  safeFiles,
2135
- async (f) => [f, hashText(await readFile2(path16.join(root, f), "utf8"))]
2445
+ async (f) => [f, hashText(await readFile2(path18.join(root, f), "utf8"))]
2136
2446
  )
2137
2447
  );
2138
2448
  const repoSummary = renderSummary(context);
@@ -2204,8 +2514,8 @@ async function runContext(options) {
2204
2514
  }
2205
2515
 
2206
2516
  // src/commands/doctor.ts
2207
- import path17 from "path";
2208
- import fs11 from "fs-extra";
2517
+ import path19 from "path";
2518
+ import fs13 from "fs-extra";
2209
2519
 
2210
2520
  // src/update/npm-version.ts
2211
2521
  var NPM_PACKAGE_NAME = "@haus-tech/haus-workflow";
@@ -2285,7 +2595,7 @@ async function runDoctor(options) {
2285
2595
  const enabled = await isHookEnabled(root, key);
2286
2596
  ok(`- HOOK ${key}: ${enabled ? "enabled" : "disabled (default)"}`);
2287
2597
  }
2288
- const rootClaudeMdPath = path17.join(root, "CLAUDE.md");
2598
+ const rootClaudeMdPath = path19.join(root, "CLAUDE.md");
2289
2599
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
2290
2600
  if (!rootClaudeMdContent) {
2291
2601
  flag(
@@ -2313,7 +2623,7 @@ async function runDoctor(options) {
2313
2623
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
2314
2624
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
2315
2625
  for (const target of importTargets) {
2316
- if (!await fs11.pathExists(hausPath(root, target))) {
2626
+ if (!await fs13.pathExists(hausPath(root, target))) {
2317
2627
  flag(
2318
2628
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
2319
2629
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -2324,7 +2634,7 @@ async function runDoctor(options) {
2324
2634
  }
2325
2635
  }
2326
2636
  const workflowPath = hausPath(root, "WORKFLOW.md");
2327
- const workflowExists = await fs11.pathExists(workflowPath);
2637
+ const workflowExists = await fs13.pathExists(workflowPath);
2328
2638
  if (!workflowExists) {
2329
2639
  flag(
2330
2640
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -2338,15 +2648,15 @@ async function runDoctor(options) {
2338
2648
  ok("- .haus-workflow/WORKFLOW.md: OK (user-owned)");
2339
2649
  } else {
2340
2650
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
2341
- const cachePath = path17.join(CACHE_DIR, "templates/agentic-workflow-standard.md");
2342
- const bundledPath = path17.join(
2651
+ const cachePath = path19.join(CACHE_DIR, "templates/agentic-workflow-standard.md");
2652
+ const bundledPath = path19.join(
2343
2653
  packageRoot(),
2344
2654
  "library",
2345
2655
  "global",
2346
2656
  "templates",
2347
2657
  "agentic-workflow-standard.md"
2348
2658
  );
2349
- const templatePath = await fs11.pathExists(cachePath) ? cachePath : bundledPath;
2659
+ const templatePath = await fs13.pathExists(cachePath) ? cachePath : bundledPath;
2350
2660
  const templateContent = await readText(templatePath);
2351
2661
  if (storedHashMatch && templateContent) {
2352
2662
  const currentHash = hashText(normaliseLF(templateContent));
@@ -2365,7 +2675,7 @@ async function runDoctor(options) {
2365
2675
  }
2366
2676
  }
2367
2677
  const workflowConfigPath = hausPath(root, "workflow-config.md");
2368
- const workflowConfigExists = await fs11.pathExists(workflowConfigPath);
2678
+ const workflowConfigExists = await fs13.pathExists(workflowConfigPath);
2369
2679
  if (!workflowConfigExists) {
2370
2680
  flag(
2371
2681
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -2373,7 +2683,7 @@ async function runDoctor(options) {
2373
2683
  "haus apply --write"
2374
2684
  );
2375
2685
  } else {
2376
- const cfg = await fs11.readFile(workflowConfigPath, "utf8");
2686
+ const cfg = await fs13.readFile(workflowConfigPath, "utf8");
2377
2687
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
2378
2688
  if (unfilled > 0) {
2379
2689
  flag(
@@ -2404,7 +2714,7 @@ async function runDoctor(options) {
2404
2714
  ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
2405
2715
  }
2406
2716
  }
2407
- const pkgJson = await readJson(path17.join(packageRoot(), "package.json"));
2717
+ const pkgJson = await readJson(path19.join(packageRoot(), "package.json"));
2408
2718
  const currentVersion = pkgJson?.version ?? "0.0.0";
2409
2719
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
2410
2720
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -2545,8 +2855,8 @@ async function runGuard(kind, _options) {
2545
2855
  }
2546
2856
 
2547
2857
  // src/commands/init.ts
2548
- import path18 from "path";
2549
- import fs12 from "fs-extra";
2858
+ import path20 from "path";
2859
+ import fs14 from "fs-extra";
2550
2860
 
2551
2861
  // src/utils/prompts.ts
2552
2862
  import { stdin as input, stdout as output } from "process";
@@ -2974,8 +3284,8 @@ async function runSetupProject(options) {
2974
3284
  // src/commands/init.ts
2975
3285
  async function runInit(options) {
2976
3286
  const root = process.cwd();
2977
- const hausDir = path18.join(root, ".haus-workflow");
2978
- const alreadyInit = await fs12.pathExists(hausDir);
3287
+ const hausDir = path20.join(root, ".haus-workflow");
3288
+ const alreadyInit = await fs14.pathExists(hausDir);
2979
3289
  if (alreadyInit) {
2980
3290
  log("Haus AI already initialized in this project.");
2981
3291
  log("Run `haus setup-project` to reconfigure.");
@@ -2988,20 +3298,7 @@ async function runInit(options) {
2988
3298
  // src/install/apply.ts
2989
3299
  import crypto2 from "crypto";
2990
3300
  import path21 from "path";
2991
- import fs14 from "fs-extra";
2992
-
2993
- // src/install/allow-rules.ts
2994
- var ALLOWED_SUBCOMMANDS = [
2995
- "setup-project",
2996
- "apply",
2997
- "doctor",
2998
- "scan",
2999
- "context",
3000
- "recommend"
3001
- ];
3002
- function buildAllowRules() {
3003
- return [...new Set(ALLOWED_SUBCOMMANDS.map((sub) => `Bash(haus ${sub}:*)`))];
3004
- }
3301
+ import fs15 from "fs-extra";
3005
3302
 
3006
3303
  // src/install/header.ts
3007
3304
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -3033,185 +3330,6 @@ ${rest}`;
3033
3330
  ${content2}`;
3034
3331
  }
3035
3332
 
3036
- // src/install/manifest.ts
3037
- import os4 from "os";
3038
- import path19 from "path";
3039
- var MANIFEST_SCHEMA = "haus-install-manifest/1";
3040
- function globalClaudeDir() {
3041
- return path19.join(os4.homedir(), ".claude");
3042
- }
3043
- function hausManifestPath() {
3044
- return path19.join(globalClaudeDir(), "haus", "install-manifest.json");
3045
- }
3046
- async function readManifest() {
3047
- return readJson(hausManifestPath());
3048
- }
3049
- async function writeManifest(manifest) {
3050
- await writeJson(hausManifestPath(), manifest);
3051
- }
3052
- function buildManifest(source, files, hooks) {
3053
- return {
3054
- _schema: MANIFEST_SCHEMA,
3055
- source,
3056
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
3057
- files,
3058
- hooks
3059
- };
3060
- }
3061
-
3062
- // src/install/settings-merge.ts
3063
- import path20 from "path";
3064
- import fs13 from "fs-extra";
3065
- function settingsJsonPath() {
3066
- return path20.join(globalClaudeDir(), "settings.json");
3067
- }
3068
- async function readSettings() {
3069
- const parsed = await readJson(settingsJsonPath());
3070
- return parsed ?? {};
3071
- }
3072
- async function writeSettings(settings) {
3073
- await writeJson(settingsJsonPath(), settings);
3074
- }
3075
- function mergeHooks(settings, fragments) {
3076
- const existing = settings._haus?.hooks ?? [];
3077
- const existingCommands = settings._haus?.hookCommands ?? [];
3078
- const existingSet = new Set(existing);
3079
- const updated = { ...settings };
3080
- updated.hooks = { ...settings.hooks ?? {} };
3081
- const addedIds = [];
3082
- const addedCommands = [];
3083
- for (const fragment of fragments) {
3084
- if (fragment.gate !== "keep") continue;
3085
- if (existingSet.has(fragment.id)) continue;
3086
- const event = fragment.event;
3087
- if (!updated.hooks[event]) updated.hooks[event] = [];
3088
- const entry = {
3089
- hooks: [{ type: "command", command: fragment.command }]
3090
- };
3091
- if (fragment.matcher) entry.matcher = fragment.matcher;
3092
- updated.hooks[event] = [...updated.hooks[event] ?? [], entry];
3093
- addedIds.push(fragment.id);
3094
- addedCommands.push(fragment.command);
3095
- }
3096
- updated._haus = {
3097
- hooks: [...existing, ...addedIds],
3098
- hookCommands: [...existingCommands, ...addedCommands],
3099
- // Preserve deny/allow tracking so hook, deny, and allow merges are order-independent.
3100
- ...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
3101
- ...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
3102
- };
3103
- return { settings: updated, addedIds };
3104
- }
3105
- function mergeDenyRules(settings, rules) {
3106
- const existingDeny = settings.permissions?.deny ?? [];
3107
- const seen = new Set(existingDeny);
3108
- const trackedDeny = settings._haus?.denyRules ?? [];
3109
- const addedRules = [];
3110
- for (const rule of rules) {
3111
- if (seen.has(rule)) continue;
3112
- seen.add(rule);
3113
- addedRules.push(rule);
3114
- }
3115
- const updated = { ...settings };
3116
- updated.permissions = {
3117
- ...settings.permissions ?? {},
3118
- deny: [...existingDeny, ...addedRules]
3119
- };
3120
- updated._haus = {
3121
- hooks: settings._haus?.hooks ?? [],
3122
- ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
3123
- denyRules: [...trackedDeny, ...addedRules],
3124
- ...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
3125
- };
3126
- return { settings: updated, addedRules };
3127
- }
3128
- function mergeAllowRules(settings, rules) {
3129
- const existingAllow = settings.permissions?.allow ?? [];
3130
- const seen = new Set(existingAllow);
3131
- const trackedAllow = settings._haus?.allowRules ?? [];
3132
- const addedRules = [];
3133
- for (const rule of rules) {
3134
- if (seen.has(rule)) continue;
3135
- seen.add(rule);
3136
- addedRules.push(rule);
3137
- }
3138
- const updated = { ...settings };
3139
- updated.permissions = {
3140
- ...settings.permissions ?? {},
3141
- allow: [...existingAllow, ...addedRules]
3142
- };
3143
- updated._haus = {
3144
- hooks: settings._haus?.hooks ?? [],
3145
- ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
3146
- ...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
3147
- allowRules: [...trackedAllow, ...addedRules]
3148
- };
3149
- return { settings: updated, addedRules };
3150
- }
3151
- function stripHausAllow(settings) {
3152
- const prevHaus = settings._haus;
3153
- if (!prevHaus?.allowRules || prevHaus.allowRules.length === 0) return settings;
3154
- const ownedSet = new Set(prevHaus.allowRules);
3155
- const updated = { ...settings };
3156
- const remainingAllow = (settings.permissions?.allow ?? []).filter((rule) => !ownedSet.has(rule));
3157
- const permissions = { ...settings.permissions ?? {} };
3158
- if (remainingAllow.length > 0) permissions.allow = remainingAllow;
3159
- else delete permissions.allow;
3160
- if (Object.keys(permissions).length > 0) updated.permissions = permissions;
3161
- else delete updated.permissions;
3162
- const haus = { ...prevHaus };
3163
- delete haus.allowRules;
3164
- const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0;
3165
- if (stillTracking) updated._haus = haus;
3166
- else delete updated._haus;
3167
- return updated;
3168
- }
3169
- function stripHausDeny(settings) {
3170
- const prevHaus = settings._haus;
3171
- if (!prevHaus?.denyRules || prevHaus.denyRules.length === 0) return settings;
3172
- const ownedSet = new Set(prevHaus.denyRules);
3173
- const updated = { ...settings };
3174
- const remainingDeny = (settings.permissions?.deny ?? []).filter((rule) => !ownedSet.has(rule));
3175
- const permissions = { ...settings.permissions ?? {} };
3176
- if (remainingDeny.length > 0) permissions.deny = remainingDeny;
3177
- else delete permissions.deny;
3178
- if (Object.keys(permissions).length > 0) updated.permissions = permissions;
3179
- else delete updated.permissions;
3180
- const haus = { ...prevHaus };
3181
- delete haus.denyRules;
3182
- const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0;
3183
- if (stillTracking) updated._haus = haus;
3184
- else delete updated._haus;
3185
- return updated;
3186
- }
3187
- function stripHausHooks(settings) {
3188
- if (!settings._haus) return settings;
3189
- const ownedCommands = new Set(settings._haus.hookCommands ?? []);
3190
- const usePrefix = ownedCommands.size === 0;
3191
- const updated = { ...settings };
3192
- updated.hooks = {};
3193
- for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
3194
- const kept = entries.filter((entry) => {
3195
- const cmd = entry.hooks[0]?.command ?? "";
3196
- return usePrefix ? !cmd.startsWith("haus ") : !ownedCommands.has(cmd);
3197
- });
3198
- if (kept.length > 0) updated.hooks[event] = kept;
3199
- }
3200
- const { _haus: _, ...rest } = updated;
3201
- void _;
3202
- return rest;
3203
- }
3204
- async function loadHooksFragment(fragmentPath) {
3205
- let raw;
3206
- try {
3207
- raw = await fs13.readJson(fragmentPath);
3208
- } catch {
3209
- return [];
3210
- }
3211
- const data = raw;
3212
- return Array.isArray(data?.hooks) ? data.hooks : [];
3213
- }
3214
-
3215
3333
  // src/install/apply.ts
3216
3334
  var SCHEMA_VERSION2 = "1";
3217
3335
  function hashContent(content2) {
@@ -3220,7 +3338,7 @@ function hashContent(content2) {
3220
3338
  function sourceVersion() {
3221
3339
  try {
3222
3340
  const pkgPath = path21.join(packageRoot(), "package.json");
3223
- const pkg = JSON.parse(fs14.readFileSync(pkgPath, "utf8"));
3341
+ const pkg = JSON.parse(fs15.readFileSync(pkgPath, "utf8"));
3224
3342
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
3225
3343
  } catch {
3226
3344
  return "haus@0.0.0";
@@ -3232,10 +3350,10 @@ function globalSrcDir() {
3232
3350
  function collectSourceFiles(srcDir, claudeDir) {
3233
3351
  const entries = [];
3234
3352
  const skillsDir = path21.join(srcDir, "skills");
3235
- if (fs14.pathExistsSync(skillsDir)) {
3236
- for (const skillName of fs14.readdirSync(skillsDir)) {
3353
+ if (fs15.pathExistsSync(skillsDir)) {
3354
+ for (const skillName of fs15.readdirSync(skillsDir)) {
3237
3355
  const skillFile = path21.join(skillsDir, skillName, "SKILL.md");
3238
- if (fs14.pathExistsSync(skillFile)) {
3356
+ if (fs15.pathExistsSync(skillFile)) {
3239
3357
  entries.push({
3240
3358
  stableId: `skill.${skillName}`,
3241
3359
  srcRelPath: path21.join("library", "global", "skills", skillName, "SKILL.md"),
@@ -3245,8 +3363,8 @@ function collectSourceFiles(srcDir, claudeDir) {
3245
3363
  }
3246
3364
  }
3247
3365
  const commandsDir = path21.join(srcDir, "commands");
3248
- if (fs14.pathExistsSync(commandsDir)) {
3249
- for (const fileName of fs14.readdirSync(commandsDir)) {
3366
+ if (fs15.pathExistsSync(commandsDir)) {
3367
+ for (const fileName of fs15.readdirSync(commandsDir)) {
3250
3368
  if (!fileName.endsWith(".md")) continue;
3251
3369
  const commandName = fileName.slice(0, -".md".length);
3252
3370
  entries.push({
@@ -3296,7 +3414,7 @@ async function applyInstall(options = {}) {
3296
3414
  }
3297
3415
  continue;
3298
3416
  }
3299
- const destExists = fs14.pathExistsSync(entry.destPath);
3417
+ const destExists = fs15.pathExistsSync(entry.destPath);
3300
3418
  if (destExists) {
3301
3419
  const currentContent = await readText(entry.destPath);
3302
3420
  if (currentContent !== void 0) {
@@ -3343,13 +3461,13 @@ async function applyInstall(options = {}) {
3343
3461
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
3344
3462
  for (const entry of existingManifest.files) {
3345
3463
  if (currentDestPaths.has(entry.destPath)) continue;
3346
- if (!fs14.pathExistsSync(entry.destPath)) continue;
3464
+ if (!fs15.pathExistsSync(entry.destPath)) continue;
3347
3465
  const content2 = await readText(entry.destPath);
3348
3466
  if (!content2) continue;
3349
3467
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
3350
3468
  const currentHash = hashContent(content2);
3351
3469
  if (hasHeader && currentHash === entry.hash) {
3352
- if (!dryRun) await fs14.remove(entry.destPath);
3470
+ if (!dryRun) await fs15.remove(entry.destPath);
3353
3471
  result.deleted.push(entry.destPath);
3354
3472
  } else {
3355
3473
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -3473,35 +3591,128 @@ async function runScan(options) {
3473
3591
 
3474
3592
  // src/commands/undo.ts
3475
3593
  import path22 from "path";
3476
- import fs15 from "fs-extra";
3477
- var CLAUDE_DIR = ".claude";
3594
+ import fs16 from "fs-extra";
3595
+
3596
+ // src/claude/managed-paths.ts
3597
+ var PROJECT_MANAGED_CLAUDE_REL = [
3598
+ "rules/haus.md",
3599
+ "rules/security.md",
3600
+ "commands/haus-doctor.md",
3601
+ "commands/haus-review.md"
3602
+ ];
3603
+ var PROJECT_MANAGED_HAUS_REL = [
3604
+ "selected-context.json",
3605
+ "haus.lock.json",
3606
+ "config.json"
3607
+ ];
3608
+ function coreManagedAbsolutePaths(root) {
3609
+ const claude = PROJECT_MANAGED_CLAUDE_REL.map((rel) => claudePath(root, rel));
3610
+ const haus = PROJECT_MANAGED_HAUS_REL.map((rel) => hausPath(root, rel));
3611
+ return [...claude, ...haus];
3612
+ }
3613
+
3614
+ // src/commands/undo.ts
3615
+ async function collectManagedPaths(root) {
3616
+ const paths = new Set(coreManagedAbsolutePaths(root));
3617
+ const lock = await readJson(hausPath(root, "haus.lock.json"));
3618
+ for (const row of lock ?? []) {
3619
+ for (const rel of row.paths ?? []) {
3620
+ paths.add(path22.resolve(root, rel));
3621
+ }
3622
+ }
3623
+ const existing = [];
3624
+ for (const abs of paths) {
3625
+ if (await fs16.pathExists(abs)) existing.push(abs);
3626
+ }
3627
+ return existing;
3628
+ }
3629
+ async function settingsHasHausContent(root) {
3630
+ const settingsPath = claudePath(root, "settings.json");
3631
+ if (!await fs16.pathExists(settingsPath)) return false;
3632
+ const settings = await readProjectSettings(root);
3633
+ return settings._haus != null;
3634
+ }
3635
+ async function claudeMdHasHausBlock(root) {
3636
+ const filePath = path22.join(root, "CLAUDE.md");
3637
+ if (!await fs16.pathExists(filePath)) return false;
3638
+ const text = await fs16.readFile(filePath, "utf8");
3639
+ return text.includes(BLOCK_BEGIN);
3640
+ }
3641
+ async function stripProjectSettings(root) {
3642
+ const settingsPath = claudePath(root, "settings.json");
3643
+ if (!await fs16.pathExists(settingsPath)) return false;
3644
+ let settings = await readProjectSettings(root);
3645
+ settings = stripHausAllow(stripHausDeny(stripHausHooks(settings)));
3646
+ const hasContent = Object.keys(settings).length > 0;
3647
+ if (hasContent) {
3648
+ await writeProjectSettings(root, settings);
3649
+ log(`Stripped haus rules from ${path22.relative(root, settingsPath)} (user settings preserved).`);
3650
+ return true;
3651
+ }
3652
+ await fs16.remove(settingsPath);
3653
+ log(`Removed ${path22.relative(root, settingsPath)} (no user-owned settings remained).`);
3654
+ return true;
3655
+ }
3656
+ async function stripRootClaudeMd(root) {
3657
+ const filePath = path22.join(root, "CLAUDE.md");
3658
+ if (!await fs16.pathExists(filePath)) return false;
3659
+ const prev = await fs16.readFile(filePath, "utf8");
3660
+ if (!prev.includes(BLOCK_BEGIN)) return false;
3661
+ const next = stripHausBlock(prev);
3662
+ if (next.length === 0) {
3663
+ await fs16.remove(filePath);
3664
+ log("Removed CLAUDE.md (only contained haus import block).");
3665
+ } else {
3666
+ await fs16.writeFile(filePath, next, "utf8");
3667
+ log("Removed haus import block from CLAUDE.md (user content preserved).");
3668
+ }
3669
+ return true;
3670
+ }
3671
+ async function pruneDirIfEmpty(dir) {
3672
+ if (!await fs16.pathExists(dir)) return;
3673
+ const entries = await fs16.readdir(dir);
3674
+ if (entries.length === 0) await fs16.remove(dir);
3675
+ }
3478
3676
  async function runUndo(options) {
3479
3677
  const root = process.cwd();
3480
- const targets = [path22.join(root, CLAUDE_DIR), path22.join(root, HAUS_DIR)];
3481
- const existing = targets.filter((p) => fs15.existsSync(p));
3482
- if (existing.length === 0) {
3483
- log("Nothing to remove: no .claude/ or .haus-workflow/ in this directory.");
3678
+ const managed = await collectManagedPaths(root);
3679
+ const stripSettings = await settingsHasHausContent(root);
3680
+ const stripClaudeMd = await claudeMdHasHausBlock(root);
3681
+ if (managed.length === 0 && !stripSettings && !stripClaudeMd) {
3682
+ log("Nothing to remove: no haus-managed files found in this directory.");
3484
3683
  return;
3485
3684
  }
3685
+ const relTargets = managed.map((p) => path22.relative(root, p));
3686
+ const summaryParts = [...relTargets];
3687
+ if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
3688
+ if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
3486
3689
  if (!options.yes) {
3487
3690
  const ok = await confirm(
3488
- `Remove ${existing.map((p) => path22.relative(root, p)).join(" and ")}? This cannot be undone.`
3691
+ `Remove haus-managed files?
3692
+ ${summaryParts.join("\n ")}
3693
+ User-owned .claude/ files will be preserved.`
3489
3694
  );
3490
3695
  if (!ok) {
3491
3696
  log("Cancelled.");
3492
3697
  return;
3493
3698
  }
3494
3699
  }
3495
- for (const p of existing) {
3496
- await fs15.remove(p);
3497
- log(`Removed ${path22.relative(root, p)}`);
3700
+ for (const abs of managed) {
3701
+ if (!await fs16.pathExists(abs)) continue;
3702
+ await fs16.remove(abs);
3703
+ log(`Removed ${path22.relative(root, abs)}`);
3498
3704
  }
3705
+ if (stripSettings) await stripProjectSettings(root);
3706
+ if (stripClaudeMd) await stripRootClaudeMd(root);
3707
+ await pruneDirIfEmpty(claudePath(root));
3708
+ await pruneDirIfEmpty(hausPath(root));
3709
+ log("haus undo complete. Scan artifacts under .haus-workflow/ were left in place.");
3499
3710
  }
3500
3711
 
3501
3712
  // src/install/uninstall.ts
3502
3713
  import crypto3 from "crypto";
3503
3714
  import path23 from "path";
3504
- import fs16 from "fs-extra";
3715
+ import fs17 from "fs-extra";
3505
3716
  async function runUninstall(options = {}) {
3506
3717
  const { force = false } = options;
3507
3718
  const manifest = await readManifest();
@@ -3511,7 +3722,7 @@ async function runUninstall(options = {}) {
3511
3722
  return result;
3512
3723
  }
3513
3724
  for (const entry of manifest.files) {
3514
- const exists = fs16.pathExistsSync(entry.destPath);
3725
+ const exists = fs17.pathExistsSync(entry.destPath);
3515
3726
  if (!exists) continue;
3516
3727
  const content2 = await readText(entry.destPath);
3517
3728
  if (content2 === void 0) continue;
@@ -3529,7 +3740,7 @@ async function runUninstall(options = {}) {
3529
3740
  result.skipped.push(entry.destPath);
3530
3741
  continue;
3531
3742
  }
3532
- await fs16.remove(entry.destPath);
3743
+ await fs17.remove(entry.destPath);
3533
3744
  await pruneEmptyDir(path23.dirname(entry.destPath));
3534
3745
  result.deleted.push(entry.destPath);
3535
3746
  }
@@ -3539,12 +3750,12 @@ async function runUninstall(options = {}) {
3539
3750
  result.hooksStripped = true;
3540
3751
  const hausDir = path23.join(globalClaudeDir(), "haus");
3541
3752
  const manifestPath2 = hausManifestPath();
3542
- if (fs16.pathExistsSync(manifestPath2)) {
3543
- await fs16.remove(manifestPath2);
3753
+ if (fs17.pathExistsSync(manifestPath2)) {
3754
+ await fs17.remove(manifestPath2);
3544
3755
  }
3545
- if (fs16.pathExistsSync(hausDir)) {
3546
- const remaining = await fs16.readdir(hausDir);
3547
- if (remaining.length === 0) await fs16.remove(hausDir);
3756
+ if (fs17.pathExistsSync(hausDir)) {
3757
+ const remaining = await fs17.readdir(hausDir);
3758
+ if (remaining.length === 0) await fs17.remove(hausDir);
3548
3759
  }
3549
3760
  return result;
3550
3761
  }
@@ -3563,8 +3774,8 @@ function printUninstallResult(result) {
3563
3774
  }
3564
3775
  async function pruneEmptyDir(dir) {
3565
3776
  try {
3566
- const entries = await fs16.readdir(dir);
3567
- if (entries.length === 0) await fs16.remove(dir);
3777
+ const entries = await fs17.readdir(dir);
3778
+ if (entries.length === 0) await fs17.remove(dir);
3568
3779
  } catch {
3569
3780
  }
3570
3781
  }
@@ -3666,10 +3877,11 @@ async function runUpdate(options) {
3666
3877
  if (options.check) {
3667
3878
  const pkgJson2 = await readJson(path25.join(packageRoot(), "package.json"));
3668
3879
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
3669
- const [status, npmVersion, latestCatalogTag] = await Promise.all([
3880
+ const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
3670
3881
  checkLock(root),
3671
3882
  fetchNpmVersionStatus(currentVersion2),
3672
- fetchLatestCatalogTag()
3883
+ fetchLatestCatalogTag(),
3884
+ detectGlobalInstallDrift()
3673
3885
  ]);
3674
3886
  const installedRef = status.catalogRef ?? "main";
3675
3887
  const catalogRefBehind = latestCatalogTag !== null && installedRef !== latestCatalogTag ? `installed from ${installedRef}, latest tag is ${latestCatalogTag}` : false;
@@ -3680,6 +3892,7 @@ async function runUpdate(options) {
3680
3892
  installedCatalogRef: installedRef,
3681
3893
  latestCatalogTag,
3682
3894
  catalogRefBehind,
3895
+ globalInstallDrift,
3683
3896
  localOverrides: await hasLocalOverrides(root),
3684
3897
  summary: diffGeneratedFiles(),
3685
3898
  npmVersion
@@ -3701,7 +3914,7 @@ async function runUpdate(options) {
3701
3914
  log(`npm package up to date: ${currentVersion}`);
3702
3915
  }
3703
3916
  if (await hasLocalOverrides(root)) {
3704
- log("Local .claude overrides detected. Preserving local files; only lockfile updated.");
3917
+ log("Existing .claude/settings.json \u2014 haus rules will be merged, not replaced.");
3705
3918
  }
3706
3919
  const { before, after } = await applyLock(root);
3707
3920
  log(diffLock(before, after));
@@ -3717,11 +3930,53 @@ async function runUpdate(options) {
3717
3930
  if (sync.failed.length > 0) {
3718
3931
  warn(`Failed to fetch ${sync.failed.length} item(s): ${sync.failed.join(", ")}`);
3719
3932
  }
3933
+ await refreshGlobalInstall();
3934
+ await refreshProjectFiles(root);
3720
3935
  log("Update applied with backup in .haus-workflow/backups/. Run haus doctor.");
3721
3936
  }
3937
+ async function refreshProjectFiles(root) {
3938
+ log("Refreshing project .claude/ files...");
3939
+ try {
3940
+ const files = await refreshProjectApply(root);
3941
+ if (files.length === 0) {
3942
+ log("No prior haus project setup detected \u2014 skipped project re-apply.");
3943
+ return;
3944
+ }
3945
+ log(`Project refreshed: ${files.length} managed path(s) updated.`);
3946
+ } catch (err) {
3947
+ warn(`Could not refresh project files: ${err instanceof Error ? err.message : String(err)}`);
3948
+ }
3949
+ }
3950
+ async function refreshGlobalInstall() {
3951
+ log("Refreshing ~/.claude/ global files...");
3952
+ try {
3953
+ const result = await applyInstall({});
3954
+ const total = result.created.length + result.updated.length;
3955
+ if (total > 0) {
3956
+ log(`~/.claude refreshed: ${result.created.length} added, ${result.updated.length} updated.`);
3957
+ } else {
3958
+ log("~/.claude already up to date.");
3959
+ }
3960
+ if (result.skipped.length > 0) {
3961
+ log(
3962
+ `Preserved ${result.skipped.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
3963
+ );
3964
+ }
3965
+ } catch (err) {
3966
+ warn(`Could not refresh ~/.claude/: ${err instanceof Error ? err.message : String(err)}`);
3967
+ }
3968
+ }
3969
+ async function detectGlobalInstallDrift() {
3970
+ try {
3971
+ const result = await applyInstall({ check: true });
3972
+ return result.drift;
3973
+ } catch {
3974
+ return null;
3975
+ }
3976
+ }
3722
3977
 
3723
3978
  // src/commands/validate-catalog.ts
3724
- import fs17 from "fs";
3979
+ import fs18 from "fs";
3725
3980
  import path26 from "path";
3726
3981
  function auditForbiddenStacks(items) {
3727
3982
  const failures = [];
@@ -3793,20 +4048,20 @@ function auditShippedFiles(manifestDir, items) {
3793
4048
  const absPath = path26.join(manifestDir, item.path);
3794
4049
  if (item.type === "skill") {
3795
4050
  const skillMd = path26.join(absPath, "SKILL.md");
3796
- if (!fs17.existsSync(skillMd)) {
4051
+ if (!fs18.existsSync(skillMd)) {
3797
4052
  failures.push(`${item.id}: missing ${path26.relative(manifestDir, skillMd)}`);
3798
4053
  continue;
3799
4054
  }
3800
- const text = fs17.readFileSync(skillMd, "utf8");
4055
+ const text = fs18.readFileSync(skillMd, "utf8");
3801
4056
  for (const section of REQUIRED_SKILL_SECTIONS) {
3802
4057
  if (!text.includes(section)) failures.push(`${item.id}: SKILL.md missing ${section}`);
3803
4058
  }
3804
4059
  } else if (item.type === "agent") {
3805
- if (!fs17.existsSync(absPath)) {
4060
+ if (!fs18.existsSync(absPath)) {
3806
4061
  failures.push(`${item.id}: missing agent file ${item.path}`);
3807
4062
  continue;
3808
4063
  }
3809
- const text = fs17.readFileSync(absPath, "utf8");
4064
+ const text = fs18.readFileSync(absPath, "utf8");
3810
4065
  if (!text.startsWith("---")) failures.push(`${item.id}: agent file missing YAML frontmatter`);
3811
4066
  for (const section of REQUIRED_AGENT_SECTIONS) {
3812
4067
  if (!text.includes(section)) failures.push(`${item.id}: agent file missing ${section}`);
@@ -3817,7 +4072,7 @@ function auditShippedFiles(manifestDir, items) {
3817
4072
  failures.push(`${item.id}: agent file contains disallowed phrase "${phrase}"`);
3818
4073
  }
3819
4074
  } else if (item.type === "template") {
3820
- if (!fs17.existsSync(absPath)) {
4075
+ if (!fs18.existsSync(absPath)) {
3821
4076
  failures.push(`${item.id}: missing template file ${item.path}`);
3822
4077
  }
3823
4078
  }
@@ -3829,9 +4084,9 @@ function auditMarkdownContent(manifestDir) {
3829
4084
  const dirs = ["skills", "agents"];
3830
4085
  for (const dir of dirs) {
3831
4086
  const abs = path26.join(manifestDir, dir);
3832
- if (!fs17.existsSync(abs)) continue;
4087
+ if (!fs18.existsSync(abs)) continue;
3833
4088
  walkMd(abs, (file) => {
3834
- const text = fs17.readFileSync(file, "utf8");
4089
+ const text = fs18.readFileSync(file, "utf8");
3835
4090
  const rel = path26.relative(manifestDir, file);
3836
4091
  const lines = text.split(/\r?\n/);
3837
4092
  for (let i = 0; i < lines.length; i++) {
@@ -3851,7 +4106,7 @@ function auditMarkdownContent(manifestDir) {
3851
4106
  return failures;
3852
4107
  }
3853
4108
  function walkMd(dir, fn) {
3854
- for (const entry of fs17.readdirSync(dir, { withFileTypes: true })) {
4109
+ for (const entry of fs18.readdirSync(dir, { withFileTypes: true })) {
3855
4110
  const full = path26.join(dir, entry.name);
3856
4111
  if (entry.isDirectory()) walkMd(full, fn);
3857
4112
  else if (entry.name.endsWith(".md")) fn(full);
@@ -4254,7 +4509,7 @@ import path32 from "path";
4254
4509
 
4255
4510
  // src/claude/write-workspace-claude-md.ts
4256
4511
  import path31 from "path";
4257
- import fs18 from "fs-extra";
4512
+ import fs19 from "fs-extra";
4258
4513
  function buildWorkspaceImportBlock(client, members) {
4259
4514
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
4260
4515
  const body = [
@@ -4273,7 +4528,7 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
4273
4528
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
4274
4529
  const dryRun = opts.dryRun ?? false;
4275
4530
  const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path31.join(workspaceRoot, "CLAUDE.md");
4276
- const prev = await fs18.pathExists(filePath) ? await fs18.readFile(filePath, "utf8") : "";
4531
+ const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
4277
4532
  const next = opts.collision ? `${block}
4278
4533
  ` : injectHausBlock(prev, block);
4279
4534
  const printable = displayPath(workspaceRoot, filePath);