@haus-tech/haus-workflow 1.2.0 → 1.3.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
@@ -60,7 +60,7 @@ var init_exec = __esm({
60
60
 
61
61
  // src/cli.ts
62
62
  import { readFileSync as readFileSync4 } from "node:fs";
63
- import path52 from "node:path";
63
+ import path53 from "node:path";
64
64
  import { Command } from "commander";
65
65
 
66
66
  // src/commands/apply.ts
@@ -879,6 +879,26 @@ var validation_rules_default = {
879
879
  "collection2",
880
880
  "deployer-php"
881
881
  ],
882
+ safetyNotesRequiredTags: [
883
+ "stripe",
884
+ "qliro",
885
+ "payments",
886
+ "bankid",
887
+ "myid",
888
+ "oidc",
889
+ "azure-ad",
890
+ "saml2"
891
+ ],
892
+ intentsRequiredTags: [
893
+ "stripe",
894
+ "qliro",
895
+ "payments",
896
+ "bankid",
897
+ "myid",
898
+ "oidc",
899
+ "azure-ad",
900
+ "saml2"
901
+ ],
882
902
  alwaysAllowedTags: [
883
903
  "haus",
884
904
  "security",
@@ -1387,6 +1407,13 @@ function findFormerIdMigrations(lockItems, manifestItems) {
1387
1407
  });
1388
1408
  }
1389
1409
 
1410
+ // src/recommender/orphaned-items.ts
1411
+ function findOrphanedLockEntries(lock, recommendedIds) {
1412
+ return lock.filter(
1413
+ (entry) => typeof entry.id === "string" && !recommendedIds.has(entry.id)
1414
+ );
1415
+ }
1416
+
1390
1417
  // src/update/hash-installed.ts
1391
1418
  import path10 from "node:path";
1392
1419
  import fg2 from "fast-glob";
@@ -1876,8 +1903,8 @@ function buildDenyRules() {
1876
1903
  for (const command of DENY_COMMANDS) {
1877
1904
  rules.push(`Bash(${command}:*)`);
1878
1905
  }
1879
- for (const path53 of DENY_PATHS) {
1880
- const pattern = DENY_DIRS.has(path53) ? `${path53}/**` : path53;
1906
+ for (const path54 of DENY_PATHS) {
1907
+ const pattern = DENY_DIRS.has(path54) ? `${path54}/**` : path54;
1881
1908
  for (const tool of FILE_TOOLS) {
1882
1909
  rules.push(`${tool}(${pattern})`);
1883
1910
  }
@@ -2164,11 +2191,23 @@ import path15 from "node:path";
2164
2191
  import fs13 from "fs-extra";
2165
2192
  var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
2166
2193
  var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
2167
- var IGNORED_PATHS = [".haus-workflow/"];
2194
+ var IGNORED_PATHS = [".haus-workflow/", ".claude/"];
2195
+ function prettierIgnoreProtects(lines, dir) {
2196
+ const bare = dir.replace(/\/+$/, "");
2197
+ const equivalents = /* @__PURE__ */ new Set([
2198
+ bare,
2199
+ `${bare}/`,
2200
+ `/${bare}`,
2201
+ `/${bare}/`,
2202
+ `${bare}/**`,
2203
+ `/${bare}/**`
2204
+ ]);
2205
+ return lines.some((line2) => equivalents.has(line2.trim()));
2206
+ }
2168
2207
  function buildPrettierIgnoreBlock() {
2169
2208
  return [
2170
2209
  PRETTIERIGNORE_BEGIN,
2171
- "# haus-owned files \u2014 do not reformat (keeps tamper-detection hashes stable)",
2210
+ "# haus-owned files \u2014 do not reformat (keeps tamper/lock hashes stable)",
2172
2211
  ...IGNORED_PATHS,
2173
2212
  PRETTIERIGNORE_END
2174
2213
  ].join("\n");
@@ -2817,6 +2856,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2817
2856
  );
2818
2857
  }
2819
2858
  await cleanupStaleCatalogItems(root, cleanupManifestById, dryRun, opts.quiet);
2859
+ if (opts.prune) {
2860
+ const notOrphaned = new Set(rec.recommended.map((r) => r.id));
2861
+ for (const migration of allMigrations) notOrphaned.add(migration.oldId);
2862
+ for (const entry of prevLock) {
2863
+ if (!entry.id) continue;
2864
+ const manifestItem = manifestById.get(entry.id);
2865
+ if (!manifestItem || manifestItem.reviewStatus === "deprecated") notOrphaned.add(entry.id);
2866
+ }
2867
+ await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
2868
+ }
2820
2869
  if (dryRun) return [...new Set(files)];
2821
2870
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
2822
2871
  const prevRefById = new Map(
@@ -2934,6 +2983,71 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2934
2983
  }
2935
2984
  }
2936
2985
  }
2986
+ async function backupBeforePrune(root, absPaths, say) {
2987
+ if (absPaths.length === 0) return;
2988
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2989
+ const backupRoot = hausPath(root, "backups", `prune-${stamp}`);
2990
+ for (const abs of absPaths) {
2991
+ if (!await fs18.pathExists(abs)) continue;
2992
+ const rel = path19.relative(root, abs);
2993
+ const backupPath = path19.join(backupRoot, rel);
2994
+ await fs18.ensureDir(path19.dirname(backupPath));
2995
+ await fs18.copy(abs, backupPath);
2996
+ }
2997
+ say(`Backed up ${absPaths.length} path(s) to ${path19.relative(root, backupRoot)} before pruning.`);
2998
+ }
2999
+ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun, quiet) {
3000
+ const say = quiet ? () => {
3001
+ } : log;
3002
+ const orphaned = findOrphanedLockEntries(prevLock, notOrphanedIds);
3003
+ if (orphaned.length === 0) return;
3004
+ const toRemove = [];
3005
+ for (const entry of orphaned) {
3006
+ if (!entry.id) continue;
3007
+ const relPaths = entry.paths ?? [];
3008
+ if (relPaths.length === 0) continue;
3009
+ const existing = [];
3010
+ for (const rel of relPaths) {
3011
+ if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3012
+ }
3013
+ if (existing.length === 0) continue;
3014
+ if (entry.hash === void 0) {
3015
+ warn(
3016
+ `Orphaned catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
3017
+ );
3018
+ continue;
3019
+ }
3020
+ const currentHash = await hashInstalledPaths(root, relPaths);
3021
+ if (currentHash !== entry.hash) {
3022
+ warn(
3023
+ `Orphaned catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
3024
+ );
3025
+ continue;
3026
+ }
3027
+ toRemove.push({ id: entry.id, existing });
3028
+ }
3029
+ if (toRemove.length === 0) return;
3030
+ if (dryRun) {
3031
+ for (const { id, existing } of toRemove) {
3032
+ for (const rel of existing) {
3033
+ say(`[dry-run] would prune orphaned ${displayPath(root, path19.join(root, rel))} (${id})`);
3034
+ }
3035
+ }
3036
+ return;
3037
+ }
3038
+ const allAbsPaths = toRemove.flatMap(
3039
+ ({ existing }) => existing.map((rel) => path19.join(root, rel))
3040
+ );
3041
+ await backupBeforePrune(root, allAbsPaths, say);
3042
+ for (const { id, existing } of toRemove) {
3043
+ for (const rel of existing) {
3044
+ const abs = path19.join(root, rel);
3045
+ await fs18.remove(abs);
3046
+ await pruneEmptyDir(path19.dirname(abs));
3047
+ say(`Pruned orphaned ${displayPath(root, abs)} (${id})`);
3048
+ }
3049
+ }
3050
+ }
2937
3051
 
2938
3052
  // src/refs/fetch-refs.ts
2939
3053
  import path21 from "node:path";
@@ -3163,7 +3277,8 @@ async function runApply(options) {
3163
3277
  }
3164
3278
  const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
3165
3279
  refillConfig: options.refillConfig,
3166
- force: options.force
3280
+ force: options.force,
3281
+ prune: options.prune
3167
3282
  });
3168
3283
  if (!isDryRun) {
3169
3284
  try {
@@ -3198,6 +3313,364 @@ async function runApply(options) {
3198
3313
  }
3199
3314
  }
3200
3315
 
3316
+ // src/commands/backups.ts
3317
+ import path23 from "node:path";
3318
+ import fs21 from "fs-extra";
3319
+
3320
+ // src/utils/prompts.ts
3321
+ import { stdin as input, stdout as output } from "node:process";
3322
+ import readline from "node:readline/promises";
3323
+ async function ask(question) {
3324
+ const rl = readline.createInterface({ input, output });
3325
+ try {
3326
+ const answer = await rl.question(`${question}
3327
+ > `);
3328
+ return answer.trim();
3329
+ } finally {
3330
+ rl.close();
3331
+ }
3332
+ }
3333
+ async function confirm(question) {
3334
+ const answer = (await ask(`${question} [y/N]`)).toLowerCase();
3335
+ return answer === "y" || answer === "yes";
3336
+ }
3337
+
3338
+ // src/commands/backups.ts
3339
+ var LOCK_BACKUP_RE = /^haus\.lock\.\d+\.json$/;
3340
+ function classify(name) {
3341
+ if (LOCK_BACKUP_RE.test(name)) return "lock";
3342
+ if (name.startsWith("undo-")) return "undo";
3343
+ if (name.startsWith("prune-")) return "prune";
3344
+ return void 0;
3345
+ }
3346
+ async function listBackups(root) {
3347
+ const dir = hausPath(root, "backups");
3348
+ if (!await fs21.pathExists(dir)) return [];
3349
+ const names = await fs21.readdir(dir);
3350
+ const entries = [];
3351
+ for (const name of names) {
3352
+ const kind = classify(name);
3353
+ if (!kind) continue;
3354
+ const absPath = path23.join(dir, name);
3355
+ const stat = await fs21.lstat(absPath);
3356
+ if (stat.isSymbolicLink()) continue;
3357
+ const expectDirectory = kind !== "lock";
3358
+ if (stat.isDirectory() !== expectDirectory) continue;
3359
+ entries.push({ id: name, kind, absPath, mtimeMs: stat.mtimeMs });
3360
+ }
3361
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
3362
+ return entries;
3363
+ }
3364
+ function formatAge(mtimeMs, now) {
3365
+ const minutes = Math.round(Math.max(0, now - mtimeMs) / 6e4);
3366
+ if (minutes < 60) return `${minutes}m ago`;
3367
+ const hours = Math.round(minutes / 60);
3368
+ if (hours < 48) return `${hours}h ago`;
3369
+ return `${Math.round(hours / 24)}d ago`;
3370
+ }
3371
+ async function runList(root) {
3372
+ const entries = await listBackups(root);
3373
+ if (entries.length === 0) {
3374
+ log("No backups found under .haus-workflow/backups/.");
3375
+ return;
3376
+ }
3377
+ const now = Date.now();
3378
+ const newestFirst = [...entries].reverse();
3379
+ for (const entry of newestFirst) {
3380
+ log(`${entry.id} [${entry.kind}] ${formatAge(entry.mtimeMs, now)}`);
3381
+ }
3382
+ }
3383
+ async function isUnsafeWriteTarget(root, target) {
3384
+ const segments = path23.relative(root, target).split(path23.sep).filter(Boolean);
3385
+ let current = root;
3386
+ for (let i = 0; i < segments.length; i++) {
3387
+ current = path23.join(current, segments[i]);
3388
+ const isLastSegment = i === segments.length - 1;
3389
+ try {
3390
+ const stat = await fs21.lstat(current);
3391
+ if (isLastSegment) {
3392
+ if (!stat.isFile()) return true;
3393
+ } else if (!stat.isDirectory()) {
3394
+ return true;
3395
+ }
3396
+ } catch (err) {
3397
+ if (err.code === "ENOENT") return false;
3398
+ throw err;
3399
+ }
3400
+ }
3401
+ return false;
3402
+ }
3403
+ async function lstatIfRegularFile(abs) {
3404
+ try {
3405
+ const stat = await fs21.lstat(abs);
3406
+ return stat.isFile() ? stat : null;
3407
+ } catch (err) {
3408
+ if (err.code === "ENOENT") return null;
3409
+ throw err;
3410
+ }
3411
+ }
3412
+ async function restoreLockBackup(root, entry, yes) {
3413
+ const target = hausPath(root, "haus.lock.json");
3414
+ if (await isUnsafeWriteTarget(root, target)) {
3415
+ error(
3416
+ `Refusing to restore: ${target} (or a parent directory) is a symlink or other non-regular file \u2014 never written through.`
3417
+ );
3418
+ return false;
3419
+ }
3420
+ if (await fs21.pathExists(target)) {
3421
+ const targetStat = await fs21.stat(target);
3422
+ if (targetStat.mtimeMs > entry.mtimeMs) {
3423
+ warn(
3424
+ `Current haus.lock.json (${new Date(targetStat.mtimeMs).toISOString()}) is newer than backup ${entry.id} (${new Date(entry.mtimeMs).toISOString()}) \u2014 it will be overwritten.`
3425
+ );
3426
+ }
3427
+ }
3428
+ if (!yes) {
3429
+ const ok = await confirm(
3430
+ `Restore .haus-workflow/haus.lock.json from backup ${entry.id}? This overwrites the current lockfile.`
3431
+ );
3432
+ if (!ok) {
3433
+ log("Cancelled.");
3434
+ return false;
3435
+ }
3436
+ }
3437
+ if (!await lstatIfRegularFile(entry.absPath) || await isUnsafeWriteTarget(root, target)) {
3438
+ error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
3439
+ return false;
3440
+ }
3441
+ await fs21.copy(entry.absPath, target, { overwrite: true });
3442
+ log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
3443
+ return true;
3444
+ }
3445
+ async function collectFilesRecursive(dir) {
3446
+ const files = [];
3447
+ const skippedLinks = [];
3448
+ const skippedOther = [];
3449
+ const names = await fs21.readdir(dir);
3450
+ for (const name of names) {
3451
+ const abs = path23.join(dir, name);
3452
+ const stat = await fs21.lstat(abs);
3453
+ if (stat.isSymbolicLink()) {
3454
+ skippedLinks.push(abs);
3455
+ continue;
3456
+ }
3457
+ if (stat.isDirectory()) {
3458
+ const nested = await collectFilesRecursive(abs);
3459
+ files.push(...nested.files);
3460
+ skippedLinks.push(...nested.skippedLinks);
3461
+ skippedOther.push(...nested.skippedOther);
3462
+ } else if (stat.isFile()) {
3463
+ files.push(abs);
3464
+ } else {
3465
+ skippedOther.push(abs);
3466
+ }
3467
+ }
3468
+ return { files, skippedLinks, skippedOther };
3469
+ }
3470
+ async function restoreDirBackup(root, entry, yes) {
3471
+ const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
3472
+ if (skippedLinks.length > 0) {
3473
+ warn(
3474
+ `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path23.relative(entry.absPath, abs)).join(", ")}`
3475
+ );
3476
+ }
3477
+ if (skippedOther.length > 0) {
3478
+ warn(
3479
+ `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path23.relative(entry.absPath, abs)).join(", ")}`
3480
+ );
3481
+ }
3482
+ if (files.length === 0) {
3483
+ log(`Backup ${entry.id} contains no files.`);
3484
+ return false;
3485
+ }
3486
+ const safeFiles = [];
3487
+ const unsafeTargets = [];
3488
+ for (const abs of files) {
3489
+ const rel = path23.relative(entry.absPath, abs);
3490
+ const target = path23.join(root, rel);
3491
+ if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
3492
+ else safeFiles.push(abs);
3493
+ }
3494
+ if (unsafeTargets.length > 0) {
3495
+ warn(
3496
+ `Skipped ${unsafeTargets.length} restore target(s) that are a symlink (or under one) rather than a plain path \u2014 never written through: ${unsafeTargets.join(", ")}`
3497
+ );
3498
+ }
3499
+ if (safeFiles.length === 0) {
3500
+ log(`Backup ${entry.id} has no restorable targets.`);
3501
+ return false;
3502
+ }
3503
+ const stale = [];
3504
+ const vanished = [];
3505
+ const toRestore = [];
3506
+ for (const abs of safeFiles) {
3507
+ const rel = path23.relative(entry.absPath, abs);
3508
+ const backupFileStat = await lstatIfRegularFile(abs);
3509
+ if (!backupFileStat) {
3510
+ vanished.push(rel);
3511
+ continue;
3512
+ }
3513
+ toRestore.push(abs);
3514
+ const target = path23.join(root, rel);
3515
+ if (await fs21.pathExists(target)) {
3516
+ const targetStat = await fs21.stat(target);
3517
+ if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
3518
+ stale.push({
3519
+ rel,
3520
+ targetMtimeMs: targetStat.mtimeMs,
3521
+ backupMtimeMs: backupFileStat.mtimeMs
3522
+ });
3523
+ }
3524
+ }
3525
+ }
3526
+ if (vanished.length > 0) {
3527
+ warn(
3528
+ `Skipped ${vanished.length} backup file(s) that changed (no longer a plain file) since being listed: ${vanished.join(", ")}`
3529
+ );
3530
+ }
3531
+ if (toRestore.length === 0) {
3532
+ log(`Backup ${entry.id} has no restorable files left after re-validation.`);
3533
+ return false;
3534
+ }
3535
+ if (stale.length > 0) {
3536
+ const detail = stale.map(
3537
+ (s) => `${s.rel} (destination: ${new Date(s.targetMtimeMs).toISOString()}, backup: ${new Date(s.backupMtimeMs).toISOString()})`
3538
+ ).join("; ");
3539
+ warn(
3540
+ `${stale.length} file(s) at the restore destination are newer than backup ${entry.id} and will be overwritten: ${detail}`
3541
+ );
3542
+ }
3543
+ if (!yes) {
3544
+ const ok = await confirm(
3545
+ `Restore ${toRestore.length} file(s) from backup ${entry.id} to their original paths?`
3546
+ );
3547
+ if (!ok) {
3548
+ log("Cancelled.");
3549
+ return false;
3550
+ }
3551
+ }
3552
+ let restoredCount = 0;
3553
+ const skippedAtWrite = [];
3554
+ for (const abs of toRestore) {
3555
+ const rel = path23.relative(entry.absPath, abs);
3556
+ const target = path23.join(root, rel);
3557
+ if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
3558
+ skippedAtWrite.push(rel);
3559
+ continue;
3560
+ }
3561
+ await fs21.ensureDir(path23.dirname(target));
3562
+ await fs21.copy(abs, target, { overwrite: true });
3563
+ restoredCount++;
3564
+ }
3565
+ if (skippedAtWrite.length > 0) {
3566
+ warn(
3567
+ `Skipped ${skippedAtWrite.length} file(s) that changed since being checked, right before restoring: ${skippedAtWrite.join(", ")}`
3568
+ );
3569
+ }
3570
+ log(`Restored ${restoredCount} file(s) from ${entry.id}.`);
3571
+ return restoredCount > 0;
3572
+ }
3573
+ async function runRestore(root, id, yes) {
3574
+ const entries = await listBackups(root);
3575
+ const entry = entries.find((e) => e.id === id);
3576
+ if (!entry) {
3577
+ error(`No backup found with id "${id}". Run \`haus backups list\` to see available backups.`);
3578
+ return false;
3579
+ }
3580
+ return entry.kind === "lock" ? restoreLockBackup(root, entry, yes) : restoreDirBackup(root, entry, yes);
3581
+ }
3582
+ async function runPrune(root, opts) {
3583
+ if (opts.olderThanDays === void 0 && opts.keep === void 0) {
3584
+ error("Specify --older-than <days> or --keep <n> \u2014 refusing to prune with no bound.");
3585
+ return false;
3586
+ }
3587
+ const entries = await listBackups(root);
3588
+ const now = Date.now();
3589
+ const toRemove = /* @__PURE__ */ new Set();
3590
+ if (opts.olderThanDays !== void 0) {
3591
+ const cutoff = now - opts.olderThanDays * 24 * 60 * 60 * 1e3;
3592
+ for (const entry of entries) if (entry.mtimeMs < cutoff) toRemove.add(entry.id);
3593
+ }
3594
+ if (opts.keep !== void 0) {
3595
+ const excess = entries.length - opts.keep;
3596
+ if (excess > 0) for (const entry of entries.slice(0, excess)) toRemove.add(entry.id);
3597
+ }
3598
+ if (toRemove.size === 0) {
3599
+ log("Nothing to prune.");
3600
+ return true;
3601
+ }
3602
+ const removeList = entries.filter((e) => toRemove.has(e.id));
3603
+ const keepCount = entries.length - removeList.length;
3604
+ if (!opts.yes) {
3605
+ const ok = await confirm(
3606
+ `Remove ${removeList.length} backup(s)?
3607
+ ${removeList.map((e) => e.id).join("\n ")}
3608
+ Keeping ${keepCount}.`
3609
+ );
3610
+ if (!ok) {
3611
+ log("Cancelled.");
3612
+ return false;
3613
+ }
3614
+ }
3615
+ const removed = [];
3616
+ const failed = [];
3617
+ for (const entry of removeList) {
3618
+ try {
3619
+ await fs21.remove(entry.absPath);
3620
+ removed.push(entry.id);
3621
+ } catch (err) {
3622
+ failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
3623
+ }
3624
+ }
3625
+ log(`Removed ${removed.length} backup(s), kept ${entries.length - removed.length}.`);
3626
+ if (failed.length > 0) {
3627
+ warn(
3628
+ `Failed to remove ${failed.length} backup(s): ${failed.map((f) => `${f.id} (${f.message})`).join(", ")}`
3629
+ );
3630
+ return false;
3631
+ }
3632
+ return true;
3633
+ }
3634
+ async function runBackups(action, opts = {}) {
3635
+ const root = opts.root ?? process.cwd();
3636
+ if (action === "list") {
3637
+ await runList(root);
3638
+ return;
3639
+ }
3640
+ if (action === "restore") {
3641
+ if (!opts.id) {
3642
+ error("Usage: haus backups restore <id>");
3643
+ process.exitCode = 1;
3644
+ return;
3645
+ }
3646
+ if (!await runRestore(root, opts.id, Boolean(opts.yes))) process.exitCode = 1;
3647
+ return;
3648
+ }
3649
+ let olderThanDays;
3650
+ if (opts.olderThan !== void 0) {
3651
+ const n = Number(opts.olderThan);
3652
+ if (!Number.isFinite(n) || n < 0) {
3653
+ error(
3654
+ `Invalid --older-than value "${opts.olderThan}" \u2014 expected a non-negative number of days.`
3655
+ );
3656
+ process.exitCode = 1;
3657
+ return;
3658
+ }
3659
+ olderThanDays = n;
3660
+ }
3661
+ let keep;
3662
+ if (opts.keep !== void 0) {
3663
+ const n = Number(opts.keep);
3664
+ if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
3665
+ error(`Invalid --keep value "${opts.keep}" \u2014 expected a non-negative integer.`);
3666
+ process.exitCode = 1;
3667
+ return;
3668
+ }
3669
+ keep = n;
3670
+ }
3671
+ if (!await runPrune(root, { olderThanDays, keep, yes: Boolean(opts.yes) })) process.exitCode = 1;
3672
+ }
3673
+
3201
3674
  // src/commands/catalog-audit.ts
3202
3675
  function auditForbiddenTags(items) {
3203
3676
  const failures = [];
@@ -3226,7 +3699,7 @@ async function runCatalogAudit() {
3226
3699
  // src/commands/clone.ts
3227
3700
  init_exec();
3228
3701
  import { existsSync as existsSync2 } from "node:fs";
3229
- import path23 from "node:path";
3702
+ import path24 from "node:path";
3230
3703
  var GIT_LOCATION_VARS = [
3231
3704
  "GIT_DIR",
3232
3705
  "GIT_WORK_TREE",
@@ -3246,15 +3719,50 @@ function repoNameFromUrl(url) {
3246
3719
  const tail = trimmed.split(/[/:]/).pop() ?? "";
3247
3720
  return tail || "repo";
3248
3721
  }
3722
+ function normalizeGitUrl(url) {
3723
+ let s = url.trim();
3724
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
3725
+ s = s.replace(/^[^/@]+@/, "");
3726
+ s = s.replace(/:(?=[^/])/, "/");
3727
+ s = s.replace(/\/+$/, "");
3728
+ s = s.replace(/\.git$/i, "");
3729
+ return s.toLowerCase();
3730
+ }
3731
+ async function checkExistingTarget(target, url) {
3732
+ const isRepo = await runGit(["-C", target, "rev-parse", "--is-inside-work-tree"], {
3733
+ env: cloneEnv(),
3734
+ extendEnv: false
3735
+ });
3736
+ if (isRepo.exitCode !== 0) return { kind: "not-a-repo" };
3737
+ const origin = await runGit(["-C", target, "remote", "get-url", "origin"], {
3738
+ env: cloneEnv(),
3739
+ extendEnv: false
3740
+ });
3741
+ if (origin.exitCode !== 0) return { kind: "different-repo", origin: null };
3742
+ const originUrl = origin.stdout.trim();
3743
+ if (normalizeGitUrl(originUrl) === normalizeGitUrl(url)) return { kind: "matches" };
3744
+ return { kind: "different-repo", origin: originUrl };
3745
+ }
3249
3746
  async function runClone(url, opts = {}) {
3250
3747
  if (!url || !url.trim()) {
3251
3748
  error("A git URL is required: `haus clone <url> [dir]`.");
3252
3749
  process.exitCode = 1;
3253
3750
  return;
3254
3751
  }
3255
- const target = path23.resolve(opts.dir?.trim() || repoNameFromUrl(url));
3752
+ const target = path24.resolve(opts.dir?.trim() || repoNameFromUrl(url));
3256
3753
  if (existsSync2(target)) {
3257
- log(`\u2022 ${path23.basename(target)} already present at ${target} \u2014 skipped`);
3754
+ const check = await checkExistingTarget(target, url);
3755
+ if (check.kind === "matches") {
3756
+ log(`\u2022 ${path24.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
3757
+ return;
3758
+ }
3759
+ const message = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}) \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
3760
+ if (opts.dryRun) {
3761
+ warn(`[dry-run] ${message}`);
3762
+ return;
3763
+ }
3764
+ error(message);
3765
+ process.exitCode = 1;
3258
3766
  return;
3259
3767
  }
3260
3768
  if (opts.dryRun) {
@@ -3325,8 +3833,8 @@ async function resolveBaseRef(root) {
3325
3833
  }
3326
3834
 
3327
3835
  // src/decisions/check.ts
3328
- import path26 from "node:path";
3329
- import fs23 from "fs-extra";
3836
+ import path27 from "node:path";
3837
+ import fs24 from "fs-extra";
3330
3838
 
3331
3839
  // src/decisions/diff.ts
3332
3840
  init_exec();
@@ -3381,22 +3889,22 @@ function matchesAnyRegex(filePath, patterns) {
3381
3889
  }
3382
3890
 
3383
3891
  // src/decisions/paths.ts
3384
- import path24 from "node:path";
3385
- import fs21 from "fs-extra";
3892
+ import path25 from "node:path";
3893
+ import fs22 from "fs-extra";
3386
3894
  async function resolveDecisionsDir(root) {
3387
- const primary = path24.join(root, DECISIONS_TRIGGERS.decisionsDir);
3388
- if (await fs21.pathExists(primary)) return primary;
3389
- const legacy = path24.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3390
- if (await fs21.pathExists(legacy)) return legacy;
3895
+ const primary = path25.join(root, DECISIONS_TRIGGERS.decisionsDir);
3896
+ if (await fs22.pathExists(primary)) return primary;
3897
+ const legacy = path25.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3898
+ if (await fs22.pathExists(legacy)) return legacy;
3391
3899
  return primary;
3392
3900
  }
3393
3901
  function relativeDecisionPath(decisionsDir, root) {
3394
- return path24.relative(root, decisionsDir).replace(/\\/g, "/");
3902
+ return path25.relative(root, decisionsDir).replace(/\\/g, "/");
3395
3903
  }
3396
3904
 
3397
3905
  // src/decisions/validate.ts
3398
- import path25 from "node:path";
3399
- import fs22 from "fs-extra";
3906
+ import path26 from "node:path";
3907
+ import fs23 from "fs-extra";
3400
3908
  function validateDecisionContent(content2) {
3401
3909
  const errors = [];
3402
3910
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -3425,14 +3933,14 @@ function decisionNumberFromFile(name) {
3425
3933
  return match?.[1] ?? null;
3426
3934
  }
3427
3935
  async function validateIndexSync(decisionsDir, decisionFiles) {
3428
- const readmePath = path25.join(decisionsDir, "README.md");
3429
- if (!await fs22.pathExists(readmePath)) {
3936
+ const readmePath = path26.join(decisionsDir, "README.md");
3937
+ if (!await fs23.pathExists(readmePath)) {
3430
3938
  return ["docs/decisions/README.md is missing"];
3431
3939
  }
3432
3940
  const readme = await readText(readmePath) ?? "";
3433
3941
  const errors = [];
3434
3942
  for (const file of decisionFiles) {
3435
- const num = decisionNumberFromFile(path25.basename(file));
3943
+ const num = decisionNumberFromFile(path26.basename(file));
3436
3944
  if (num && !indexReferencesNumber(readme, num)) {
3437
3945
  errors.push(`README.md missing index row for ADR-${num}`);
3438
3946
  }
@@ -3486,22 +3994,22 @@ async function checkSatisfaction(input2) {
3486
3994
  const errors = [];
3487
3995
  const decisionsPrefix = `${input2.decisionsRel}/`;
3488
3996
  const newDecisionFiles = input2.changedFiles.filter(
3489
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path26.basename(f))
3997
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path27.basename(f))
3490
3998
  );
3491
3999
  if (newDecisionFiles.length === 0) {
3492
4000
  errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
3493
4001
  return errors;
3494
4002
  }
3495
- const decisionsDir = path26.join(input2.root, input2.decisionsRel);
4003
+ const decisionsDir = path27.join(input2.root, input2.decisionsRel);
3496
4004
  for (const rel of newDecisionFiles) {
3497
- const abs = path26.join(input2.root, rel);
4005
+ const abs = path27.join(input2.root, rel);
3498
4006
  const content2 = await readText(abs) ?? "";
3499
4007
  const valid = validateDecisionContent(content2);
3500
4008
  if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
3501
4009
  }
3502
4010
  const indexErrors = await validateIndexSync(
3503
4011
  decisionsDir,
3504
- newDecisionFiles.map((f) => path26.basename(f))
4012
+ newDecisionFiles.map((f) => path27.basename(f))
3505
4013
  );
3506
4014
  errors.push(...indexErrors);
3507
4015
  return errors;
@@ -3545,8 +4053,8 @@ ${opts.commitMessages ?? ""}`;
3545
4053
  changedFiles: stats.files
3546
4054
  });
3547
4055
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
3548
- const legacyExists = await fs23.pathExists(path26.join(root, legacyRel));
3549
- if (legacyExists && !await fs23.pathExists(decisionsDir)) {
4056
+ const legacyExists = await fs24.pathExists(path27.join(root, legacyRel));
4057
+ if (legacyExists && !await fs24.pathExists(decisionsDir)) {
3550
4058
  satisfactionErrors = await checkSatisfaction({
3551
4059
  root,
3552
4060
  decisionsRel: legacyRel,
@@ -3566,8 +4074,8 @@ ${opts.commitMessages ?? ""}`;
3566
4074
  return { triggered: true, satisfied: false, reasons, securityTriggered };
3567
4075
  }
3568
4076
  async function listDecisionNumbers(decisionsDir) {
3569
- if (!await fs23.pathExists(decisionsDir)) return [];
3570
- const entries = await fs23.readdir(decisionsDir);
4077
+ if (!await fs24.pathExists(decisionsDir)) return [];
4078
+ const entries = await fs24.readdir(decisionsDir);
3571
4079
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
3572
4080
  }
3573
4081
 
@@ -3609,7 +4117,7 @@ async function runDecisionsGuard(options = {}) {
3609
4117
  }
3610
4118
 
3611
4119
  // src/commands/decisions.ts
3612
- import path27 from "node:path";
4120
+ import path28 from "node:path";
3613
4121
 
3614
4122
  // src/decisions/next-number.ts
3615
4123
  async function nextDecisionNumber(root) {
@@ -3720,7 +4228,7 @@ async function runDecisions(sub, arg, options = {}) {
3720
4228
  return;
3721
4229
  }
3722
4230
  if (sub === "validate") {
3723
- const target = arg ? path27.resolve(root, arg) : void 0;
4231
+ const target = arg ? path28.resolve(root, arg) : void 0;
3724
4232
  if (!target) {
3725
4233
  error("usage: haus decisions validate <path-to-decision.md>");
3726
4234
  process.exitCode = 1;
@@ -3737,18 +4245,18 @@ async function runDecisions(sub, arg, options = {}) {
3737
4245
  }
3738
4246
 
3739
4247
  // src/commands/doctor.ts
3740
- import path34 from "node:path";
3741
- import fs27 from "fs-extra";
4248
+ import path35 from "node:path";
4249
+ import fs28 from "fs-extra";
3742
4250
 
3743
4251
  // src/decisions/doctor.ts
3744
- import path28 from "node:path";
3745
- import fs24 from "fs-extra";
4252
+ import path29 from "node:path";
4253
+ import fs25 from "fs-extra";
3746
4254
  async function auditDecisionsLayout(root) {
3747
4255
  const findings = [];
3748
- const primary = path28.join(root, DECISIONS_TRIGGERS.decisionsDir);
3749
- const legacy = path28.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3750
- const hasPrimary = await fs24.pathExists(primary);
3751
- const hasLegacy = await fs24.pathExists(legacy);
4256
+ const primary = path29.join(root, DECISIONS_TRIGGERS.decisionsDir);
4257
+ const legacy = path29.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4258
+ const hasPrimary = await fs25.pathExists(primary);
4259
+ const hasLegacy = await fs25.pathExists(legacy);
3752
4260
  if (!hasPrimary && hasLegacy) {
3753
4261
  findings.push({
3754
4262
  level: "advisory",
@@ -3761,8 +4269,8 @@ async function auditDecisionsLayout(root) {
3761
4269
  if (!hasPrimary) return findings;
3762
4270
  const decisionsDir = await resolveDecisionsDir(root);
3763
4271
  const rel = relativeDecisionPath(decisionsDir, root);
3764
- const readmePath = path28.join(decisionsDir, "README.md");
3765
- if (!await fs24.pathExists(readmePath)) {
4272
+ const readmePath = path29.join(decisionsDir, "README.md");
4273
+ if (!await fs25.pathExists(readmePath)) {
3766
4274
  findings.push({
3767
4275
  level: "advisory",
3768
4276
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -3772,7 +4280,7 @@ async function auditDecisionsLayout(root) {
3772
4280
  return findings;
3773
4281
  }
3774
4282
  const readme = await readText(readmePath);
3775
- const entries = (await fs24.readdir(decisionsDir)).filter(
4283
+ const entries = (await fs25.readdir(decisionsDir)).filter(
3776
4284
  (f) => f.endsWith(".md") && f !== "README.md"
3777
4285
  );
3778
4286
  for (const file of entries) {
@@ -3786,7 +4294,7 @@ async function auditDecisionsLayout(root) {
3786
4294
  });
3787
4295
  }
3788
4296
  }
3789
- const claude = await readText(path28.join(root, "CLAUDE.md"));
4297
+ const claude = await readText(path29.join(root, "CLAUDE.md"));
3790
4298
  if (claude && !claude.includes(`@${rel}/README.md`)) {
3791
4299
  findings.push({
3792
4300
  level: "advisory",
@@ -3817,11 +4325,11 @@ async function auditDecisionsLayout(root) {
3817
4325
  }
3818
4326
 
3819
4327
  // src/scanner/read-context.ts
3820
- import path33 from "node:path";
3821
- import fs26 from "fs-extra";
4328
+ import path34 from "node:path";
4329
+ import fs27 from "fs-extra";
3822
4330
 
3823
4331
  // src/scanner/scan-project.ts
3824
- import path32 from "node:path";
4332
+ import path33 from "node:path";
3825
4333
 
3826
4334
  // src/utils/versions.ts
3827
4335
  import semver from "semver";
@@ -3843,8 +4351,8 @@ function compareVersions(a, b) {
3843
4351
  }
3844
4352
 
3845
4353
  // src/scanner/detect-package-manager.ts
3846
- import path29 from "node:path";
3847
- import fs25 from "fs-extra";
4354
+ import path30 from "node:path";
4355
+ import fs26 from "fs-extra";
3848
4356
  function detectPackageManager(root, packageManagerField) {
3849
4357
  const field = String(packageManagerField ?? "").trim();
3850
4358
  if (field.startsWith("yarn@")) {
@@ -3862,9 +4370,9 @@ function detectPackageManager(root, packageManagerField) {
3862
4370
  if (satisfiesVersion(version, ">=9")) return "npm";
3863
4371
  return "unknown";
3864
4372
  }
3865
- if (fs25.existsSync(path29.join(root, "yarn.lock"))) return "yarn";
3866
- if (fs25.existsSync(path29.join(root, "pnpm-lock.yaml"))) return "pnpm";
3867
- if (fs25.existsSync(path29.join(root, "package-lock.json"))) return "npm";
4373
+ if (fs26.existsSync(path30.join(root, "yarn.lock"))) return "yarn";
4374
+ if (fs26.existsSync(path30.join(root, "pnpm-lock.yaml"))) return "pnpm";
4375
+ if (fs26.existsSync(path30.join(root, "package-lock.json"))) return "npm";
3868
4376
  return "unknown";
3869
4377
  }
3870
4378
 
@@ -4066,7 +4574,7 @@ function runDetection(ctx, rules = STACK_RULES) {
4066
4574
  }
4067
4575
 
4068
4576
  // src/scanner/detection.ts
4069
- import path30 from "node:path";
4577
+ import path31 from "node:path";
4070
4578
  var UNSUPPORTED_MARKERS = {
4071
4579
  "requirements.txt": "python",
4072
4580
  "pyproject.toml": "python",
@@ -4120,14 +4628,14 @@ function finalizeRoles(registryRoles, deps, files) {
4120
4628
  function collectUnsupportedSignals(files) {
4121
4629
  return [
4122
4630
  ...new Set(
4123
- files.map((f) => UNSUPPORTED_MARKERS[path30.basename(f)]).filter((s) => Boolean(s))
4631
+ files.map((f) => UNSUPPORTED_MARKERS[path31.basename(f)]).filter((s) => Boolean(s))
4124
4632
  )
4125
4633
  ].sort();
4126
4634
  }
4127
4635
 
4128
4636
  // src/scanner/render.ts
4129
4637
  import { readFile } from "node:fs/promises";
4130
- import path31 from "node:path";
4638
+ import path32 from "node:path";
4131
4639
  async function buildContentBlob(root, files) {
4132
4640
  const candidates = files.filter(
4133
4641
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -4135,7 +4643,7 @@ async function buildContentBlob(root, files) {
4135
4643
  const slice = candidates.slice(0, 300);
4136
4644
  const parts = await mapWithConcurrency(slice, async (rel) => {
4137
4645
  try {
4138
- return await readFile(path31.join(root, rel), "utf8");
4646
+ return await readFile(path32.join(root, rel), "utf8");
4139
4647
  } catch {
4140
4648
  return "";
4141
4649
  }
@@ -4222,8 +4730,8 @@ var SAFE_FILES = [
4222
4730
  "Gemfile"
4223
4731
  ];
4224
4732
  async function scanProject(root) {
4225
- const pkg = await readJson(path32.join(root, "package.json"));
4226
- const composer = await readJson(path32.join(root, "composer.json"));
4733
+ const pkg = await readJson(path33.join(root, "package.json"));
4734
+ const composer = await readJson(path33.join(root, "composer.json"));
4227
4735
  const files = await listFiles(root, SAFE_FILES);
4228
4736
  const safeFiles = files.filter((f) => !blocked(f));
4229
4737
  const deps = dependencySet(pkg, composer);
@@ -4250,7 +4758,7 @@ async function scanProject(root) {
4250
4758
  const context = {
4251
4759
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4252
4760
  root,
4253
- repoName: String(pkg?.name ?? path32.basename(root)),
4761
+ repoName: String(pkg?.name ?? path33.basename(root)),
4254
4762
  packageManager,
4255
4763
  repoRoles: roles,
4256
4764
  detectedStacks: stacks,
@@ -4268,9 +4776,9 @@ async function scanProject(root) {
4268
4776
 
4269
4777
  // src/scanner/read-context.ts
4270
4778
  async function isCacheFresh(root, cachePath) {
4271
- const pkgJsonPath = path33.join(root, "package.json");
4272
- if (!await fs26.pathExists(pkgJsonPath)) return true;
4273
- const [cacheStat, pkgStat] = await Promise.all([fs26.stat(cachePath), fs26.stat(pkgJsonPath)]);
4779
+ const pkgJsonPath = path34.join(root, "package.json");
4780
+ if (!await fs27.pathExists(pkgJsonPath)) return true;
4781
+ const [cacheStat, pkgStat] = await Promise.all([fs27.stat(cachePath), fs27.stat(pkgJsonPath)]);
4274
4782
  return cacheStat.mtimeMs >= pkgStat.mtimeMs;
4275
4783
  }
4276
4784
  async function readContextOrScan(root) {
@@ -4369,7 +4877,7 @@ async function runDoctor(options) {
4369
4877
  } else {
4370
4878
  ok(`- HOOKS OK: ${hooks.message}`);
4371
4879
  }
4372
- const rootClaudeMdPath = path34.join(root, "CLAUDE.md");
4880
+ const rootClaudeMdPath = path35.join(root, "CLAUDE.md");
4373
4881
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
4374
4882
  if (!rootClaudeMdContent) {
4375
4883
  flag(
@@ -4397,7 +4905,7 @@ async function runDoctor(options) {
4397
4905
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
4398
4906
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
4399
4907
  for (const target of importTargets) {
4400
- if (!await fs27.pathExists(hausPath(root, target))) {
4908
+ if (!await fs28.pathExists(hausPath(root, target))) {
4401
4909
  flag(
4402
4910
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
4403
4911
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -4408,7 +4916,7 @@ async function runDoctor(options) {
4408
4916
  }
4409
4917
  }
4410
4918
  const workflowPath = hausPath(root, "WORKFLOW.md");
4411
- const workflowExists = await fs27.pathExists(workflowPath);
4919
+ const workflowExists = await fs28.pathExists(workflowPath);
4412
4920
  if (!workflowExists) {
4413
4921
  flag(
4414
4922
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -4423,15 +4931,15 @@ async function runDoctor(options) {
4423
4931
  } else {
4424
4932
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
4425
4933
  const bodyContent = workflowContent.slice(firstLine.length + 1);
4426
- const cachePath = path34.join(getCacheDir(), "templates/agentic-workflow-standard.md");
4427
- const bundledPath = path34.join(
4934
+ const cachePath = path35.join(getCacheDir(), "templates/agentic-workflow-standard.md");
4935
+ const bundledPath = path35.join(
4428
4936
  packageRoot(),
4429
4937
  "library",
4430
4938
  "global",
4431
4939
  "templates",
4432
4940
  "agentic-workflow-standard.md"
4433
4941
  );
4434
- const templatePath = await fs27.pathExists(cachePath) ? cachePath : bundledPath;
4942
+ const templatePath = await fs28.pathExists(cachePath) ? cachePath : bundledPath;
4435
4943
  const templateContent = await readText(templatePath);
4436
4944
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
4437
4945
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -4452,7 +4960,7 @@ async function runDoctor(options) {
4452
4960
  }
4453
4961
  }
4454
4962
  const workflowConfigPath = hausPath(root, "workflow-config.md");
4455
- const workflowConfigExists = await fs27.pathExists(workflowConfigPath);
4963
+ const workflowConfigExists = await fs28.pathExists(workflowConfigPath);
4456
4964
  if (!workflowConfigExists) {
4457
4965
  flag(
4458
4966
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -4460,7 +4968,7 @@ async function runDoctor(options) {
4460
4968
  "haus apply --write"
4461
4969
  );
4462
4970
  } else {
4463
- const cfg = await fs27.readFile(workflowConfigPath, "utf8");
4971
+ const cfg = await fs28.readFile(workflowConfigPath, "utf8");
4464
4972
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
4465
4973
  if (unfilled > 0) {
4466
4974
  suggest(
@@ -4473,15 +4981,17 @@ async function runDoctor(options) {
4473
4981
  }
4474
4982
  }
4475
4983
  if (workflowExists) {
4476
- const prettierIgnore = await readText(path34.join(root, ".prettierignore")) ?? "";
4477
- if (!prettierIgnore.split("\n").some((l) => l.trim() === ".haus-workflow/")) {
4984
+ const prettierIgnore = await readText(path35.join(root, ".prettierignore")) ?? "";
4985
+ const lines = prettierIgnore.split("\n");
4986
+ const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
4987
+ if (missing.length > 0) {
4478
4988
  flag(
4479
- "- .prettierignore: not protecting .haus-workflow/ (run `haus apply --write`)",
4989
+ `- .prettierignore: not protecting ${missing.join(", ")} (run \`haus apply --write\`)`,
4480
4990
  'The formatter may reformat managed files and trigger false "modified locally" reports',
4481
4991
  "haus apply --write"
4482
4992
  );
4483
4993
  } else {
4484
- ok("- .prettierignore: protects .haus-workflow/");
4994
+ ok(`- .prettierignore: protects ${IGNORED_PATHS.join(" and ")}`);
4485
4995
  }
4486
4996
  }
4487
4997
  for (const finding of await auditDecisionsLayout(root)) {
@@ -4510,9 +5020,9 @@ async function runDoctor(options) {
4510
5020
  if (recommendation) {
4511
5021
  const lock = await readJson(hausPath(root, "haus.lock.json"));
4512
5022
  const recommendedIds = new Set(
4513
- recommendation.recommended.map((r) => r.id)
5023
+ recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
4514
5024
  );
4515
- const orphaned = (lock ?? []).map((entry) => entry.id).filter((id) => typeof id === "string" && !recommendedIds.has(id));
5025
+ const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
4516
5026
  if (orphaned.length > 0) {
4517
5027
  suggest(
4518
5028
  `- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
@@ -4521,7 +5031,7 @@ async function runDoctor(options) {
4521
5031
  );
4522
5032
  }
4523
5033
  }
4524
- const pkgJson = await readJson(path34.join(packageRoot(), "package.json"));
5034
+ const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
4525
5035
  const currentVersion = pkgJson?.version ?? "0.0.0";
4526
5036
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4527
5037
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -4779,8 +5289,8 @@ async function runGuard(kind, _options) {
4779
5289
  }
4780
5290
 
4781
5291
  // src/commands/init.ts
4782
- import path35 from "node:path";
4783
- import fs28 from "fs-extra";
5292
+ import path36 from "node:path";
5293
+ import fs29 from "fs-extra";
4784
5294
 
4785
5295
  // src/recommender/git-signal.ts
4786
5296
  init_exec();
@@ -5283,24 +5793,6 @@ async function runSetupCore(root, opts) {
5283
5793
  return { ...baseResult, hooksOk: hooksOkAfter, written: files };
5284
5794
  }
5285
5795
 
5286
- // src/utils/prompts.ts
5287
- import { stdin as input, stdout as output } from "node:process";
5288
- import readline from "node:readline/promises";
5289
- async function ask(question) {
5290
- const rl = readline.createInterface({ input, output });
5291
- try {
5292
- const answer = await rl.question(`${question}
5293
- > `);
5294
- return answer.trim();
5295
- } finally {
5296
- rl.close();
5297
- }
5298
- }
5299
- async function confirm(question) {
5300
- const answer = (await ask(`${question} [y/N]`)).toLowerCase();
5301
- return answer === "y" || answer === "yes";
5302
- }
5303
-
5304
5796
  // src/commands/setup-project.ts
5305
5797
  async function runSetupProject(options) {
5306
5798
  const root = process.cwd();
@@ -5315,8 +5807,8 @@ async function runSetupProject(options) {
5315
5807
  // src/commands/init.ts
5316
5808
  async function runInit(options) {
5317
5809
  const root = process.cwd();
5318
- const hausDir = path35.join(root, ".haus-workflow");
5319
- const alreadyInit = await fs28.pathExists(hausDir);
5810
+ const hausDir = path36.join(root, ".haus-workflow");
5811
+ const alreadyInit = await fs29.pathExists(hausDir);
5320
5812
  if (alreadyInit) {
5321
5813
  log("Haus AI already initialized in this project.");
5322
5814
  log("Run `haus setup-project` to reconfigure.");
@@ -5328,8 +5820,8 @@ async function runInit(options) {
5328
5820
 
5329
5821
  // src/install/apply.ts
5330
5822
  import crypto2 from "node:crypto";
5331
- import path36 from "node:path";
5332
- import fs29 from "fs-extra";
5823
+ import path37 from "node:path";
5824
+ import fs30 from "fs-extra";
5333
5825
 
5334
5826
  // src/install/header.ts
5335
5827
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -5415,57 +5907,57 @@ function hashContent(content2) {
5415
5907
  }
5416
5908
  function sourceVersion() {
5417
5909
  try {
5418
- const pkgPath = path36.join(packageRoot(), "package.json");
5419
- const pkg = JSON.parse(fs29.readFileSync(pkgPath, "utf8"));
5910
+ const pkgPath = path37.join(packageRoot(), "package.json");
5911
+ const pkg = JSON.parse(fs30.readFileSync(pkgPath, "utf8"));
5420
5912
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
5421
5913
  } catch {
5422
5914
  return "haus@0.0.0";
5423
5915
  }
5424
5916
  }
5425
5917
  function globalSrcDir() {
5426
- return path36.join(packageRoot(), "library", "global");
5918
+ return path37.join(packageRoot(), "library", "global");
5427
5919
  }
5428
5920
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
5429
- const skillDir = path36.join(skillsDir, skillName);
5921
+ const skillDir = path37.join(skillsDir, skillName);
5430
5922
  const entries = [];
5431
- for (const rel of fs29.readdirSync(skillDir, { recursive: true })) {
5923
+ for (const rel of fs30.readdirSync(skillDir, { recursive: true })) {
5432
5924
  if (rel === "SKILL.md") continue;
5433
- const abs = path36.join(skillDir, rel);
5434
- if (!fs29.statSync(abs).isFile()) continue;
5435
- const posixRel = rel.split(path36.sep).join("/");
5925
+ const abs = path37.join(skillDir, rel);
5926
+ if (!fs30.statSync(abs).isFile()) continue;
5927
+ const posixRel = rel.split(path37.sep).join("/");
5436
5928
  entries.push({
5437
5929
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
5438
- srcRelPath: path36.join("library", "global", "skills", skillName, rel),
5439
- destPath: path36.join(claudeDir, "skills", skillName, rel)
5930
+ srcRelPath: path37.join("library", "global", "skills", skillName, rel),
5931
+ destPath: path37.join(claudeDir, "skills", skillName, rel)
5440
5932
  });
5441
5933
  }
5442
5934
  return entries;
5443
5935
  }
5444
5936
  function collectSourceFiles(srcDir, claudeDir) {
5445
5937
  const entries = [];
5446
- const skillsDir = path36.join(srcDir, "skills");
5447
- if (fs29.pathExistsSync(skillsDir)) {
5448
- for (const skillName of fs29.readdirSync(skillsDir)) {
5449
- const skillFile = path36.join(skillsDir, skillName, "SKILL.md");
5450
- if (fs29.pathExistsSync(skillFile)) {
5938
+ const skillsDir = path37.join(srcDir, "skills");
5939
+ if (fs30.pathExistsSync(skillsDir)) {
5940
+ for (const skillName of fs30.readdirSync(skillsDir)) {
5941
+ const skillFile = path37.join(skillsDir, skillName, "SKILL.md");
5942
+ if (fs30.pathExistsSync(skillFile)) {
5451
5943
  entries.push({
5452
5944
  stableId: `skill.${skillName}`,
5453
- srcRelPath: path36.join("library", "global", "skills", skillName, "SKILL.md"),
5454
- destPath: path36.join(claudeDir, "skills", skillName, "SKILL.md")
5945
+ srcRelPath: path37.join("library", "global", "skills", skillName, "SKILL.md"),
5946
+ destPath: path37.join(claudeDir, "skills", skillName, "SKILL.md")
5455
5947
  });
5456
5948
  entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
5457
5949
  }
5458
5950
  }
5459
5951
  }
5460
- const commandsDir = path36.join(srcDir, "commands");
5461
- if (fs29.pathExistsSync(commandsDir)) {
5462
- for (const fileName of fs29.readdirSync(commandsDir)) {
5952
+ const commandsDir = path37.join(srcDir, "commands");
5953
+ if (fs30.pathExistsSync(commandsDir)) {
5954
+ for (const fileName of fs30.readdirSync(commandsDir)) {
5463
5955
  if (!fileName.endsWith(".md")) continue;
5464
5956
  const commandName = fileName.slice(0, -".md".length);
5465
5957
  entries.push({
5466
5958
  stableId: `command.${commandName}`,
5467
- srcRelPath: path36.join("library", "global", "commands", fileName),
5468
- destPath: path36.join(claudeDir, "commands", fileName)
5959
+ srcRelPath: path37.join("library", "global", "commands", fileName),
5960
+ destPath: path37.join(claudeDir, "commands", fileName)
5469
5961
  });
5470
5962
  }
5471
5963
  }
@@ -5491,7 +5983,7 @@ async function applyInstall(options = {}) {
5491
5983
  };
5492
5984
  const manifestFiles = [];
5493
5985
  for (const entry of sourceFiles) {
5494
- const srcPath = path36.join(packageRoot(), entry.srcRelPath);
5986
+ const srcPath = path37.join(packageRoot(), entry.srcRelPath);
5495
5987
  const rawContent = await readText(srcPath);
5496
5988
  if (rawContent === void 0) {
5497
5989
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -5511,7 +6003,7 @@ async function applyInstall(options = {}) {
5511
6003
  }
5512
6004
  continue;
5513
6005
  }
5514
- const destExists = fs29.pathExistsSync(entry.destPath);
6006
+ const destExists = fs30.pathExistsSync(entry.destPath);
5515
6007
  if (destExists) {
5516
6008
  const currentContent = await readText(entry.destPath);
5517
6009
  if (currentContent !== void 0) {
@@ -5551,7 +6043,7 @@ async function applyInstall(options = {}) {
5551
6043
  }
5552
6044
  let mergedSettings;
5553
6045
  if (!check) {
5554
- const fragmentPath = path36.join(srcDir, "settings-fragments", "hooks.json");
6046
+ const fragmentPath = path37.join(srcDir, "settings-fragments", "hooks.json");
5555
6047
  const fragments = await loadHooksFragment(fragmentPath);
5556
6048
  const settings = await readSettings();
5557
6049
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -5565,13 +6057,13 @@ async function applyInstall(options = {}) {
5565
6057
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
5566
6058
  for (const entry of existingManifest.files) {
5567
6059
  if (currentDestPaths.has(entry.destPath)) continue;
5568
- if (!fs29.pathExistsSync(entry.destPath)) continue;
6060
+ if (!fs30.pathExistsSync(entry.destPath)) continue;
5569
6061
  const content2 = await readText(entry.destPath);
5570
6062
  if (!content2) continue;
5571
6063
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
5572
6064
  const currentHash = hashContent(content2);
5573
6065
  if (hasHeader && currentHash === entry.hash) {
5574
- if (!dryRun) await fs29.remove(entry.destPath);
6066
+ if (!dryRun) await fs30.remove(entry.destPath);
5575
6067
  result.deleted.push(entry.destPath);
5576
6068
  } else {
5577
6069
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -5740,8 +6232,8 @@ async function runRefresh() {
5740
6232
  }
5741
6233
 
5742
6234
  // src/install/scaffold.ts
5743
- import path37 from "node:path";
5744
- import fs30 from "fs-extra";
6235
+ import path38 from "node:path";
6236
+ import fs31 from "fs-extra";
5745
6237
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5746
6238
  const scaffolded = [];
5747
6239
  const skipped = [];
@@ -5754,27 +6246,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5754
6246
  skipped.push(name);
5755
6247
  if (outcome === "skipped-exists") skippedExisting.push(name);
5756
6248
  };
5757
- const realRoot = await fs30.realpath(catalogRoot).catch(() => null);
6249
+ const realRoot = await fs31.realpath(catalogRoot).catch(() => null);
5758
6250
  for (const item of items) {
5759
6251
  if (item.type !== "config") continue;
5760
- const sourcePath = path37.resolve(catalogRoot, item.path);
5761
- const rel = path37.relative(catalogRoot, sourcePath);
5762
- const escapes = rel === "" || path37.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
6252
+ const sourcePath = path38.resolve(catalogRoot, item.path);
6253
+ const rel = path38.relative(catalogRoot, sourcePath);
6254
+ const escapes = rel === "" || path38.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
5763
6255
  if (escapes) {
5764
6256
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
5765
6257
  continue;
5766
6258
  }
5767
- const realSource = await fs30.realpath(sourcePath).catch(() => null);
6259
+ const realSource = await fs31.realpath(sourcePath).catch(() => null);
5768
6260
  if (!realRoot || !realSource) {
5769
6261
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
5770
6262
  continue;
5771
6263
  }
5772
- const realRel = path37.relative(realRoot, realSource);
5773
- if (realRel !== "" && (path37.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
6264
+ const realRel = path38.relative(realRoot, realSource);
6265
+ if (realRel !== "" && (path38.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
5774
6266
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
5775
6267
  continue;
5776
6268
  }
5777
- const stat = await fs30.lstat(sourcePath).catch(() => null);
6269
+ const stat = await fs31.lstat(sourcePath).catch(() => null);
5778
6270
  if (!stat) {
5779
6271
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
5780
6272
  continue;
@@ -5784,14 +6276,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5784
6276
  continue;
5785
6277
  }
5786
6278
  if (stat.isFile()) {
5787
- const filename = path37.basename(sourcePath);
5788
- const dest = path37.join(projectRoot, filename);
6279
+ const filename = path38.basename(sourcePath);
6280
+ const dest = path38.join(projectRoot, filename);
5789
6281
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
5790
6282
  } else if (stat.isDirectory()) {
5791
- const entries = await fs30.readdir(sourcePath);
6283
+ const entries = await fs31.readdir(sourcePath);
5792
6284
  for (const entry of entries) {
5793
- const src = path37.join(sourcePath, entry);
5794
- const dest = path37.join(projectRoot, entry);
6285
+ const src = path38.join(sourcePath, entry);
6286
+ const dest = path38.join(projectRoot, entry);
5795
6287
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
5796
6288
  }
5797
6289
  }
@@ -5799,42 +6291,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5799
6291
  return { scaffolded, skipped, skippedExisting };
5800
6292
  }
5801
6293
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
5802
- const destDisplay = path37.relative(projectRoot, dest) || path37.basename(dest);
5803
- const srcStat = await fs30.lstat(src).catch(() => null);
6294
+ const destDisplay = path38.relative(projectRoot, dest) || path38.basename(dest);
6295
+ const srcStat = await fs31.lstat(src).catch(() => null);
5804
6296
  if (!srcStat) {
5805
- warn(`Skipping ${path37.basename(src)}: source not found`);
6297
+ warn(`Skipping ${path38.basename(src)}: source not found`);
5806
6298
  return "skipped-refused";
5807
6299
  }
5808
6300
  if (srcStat.isSymbolicLink()) {
5809
- warn(`Skipping ${path37.basename(src)}: source is a symlink`);
6301
+ warn(`Skipping ${path38.basename(src)}: source is a symlink`);
5810
6302
  return "skipped-refused";
5811
6303
  }
5812
- const exists = await fs30.pathExists(dest);
6304
+ const exists = await fs31.pathExists(dest);
5813
6305
  if (exists && !opts.force) {
5814
6306
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
5815
6307
  return "skipped-exists";
5816
6308
  }
5817
6309
  if (opts.dryRun) {
5818
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path37.basename(dest)} (${itemId})`);
6310
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path38.basename(dest)} (${itemId})`);
5819
6311
  return "scaffolded";
5820
6312
  }
5821
6313
  if (exists && opts.force) {
5822
- await fs30.remove(dest);
6314
+ await fs31.remove(dest);
5823
6315
  }
5824
- await fs30.ensureDir(path37.dirname(dest));
5825
- await fs30.copy(src, dest, {
6316
+ await fs31.ensureDir(path38.dirname(dest));
6317
+ await fs31.copy(src, dest, {
5826
6318
  overwrite: true,
5827
6319
  dereference: false,
5828
6320
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
5829
6321
  filter: (s) => {
5830
6322
  try {
5831
- return !fs30.lstatSync(s).isSymbolicLink();
6323
+ return !fs31.lstatSync(s).isSymbolicLink();
5832
6324
  } catch {
5833
6325
  return false;
5834
6326
  }
5835
6327
  }
5836
6328
  });
5837
- log(`${exists ? "Overwrote" : "Created"} ${path37.basename(dest)} (${itemId})`);
6329
+ log(`${exists ? "Overwrote" : "Created"} ${path38.basename(dest)} (${itemId})`);
5838
6330
  return "scaffolded";
5839
6331
  }
5840
6332
 
@@ -5891,8 +6383,8 @@ async function runScan(options) {
5891
6383
  }
5892
6384
 
5893
6385
  // src/commands/undo.ts
5894
- import path38 from "node:path";
5895
- import fs31 from "fs-extra";
6386
+ import path39 from "node:path";
6387
+ import fs32 from "fs-extra";
5896
6388
 
5897
6389
  // src/claude/managed-paths.ts
5898
6390
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
@@ -5907,7 +6399,7 @@ function coreManagedAbsolutePaths(root) {
5907
6399
  async function collectCoreManagedPaths(root) {
5908
6400
  const existing = [];
5909
6401
  for (const abs of coreManagedAbsolutePaths(root)) {
5910
- if (await fs31.pathExists(abs)) existing.push(abs);
6402
+ if (await fs32.pathExists(abs)) existing.push(abs);
5911
6403
  }
5912
6404
  return existing;
5913
6405
  }
@@ -5919,18 +6411,18 @@ async function collectLockTrackedPaths(root) {
5919
6411
  const relPaths = row.paths ?? [];
5920
6412
  const existingRel = [];
5921
6413
  for (const rel of relPaths) {
5922
- if (await fs31.pathExists(path38.resolve(root, rel))) existingRel.push(rel);
6414
+ if (await fs32.pathExists(path39.resolve(root, rel))) existingRel.push(rel);
5923
6415
  }
5924
6416
  if (existingRel.length === 0) continue;
5925
6417
  if (row.hash === void 0) {
5926
- removable.push(...existingRel.map((rel) => path38.resolve(root, rel)));
6418
+ removable.push(...existingRel.map((rel) => path39.resolve(root, rel)));
5927
6419
  continue;
5928
6420
  }
5929
6421
  const currentHash = await hashInstalledPaths(root, existingRel);
5930
6422
  if (currentHash === row.hash) {
5931
- removable.push(...existingRel.map((rel) => path38.resolve(root, rel)));
6423
+ removable.push(...existingRel.map((rel) => path39.resolve(root, rel)));
5932
6424
  } else {
5933
- preserved.push(...existingRel.map((rel) => path38.resolve(root, rel)));
6425
+ preserved.push(...existingRel.map((rel) => path39.resolve(root, rel)));
5934
6426
  log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
5935
6427
  }
5936
6428
  }
@@ -5938,51 +6430,51 @@ async function collectLockTrackedPaths(root) {
5938
6430
  }
5939
6431
  async function settingsHasHausContent(root) {
5940
6432
  const settingsPath = claudePath(root, "settings.json");
5941
- if (!await fs31.pathExists(settingsPath)) return false;
6433
+ if (!await fs32.pathExists(settingsPath)) return false;
5942
6434
  const settings = await readProjectSettings(root);
5943
6435
  return settings._haus != null;
5944
6436
  }
5945
6437
  async function claudeMdHasHausBlock(root) {
5946
- const filePath = path38.join(root, "CLAUDE.md");
5947
- if (!await fs31.pathExists(filePath)) return false;
5948
- const text = await fs31.readFile(filePath, "utf8");
6438
+ const filePath = path39.join(root, "CLAUDE.md");
6439
+ if (!await fs32.pathExists(filePath)) return false;
6440
+ const text = await fs32.readFile(filePath, "utf8");
5949
6441
  return text.includes(BLOCK_BEGIN);
5950
6442
  }
5951
6443
  async function stripProjectSettings(root) {
5952
6444
  const settingsPath = claudePath(root, "settings.json");
5953
- if (!await fs31.pathExists(settingsPath)) return false;
6445
+ if (!await fs32.pathExists(settingsPath)) return false;
5954
6446
  let settings = await readProjectSettings(root);
5955
6447
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
5956
6448
  const hasContent = Object.keys(settings).length > 0;
5957
6449
  if (hasContent) {
5958
6450
  await writeProjectSettings(root, settings);
5959
- log(`Stripped haus rules from ${path38.relative(root, settingsPath)} (user settings preserved).`);
6451
+ log(`Stripped haus rules from ${path39.relative(root, settingsPath)} (user settings preserved).`);
5960
6452
  return true;
5961
6453
  }
5962
- await fs31.remove(settingsPath);
5963
- log(`Removed ${path38.relative(root, settingsPath)} (no user-owned settings remained).`);
6454
+ await fs32.remove(settingsPath);
6455
+ log(`Removed ${path39.relative(root, settingsPath)} (no user-owned settings remained).`);
5964
6456
  return true;
5965
6457
  }
5966
6458
  async function stripRootClaudeMd(root) {
5967
- const filePath = path38.join(root, "CLAUDE.md");
5968
- if (!await fs31.pathExists(filePath)) return false;
5969
- const prev = await fs31.readFile(filePath, "utf8");
6459
+ const filePath = path39.join(root, "CLAUDE.md");
6460
+ if (!await fs32.pathExists(filePath)) return false;
6461
+ const prev = await fs32.readFile(filePath, "utf8");
5970
6462
  if (!prev.includes(BLOCK_BEGIN)) return false;
5971
6463
  const next = stripHausBlock(prev);
5972
6464
  if (next.length === 0) {
5973
- await fs31.remove(filePath);
6465
+ await fs32.remove(filePath);
5974
6466
  log("Removed CLAUDE.md (only contained haus import block).");
5975
6467
  } else {
5976
- await fs31.writeFile(filePath, next, "utf8");
6468
+ await fs32.writeFile(filePath, next, "utf8");
5977
6469
  log("Removed haus import block from CLAUDE.md (user content preserved).");
5978
6470
  }
5979
6471
  return true;
5980
6472
  }
5981
6473
  async function pruneDirIfEmpty(dir) {
5982
- if (!await fs31.pathExists(dir)) return;
6474
+ if (!await fs32.pathExists(dir)) return;
5983
6475
  try {
5984
- const entries = await fs31.readdir(dir);
5985
- if (entries.length === 0) await fs31.remove(dir);
6476
+ const entries = await fs32.readdir(dir);
6477
+ if (entries.length === 0) await fs32.remove(dir);
5986
6478
  } catch {
5987
6479
  }
5988
6480
  }
@@ -5991,13 +6483,13 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
5991
6483
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5992
6484
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
5993
6485
  for (const abs of managedAbsPaths) {
5994
- if (!await fs31.pathExists(abs)) continue;
5995
- const rel = path38.relative(root, abs);
5996
- const backupPath = path38.join(backupRoot, rel);
5997
- await fs31.ensureDir(path38.dirname(backupPath));
5998
- await fs31.copy(abs, backupPath);
6486
+ if (!await fs32.pathExists(abs)) continue;
6487
+ const rel = path39.relative(root, abs);
6488
+ const backupPath = path39.join(backupRoot, rel);
6489
+ await fs32.ensureDir(path39.dirname(backupPath));
6490
+ await fs32.copy(abs, backupPath);
5999
6491
  }
6000
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path38.relative(root, backupRoot)}.`);
6492
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path39.relative(root, backupRoot)}.`);
6001
6493
  }
6002
6494
  async function runUndo(options) {
6003
6495
  const root = options.root ?? process.cwd();
@@ -6010,7 +6502,7 @@ async function runUndo(options) {
6010
6502
  log("Nothing to remove: no haus-managed files found in this directory.");
6011
6503
  return;
6012
6504
  }
6013
- const relTargets = managed.map((p) => path38.relative(root, p));
6505
+ const relTargets = managed.map((p) => path39.relative(root, p));
6014
6506
  const summaryParts = [...relTargets];
6015
6507
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
6016
6508
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
@@ -6032,9 +6524,9 @@ User-owned .claude/ files will be preserved.`
6032
6524
  }
6033
6525
  await backupManagedFilesBeforeUndo(root, managed);
6034
6526
  for (const abs of managed) {
6035
- if (!await fs31.pathExists(abs)) continue;
6036
- await fs31.remove(abs);
6037
- log(`Removed ${path38.relative(root, abs)}`);
6527
+ if (!await fs32.pathExists(abs)) continue;
6528
+ await fs32.remove(abs);
6529
+ log(`Removed ${path39.relative(root, abs)}`);
6038
6530
  }
6039
6531
  if (stripSettings) await stripProjectSettings(root);
6040
6532
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -6045,8 +6537,8 @@ User-owned .claude/ files will be preserved.`
6045
6537
 
6046
6538
  // src/install/uninstall.ts
6047
6539
  import crypto3 from "node:crypto";
6048
- import path39 from "node:path";
6049
- import fs32 from "fs-extra";
6540
+ import path40 from "node:path";
6541
+ import fs33 from "fs-extra";
6050
6542
  async function runUninstall(options = {}) {
6051
6543
  const { force = false } = options;
6052
6544
  const manifest = await readManifest();
@@ -6056,7 +6548,7 @@ async function runUninstall(options = {}) {
6056
6548
  return result;
6057
6549
  }
6058
6550
  for (const entry of manifest.files) {
6059
- const exists = fs32.pathExistsSync(entry.destPath);
6551
+ const exists = fs33.pathExistsSync(entry.destPath);
6060
6552
  if (!exists) continue;
6061
6553
  const content2 = await readText(entry.destPath);
6062
6554
  if (content2 === void 0) continue;
@@ -6074,23 +6566,23 @@ async function runUninstall(options = {}) {
6074
6566
  result.skipped.push(entry.destPath);
6075
6567
  continue;
6076
6568
  }
6077
- await fs32.remove(entry.destPath);
6078
- await pruneEmptyDir(path39.dirname(entry.destPath));
6569
+ await fs33.remove(entry.destPath);
6570
+ await pruneEmptyDir(path40.dirname(entry.destPath));
6079
6571
  result.deleted.push(entry.destPath);
6080
6572
  }
6081
6573
  const settings = await readSettings();
6082
6574
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
6083
6575
  await writeSettings(stripped);
6084
6576
  result.hooksStripped = true;
6085
- const hausDir = path39.join(globalClaudeDir(), "haus");
6577
+ const hausDir = path40.join(globalClaudeDir(), "haus");
6086
6578
  const manifestPath2 = hausManifestPath();
6087
- if (fs32.pathExistsSync(manifestPath2)) {
6088
- await fs32.remove(manifestPath2);
6579
+ if (fs33.pathExistsSync(manifestPath2)) {
6580
+ await fs33.remove(manifestPath2);
6089
6581
  }
6090
- if (fs32.pathExistsSync(hausDir)) {
6582
+ if (fs33.pathExistsSync(hausDir)) {
6091
6583
  try {
6092
- const remaining = await fs32.readdir(hausDir);
6093
- if (remaining.length === 0) await fs32.remove(hausDir);
6584
+ const remaining = await fs33.readdir(hausDir);
6585
+ if (remaining.length === 0) await fs33.remove(hausDir);
6094
6586
  } catch {
6095
6587
  }
6096
6588
  }
@@ -6123,12 +6615,12 @@ async function runUninstallCommand(options) {
6123
6615
  }
6124
6616
 
6125
6617
  // src/commands/update.ts
6126
- import path41 from "node:path";
6618
+ import path42 from "node:path";
6127
6619
 
6128
6620
  // src/claude/refresh-project.ts
6129
- import fs33 from "fs-extra";
6621
+ import fs34 from "fs-extra";
6130
6622
  async function isHausProject(root) {
6131
- if (await fs33.pathExists(hausPath(root, "recommendation.json"))) return true;
6623
+ if (await fs34.pathExists(hausPath(root, "recommendation.json"))) return true;
6132
6624
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
6133
6625
  if (result.status === "ok") return result.value._haus != null;
6134
6626
  return false;
@@ -6163,7 +6655,7 @@ function summarizeLockDiff(before, after) {
6163
6655
 
6164
6656
  // src/update/lockfile.ts
6165
6657
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
6166
- import path40 from "node:path";
6658
+ import path41 from "node:path";
6167
6659
  async function readLockSummary(root) {
6168
6660
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
6169
6661
  const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
@@ -6199,7 +6691,7 @@ async function applyLock(root) {
6199
6691
  try {
6200
6692
  const backupDir = hausPath(root, "backups");
6201
6693
  await mkdir(backupDir, { recursive: true });
6202
- await copyFile(lockPath, path40.join(backupDir, `haus.lock.${Date.now()}.json`));
6694
+ await copyFile(lockPath, path41.join(backupDir, `haus.lock.${Date.now()}.json`));
6203
6695
  } catch {
6204
6696
  }
6205
6697
  const enriched = await Promise.all(
@@ -6221,7 +6713,7 @@ function diffLock(before, after) {
6221
6713
  }
6222
6714
  async function hasLocalOverrides(root) {
6223
6715
  try {
6224
- await readFile2(path40.join(root, ".claude", "settings.json"), "utf8");
6716
+ await readFile2(path41.join(root, ".claude", "settings.json"), "utf8");
6225
6717
  return true;
6226
6718
  } catch {
6227
6719
  return false;
@@ -6237,7 +6729,7 @@ async function runUpdate(options) {
6237
6729
  return;
6238
6730
  }
6239
6731
  if (options.check) {
6240
- const pkgJson2 = await readJson(path41.join(packageRoot(), "package.json"));
6732
+ const pkgJson2 = await readJson(path42.join(packageRoot(), "package.json"));
6241
6733
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
6242
6734
  const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
6243
6735
  options.fast ? fastLockStatus(root) : checkLock(root),
@@ -6272,7 +6764,7 @@ async function runUpdate(options) {
6272
6764
  if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
6273
6765
  return;
6274
6766
  }
6275
- const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6767
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6276
6768
  const currentVersion = pkgJson?.version ?? "0.0.0";
6277
6769
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
6278
6770
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -6311,7 +6803,7 @@ async function runFromHookCheck(root) {
6311
6803
  try {
6312
6804
  const summary = await readLockSummary(root);
6313
6805
  if (summary.count === 0) return;
6314
- const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6806
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6315
6807
  const currentVersion = pkgJson?.version ?? "0.0.0";
6316
6808
  const [npmVersion, latestCatalogTag] = await Promise.all([
6317
6809
  fetchNpmVersionStatus(currentVersion),
@@ -6383,11 +6875,11 @@ async function fastLockStatus(root) {
6383
6875
  }
6384
6876
 
6385
6877
  // src/commands/validate-catalog.ts
6386
- import path43 from "node:path";
6878
+ import path44 from "node:path";
6387
6879
 
6388
6880
  // src/catalog/validate-core.ts
6389
- import fs34 from "node:fs";
6390
- import path42 from "node:path";
6881
+ import fs35 from "node:fs";
6882
+ import path43 from "node:path";
6391
6883
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
6392
6884
  function auditForbiddenStacks(items) {
6393
6885
  const failures = [];
@@ -6497,14 +6989,14 @@ function checkRequiredFrontmatter(text, label) {
6497
6989
  }
6498
6990
  function readForAudit(absPath, label, failures) {
6499
6991
  try {
6500
- return fs34.readFileSync(absPath, "utf8");
6992
+ return fs35.readFileSync(absPath, "utf8");
6501
6993
  } catch (err) {
6502
6994
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
6503
6995
  return null;
6504
6996
  }
6505
6997
  }
6506
6998
  function auditTemplateContent(manifestDir, absPath, itemId) {
6507
- const rel = path42.relative(manifestDir, absPath);
6999
+ const rel = path43.relative(manifestDir, absPath);
6508
7000
  const failures = [];
6509
7001
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
6510
7002
  if (text === null) return failures;
@@ -6526,47 +7018,47 @@ function auditShippedFiles(manifestDir, items) {
6526
7018
  const failures = [];
6527
7019
  for (const item of items) {
6528
7020
  if (!item.path || !isSafeCatalogPath(item.path)) continue;
6529
- const absPath = path42.join(manifestDir, item.path);
7021
+ const absPath = path43.join(manifestDir, item.path);
6530
7022
  if (item.type === "skill") {
6531
- const skillMd = path42.join(absPath, "SKILL.md");
6532
- if (!fs34.existsSync(skillMd)) {
6533
- failures.push(`${item.id}: missing ${path42.relative(manifestDir, skillMd)}`);
7023
+ const skillMd = path43.join(absPath, "SKILL.md");
7024
+ if (!fs35.existsSync(skillMd)) {
7025
+ failures.push(`${item.id}: missing ${path43.relative(manifestDir, skillMd)}`);
6534
7026
  continue;
6535
7027
  }
6536
7028
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
6537
7029
  if (text === null) continue;
6538
7030
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
6539
7031
  failures.push(
6540
- ...auditForbiddenTagsInText(text, `${item.id}: ${path42.relative(manifestDir, skillMd)}`)
7032
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path43.relative(manifestDir, skillMd)}`)
6541
7033
  );
6542
7034
  } else if (item.type === "agent") {
6543
- if (!fs34.existsSync(absPath)) {
7035
+ if (!fs35.existsSync(absPath)) {
6544
7036
  failures.push(`${item.id}: missing agent file ${item.path}`);
6545
7037
  continue;
6546
7038
  }
6547
- const rel = path42.relative(manifestDir, absPath);
7039
+ const rel = path43.relative(manifestDir, absPath);
6548
7040
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6549
7041
  if (text === null) continue;
6550
7042
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
6551
7043
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
6552
7044
  } else if (item.type === "template") {
6553
- if (!fs34.existsSync(absPath)) {
7045
+ if (!fs35.existsSync(absPath)) {
6554
7046
  failures.push(`${item.id}: missing template file ${item.path}`);
6555
7047
  continue;
6556
7048
  }
6557
7049
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
6558
7050
  } else if (item.type === "command") {
6559
- if (!fs34.existsSync(absPath)) {
7051
+ if (!fs35.existsSync(absPath)) {
6560
7052
  failures.push(`${item.id}: missing command file ${item.path}`);
6561
7053
  continue;
6562
7054
  }
6563
- const rel = path42.relative(manifestDir, absPath);
7055
+ const rel = path43.relative(manifestDir, absPath);
6564
7056
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6565
7057
  if (text === null) continue;
6566
7058
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
6567
7059
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
6568
7060
  } else if (item.type === "config") {
6569
- if (!fs34.existsSync(absPath)) {
7061
+ if (!fs35.existsSync(absPath)) {
6570
7062
  failures.push(`${item.id}: missing config path ${item.path}`);
6571
7063
  }
6572
7064
  }
@@ -6582,7 +7074,7 @@ var DIR_ITEM_TYPE = {
6582
7074
  function walkMd(dir, fn, failures) {
6583
7075
  let entries;
6584
7076
  try {
6585
- entries = fs34.readdirSync(dir, { withFileTypes: true });
7077
+ entries = fs35.readdirSync(dir, { withFileTypes: true });
6586
7078
  } catch (err) {
6587
7079
  failures.push(
6588
7080
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -6590,7 +7082,7 @@ function walkMd(dir, fn, failures) {
6590
7082
  return;
6591
7083
  }
6592
7084
  for (const entry of entries) {
6593
- const full = path42.join(dir, entry.name);
7085
+ const full = path43.join(dir, entry.name);
6594
7086
  if (entry.isDirectory()) walkMd(full, fn, failures);
6595
7087
  else if (entry.name.endsWith(".md")) fn(full);
6596
7088
  }
@@ -6599,13 +7091,13 @@ function auditMarkdownContent(manifestDir, items) {
6599
7091
  const failures = [];
6600
7092
  const pathSourceMap = buildItemPathSourceMap(items);
6601
7093
  for (const dir of ["skills", "agents", "templates", "commands"]) {
6602
- const abs = path42.join(manifestDir, dir);
6603
- if (!fs34.existsSync(abs)) continue;
7094
+ const abs = path43.join(manifestDir, dir);
7095
+ if (!fs35.existsSync(abs)) continue;
6604
7096
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
6605
7097
  walkMd(
6606
7098
  abs,
6607
7099
  (file) => {
6608
- const rel = path42.relative(manifestDir, file);
7100
+ const rel = path43.relative(manifestDir, file);
6609
7101
  const text = readForAudit(file, rel, failures);
6610
7102
  if (text === null) return;
6611
7103
  const norm = rel.replace(/\\/g, "/");
@@ -6647,8 +7139,8 @@ async function runValidateCatalog(manifestPath2) {
6647
7139
  process.exitCode = 1;
6648
7140
  return;
6649
7141
  }
6650
- const abs = path43.resolve(process.cwd(), manifestPath2);
6651
- const manifestDir = path43.dirname(abs);
7142
+ const abs = path44.resolve(process.cwd(), manifestPath2);
7143
+ const manifestDir = path44.dirname(abs);
6652
7144
  const data = await readJson(abs);
6653
7145
  if (!data?.items) {
6654
7146
  error(`Could not read catalog manifest at ${abs}`);
@@ -6666,7 +7158,7 @@ async function runValidateCatalog(manifestPath2) {
6666
7158
 
6667
7159
  // src/commands/workspace.ts
6668
7160
  import { existsSync as existsSync6, statSync as statSync3 } from "node:fs";
6669
- import path51 from "node:path";
7161
+ import path52 from "node:path";
6670
7162
 
6671
7163
  // src/commands/workspace/aggregate.ts
6672
7164
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -6720,7 +7212,7 @@ ${summaries.map(
6720
7212
  }
6721
7213
 
6722
7214
  // src/commands/workspace/config.ts
6723
- import path44 from "node:path";
7215
+ import path45 from "node:path";
6724
7216
  import YAML from "yaml";
6725
7217
  var WORKSPACE_FILE = "haus.workspace.yaml";
6726
7218
  function parseWorkspaceConfig(text) {
@@ -6743,11 +7235,11 @@ function parseWorkspaceConfig(text) {
6743
7235
  };
6744
7236
  }
6745
7237
  async function readWorkspaceConfig(workspaceRoot) {
6746
- return parseWorkspaceConfig(await readText(path44.join(workspaceRoot, WORKSPACE_FILE)));
7238
+ return parseWorkspaceConfig(await readText(path45.join(workspaceRoot, WORKSPACE_FILE)));
6747
7239
  }
6748
7240
 
6749
7241
  // src/commands/workspace/discover.ts
6750
- import path45 from "node:path";
7242
+ import path46 from "node:path";
6751
7243
  import fg4 from "fast-glob";
6752
7244
  import YAML2 from "yaml";
6753
7245
  var DEFAULT_MAX_DEPTH = 3;
@@ -6778,8 +7270,8 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
6778
7270
  const gitDirs = /* @__PURE__ */ new Set();
6779
7271
  const manifestDirs = /* @__PURE__ */ new Set();
6780
7272
  for (const match of matches) {
6781
- const base = path45.posix.basename(match);
6782
- const dir = path45.posix.dirname(match);
7273
+ const base = path46.posix.basename(match);
7274
+ const dir = path46.posix.dirname(match);
6783
7275
  const owner = dir === "." ? "." : dir;
6784
7276
  if (base === ".git") gitDirs.add(owner);
6785
7277
  else manifestDirs.add(owner);
@@ -6795,9 +7287,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
6795
7287
  }
6796
7288
  repoRoots.sort((a, b) => a.localeCompare(b));
6797
7289
  return mapWithConcurrency(repoRoots, async (relDir) => {
6798
- const absDir = path45.resolve(workspaceRoot, relDir);
6799
- const pkg = await readJson(path45.join(absDir, "package.json"));
6800
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path45.basename(relDir === "." ? workspaceRoot : absDir);
7290
+ const absDir = path46.resolve(workspaceRoot, relDir);
7291
+ const pkg = await readJson(path46.join(absDir, "package.json"));
7292
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path46.basename(relDir === "." ? workspaceRoot : absDir);
6801
7293
  let role = "auto";
6802
7294
  try {
6803
7295
  const scan = await scanProject(absDir);
@@ -6840,7 +7332,7 @@ function renderWorkspaceYaml(config) {
6840
7332
  });
6841
7333
  }
6842
7334
  async function runDiscover(workspaceRoot, opts = {}) {
6843
- const yamlPath = path45.join(workspaceRoot, "haus.workspace.yaml");
7335
+ const yamlPath = path46.join(workspaceRoot, "haus.workspace.yaml");
6844
7336
  const existingText = await readText(yamlPath);
6845
7337
  const existing = parseWorkspaceConfig(existingText);
6846
7338
  if (existingText && !existing) {
@@ -6877,18 +7369,18 @@ async function runDiscover(workspaceRoot, opts = {}) {
6877
7369
 
6878
7370
  // src/commands/workspace/doctor.ts
6879
7371
  import { existsSync as existsSync3 } from "node:fs";
6880
- import path47 from "node:path";
7372
+ import path48 from "node:path";
6881
7373
 
6882
7374
  // src/commands/workspace/manifest.ts
6883
7375
  import { readFileSync as readFileSync3 } from "node:fs";
6884
- import path46 from "node:path";
7376
+ import path47 from "node:path";
6885
7377
  var MANIFEST_FILE = "workspace.manifest.json";
6886
7378
  function manifestPath(workspaceRoot) {
6887
7379
  return hausPath(workspaceRoot, MANIFEST_FILE);
6888
7380
  }
6889
7381
  function hausVersion() {
6890
7382
  try {
6891
- const pkg = JSON.parse(readFileSync3(path46.join(packageRoot(), "package.json"), "utf8"));
7383
+ const pkg = JSON.parse(readFileSync3(path47.join(packageRoot(), "package.json"), "utf8"));
6892
7384
  return pkg.version ?? "0.0.0";
6893
7385
  } catch {
6894
7386
  return "0.0.0";
@@ -6955,7 +7447,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
6955
7447
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
6956
7448
  const catalogRefByRepo = [];
6957
7449
  for (const repo of config.repos) {
6958
- const repoRoot = path47.resolve(workspaceRoot, repo.path);
7450
+ const repoRoot = path48.resolve(workspaceRoot, repo.path);
6959
7451
  const entry = manifestByName.get(repo.name);
6960
7452
  if (!entry) {
6961
7453
  flag({
@@ -7038,11 +7530,11 @@ function emit(args) {
7038
7530
 
7039
7531
  // src/commands/workspace/setup.ts
7040
7532
  import { existsSync as existsSync4, statSync } from "node:fs";
7041
- import path49 from "node:path";
7533
+ import path50 from "node:path";
7042
7534
 
7043
7535
  // src/claude/write-workspace-claude-md.ts
7044
- import path48 from "node:path";
7045
- import fs35 from "fs-extra";
7536
+ import path49 from "node:path";
7537
+ import fs36 from "fs-extra";
7046
7538
  function buildWorkspaceImportBlock(client, members) {
7047
7539
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
7048
7540
  const body = [
@@ -7060,11 +7552,11 @@ ${BLOCK_END}`;
7060
7552
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
7061
7553
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
7062
7554
  const dryRun = opts.dryRun ?? false;
7063
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path48.join(workspaceRoot, "CLAUDE.md");
7555
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path49.join(workspaceRoot, "CLAUDE.md");
7064
7556
  const say = opts.quiet ? () => {
7065
7557
  } : log;
7066
- const existed = await fs35.pathExists(filePath);
7067
- const prev = existed ? await fs35.readFile(filePath, "utf8") : "";
7558
+ const existed = await fs36.pathExists(filePath);
7559
+ const prev = existed ? await fs36.readFile(filePath, "utf8") : "";
7068
7560
  const next = opts.collision ? `${block}
7069
7561
  ` : injectHausBlock(prev, block);
7070
7562
  const printable = displayPath(workspaceRoot, filePath);
@@ -7091,20 +7583,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
7091
7583
 
7092
7584
  // src/commands/workspace/setup.ts
7093
7585
  function resolveWorkspaceRoot(start = process.cwd()) {
7094
- let dir = path49.resolve(start);
7586
+ let dir = path50.resolve(start);
7095
7587
  for (; ; ) {
7096
- if (existsSync4(path49.join(dir, WORKSPACE_FILE))) return dir;
7097
- const parent = path49.dirname(dir);
7098
- if (parent === dir) return path49.resolve(start);
7588
+ if (existsSync4(path50.join(dir, WORKSPACE_FILE))) return dir;
7589
+ const parent = path50.dirname(dir);
7590
+ if (parent === dir) return path50.resolve(start);
7099
7591
  dir = parent;
7100
7592
  }
7101
7593
  }
7102
7594
  function isRootRepo(workspaceRoot, repoPath) {
7103
- return path49.resolve(workspaceRoot, repoPath) === path49.resolve(workspaceRoot);
7595
+ return path50.resolve(workspaceRoot, repoPath) === path50.resolve(workspaceRoot);
7104
7596
  }
7105
7597
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
7106
7598
  const apply = options.write ?? false;
7107
- const configText = await readText(path49.join(workspaceRoot, WORKSPACE_FILE));
7599
+ const configText = await readText(path50.join(workspaceRoot, WORKSPACE_FILE));
7108
7600
  if (!configText) {
7109
7601
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
7110
7602
  process.exitCode = 1;
@@ -7131,7 +7623,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
7131
7623
  const statuses = [];
7132
7624
  const aggregateInputs = [];
7133
7625
  for (const repo of repos) {
7134
- const repoRoot = path49.resolve(workspaceRoot, repo.path);
7626
+ const repoRoot = path50.resolve(workspaceRoot, repo.path);
7135
7627
  say(`
7136
7628
  \u2192 ${repo.name} (${repo.path})`);
7137
7629
  try {
@@ -7202,7 +7694,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
7202
7694
  const status = statusByName.get(repo.name);
7203
7695
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
7204
7696
  if (status?.status === "ok") {
7205
- const lock = await checkLock(path49.resolve(workspaceRoot, repo.path));
7697
+ const lock = await checkLock(path50.resolve(workspaceRoot, repo.path));
7206
7698
  manifestRepos.push({
7207
7699
  name: repo.name,
7208
7700
  path: repo.path,
@@ -7266,8 +7758,8 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
7266
7758
 
7267
7759
  // src/commands/workspace/undo.ts
7268
7760
  import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
7269
- import path50 from "node:path";
7270
- import fs36 from "fs-extra";
7761
+ import path51 from "node:path";
7762
+ import fs37 from "fs-extra";
7271
7763
  var WORKSPACE_AGGREGATE_FILES = [
7272
7764
  "workspace-summary.json",
7273
7765
  "dependency-ownership-map.json",
@@ -7295,7 +7787,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
7295
7787
  }
7296
7788
  let anyRepoFailed = false;
7297
7789
  for (const repo of config.repos) {
7298
- const repoRoot = path50.resolve(workspaceRoot, repo.path);
7790
+ const repoRoot = path51.resolve(workspaceRoot, repo.path);
7299
7791
  log(`
7300
7792
  \u2192 ${repo.name} (${repo.path})`);
7301
7793
  try {
@@ -7310,31 +7802,31 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
7310
7802
  }
7311
7803
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
7312
7804
  const abs = hausPath(workspaceRoot, rel);
7313
- if (await fs36.pathExists(abs)) {
7314
- await fs36.remove(abs);
7315
- log(`Removed ${path50.relative(workspaceRoot, abs)}`);
7805
+ if (await fs37.pathExists(abs)) {
7806
+ await fs37.remove(abs);
7807
+ log(`Removed ${path51.relative(workspaceRoot, abs)}`);
7316
7808
  }
7317
7809
  }
7318
7810
  const manifestFile = manifestPath(workspaceRoot);
7319
- if (await fs36.pathExists(manifestFile)) {
7320
- await fs36.remove(manifestFile);
7321
- log(`Removed ${path50.relative(workspaceRoot, manifestFile)}`);
7811
+ if (await fs37.pathExists(manifestFile)) {
7812
+ await fs37.remove(manifestFile);
7813
+ log(`Removed ${path51.relative(workspaceRoot, manifestFile)}`);
7322
7814
  }
7323
7815
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
7324
- if (await fs36.pathExists(workspaceMdPath)) {
7325
- await fs36.remove(workspaceMdPath);
7326
- log(`Removed ${path50.relative(workspaceRoot, workspaceMdPath)}`);
7816
+ if (await fs37.pathExists(workspaceMdPath)) {
7817
+ await fs37.remove(workspaceMdPath);
7818
+ log(`Removed ${path51.relative(workspaceRoot, workspaceMdPath)}`);
7327
7819
  } else {
7328
- const claudeMdPath = path50.join(workspaceRoot, "CLAUDE.md");
7820
+ const claudeMdPath = path51.join(workspaceRoot, "CLAUDE.md");
7329
7821
  if (existsSync5(claudeMdPath)) {
7330
7822
  const prev = await readText(claudeMdPath);
7331
7823
  if (prev?.includes(BLOCK_BEGIN)) {
7332
7824
  const next = stripHausBlock(prev);
7333
7825
  if (next.length === 0) {
7334
- await fs36.remove(claudeMdPath);
7826
+ await fs37.remove(claudeMdPath);
7335
7827
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
7336
7828
  } else {
7337
- await fs36.writeFile(claudeMdPath, next, "utf8");
7829
+ await fs37.writeFile(claudeMdPath, next, "utf8");
7338
7830
  log("Removed haus import block from CLAUDE.md (user content preserved).");
7339
7831
  }
7340
7832
  }
@@ -7377,7 +7869,7 @@ relationships: []
7377
7869
  log("Workspace initialized.");
7378
7870
  }
7379
7871
  async function scanWorkspace(workspaceRoot, opts) {
7380
- const configText = await readText(path51.join(workspaceRoot, WORKSPACE_FILE));
7872
+ const configText = await readText(path52.join(workspaceRoot, WORKSPACE_FILE));
7381
7873
  if (!configText) {
7382
7874
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
7383
7875
  process.exitCode = 1;
@@ -7399,7 +7891,7 @@ async function scanWorkspace(workspaceRoot, opts) {
7399
7891
  const inputs = [];
7400
7892
  const failures = [];
7401
7893
  for (const repo of config.repos) {
7402
- const repoRoot = path51.resolve(workspaceRoot, repo.path);
7894
+ const repoRoot = path52.resolve(workspaceRoot, repo.path);
7403
7895
  try {
7404
7896
  if (!existsSync6(repoRoot) || !statSync3(repoRoot).isDirectory()) {
7405
7897
  throw new Error("path is not a directory");
@@ -7462,7 +7954,7 @@ async function runWorkspace(action, options = {}) {
7462
7954
  // src/cli.ts
7463
7955
  function cliVersion() {
7464
7956
  try {
7465
- const pkgPath = path52.join(packageRoot(), "package.json");
7957
+ const pkgPath = path53.join(packageRoot(), "package.json");
7466
7958
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
7467
7959
  return pkg.version ?? "0.0.0";
7468
7960
  } catch {
@@ -7472,7 +7964,7 @@ function cliVersion() {
7472
7964
  var program = new Command();
7473
7965
  function validateRuntimeNodeVersion() {
7474
7966
  try {
7475
- const pkgPath = path52.join(packageRoot(), "package.json");
7967
+ const pkgPath = path53.join(packageRoot(), "package.json");
7476
7968
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
7477
7969
  const requiredRange = pkg.engines?.node;
7478
7970
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -7503,8 +7995,17 @@ program.command("apply").option("--dry-run").option("--write").option("--select"
7503
7995
  ).option(
7504
7996
  "--refill-config",
7505
7997
  "Fill still-blank fields in an existing workflow-config.md without touching edited ones"
7506
- ).option("--force", "Overwrite user-modified managed workflow files").action(runApply);
7998
+ ).option("--force", "Overwrite user-modified managed workflow files").option(
7999
+ "--prune",
8000
+ "Remove lock-tracked catalog items no longer in the current recommendation (hash-gated, backed up first)"
8001
+ ).action(runApply);
7507
8002
  program.command("undo").option("-y, --yes", "Skip confirmation").action(runUndo);
8003
+ var backups = program.command("backups");
8004
+ backups.command("list").description("List backups under .haus-workflow/backups/").action(() => runBackups("list"));
8005
+ backups.command("restore <id>").description("Restore a specific backup by id (see `haus backups list`)").option("-y, --yes", "Skip confirmation").action((id, opts) => runBackups("restore", { id, yes: opts.yes }));
8006
+ backups.command("prune").description("Delete old backups by age and/or count (requires --older-than or --keep)").option("--older-than <days>", "Remove backups older than N days").option("--keep <n>", "Keep only the N most recent backups").option("-y, --yes", "Skip confirmation").action(
8007
+ (opts) => runBackups("prune", { olderThan: opts.olderThan, keep: opts.keep, yes: opts.yes })
8008
+ );
7508
8009
  program.command("explain-recommendation").option("--json").action(runExplainRecommendation);
7509
8010
  program.command("init").option("--json").action(runInit);
7510
8011
  program.command("clone").description("Clone a single git repository by URL").argument("<url>", "Git URL to clone").argument("[dir]", "Target directory (default: repo name derived from the URL)").option("--dry-run", "Print what would happen without cloning").action(