@haus-tech/haus-workflow 4.7.0 → 4.8.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
@@ -1369,7 +1369,9 @@ var init_validation_rules = __esm({
1369
1369
  "myid",
1370
1370
  "oidc",
1371
1371
  "azure-ad",
1372
- "saml2"
1372
+ "saml2",
1373
+ "auth",
1374
+ "next-auth"
1373
1375
  ],
1374
1376
  intentsRequiredTags: [
1375
1377
  "stripe",
@@ -1379,7 +1381,9 @@ var init_validation_rules = __esm({
1379
1381
  "myid",
1380
1382
  "oidc",
1381
1383
  "azure-ad",
1382
- "saml2"
1384
+ "saml2",
1385
+ "auth",
1386
+ "next-auth"
1383
1387
  ],
1384
1388
  alwaysAllowedTags: [
1385
1389
  "haus",
@@ -1936,8 +1940,8 @@ var init_sync = __esm({
1936
1940
  // src/cli.ts
1937
1941
  init_offline();
1938
1942
  import { readFileSync as readFileSync5 } from "node:fs";
1939
- import path81 from "node:path";
1940
- import { Command } from "commander";
1943
+ import path85 from "node:path";
1944
+ import { Command, Option } from "commander";
1941
1945
 
1942
1946
  // src/commands/apply.ts
1943
1947
  import checkbox from "@inquirer/checkbox";
@@ -2316,20 +2320,26 @@ var DENY_PATHS = [
2316
2320
  "*.key",
2317
2321
  "*.p12",
2318
2322
  "*.pfx",
2323
+ "*.ppk",
2319
2324
  "id_rsa",
2320
2325
  "id_ed25519",
2326
+ "id_ecdsa",
2327
+ "id_dsa",
2321
2328
  "customer-data",
2322
2329
  "secrets",
2323
2330
  "certs"
2324
2331
  ];
2325
2332
  var DENY_DIRS = /* @__PURE__ */ new Set(["customer-data", "secrets", "certs"]);
2326
2333
  var DENY_PATH_REGEXES = [
2327
- /(^|\/)[^/]+\.pem$/i,
2328
- /(^|\/)[^/]+\.key$/i,
2329
- /(^|\/)[^/]+\.p12$/i,
2330
- /(^|\/)[^/]+\.pfx$/i,
2334
+ /(^|\/)[^/]*\.pem$/i,
2335
+ /(^|\/)[^/]*\.key$/i,
2336
+ /(^|\/)[^/]*\.p12$/i,
2337
+ /(^|\/)[^/]*\.pfx$/i,
2338
+ /(^|\/)[^/]*\.ppk$/i,
2331
2339
  /(^|\/)id_rsa$/i,
2332
2340
  /(^|\/)id_ed25519$/i,
2341
+ /(^|\/)id_ecdsa$/i,
2342
+ /(^|\/)id_dsa$/i,
2333
2343
  /(^|\/)customer-data(\/|$)/i,
2334
2344
  /(^|\/)secrets(\/|$)/i,
2335
2345
  /(^|\/)certs(\/|$)/i
@@ -2892,6 +2902,9 @@ var ALLOWED_SUBCOMMANDS = [
2892
2902
  "workspace link-context",
2893
2903
  "workspace worktree add",
2894
2904
  "workspace worktree hydrate",
2905
+ // Same class as `hydrate`: materializes and installs, deletes nothing. The
2906
+ // deleters in this family (`remove`, `prune`, `dematerialize`, `reclaim`) stay out.
2907
+ "workspace worktree autohydrate",
2895
2908
  "workspace worktree list",
2896
2909
  "workspace worktree doctor"
2897
2910
  ];
@@ -2956,14 +2969,61 @@ async function readSettings() {
2956
2969
  async function writeSettings(settings) {
2957
2970
  await writeJson(settingsJsonPath(), settings);
2958
2971
  }
2959
- function collectEventHookCommands(entries) {
2960
- const cmds = /* @__PURE__ */ new Set();
2961
- for (const entry of entries) {
2962
- for (const h of entry.hooks ?? []) {
2963
- if (h.command) cmds.add(h.command);
2964
- }
2972
+ function placementKey(event, matcher) {
2973
+ return `${event}\0${matcher ?? ""}`;
2974
+ }
2975
+ function collectCanonicalPlacements(fragments) {
2976
+ const placements = /* @__PURE__ */ new Map();
2977
+ for (const fragment of fragments) {
2978
+ if (fragment.gate !== "keep") continue;
2979
+ const key = placementKey(fragment.event, fragment.matcher);
2980
+ const known = placements.get(fragment.command);
2981
+ if (known) known.add(key);
2982
+ else placements.set(fragment.command, /* @__PURE__ */ new Set([key]));
2983
+ }
2984
+ return placements;
2985
+ }
2986
+ function collectHausOwnedCommands(fragments, priorHaus) {
2987
+ const owned = new Set(priorHaus?.hookCommands ?? []);
2988
+ const trackedIds = new Set(priorHaus?.hooks ?? []);
2989
+ for (const fragment of fragments) {
2990
+ if (fragment.gate === "keep" && trackedIds.has(fragment.id)) owned.add(fragment.command);
2991
+ }
2992
+ return owned;
2993
+ }
2994
+ function pruneStaleHausPlacements(hooks, canonicalPlacements, hausOwnedCommands) {
2995
+ const kept = {};
2996
+ for (const [event, entries] of Object.entries(hooks)) {
2997
+ const keptEntries = entries.map(
2998
+ (entry) => pruneEntryCommands(entry, (command) => {
2999
+ if (!hausOwnedCommands.has(command)) return false;
3000
+ const placements = canonicalPlacements.get(command);
3001
+ if (!placements) return false;
3002
+ return !placements.has(placementKey(event, entry.matcher));
3003
+ })
3004
+ ).filter((entry) => entry !== null);
3005
+ if (keptEntries.length > 0) kept[event] = keptEntries;
3006
+ }
3007
+ return kept;
3008
+ }
3009
+ function splitHookMap(hooks) {
3010
+ const usable = {};
3011
+ const foreign = {};
3012
+ for (const [event, entries] of Object.entries(hooks)) {
3013
+ if (Array.isArray(entries)) usable[event] = entries;
3014
+ else foreign[event] = entries;
2965
3015
  }
2966
- return cmds;
3016
+ return { usable, foreign };
3017
+ }
3018
+ function entryExistsAtPlacement(entries, matcher, command) {
3019
+ return entries.some(
3020
+ (entry) => (entry.matcher ?? "") === (matcher ?? "") && (entry.hooks ?? []).some((hook) => hook.command === command)
3021
+ );
3022
+ }
3023
+ function commandPresentAnywhere(hooks, command) {
3024
+ return Object.values(hooks).some(
3025
+ (entries) => (entries ?? []).some((entry) => (entry.hooks ?? []).some((hook) => hook.command === command))
3026
+ );
2967
3027
  }
2968
3028
  function pruneEntryCommands(entry, shouldDrop) {
2969
3029
  const keptHooks = (entry.hooks ?? []).filter((hook) => {
@@ -3011,17 +3071,25 @@ function reconcileRetiredHausHooks(updated, fragments, priorHaus, addedIds, adde
3011
3071
  function mergeHooks(settings, fragments) {
3012
3072
  const existing = settings._haus?.hooks ?? [];
3013
3073
  const existingCommands = settings._haus?.hookCommands ?? [];
3074
+ const { usable, foreign } = splitHookMap(settings.hooks ?? {});
3014
3075
  const updated = { ...settings };
3015
- updated.hooks = { ...settings.hooks ?? {} };
3076
+ updated.hooks = { ...usable };
3016
3077
  const addedIds = [];
3017
3078
  const addedCommands = [];
3079
+ const canonicalPlacements = collectCanonicalPlacements(fragments);
3080
+ const hausOwnedCommands = collectHausOwnedCommands(fragments, settings._haus);
3081
+ updated.hooks = pruneStaleHausPlacements(updated.hooks, canonicalPlacements, hausOwnedCommands);
3018
3082
  for (const fragment of fragments) {
3019
3083
  if (fragment.gate !== "keep") continue;
3020
3084
  const event = fragment.event;
3021
3085
  const eventEntries = updated.hooks[event] ?? [];
3022
- const presentCommands = collectEventHookCommands(eventEntries);
3023
- if (presentCommands.has(fragment.command)) {
3024
- if (!existingCommands.includes(fragment.command)) addedCommands.push(fragment.command);
3086
+ if (entryExistsAtPlacement(eventEntries, fragment.matcher, fragment.command)) {
3087
+ if (existing.includes(fragment.id) && !existingCommands.includes(fragment.command)) {
3088
+ addedCommands.push(fragment.command);
3089
+ }
3090
+ continue;
3091
+ }
3092
+ if (!hausOwnedCommands.has(fragment.command) && commandPresentAnywhere(updated.hooks, fragment.command)) {
3025
3093
  continue;
3026
3094
  }
3027
3095
  if (!updated.hooks[event]) updated.hooks[event] = [];
@@ -3034,6 +3102,11 @@ function mergeHooks(settings, fragments) {
3034
3102
  if (!existingCommands.includes(fragment.command)) addedCommands.push(fragment.command);
3035
3103
  }
3036
3104
  reconcileRetiredHausHooks(updated, fragments, settings._haus, addedIds, addedCommands);
3105
+ for (const [event, value] of Object.entries(foreign)) {
3106
+ if (updated.hooks[event] === void 0) {
3107
+ updated.hooks[event] = value;
3108
+ }
3109
+ }
3037
3110
  return { settings: updated, addedIds };
3038
3111
  }
3039
3112
  function reconcileManagedRules(existing, prevTracked, newRules) {
@@ -3152,15 +3225,20 @@ async function loadHooksFragment(fragmentPath) {
3152
3225
  }
3153
3226
 
3154
3227
  // src/security/dangerous-commands.ts
3228
+ var RUNTIME_ONLY_DENY_COMMANDS = ["drop database", "truncate table"];
3155
3229
  var DENY_COMMANDS = [
3156
3230
  "sudo",
3157
3231
  "chmod -R 777",
3158
3232
  "git push --force",
3159
3233
  "git push --force-with-lease",
3160
3234
  "git push -f",
3161
- "drop database",
3162
- "truncate table",
3235
+ // Skipping the hooks is a NEVER rule in WORKFLOW.md and was missing from every tier.
3236
+ "git commit --no-verify",
3237
+ "git push --no-verify",
3238
+ ...RUNTIME_ONLY_DENY_COMMANDS,
3163
3239
  "npm publish",
3240
+ // Classic v1 syntax alongside the Berry `yarn npm publish` form.
3241
+ "yarn publish",
3164
3242
  "yarn npm publish",
3165
3243
  "pnpm publish"
3166
3244
  ];
@@ -3187,14 +3265,15 @@ function buildAskRules() {
3187
3265
  }
3188
3266
 
3189
3267
  // src/security/deny-rules.ts
3190
- var FILE_TOOLS = ["Read", "Edit", "Write"];
3268
+ var FILE_TOOLS = ["Read", "Edit", "Write", "NotebookEdit"];
3191
3269
  function buildDenyRules() {
3192
3270
  const rules = [];
3193
3271
  for (const command of DENY_COMMANDS) {
3272
+ if (RUNTIME_ONLY_DENY_COMMANDS.includes(command)) continue;
3194
3273
  rules.push(`Bash(${command}:*)`);
3195
3274
  }
3196
- for (const path82 of DENY_PATHS) {
3197
- const pattern = DENY_DIRS.has(path82) ? `${path82}/**` : path82;
3275
+ for (const path86 of DENY_PATHS) {
3276
+ const pattern = DENY_DIRS.has(path86) ? `${path86}/**` : path86;
3198
3277
  for (const tool of FILE_TOOLS) {
3199
3278
  rules.push(`${tool}(${pattern})`);
3200
3279
  }
@@ -3211,7 +3290,7 @@ var PROJECT_HOOK_FRAGMENTS = [
3211
3290
  id: "haus.guard-file",
3212
3291
  gate: "keep",
3213
3292
  event: "PreToolUse",
3214
- matcher: "Read|Edit|Write",
3293
+ matcher: "Read|Edit|Write|NotebookEdit",
3215
3294
  command: "haus guard file-access --from-hook"
3216
3295
  },
3217
3296
  {
@@ -3248,6 +3327,13 @@ var PROJECT_HOOK_FRAGMENTS = [
3248
3327
  event: "SessionStart",
3249
3328
  matcher: "*",
3250
3329
  command: "haus workspace worktree doctor --from-hook"
3330
+ },
3331
+ {
3332
+ id: "hook.workspace.worktree-autohydrate",
3333
+ gate: "keep",
3334
+ event: "SessionStart",
3335
+ matcher: "*",
3336
+ command: "haus workspace worktree autohydrate --from-hook"
3251
3337
  }
3252
3338
  ];
3253
3339
  async function readProjectSettings(root) {
@@ -3276,6 +3362,7 @@ async function applyProjectSettingsMerge(root) {
3276
3362
  }
3277
3363
 
3278
3364
  // src/claude/remove-items.ts
3365
+ import crypto2 from "node:crypto";
3279
3366
  import path18 from "node:path";
3280
3367
  import fs14 from "fs-extra";
3281
3368
  init_fs();
@@ -3304,14 +3391,44 @@ function classifyBackupName(name) {
3304
3391
  }
3305
3392
 
3306
3393
  // src/claude/remove-items.ts
3307
- async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn) {
3394
+ async function unsafeTrackedPath(root, rel) {
3395
+ const abs = resolveContainedPath(root, rel);
3396
+ if (abs === null) return "escapes-root";
3397
+ if (!await fs14.pathExists(abs)) {
3398
+ try {
3399
+ if ((await fs14.lstat(abs)).isSymbolicLink()) return "symlink";
3400
+ } catch {
3401
+ return null;
3402
+ }
3403
+ return null;
3404
+ }
3405
+ if ((await fs14.lstat(abs)).isSymbolicLink()) return "symlink";
3406
+ const [realAbs, realRoot] = await Promise.all([fs14.realpath(abs), fs14.realpath(root)]);
3407
+ const prefix = realRoot.endsWith(path18.sep) ? realRoot : realRoot + path18.sep;
3408
+ if (realAbs !== realRoot && !realAbs.startsWith(prefix)) return "symlink";
3409
+ return null;
3410
+ }
3411
+ async function unsafeReasonsFor(root, relPaths) {
3412
+ const reasons = /* @__PURE__ */ new Map();
3413
+ for (const rel of relPaths) {
3414
+ if (reasons.has(rel)) continue;
3415
+ reasons.set(rel, await unsafeTrackedPath(root, rel));
3416
+ }
3417
+ return reasons;
3418
+ }
3419
+ async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn, unsafeByRel) {
3308
3420
  const existing = [];
3309
3421
  for (const rel of relPaths) {
3310
- const abs = resolveContainedPath(root, rel);
3311
- if (abs === null) {
3422
+ const unsafe = unsafeByRel?.has(rel) ? unsafeByRel.get(rel) ?? null : await unsafeTrackedPath(root, rel);
3423
+ if (unsafe === "escapes-root") {
3312
3424
  warnFn(`Refusing to touch ${itemId}'s recorded path outside the project root: ${rel}`);
3313
3425
  continue;
3314
3426
  }
3427
+ if (unsafe === "symlink") {
3428
+ warnFn(`Refusing to touch ${itemId}'s recorded path through a symlink: ${rel}`);
3429
+ continue;
3430
+ }
3431
+ const abs = path18.resolve(root, rel);
3315
3432
  if (await fs14.pathExists(abs)) existing.push({ rel, abs });
3316
3433
  }
3317
3434
  return existing;
@@ -3327,7 +3444,7 @@ async function hashGateEntry(root, entry, existing) {
3327
3444
  }
3328
3445
  async function backupTrackedPaths(root, absPaths, say, prefix) {
3329
3446
  if (absPaths.length === 0) return null;
3330
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3447
+ const stamp = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${crypto2.randomBytes(3).toString("hex")}`;
3331
3448
  const backupRoot = hausPath(root, "backups", dirBackupDirName(prefix, stamp));
3332
3449
  for (const abs of absPaths) {
3333
3450
  if (!await fs14.pathExists(abs)) continue;
@@ -3467,7 +3584,7 @@ var CANONICAL_HOOKS = {
3467
3584
  hooks: {
3468
3585
  PreToolUse: [
3469
3586
  {
3470
- matcher: "Read|Edit|Write",
3587
+ matcher: "Read|Edit|Write|NotebookEdit",
3471
3588
  hooks: [{ type: "command", command: "haus guard file-access --from-hook" }]
3472
3589
  },
3473
3590
  {
@@ -3493,6 +3610,14 @@ var CANONICAL_HOOKS = {
3493
3610
  {
3494
3611
  matcher: "*",
3495
3612
  hooks: [{ type: "command", command: "haus workspace worktree doctor --from-hook" }]
3613
+ },
3614
+ // The acting half of `worktree.autoHydrate` (#419, ADR-0074). Kept separate
3615
+ // from the doctor hook above so `doctor --from-hook` keeps the
3616
+ // side-effect-free contract ADR-0029 gave it, and so the mutating half can
3617
+ // be switched off by removing one entry.
3618
+ {
3619
+ matcher: "*",
3620
+ hooks: [{ type: "command", command: "haus workspace worktree autohydrate --from-hook" }]
3496
3621
  }
3497
3622
  ]
3498
3623
  }
@@ -3502,30 +3627,36 @@ async function loadClaudeHooksSettings() {
3502
3627
  }
3503
3628
 
3504
3629
  // src/claude/verify-hooks-contract.ts
3505
- function collectHookCommands(settings) {
3506
- const cmds = [];
3507
- for (const entries of Object.values(settings.hooks ?? {})) {
3630
+ function placementKey2(event, matcher, command) {
3631
+ return `${event}\0${matcher ?? ""}\0${command}`;
3632
+ }
3633
+ function collectHookPlacements(settings) {
3634
+ const placements = /* @__PURE__ */ new Set();
3635
+ for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
3636
+ if (!Array.isArray(entries)) continue;
3508
3637
  for (const entry of entries) {
3509
3638
  for (const h of entry.hooks ?? []) {
3510
- if (h.command) cmds.push(h.command);
3639
+ if (h.command) placements.add(placementKey2(event, entry.matcher, h.command));
3511
3640
  }
3512
3641
  }
3513
3642
  }
3514
- return cmds;
3643
+ return placements;
3644
+ }
3645
+ function coversRules(installed, required) {
3646
+ const present = new Set(installed ?? []);
3647
+ return (required ?? []).every((rule) => present.has(rule));
3515
3648
  }
3516
3649
  function hausHookContractSatisfied(project, canonical) {
3517
- const present = new Set(collectHookCommands(project));
3518
- for (const blocks of Object.values(canonical.hooks)) {
3650
+ const present = collectHookPlacements(project);
3651
+ for (const [event, blocks] of Object.entries(canonical.hooks)) {
3519
3652
  for (const block of blocks ?? []) {
3520
3653
  for (const h of block.hooks) {
3521
- if (!present.has(h.command)) return false;
3654
+ if (!present.has(placementKey2(event, block.matcher, h.command))) return false;
3522
3655
  }
3523
3656
  }
3524
3657
  }
3525
- const denySet = new Set(project.permissions?.deny ?? []);
3526
- for (const rule of canonical.permissions?.deny ?? []) {
3527
- if (!denySet.has(rule)) return false;
3528
- }
3658
+ if (!coversRules(project.permissions?.deny, canonical.permissions?.deny)) return false;
3659
+ if (!coversRules(project.permissions?.ask, canonical.permissions?.ask)) return false;
3529
3660
  return true;
3530
3661
  }
3531
3662
  async function assertPostApplySettingsHausContract(root) {
@@ -3538,7 +3669,7 @@ async function assertPostApplySettingsHausContract(root) {
3538
3669
  }
3539
3670
  if (!hausHookContractSatisfied(written, canonical)) {
3540
3671
  throw new Error(
3541
- "haus: post-apply self-check failed: .claude/settings.json missing required haus hooks or deny rules"
3672
+ "haus: post-apply self-check failed: .claude/settings.json is missing required haus hooks (or has them wired to the wrong event/matcher), deny rules, or ask rules"
3542
3673
  );
3543
3674
  }
3544
3675
  }
@@ -3564,7 +3695,7 @@ async function verifyProjectSettingsHooksContract(root) {
3564
3695
  if (!hausHookContractSatisfied(project, canonical)) {
3565
3696
  return {
3566
3697
  ok: false,
3567
- message: ".claude/settings.json missing required haus hooks or deny rules (regenerate with `haus apply --write`)."
3698
+ message: ".claude/settings.json is missing required haus hooks (or has them wired to the wrong event/matcher), deny rules, or ask rules (regenerate with `haus apply --write`)."
3568
3699
  };
3569
3700
  }
3570
3701
  return { ok: true, message: "settings.json carries required haus hook contract." };
@@ -4279,6 +4410,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4279
4410
  await cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, opts.quiet);
4280
4411
  const installedPathsByItem = /* @__PURE__ */ new Map();
4281
4412
  const installedIds = /* @__PURE__ */ new Set();
4413
+ const keptEditedIds = /* @__PURE__ */ new Set();
4282
4414
  const selectedCatalogItems = selectedIds !== void 0 ? rec.recommended.filter((r) => selectedIds.includes(r.id)) : rec.recommended;
4283
4415
  const catalogItemsById = /* @__PURE__ */ new Map();
4284
4416
  for (const recommended of selectedCatalogItems) {
@@ -4386,7 +4518,8 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4386
4518
  opts.force,
4387
4519
  files,
4388
4520
  installedPathsByItem,
4389
- installedIds
4521
+ installedIds,
4522
+ keptEditedIds
4390
4523
  )) {
4391
4524
  continue;
4392
4525
  } else if (item.type === "skill") {
@@ -4536,15 +4669,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4536
4669
  const relPaths = installedPathsByItem.get(r.id) ?? [];
4537
4670
  const manifestItem = manifestById.get(r.id);
4538
4671
  const isCurated = manifestItem?.source === "curated";
4672
+ const keptEditedPrev = keptEditedIds.has(r.id) ? prevLockById.get(r.id) : void 0;
4539
4673
  const base = {
4540
4674
  id: r.id,
4541
4675
  type: r.type,
4542
4676
  source: isCurated ? "curated" : "haus",
4543
4677
  version: hausVersion2,
4544
4678
  catalogRef: lockCatalogRef(r.id),
4545
- hash: await hashInstalledPaths(root, relPaths),
4679
+ hash: keptEditedPrev?.hash ?? await hashInstalledPaths(root, relPaths),
4546
4680
  installMode: "copied",
4547
- paths: relPaths
4681
+ paths: keptEditedPrev?.paths ?? relPaths
4548
4682
  };
4549
4683
  if (!isCurated || !manifestItem) return base;
4550
4684
  return {
@@ -4573,14 +4707,15 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4573
4707
  );
4574
4708
  return [...new Set(files)];
4575
4709
  }
4576
- async function hasProtectedUnsafePath(root, relPaths) {
4710
+ async function hasProtectedUnsafePath(root, relPaths, unsafeByRel) {
4577
4711
  for (const rel of relPaths) {
4578
- if (resolveContainedPath(root, rel) !== null) continue;
4579
- if (await fs23.pathExists(path25.resolve(root, rel))) return true;
4712
+ const unsafe = unsafeByRel.get(rel) ?? null;
4713
+ if (unsafe === "symlink") return true;
4714
+ if (unsafe === "escapes-root" && await fs23.pathExists(path25.resolve(root, rel))) return true;
4580
4715
  }
4581
4716
  return false;
4582
4717
  }
4583
- async function skipLocallyEditedItem(root, itemId, prevEntry, force, files, installedPathsByItem, installedIds) {
4718
+ async function skipLocallyEditedItem(root, itemId, prevEntry, force, files, installedPathsByItem, installedIds, keptEditedIds) {
4584
4719
  if (!prevEntry || prevEntry.hash === void 0 || force) return false;
4585
4720
  const existing = await resolveExistingTrackedPaths(root, itemId, prevEntry.paths ?? [], warn);
4586
4721
  if (existing.length === 0) return false;
@@ -4598,6 +4733,7 @@ async function skipLocallyEditedItem(root, itemId, prevEntry, force, files, inst
4598
4733
  existing.map((e) => e.rel)
4599
4734
  );
4600
4735
  installedIds.add(itemId);
4736
+ keptEditedIds.add(itemId);
4601
4737
  return true;
4602
4738
  }
4603
4739
  async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, quiet) {
@@ -4674,8 +4810,9 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet, activ
4674
4810
  if (!removedFromManifest && !deprecated && !superseded) continue;
4675
4811
  const relPaths = entry.paths ?? [];
4676
4812
  if (relPaths.length === 0) continue;
4677
- const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
4678
- if (await hasProtectedUnsafePath(root, relPaths)) {
4813
+ const unsafeByRel = await unsafeReasonsFor(root, relPaths);
4814
+ const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn, unsafeByRel);
4815
+ if (await hasProtectedUnsafePath(root, relPaths, unsafeByRel)) {
4679
4816
  refusedIds.add(entry.id);
4680
4817
  continue;
4681
4818
  }
@@ -4737,8 +4874,9 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
4737
4874
  if (!entry.id) continue;
4738
4875
  const relPaths = entry.paths ?? [];
4739
4876
  if (relPaths.length === 0) continue;
4740
- const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
4741
- if (await hasProtectedUnsafePath(root, relPaths)) {
4877
+ const unsafeByRel = await unsafeReasonsFor(root, relPaths);
4878
+ const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn, unsafeByRel);
4879
+ if (await hasProtectedUnsafePath(root, relPaths, unsafeByRel)) {
4742
4880
  refusedIds.add(entry.id);
4743
4881
  continue;
4744
4882
  }
@@ -4783,7 +4921,11 @@ var GITIGNORED_ARTIFACT_PATHS = [
4783
4921
  ".haus-workflow/sources-report.json",
4784
4922
  ".haus-workflow/deep-context.json",
4785
4923
  ".haus-workflow/backups/",
4786
- ".haus-workflow/llms-cache/"
4924
+ ".haus-workflow/llms-cache/",
4925
+ // Hydration locks and the detached hydration log/last-run record (#419): live
4926
+ // process state and machine-local diagnostics, meaningless in another checkout.
4927
+ ".haus-workflow/locks/",
4928
+ ".haus-workflow/logs/"
4787
4929
  ];
4788
4930
  function findLineMarker3(content2, marker, from = 0) {
4789
4931
  let idx = content2.indexOf(marker, from);
@@ -7509,7 +7651,12 @@ function parseWorkspaceConfig(text) {
7509
7651
  (r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
7510
7652
  ) : [];
7511
7653
  const worktreeRaw = parsed.worktree;
7512
- const worktree2 = typeof worktreeRaw === "object" && worktreeRaw !== null && !Array.isArray(worktreeRaw) ? { autoHydrate: worktreeRaw.autoHydrate === true } : void 0;
7654
+ const reclaimRaw = worktreeRaw?.reclaimAfterDays;
7655
+ const reclaimAfterDays = typeof reclaimRaw === "number" && Number.isFinite(reclaimRaw) && reclaimRaw > 0 ? reclaimRaw : void 0;
7656
+ const worktree2 = typeof worktreeRaw === "object" && worktreeRaw !== null && !Array.isArray(worktreeRaw) ? {
7657
+ autoHydrate: worktreeRaw.autoHydrate === true,
7658
+ ...reclaimAfterDays !== void 0 ? { reclaimAfterDays } : {}
7659
+ } : void 0;
7513
7660
  return {
7514
7661
  client: typeof obj.client === "string" ? obj.client : "unknown",
7515
7662
  repos,
@@ -8420,10 +8567,10 @@ async function runDoctor(options) {
8420
8567
  } else {
8421
8568
  const workflowContent = await readText(workflowPath) ?? "";
8422
8569
  const firstLine = workflowContent.split("\n")[0] ?? "";
8423
- if (!firstLine.includes("HAUS-MANAGED")) {
8570
+ const managedHeader = parseHausManagedHeader(firstLine);
8571
+ if (!managedHeader) {
8424
8572
  ok("- .haus-workflow/WORKFLOW.md: OK (user-owned)");
8425
8573
  } else {
8426
- const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
8427
8574
  const bodyContent = workflowContent.slice(firstLine.length + 1);
8428
8575
  const cachePath = path46.join(getCacheDir(), "templates/agentic-workflow-standard.md");
8429
8576
  const bundledPath = path46.join(
@@ -8435,7 +8582,7 @@ async function runDoctor(options) {
8435
8582
  );
8436
8583
  const templatePath = await fs38.pathExists(cachePath) ? cachePath : bundledPath;
8437
8584
  const templateContent = await readText(templatePath);
8438
- const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
8585
+ const verdict = checkManagedTamper(bodyContent, managedHeader.hash, templateContent ?? null);
8439
8586
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
8440
8587
  let diffNote = "";
8441
8588
  if (templateContent != null) {
@@ -8886,7 +9033,7 @@ async function refreshProjectApply(root) {
8886
9033
  }
8887
9034
 
8888
9035
  // src/install/apply.ts
8889
- import crypto2 from "node:crypto";
9036
+ import crypto3 from "node:crypto";
8890
9037
  import path47 from "node:path";
8891
9038
  import fs40 from "fs-extra";
8892
9039
  init_fs();
@@ -8998,7 +9145,7 @@ ${content2}`;
8998
9145
  // src/install/apply.ts
8999
9146
  var SCHEMA_VERSION2 = "1";
9000
9147
  function hashContent(content2) {
9001
- return `sha256-${crypto2.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
9148
+ return `sha256-${crypto3.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
9002
9149
  }
9003
9150
  function destFileMissing(destPath) {
9004
9151
  try {
@@ -10345,20 +10492,246 @@ async function runFetchRefs(options) {
10345
10492
  }
10346
10493
  }
10347
10494
 
10348
- // src/security/guard-bash.ts
10349
- function matchesDenyToken(command, denyPhrase) {
10350
- if (denyPhrase === "sudo") {
10351
- return /(?:^|[|;&]\s*)sudo\b/i.test(command);
10495
+ // src/security/shell-tokens.ts
10496
+ var WRAPPER_COMMANDS = /* @__PURE__ */ new Set([
10497
+ "builtin",
10498
+ "command",
10499
+ "env",
10500
+ "exec",
10501
+ "ionice",
10502
+ "nice",
10503
+ "nohup",
10504
+ "setsid",
10505
+ "stdbuf",
10506
+ "time",
10507
+ "timeout",
10508
+ "xargs"
10509
+ ]);
10510
+ var ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
10511
+ function commandBaseName(word) {
10512
+ const lowered = word.toLowerCase();
10513
+ const lastSlash = lowered.lastIndexOf("/");
10514
+ return lastSlash === -1 ? lowered : lowered.slice(lastSlash + 1);
10515
+ }
10516
+ function parseShellSegments(command) {
10517
+ const segments = [];
10518
+ let segment = [];
10519
+ let word = "";
10520
+ let hasUnquoted = false;
10521
+ let started = false;
10522
+ let quote = null;
10523
+ const endWord = () => {
10524
+ if (started) segment.push({ value: word, quoted: !hasUnquoted });
10525
+ word = "";
10526
+ hasUnquoted = false;
10527
+ started = false;
10528
+ };
10529
+ const endSegment = () => {
10530
+ endWord();
10531
+ if (segment.length > 0) segments.push(segment);
10532
+ segment = [];
10533
+ };
10534
+ for (let i = 0; i < command.length; i += 1) {
10535
+ const char = command[i];
10536
+ if (quote) {
10537
+ if (char === quote) quote = null;
10538
+ else {
10539
+ word += char;
10540
+ started = true;
10541
+ }
10542
+ continue;
10543
+ }
10544
+ if (char === '"' || char === "'") {
10545
+ quote = char;
10546
+ started = true;
10547
+ continue;
10548
+ }
10549
+ if (char === "\\") {
10550
+ const next = command[i + 1];
10551
+ if (next === "\n") {
10552
+ i += 1;
10553
+ continue;
10554
+ }
10555
+ if (next !== void 0) {
10556
+ word += next;
10557
+ hasUnquoted = true;
10558
+ started = true;
10559
+ i += 1;
10560
+ }
10561
+ continue;
10562
+ }
10563
+ if (";&|\n`(){}".includes(char)) {
10564
+ endSegment();
10565
+ continue;
10566
+ }
10567
+ if (/\s/.test(char)) {
10568
+ endWord();
10569
+ continue;
10570
+ }
10571
+ word += char;
10572
+ hasUnquoted = true;
10573
+ started = true;
10352
10574
  }
10353
- const normalizedCommand = command.replace(/["'`]/g, "").toLowerCase().replace(/\s+/g, " ").trim();
10354
- return normalizedCommand.includes(denyPhrase.toLowerCase());
10575
+ endSegment();
10576
+ return segments;
10355
10577
  }
10356
- function guardBash(command) {
10357
- const matched = DENY_COMMANDS.find((denyPhrase) => matchesDenyToken(command, denyPhrase));
10358
- if (matched) return `I didn't run that \u2014 it can permanently change or delete things: ${command}`;
10578
+ function commandWords(segment) {
10579
+ const words = segment.filter((token) => !(token.quoted && /\s/.test(token.value))).map((token) => token.value);
10580
+ let start = 0;
10581
+ while (start < words.length) {
10582
+ const word = words[start];
10583
+ if (ENV_ASSIGNMENT.test(word)) {
10584
+ start += 1;
10585
+ continue;
10586
+ }
10587
+ if (WRAPPER_COMMANDS.has(commandBaseName(word))) {
10588
+ start += 1;
10589
+ while (start < words.length && words[start].startsWith("-")) start += 1;
10590
+ continue;
10591
+ }
10592
+ break;
10593
+ }
10594
+ return words.slice(start);
10595
+ }
10596
+
10597
+ // src/security/dangerous-command-shapes.ts
10598
+ var FORCE_PUSH_FLAGS = /* @__PURE__ */ new Set(["--force", "-f", "--force-with-lease", "--force-if-includes"]);
10599
+ var SQL_CLIENTS = /* @__PURE__ */ new Set([
10600
+ "clickhouse-client",
10601
+ "cockroach",
10602
+ "mariadb",
10603
+ "mongo",
10604
+ "mongosh",
10605
+ "mycli",
10606
+ "mysql",
10607
+ "pgcli",
10608
+ "psql",
10609
+ "sqlite",
10610
+ "sqlite3"
10611
+ ]);
10612
+ var PUBLISHERS = /* @__PURE__ */ new Set(["bun", "npm", "pnpm", "yarn"]);
10613
+ var RUN_SCRIPT_VERBS = ["run", "run-script", "rum", "urn"];
10614
+ function shortFlagGroupHas(word, flag) {
10615
+ return /^-[A-Za-z]+$/.test(word) && !word.startsWith("--") && word.slice(1).includes(flag);
10616
+ }
10617
+ function commandName(words) {
10618
+ return commandBaseName(words[0] ?? "");
10619
+ }
10620
+ function hasWord(words, name) {
10621
+ return words.some((word) => word.toLowerCase() === name);
10622
+ }
10623
+ var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
10624
+ "-c",
10625
+ "-C",
10626
+ "--config-env",
10627
+ "--exec-path",
10628
+ "--git-dir",
10629
+ "--namespace",
10630
+ "--work-tree"
10631
+ ]);
10632
+ var GIT_TEXT_FLAGS = /* @__PURE__ */ new Set(["-m", "--message", "-F", "--file"]);
10633
+ function gitSubcommand(words) {
10634
+ if (commandName(words) !== "git") return void 0;
10635
+ for (let i = 1; i < words.length; i += 1) {
10636
+ const word = words[i];
10637
+ if (GIT_VALUE_FLAGS.has(word)) {
10638
+ i += 1;
10639
+ continue;
10640
+ }
10641
+ if (word.startsWith("-")) continue;
10642
+ return word.toLowerCase();
10643
+ }
10644
+ return void 0;
10645
+ }
10646
+ function gitArguments(words) {
10647
+ const args = [];
10648
+ for (let i = 1; i < words.length; i += 1) {
10649
+ const word = words[i];
10650
+ if (GIT_VALUE_FLAGS.has(word) || GIT_TEXT_FLAGS.has(word)) {
10651
+ i += 1;
10652
+ continue;
10653
+ }
10654
+ args.push(word);
10655
+ }
10656
+ return args;
10657
+ }
10658
+ function isForcePush(words) {
10659
+ if (gitSubcommand(words) !== "push") return false;
10660
+ return gitArguments(words).some(
10661
+ (word) => FORCE_PUSH_FLAGS.has(word) || word.startsWith("--force-with-lease=") || shortFlagGroupHas(word, "f") || /^\+\S+/.test(word)
10662
+ );
10663
+ }
10664
+ function isHookBypass(words) {
10665
+ const subcommand = gitSubcommand(words);
10666
+ if (subcommand !== "commit" && subcommand !== "push") return false;
10667
+ const args = gitArguments(words);
10668
+ if (args.some((word) => word === "--no-verify" || word.startsWith("--no-verify="))) return true;
10669
+ return subcommand === "commit" && args.some((word) => shortFlagGroupHas(word, "n"));
10670
+ }
10671
+ function isWorldWritableMode(word) {
10672
+ if (/^[0-7]+$/.test(word)) {
10673
+ return (Number(word.slice(-1)) & 2) !== 0;
10674
+ }
10675
+ return word.split(",").some(isWorldWritableClause);
10676
+ }
10677
+ function isWorldWritableClause(clause) {
10678
+ const symbolic = /^([ugoa]*)([+=])([rwxstugo]+)$/i.exec(clause);
10679
+ if (!symbolic) return false;
10680
+ const [, who, , perms] = symbolic;
10681
+ if (!perms.toLowerCase().includes("w")) return false;
10682
+ const scope = who.toLowerCase();
10683
+ return scope === "" || scope.includes("a") || scope.includes("o");
10684
+ }
10685
+ function isRecursiveChmod(words) {
10686
+ if (commandName(words) !== "chmod") return false;
10687
+ const recursive = words.some((word) => word === "--recursive" || shortFlagGroupHas(word, "R"));
10688
+ if (!recursive) return false;
10689
+ return words.slice(1).some((word) => !word.startsWith("-") && isWorldWritableMode(word));
10690
+ }
10691
+ function isPackagePublish(words) {
10692
+ if (!PUBLISHERS.has(commandName(words))) return false;
10693
+ if (!hasWord(words, "publish")) return false;
10694
+ if (RUN_SCRIPT_VERBS.some((verb) => hasWord(words, verb))) return false;
10695
+ return !words.some((word) => word === "--dry-run" || word === "--dry-run=true");
10696
+ }
10697
+ function isPrivilegeEscalation(words) {
10698
+ return commandName(words) === "sudo";
10699
+ }
10700
+ function isDestructiveSql(segment) {
10701
+ const words = commandWords(segment);
10702
+ if (words.length === 0) return false;
10703
+ if (words.some((word) => SQL_CLIENTS.has(commandBaseName(word)))) {
10704
+ const text = segment.map((token) => token.value).join(" ").toLowerCase().replace(/\s+/g, " ");
10705
+ return RUNTIME_ONLY_DENY_COMMANDS.some((phrase) => text.includes(phrase));
10706
+ }
10707
+ const spoken = words.join(" ").toLowerCase();
10708
+ return RUNTIME_ONLY_DENY_COMMANDS.some((phrase) => spoken.startsWith(phrase));
10709
+ }
10710
+ var SEGMENT_MATCHERS = [
10711
+ { name: "a privilege escalation", matches: isPrivilegeEscalation },
10712
+ { name: "a force push", matches: isForcePush },
10713
+ { name: "a commit or push that skips the hooks", matches: isHookBypass },
10714
+ { name: "a recursive permission change", matches: isRecursiveChmod },
10715
+ { name: "a package publish", matches: isPackagePublish }
10716
+ ];
10717
+ function matchDangerousCommandShape(command) {
10718
+ for (const segment of parseShellSegments(command)) {
10719
+ const words = commandWords(segment);
10720
+ if (words.length === 0) continue;
10721
+ const matched = SEGMENT_MATCHERS.find((matcher) => matcher.matches(words));
10722
+ if (matched) return matched.name;
10723
+ if (isDestructiveSql(segment)) return "a destructive database statement";
10724
+ }
10359
10725
  return void 0;
10360
10726
  }
10361
10727
 
10728
+ // src/security/guard-bash.ts
10729
+ function guardBash(command) {
10730
+ const shape = matchDangerousCommandShape(command);
10731
+ if (!shape) return void 0;
10732
+ return `I didn't run that \u2014 it looks like ${shape}, which can permanently change or delete things: ${command}`;
10733
+ }
10734
+
10362
10735
  // src/security/guard-file-access.ts
10363
10736
  function guardFileAccess(candidate) {
10364
10737
  const normalized = normalizePathForMatch(candidate);
@@ -10377,7 +10750,7 @@ async function runGuard(kind, _options) {
10377
10750
  }
10378
10751
  const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
10379
10752
  if (kind === "file-access") {
10380
- const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
10753
+ const candidate = String(toolInput.notebook_path ?? toolInput.path ?? toolInput.file_path ?? "");
10381
10754
  const reason2 = guardFileAccess(candidate);
10382
10755
  if (reason2) {
10383
10756
  emitPreToolUseDeny(reason2);
@@ -10627,7 +11000,7 @@ async function recommend(root, context, opts = {}) {
10627
11000
  const tagMatch = item.tags.find((t) => stackSet.has(t.toLowerCase()));
10628
11001
  if (tagMatch) push("stack-match", "stack/dependency match", stackSignal(tagMatch));
10629
11002
  const pm = context.packageManager;
10630
- const pmVersionedMatch = pm === "yarn" || pm === "pnpm" ? item.tags.includes(pm) || item.tags.includes(`${pm}4`) || item.tags.includes(`${pm}89`) : item.tags.includes(pm);
11003
+ const pmVersionedMatch = pm === "yarn" || pm === "pnpm" ? itemTags.includes(pm) || itemTags.includes(`${pm}4`) || pm === "pnpm" && itemTags.includes("pnpm89") : itemTags.includes(pm);
10631
11004
  if (pmVersionedMatch) {
10632
11005
  push(
10633
11006
  "package-manager-match",
@@ -11987,17 +12360,19 @@ async function runRemove(ids, options = {}) {
11987
12360
  for (const id of found) {
11988
12361
  const entry = byId.get(id);
11989
12362
  const relPaths = entry.paths ?? [];
11990
- const hasUnsafePath = relPaths.some((rel) => resolveContainedPath(root, rel) === null);
11991
- if (hasUnsafePath) {
11992
- for (const rel of relPaths) {
11993
- if (resolveContainedPath(root, rel) === null) {
12363
+ const unsafeByRel = await unsafeReasonsFor(root, relPaths);
12364
+ if ([...unsafeByRel.values()].some((reason) => reason !== null)) {
12365
+ for (const [rel, reason] of unsafeByRel) {
12366
+ if (reason === "escapes-root") {
11994
12367
  warn(`Refusing to touch ${id}'s recorded path outside the project root: ${rel}`);
12368
+ } else if (reason === "symlink") {
12369
+ warn(`Refusing to touch ${id}'s recorded path through a symlink: ${rel}`);
11995
12370
  }
11996
12371
  }
11997
12372
  report.skipped.push({ id, reason: "unsafe-path" });
11998
12373
  continue;
11999
12374
  }
12000
- const existing = await resolveExistingTrackedPaths(root, id, relPaths, warn);
12375
+ const existing = await resolveExistingTrackedPaths(root, id, relPaths, warn, unsafeByRel);
12001
12376
  if (existing.length === 0) {
12002
12377
  if (relPaths.length === 0) {
12003
12378
  warn(`${id} has no recorded paths \u2014 leaving its lock entry in place.`);
@@ -12173,7 +12548,12 @@ async function collectLockTrackedPaths(root) {
12173
12548
  const preserved = [];
12174
12549
  for (const row of lock ?? []) {
12175
12550
  const id = row.id ?? "(unknown item)";
12176
- const existing = await resolveExistingTrackedPaths(root, id, row.paths ?? [], log);
12551
+ const relPaths = row.paths ?? [];
12552
+ const unsafeByRel = await unsafeReasonsFor(root, relPaths);
12553
+ const existing = await resolveExistingTrackedPaths(root, id, relPaths, log, unsafeByRel);
12554
+ for (const [rel, reason] of unsafeByRel) {
12555
+ if (reason !== null) preserved.push(path56.resolve(root, rel));
12556
+ }
12177
12557
  if (existing.length === 0) continue;
12178
12558
  const gate = await hashGateEntry(root, { hash: row.hash }, existing);
12179
12559
  if (!gate.ok) {
@@ -12305,7 +12685,7 @@ User-owned .claude/ files will be preserved.`
12305
12685
  init_fs();
12306
12686
  init_logger();
12307
12687
  init_paths();
12308
- import crypto3 from "node:crypto";
12688
+ import crypto4 from "node:crypto";
12309
12689
  import path57 from "node:path";
12310
12690
  import fs44 from "fs-extra";
12311
12691
  async function runUninstall(options = {}) {
@@ -12354,7 +12734,7 @@ async function runUninstall(options = {}) {
12354
12734
  result.skipped.push(entry.destPath);
12355
12735
  continue;
12356
12736
  }
12357
- const currentHash = `sha256-${crypto3.createHash("sha256").update(content2).digest("hex")}`;
12737
+ const currentHash = `sha256-${crypto4.createHash("sha256").update(content2).digest("hex")}`;
12358
12738
  if (currentHash !== entry.hash && !force) {
12359
12739
  warn(
12360
12740
  `Skipping user-edited haus file (hash mismatch): ${entry.destPath} \u2014 use --force to delete`
@@ -12561,6 +12941,22 @@ function auditManifestStructure(manifestVersion2, items) {
12561
12941
  if (item.version && !ITEM_SEMVER_RE.test(item.version)) {
12562
12942
  failures.push(`${item.id}: version "${item.version}" is not valid semver (expected X.Y.Z)`);
12563
12943
  }
12944
+ if (typeof item.tokenEstimate === "number" && item.tokenEstimate > 0) {
12945
+ if (typeof item.tokenBudget !== "number") {
12946
+ failures.push(
12947
+ `${item.id}: tokenEstimate ${item.tokenEstimate} but no tokenBudget \u2014 an item that loads into context needs a growth ceiling (estimate + ~30% headroom, so ${Math.ceil(item.tokenEstimate * 1.3)})`
12948
+ );
12949
+ } else if (item.tokenEstimate > item.tokenBudget) {
12950
+ failures.push(
12951
+ `${item.id}: tokenEstimate (${item.tokenEstimate}) exceeds tokenBudget (${item.tokenBudget})`
12952
+ );
12953
+ }
12954
+ }
12955
+ if (item.default !== true && !String(item.ecosystem ?? "").trim()) {
12956
+ failures.push(
12957
+ `${item.id}: non-default item missing ecosystem (it groups alternatives for conflict detection; baseline items are exempt)`
12958
+ );
12959
+ }
12564
12960
  const prev = seenIds.get(item.id);
12565
12961
  if (prev !== void 0) {
12566
12962
  failures.push(`${item.id}: duplicate id (first at index ${prev})`);
@@ -13145,6 +13541,9 @@ function summarizeInstall(install) {
13145
13541
  const details = [...envNotes, ...tail.length > 0 ? tail.split("\n") : []];
13146
13542
  return details.length > 0 ? [head, ...details].join("\n ") : head;
13147
13543
  }
13544
+ function installFailed(install) {
13545
+ return install?.ran === true && install.ok === false;
13546
+ }
13148
13547
  function withMissingEnv(result, missingEnvVars) {
13149
13548
  return missingEnvVars.length > 0 ? { ...result, missingEnvVars } : result;
13150
13549
  }
@@ -13202,6 +13601,9 @@ async function runInstallPlan(dir, plan, deps = {}) {
13202
13601
 
13203
13602
  // src/workspace/worktree/hydrate.ts
13204
13603
  var HYDRATION_TARGETS = ["node_modules", "vendor", "obj", "bin"];
13604
+ function memberOutcomeOk(member) {
13605
+ return member.status !== "failed" && !installFailed(member.hydration?.install);
13606
+ }
13205
13607
  async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
13206
13608
  const start = Date.now();
13207
13609
  const targets = [];
@@ -13304,6 +13706,13 @@ function worktreePath(mainRoot, slug) {
13304
13706
  }
13305
13707
  return path65.join(worktreesDir(mainRoot), slug);
13306
13708
  }
13709
+ function slugFromWorktreePath(mainRoot, somePath) {
13710
+ const dir = worktreesDir(mainRoot);
13711
+ const prefix = `${dir}${path65.sep}`;
13712
+ if (!somePath.startsWith(prefix)) return void 0;
13713
+ const segment = somePath.slice(prefix.length).split(path65.sep)[0];
13714
+ return segment !== void 0 && segment !== "" ? segment : void 0;
13715
+ }
13307
13716
  async function readWorktreeState(worktreeRoot) {
13308
13717
  const state = await readJson(path65.join(worktreeRoot, WORKTREE_STATE_FILE));
13309
13718
  if (state == null || typeof state !== "object" || !Array.isArray(state.members)) return void 0;
@@ -13394,7 +13803,7 @@ async function runAdd(opts) {
13394
13803
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
13395
13804
  members: stateMembers
13396
13805
  });
13397
- const anyFailed = memberResults.some((m) => m.status === "failed");
13806
+ const anyFailed = memberResults.some((m) => !memberOutcomeOk(m));
13398
13807
  return {
13399
13808
  ok: !anyFailed,
13400
13809
  dryRun: false,
@@ -13439,27 +13848,135 @@ async function runDryRun(args) {
13439
13848
  };
13440
13849
  }
13441
13850
 
13851
+ // src/workspace/worktree/autohydrate.ts
13852
+ import { spawn } from "node:child_process";
13853
+ import fsSync from "node:fs";
13854
+ import path71 from "node:path";
13855
+ import fs51 from "fs-extra";
13856
+
13442
13857
  // src/workspace/worktree/doctor.ts
13443
13858
  import { existsSync as existsSync6 } from "node:fs";
13444
- import path68 from "node:path";
13859
+ import path69 from "node:path";
13860
+
13861
+ // src/workspace/worktree/hydrate-lock.ts
13862
+ init_paths();
13863
+ import path67 from "node:path";
13864
+ import fs50 from "fs-extra";
13865
+ var STALE_LOCK_MS = 30 * 60 * 1e3;
13866
+ function locksDir(mainRoot) {
13867
+ return hausPath(mainRoot, "locks");
13868
+ }
13869
+ function logsDir(mainRoot) {
13870
+ return hausPath(mainRoot, "logs");
13871
+ }
13872
+ function assertSlug(slug) {
13873
+ if (!isValidWorktreeSlug(slug)) {
13874
+ throw new Error(
13875
+ `Invalid worktree slug ${JSON.stringify(slug)} \u2014 slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
13876
+ );
13877
+ }
13878
+ return slug;
13879
+ }
13880
+ function hydrateLockPath(mainRoot, slug) {
13881
+ return path67.join(locksDir(mainRoot), `hydrate-${assertSlug(slug)}.lock`);
13882
+ }
13883
+ function hydrateLogPath(mainRoot, slug) {
13884
+ return path67.join(logsDir(mainRoot), `hydrate-${assertSlug(slug)}.log`);
13885
+ }
13886
+ function hydrateLastRunPath(mainRoot, slug) {
13887
+ return path67.join(logsDir(mainRoot), `hydrate-${assertSlug(slug)}.last.json`);
13888
+ }
13889
+ function defaultIsAlive(pid) {
13890
+ try {
13891
+ process.kill(pid, 0);
13892
+ return true;
13893
+ } catch (err) {
13894
+ return err.code === "EPERM";
13895
+ }
13896
+ }
13897
+ async function readOwnerPid(lockPath) {
13898
+ try {
13899
+ const raw = (await fs50.readFile(path67.join(lockPath, "pid"), "utf8")).trim();
13900
+ if (raw === "") return void 0;
13901
+ const pid = Number.parseInt(raw, 10);
13902
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
13903
+ } catch {
13904
+ return void 0;
13905
+ }
13906
+ }
13907
+ async function claim(lockPath, pid) {
13908
+ await fs50.ensureDir(path67.dirname(lockPath));
13909
+ try {
13910
+ await fs50.mkdir(lockPath);
13911
+ } catch {
13912
+ return false;
13913
+ }
13914
+ await fs50.writeFile(path67.join(lockPath, "pid"), String(pid), "utf8");
13915
+ return true;
13916
+ }
13917
+ async function acquireHydrateLock(mainRoot, slug, deps = {}) {
13918
+ const lockPath = hydrateLockPath(mainRoot, slug);
13919
+ const pid = deps.pid ?? process.pid;
13920
+ const isAlive = deps.isAlive ?? defaultIsAlive;
13921
+ const now = deps.now ?? Date.now;
13922
+ if (await claim(lockPath, pid)) return { acquired: true, lockPath };
13923
+ const ownerPid = await readOwnerPid(lockPath);
13924
+ if (ownerPid !== void 0) {
13925
+ let alive;
13926
+ try {
13927
+ alive = isAlive(ownerPid);
13928
+ } catch {
13929
+ alive = true;
13930
+ }
13931
+ if (alive) return { acquired: false, reason: "live", ownerPid, lockPath };
13932
+ } else {
13933
+ const stat = await fs50.stat(lockPath).catch(() => void 0);
13934
+ if (stat === void 0) {
13935
+ return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", lockPath };
13936
+ }
13937
+ if (now() - stat.mtimeMs <= STALE_LOCK_MS) {
13938
+ return { acquired: false, reason: "starting", lockPath };
13939
+ }
13940
+ }
13941
+ await fs50.remove(lockPath);
13942
+ return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", ownerPid, lockPath };
13943
+ }
13944
+ async function recordHydrateLockOwner(mainRoot, slug, pid) {
13945
+ await fs50.writeFile(path67.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
13946
+ }
13947
+ async function releaseHydrateLock(mainRoot, slug) {
13948
+ await fs50.remove(hydrateLockPath(mainRoot, slug));
13949
+ }
13950
+ async function readHydrateLock(mainRoot, slug) {
13951
+ const lockPath = hydrateLockPath(mainRoot, slug);
13952
+ const stat = await fs50.stat(lockPath).catch(() => void 0);
13953
+ if (stat === void 0) return { held: false };
13954
+ const ownerPid = await readOwnerPid(lockPath);
13955
+ return {
13956
+ held: true,
13957
+ ...ownerPid !== void 0 ? { ownerPid } : {},
13958
+ ageMs: Date.now() - stat.mtimeMs,
13959
+ lockPath
13960
+ };
13961
+ }
13445
13962
 
13446
13963
  // src/workspace/worktree/orphans.ts
13447
13964
  import { existsSync as existsSync5 } from "node:fs";
13448
- import path67 from "node:path";
13965
+ import path68 from "node:path";
13449
13966
  var WORKSPACE_REPO_ID = "(workspace)";
13450
13967
  async function findOrphanedRegistrations(repos, wsWorktreesDir) {
13451
13968
  const orphans = [];
13452
13969
  for (const repo of repos) {
13453
13970
  const entries = await listWorktrees(repo.absPath);
13454
13971
  for (const entry of entries) {
13455
- const entryPath = path67.normalize(entry.path);
13456
- const marker = `${path67.sep}.claude${path67.sep}worktrees${path67.sep}`;
13972
+ const entryPath = path68.normalize(entry.path);
13973
+ const marker = `${path68.sep}.claude${path68.sep}worktrees${path68.sep}`;
13457
13974
  const idx = entryPath.indexOf(marker);
13458
13975
  if (idx === -1) continue;
13459
13976
  const rest = entryPath.slice(idx + marker.length);
13460
- const slug = rest.split(path67.sep)[0];
13977
+ const slug = rest.split(path68.sep)[0];
13461
13978
  if (!slug) continue;
13462
- if (existsSync5(path67.join(wsWorktreesDir, slug))) continue;
13979
+ if (existsSync5(path68.join(wsWorktreesDir, slug))) continue;
13463
13980
  orphans.push({
13464
13981
  repoId: repo.id,
13465
13982
  ownerRepo: repo.absPath,
@@ -13474,7 +13991,7 @@ async function findOrphanedRegistrations(repos, wsWorktreesDir) {
13474
13991
  // src/workspace/worktree/doctor.ts
13475
13992
  var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
13476
13993
  function looksHydrated(memberPath) {
13477
- return HYDRATION_MARKERS.some((marker) => existsSync6(path68.join(memberPath, marker)));
13994
+ return HYDRATION_MARKERS.some((marker) => existsSync6(path69.join(memberPath, marker)));
13478
13995
  }
13479
13996
  async function runDoctor2() {
13480
13997
  const rootInfo = await resolveRoots();
@@ -13486,11 +14003,12 @@ async function runDoctor2() {
13486
14003
  orphans: [],
13487
14004
  problems: ["Not inside a git repository."],
13488
14005
  remedies: [],
13489
- autoHydrate: false
14006
+ autoHydrate: false,
14007
+ hydrationInProgress: false
13490
14008
  };
13491
14009
  }
13492
14010
  const workspaceRoot = rootInfo.mainRoot;
13493
- const isWorkspace = existsSync6(path68.join(workspaceRoot, WORKSPACE_FILE)) || existsSync6(path68.join(workspaceRoot, REPOS_MANIFEST_FILE));
14011
+ const isWorkspace = existsSync6(path69.join(workspaceRoot, WORKSPACE_FILE)) || existsSync6(path69.join(workspaceRoot, REPOS_MANIFEST_FILE));
13494
14012
  if (!isWorkspace) {
13495
14013
  return {
13496
14014
  isWorkspace: false,
@@ -13501,11 +14019,12 @@ async function runDoctor2() {
13501
14019
  `Not a haus workspace \u2014 no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
13502
14020
  ],
13503
14021
  remedies: [],
13504
- autoHydrate: false
14022
+ autoHydrate: false,
14023
+ hydrationInProgress: false
13505
14024
  };
13506
14025
  }
13507
14026
  const wsWorktreesDir = worktreesDir(workspaceRoot);
13508
- const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path68.sep}`);
14027
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path69.sep}`);
13509
14028
  const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
13510
14029
  const workspaceConfig = await readWorkspaceConfig(workspaceRoot);
13511
14030
  const autoHydrate = workspaceConfig?.worktree?.autoHydrate === true;
@@ -13514,12 +14033,14 @@ async function runDoctor2() {
13514
14033
  const remedies = [];
13515
14034
  const memberChecks = [];
13516
14035
  let slug;
14036
+ let hydrationInProgress = false;
14037
+ let hydrationOwnerPid;
13517
14038
  if (inWorkspaceWorktree) {
13518
14039
  const wtPath = rootInfo.repoRoot;
13519
- slug = rootInfo.worktreeName ?? path68.basename(wtPath);
14040
+ slug = rootInfo.worktreeName ?? path69.basename(wtPath);
13520
14041
  const state = await readWorktreeState(wtPath);
13521
14042
  for (const member of members) {
13522
- const memberPath = path68.join(wtPath, member.folder);
14043
+ const memberPath = path69.join(wtPath, member.folder);
13523
14044
  const materialized = existsSync6(memberPath);
13524
14045
  const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
13525
14046
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
@@ -13545,9 +14066,14 @@ async function runDoctor2() {
13545
14066
  );
13546
14067
  }
13547
14068
  }
14069
+ const lock = await readHydrateLock(workspaceRoot, slug).catch(() => ({ held: false }));
14070
+ if (lock.held) {
14071
+ hydrationInProgress = true;
14072
+ hydrationOwnerPid = lock.ownerPid;
14073
+ }
13548
14074
  if (memberChecks.some((m) => !m.materialized)) {
13549
14075
  remedies.push(
13550
- "Run `haus workspace worktree hydrate --materialize` to materialize the missing members."
14076
+ hydrationInProgress ? `A hydration is already running for "${slug}"${hydrationOwnerPid !== void 0 ? ` (pid ${hydrationOwnerPid})` : ""} \u2014 wait for ${lock.held ? lock.lockPath : "the lock"} to disappear rather than starting another.` : "Run `haus workspace worktree hydrate --materialize` to materialize the missing members."
13551
14077
  );
13552
14078
  }
13553
14079
  }
@@ -13559,7 +14085,7 @@ async function runDoctor2() {
13559
14085
  wsWorktreesDir
13560
14086
  );
13561
14087
  const orphans = orphanScan.map(
13562
- (o) => `${o.repoId}: worktree registered at ${o.registeredPath} but ${path68.join(wsWorktreesDir, o.slug)} no longer exists.`
14088
+ (o) => `${o.repoId}: worktree registered at ${o.registeredPath} but ${path69.join(wsWorktreesDir, o.slug)} no longer exists.`
13563
14089
  );
13564
14090
  if (orphans.length > 0) {
13565
14091
  problems.push(...orphans);
@@ -13573,14 +14099,16 @@ async function runDoctor2() {
13573
14099
  orphans,
13574
14100
  problems,
13575
14101
  remedies,
13576
- autoHydrate
14102
+ autoHydrate,
14103
+ hydrationInProgress,
14104
+ ...hydrationOwnerPid !== void 0 ? { hydrationOwnerPid } : {}
13577
14105
  };
13578
14106
  }
13579
14107
 
13580
14108
  // src/workspace/worktree/hydrate-command.ts
13581
14109
  init_fs();
13582
14110
  import { existsSync as existsSync7 } from "node:fs";
13583
- import path69 from "node:path";
14111
+ import path70 from "node:path";
13584
14112
  async function runHydrateWorktree(opts) {
13585
14113
  const rootInfo = await resolveRoots();
13586
14114
  if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
@@ -13591,7 +14119,7 @@ async function runHydrateWorktree(opts) {
13591
14119
  }
13592
14120
  const workspaceRoot = rootInfo.mainRoot;
13593
14121
  const wtPath = rootInfo.repoRoot;
13594
- if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path69.sep}`)) {
14122
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path70.sep}`)) {
13595
14123
  return {
13596
14124
  ok: false,
13597
14125
  error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
@@ -13603,13 +14131,13 @@ async function runHydrateWorktree(opts) {
13603
14131
  return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
13604
14132
  }
13605
14133
  const state = await readWorktreeState(wtPath);
13606
- const slug = state?.slug ?? path69.basename(wtPath);
14134
+ const slug = state?.slug ?? path70.basename(wtPath);
13607
14135
  const detectedBranch = await currentBranch(wtPath);
13608
14136
  const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
13609
14137
  if (opts.dryRun) {
13610
14138
  const results2 = await Promise.all(
13611
14139
  selected.map(async (member) => {
13612
- const memberPath = path69.join(wtPath, member.folder);
14140
+ const memberPath = path70.join(wtPath, member.folder);
13613
14141
  if (existsSync7(memberPath)) {
13614
14142
  return { member: member.id, folder: member.folder, status: "planned" };
13615
14143
  }
@@ -13630,7 +14158,7 @@ async function runHydrateWorktree(opts) {
13630
14158
  const results = await mapWithConcurrency(
13631
14159
  selected,
13632
14160
  async (member) => {
13633
- const memberPath = path69.join(wtPath, member.folder);
14161
+ const memberPath = path70.join(wtPath, member.folder);
13634
14162
  let branchAction;
13635
14163
  if (!existsSync7(memberPath)) {
13636
14164
  if (!opts.materialize) {
@@ -13689,47 +14217,7 @@ async function runHydrateWorktree(opts) {
13689
14217
  members: Array.from(byId.values())
13690
14218
  });
13691
14219
  }
13692
- return { ok: results.every((r) => r.status !== "failed"), dryRun: false, slug, results };
13693
- }
13694
-
13695
- // src/workspace/worktree/list.ts
13696
- import path70 from "node:path";
13697
- import fs50 from "fs-extra";
13698
- async function runList2() {
13699
- const resolved = await resolveWorkspaceForWorktree();
13700
- if (!resolved.ok) return { ok: false, error: resolved.reason };
13701
- const { rootInfo, workspaceRoot } = resolved;
13702
- const dir = worktreesDir(workspaceRoot);
13703
- if (!await fs50.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
13704
- const entries = await fs50.readdir(dir, { withFileTypes: true });
13705
- const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
13706
- const configuredMembers = await readMembers(rootInfo);
13707
- const worktrees = [];
13708
- for (const slug of slugs) {
13709
- const wtPath = path70.join(dir, slug);
13710
- const state = await readWorktreeState(wtPath);
13711
- const members = [];
13712
- const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
13713
- for (const m of source) {
13714
- const memberPath = path70.join(wtPath, m.folder);
13715
- const materialized = await fs50.pathExists(memberPath);
13716
- const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
13717
- members.push({
13718
- id: m.id,
13719
- folder: m.folder,
13720
- expectedBranch: m.branch,
13721
- materialized,
13722
- actualBranch
13723
- });
13724
- }
13725
- worktrees.push({
13726
- slug,
13727
- branch: state?.branch ?? await currentBranch(wtPath) ?? "unknown",
13728
- createdAt: state?.createdAt,
13729
- members
13730
- });
13731
- }
13732
- return { ok: true, workspaceRoot, worktrees };
14220
+ return { ok: results.every(memberOutcomeOk), dryRun: false, slug, results };
13733
14221
  }
13734
14222
 
13735
14223
  // src/workspace/worktree/prune.ts
@@ -13772,58 +14260,523 @@ async function runPrune2(opts) {
13772
14260
  return { ok: results.every((r) => r.pruned), dryRun: false, results };
13773
14261
  }
13774
14262
 
13775
- // src/workspace/worktree/remove.ts
13776
- import path71 from "node:path";
13777
- import fs51 from "fs-extra";
13778
- async function runRemove2(opts) {
13779
- const resolved = await resolveWorkspaceForWorktree();
13780
- if (!resolved.ok) return { ok: false, error: resolved.reason };
13781
- const { rootInfo, workspaceRoot } = resolved;
13782
- const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
13783
- if (!await fs51.pathExists(wsWorktreePath)) {
13784
- return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
14263
+ // src/workspace/worktree/autohydrate.ts
14264
+ var NO_AUTO_HYDRATE_ENV = "HAUS_NO_AUTO_HYDRATE";
14265
+ var LOG_SIZE_CAP_BYTES = 512 * 1024;
14266
+ function defaultSpawnDetached(spec) {
14267
+ const fd = fsSync.openSync(spec.logPath, "a");
14268
+ try {
14269
+ const child = spawn(spec.command, spec.args, {
14270
+ cwd: spec.cwd,
14271
+ detached: true,
14272
+ stdio: ["ignore", fd, fd]
14273
+ });
14274
+ child.unref();
14275
+ return child.pid;
14276
+ } finally {
14277
+ fsSync.closeSync(fd);
13785
14278
  }
13786
- const state = await readWorktreeState(wsWorktreePath);
13787
- const allMembers = await readMembers(rootInfo);
13788
- const stateMembers = state?.members.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath })) ?? allMembers.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath }));
13789
- const targets = [
13790
- { repo: "(workspace)", ownerRepo: workspaceRoot, path: wsWorktreePath }
13791
- ];
13792
- const unresolvedDroppedMembers = [];
13793
- for (const stateMember of stateMembers) {
13794
- const configMember = allMembers.find((m) => m.folder === stateMember.folder);
13795
- const ownerRepo = configMember?.absPath ?? stateMember.absPath;
13796
- if (!ownerRepo) {
13797
- if (!configMember) unresolvedDroppedMembers.push(stateMember.folder);
13798
- continue;
14279
+ }
14280
+ function appendLog(logPath, line2) {
14281
+ try {
14282
+ fsSync.mkdirSync(path71.dirname(logPath), { recursive: true });
14283
+ const size = fsSync.statSync(logPath, { throwIfNoEntry: false })?.size ?? 0;
14284
+ if (size > LOG_SIZE_CAP_BYTES) {
14285
+ fsSync.writeFileSync(logPath, `--- log rotated (exceeded ${LOG_SIZE_CAP_BYTES} bytes) ---
14286
+ `);
13799
14287
  }
13800
- targets.push({
13801
- repo: configMember?.id ?? stateMember.id,
13802
- ownerRepo,
13803
- path: path71.join(wsWorktreePath, stateMember.folder)
13804
- });
14288
+ fsSync.appendFileSync(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${line2}
14289
+ `);
14290
+ } catch {
13805
14291
  }
13806
- const existingTargets = [];
13807
- for (const target of targets) {
13808
- if (await fs51.pathExists(target.path)) existingTargets.push(target);
14292
+ }
14293
+ async function consumeLastRunFailure(mainRoot, slug) {
14294
+ const file = hydrateLastRunPath(mainRoot, slug);
14295
+ let parsed;
14296
+ try {
14297
+ parsed = JSON.parse(await fs51.readFile(file, "utf8"));
14298
+ } catch {
14299
+ return void 0;
13809
14300
  }
13810
- if (!opts.force) {
13811
- const blockers = [];
13812
- for (const folder of unresolvedDroppedMembers) {
13813
- blockers.push({
13814
- repo: folder,
13815
- reason: "no longer in the workspace config and this worktree predates absPath tracking \u2014 cannot verify for uncommitted work or unregister its git worktree; removing anyway will delete its directory here but leak the registration in whatever repo it came from (run `git worktree prune` there manually afterward)"
13816
- });
14301
+ await fs51.remove(file).catch(() => void 0);
14302
+ if (parsed && parsed.ok === false) return parsed;
14303
+ return void 0;
14304
+ }
14305
+ async function resolveWorktree() {
14306
+ const rootInfo = await resolveRoots();
14307
+ if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) return void 0;
14308
+ const mainRoot = rootInfo.mainRoot;
14309
+ const wtPath = rootInfo.repoRoot;
14310
+ const slug = slugFromWorktreePath(mainRoot, wtPath);
14311
+ if (slug === void 0) return void 0;
14312
+ return { mainRoot, wtPath, slug };
14313
+ }
14314
+ async function runAutoHydrate(opts = {}, deps = {}) {
14315
+ const env = opts.env ?? process.env;
14316
+ const resolved = await resolveWorktree();
14317
+ if (!resolved) return { status: "not-a-workspace-worktree" };
14318
+ const { mainRoot, wtPath, slug } = resolved;
14319
+ if (env[NO_AUTO_HYDRATE_ENV] === "1") {
14320
+ return { status: "disabled", slug, reason: `${NO_AUTO_HYDRATE_ENV}=1 is set` };
14321
+ }
14322
+ const config = await readWorkspaceConfig(mainRoot);
14323
+ if (config?.worktree?.autoHydrate !== true) {
14324
+ return {
14325
+ status: "disabled",
14326
+ slug,
14327
+ reason: "worktree.autoHydrate is not enabled in haus.workspace.yaml"
14328
+ };
14329
+ }
14330
+ const lastRunFailure = await consumeLastRunFailure(mainRoot, slug);
14331
+ const withLastRun = (result) => lastRunFailure ? { ...result, lastRunFailure } : result;
14332
+ let prunedRepos;
14333
+ if (!opts.dryRun) {
14334
+ const prune = await runPrune2({}).catch(() => void 0);
14335
+ if (prune && !("error" in prune)) {
14336
+ prunedRepos = prune.results.filter((r) => r.pruned).map((r) => r.repo);
13817
14337
  }
13818
- for (const target of existingTargets) {
13819
- const ignorePaths = target.repo === "(workspace)" ? [WORKTREE_STATE_FILE, ...stateMembers.map((m) => m.folder)] : [];
13820
- if (await hasUncommittedChanges(target.path, ignorePaths)) {
13821
- blockers.push({ repo: target.repo, reason: "uncommitted changes" });
14338
+ }
14339
+ const report = await runDoctor2();
14340
+ const missingMembers = report.members.filter((m) => !m.materialized).map((m) => m.id);
14341
+ if (missingMembers.length === 0) {
14342
+ return withLastRun({
14343
+ status: "up-to-date",
14344
+ slug,
14345
+ missingMembers,
14346
+ ...prunedRepos ? { prunedRepos } : {}
14347
+ });
14348
+ }
14349
+ const logPath = hydrateLogPath(mainRoot, slug);
14350
+ if (opts.dryRun) {
14351
+ return withLastRun({ status: "planned", slug, missingMembers, logPath });
14352
+ }
14353
+ const acquired = await acquireHydrateLock(mainRoot, slug);
14354
+ if (!acquired.acquired) {
14355
+ return withLastRun({
14356
+ status: "in-progress",
14357
+ slug,
14358
+ missingMembers,
14359
+ ...acquired.ownerPid !== void 0 ? { ownerPid: acquired.ownerPid } : {},
14360
+ lockPath: acquired.lockPath,
14361
+ logPath
14362
+ });
14363
+ }
14364
+ const entry = process.argv[1];
14365
+ if (entry === void 0) {
14366
+ await releaseHydrateLock(mainRoot, slug);
14367
+ return {
14368
+ status: "error",
14369
+ slug,
14370
+ reason: "could not determine the haus CLI entry point to re-invoke"
14371
+ };
14372
+ }
14373
+ await fs51.ensureDir(logsDir(mainRoot));
14374
+ appendLog(
14375
+ logPath,
14376
+ `autohydrate: forking hydrate --materialize for "${slug}" (${missingMembers.join(", ")})`
14377
+ );
14378
+ const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
14379
+ let pid;
14380
+ try {
14381
+ pid = spawnDetached({
14382
+ command: process.execPath,
14383
+ args: [entry, "workspace", "worktree", "autohydrate", "--detached-child"],
14384
+ cwd: wtPath,
14385
+ logPath
14386
+ });
14387
+ } catch (err) {
14388
+ await releaseHydrateLock(mainRoot, slug);
14389
+ return {
14390
+ status: "error",
14391
+ slug,
14392
+ reason: err instanceof Error ? err.message : String(err)
14393
+ };
14394
+ }
14395
+ if (pid === void 0) {
14396
+ await releaseHydrateLock(mainRoot, slug);
14397
+ return { status: "error", slug, reason: "the detached hydration reported no pid" };
14398
+ }
14399
+ await recordHydrateLockOwner(mainRoot, slug, pid);
14400
+ return withLastRun({
14401
+ status: "started",
14402
+ slug,
14403
+ missingMembers,
14404
+ pid,
14405
+ lockPath: acquired.lockPath,
14406
+ logPath,
14407
+ ...prunedRepos ? { prunedRepos } : {}
14408
+ });
14409
+ }
14410
+ async function runDetachedHydration() {
14411
+ const resolved = await resolveWorktree();
14412
+ if (!resolved) return { ok: false, failures: ["not inside a workspace worktree"] };
14413
+ const { mainRoot, slug } = resolved;
14414
+ const logPath = hydrateLogPath(mainRoot, slug);
14415
+ try {
14416
+ const result = await runHydrateWorktree({ materialize: true });
14417
+ if ("error" in result && !result.ok) {
14418
+ const failures2 = [result.error];
14419
+ await writeLastRun(mainRoot, slug, false, failures2);
14420
+ appendLog(logPath, `hydrate failed: ${result.error}`);
14421
+ return { ok: false, slug, failures: failures2 };
14422
+ }
14423
+ const detail = "results" in result ? result.results : [];
14424
+ const failures = detail.filter((r) => r.status === "failed" || installFailed(r.hydration?.install)).map(
14425
+ (r) => r.status === "failed" ? `${r.member}: failed to materialize \u2014 ${r.error ?? "unknown error"}` : `${r.member}: materialized, but its install FAILED via ${r.hydration?.install.plan.manager}`
14426
+ );
14427
+ await writeLastRun(mainRoot, slug, result.ok, failures);
14428
+ appendLog(logPath, `hydrate finished: ${result.ok ? "ok" : `FAILED (${failures.join("; ")})`}`);
14429
+ return { ok: result.ok, slug, failures };
14430
+ } catch (err) {
14431
+ const message2 = err instanceof Error ? err.message : String(err);
14432
+ await writeLastRun(mainRoot, slug, false, [message2]);
14433
+ appendLog(logPath, `hydrate threw: ${message2}`);
14434
+ return { ok: false, slug, failures: [message2] };
14435
+ } finally {
14436
+ await releaseHydrateLock(mainRoot, slug).catch(() => void 0);
14437
+ }
14438
+ }
14439
+ async function writeLastRun(mainRoot, slug, ok, failures) {
14440
+ const file = hydrateLastRunPath(mainRoot, slug);
14441
+ if (ok) {
14442
+ await fs51.remove(file).catch(() => void 0);
14443
+ return;
14444
+ }
14445
+ const record = { slug, finishedAt: (/* @__PURE__ */ new Date()).toISOString(), ok, failures };
14446
+ await fs51.ensureDir(path71.dirname(file));
14447
+ await fs51.writeJson(file, record, { spaces: 2 }).catch(() => void 0);
14448
+ }
14449
+
14450
+ // src/workspace/worktree/dematerialize.ts
14451
+ import path73 from "node:path";
14452
+ import fs53 from "fs-extra";
14453
+
14454
+ // src/workspace/worktree/member-targets.ts
14455
+ import path72 from "node:path";
14456
+ import fs52 from "fs-extra";
14457
+ function normalizeStateMembers(state, allMembers) {
14458
+ return state?.members.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath })) ?? allMembers.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath }));
14459
+ }
14460
+ function resolveMemberTargets(wsWorktreePath, stateMembers, allMembers) {
14461
+ const targets = [];
14462
+ const unresolvedDroppedMembers = [];
14463
+ for (const stateMember of stateMembers) {
14464
+ const configMember = allMembers.find((m) => m.folder === stateMember.folder);
14465
+ const ownerRepo = configMember?.absPath ?? stateMember.absPath;
14466
+ if (!ownerRepo) {
14467
+ if (!configMember) unresolvedDroppedMembers.push(stateMember.folder);
14468
+ continue;
14469
+ }
14470
+ targets.push({
14471
+ repo: configMember?.id ?? stateMember.id,
14472
+ ownerRepo,
14473
+ path: path72.join(wsWorktreePath, stateMember.folder)
14474
+ });
14475
+ }
14476
+ return { targets, unresolvedDroppedMembers };
14477
+ }
14478
+ async function filterExistingTargets(targets) {
14479
+ const existing = [];
14480
+ for (const target of targets) {
14481
+ if (await fs52.pathExists(target.path)) existing.push(target);
14482
+ }
14483
+ return existing;
14484
+ }
14485
+ function unresolvedDroppedBlockerReason() {
14486
+ return "no longer in the workspace config and this worktree predates absPath tracking \u2014 cannot verify for uncommitted work or unregister its git worktree; removing anyway will delete its directory here but leak the registration in whatever repo it came from (run `git worktree prune` there manually afterward)";
14487
+ }
14488
+ function unresolvedDroppedFailureReason() {
14489
+ return "directory removed, but its git worktree registration could not be unregistered (no recorded absPath) \u2014 run `git worktree prune` in its original owning repo";
14490
+ }
14491
+ async function findWorkBlockers(targets, ignorePathsFor = () => []) {
14492
+ const blockers = [];
14493
+ for (const target of targets) {
14494
+ if (await hasUncommittedChanges(target.path, ignorePathsFor(target))) {
14495
+ blockers.push({ repo: target.repo, reason: "uncommitted changes" });
14496
+ continue;
14497
+ }
14498
+ const unpushed = await hasUnpushedWork(target.path);
14499
+ if (unpushed.unpushed) blockers.push({ repo: target.repo, reason: unpushed.reason });
14500
+ }
14501
+ return blockers;
14502
+ }
14503
+
14504
+ // src/workspace/worktree/dematerialize.ts
14505
+ function resolveSlug(opts, workspaceRoot, repoRoot, isLinkedWorktree) {
14506
+ if (opts.slug !== void 0) return { slug: opts.slug };
14507
+ const slug = isLinkedWorktree ? slugFromWorktreePath(workspaceRoot, repoRoot) : void 0;
14508
+ if (slug !== void 0) return { slug };
14509
+ return {
14510
+ error: "Name the worktree to dematerialize (`haus workspace worktree dematerialize <slug>`), or run this from inside one of this workspace's .claude/worktrees/<slug> checkouts."
14511
+ };
14512
+ }
14513
+ async function runDematerialize(opts) {
14514
+ const resolved = await resolveWorkspaceForWorktree();
14515
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
14516
+ const { rootInfo, workspaceRoot } = resolved;
14517
+ const slugResult = resolveSlug(opts, workspaceRoot, rootInfo.repoRoot, rootInfo.isLinkedWorktree);
14518
+ if ("error" in slugResult) return { ok: false, error: slugResult.error };
14519
+ const { slug } = slugResult;
14520
+ let wsWorktreePath;
14521
+ try {
14522
+ wsWorktreePath = worktreePath(workspaceRoot, slug);
14523
+ } catch (err) {
14524
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
14525
+ }
14526
+ if (!await fs53.pathExists(wsWorktreePath)) {
14527
+ return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
14528
+ }
14529
+ const lock = await readHydrateLock(workspaceRoot, slug);
14530
+ if (lock.held) {
14531
+ return {
14532
+ ok: false,
14533
+ error: `A hydration is running for "${slug}"${lock.ownerPid !== void 0 ? ` (pid ${lock.ownerPid})` : ""} \u2014 wait for ${lock.lockPath} to disappear before dematerializing it.`
14534
+ };
14535
+ }
14536
+ const state = await readWorktreeState(wsWorktreePath);
14537
+ const allMembers = await readMembers(rootInfo);
14538
+ const stateMembers = normalizeStateMembers(state, allMembers);
14539
+ const { targets, unresolvedDroppedMembers } = resolveMemberTargets(
14540
+ wsWorktreePath,
14541
+ stateMembers,
14542
+ allMembers
14543
+ );
14544
+ const existingTargets = await filterExistingTargets(targets);
14545
+ if (!opts.force) {
14546
+ const blockers = unresolvedDroppedMembers.map((folder) => ({
14547
+ repo: folder,
14548
+ reason: unresolvedDroppedBlockerReason()
14549
+ }));
14550
+ blockers.push(...await findWorkBlockers(existingTargets));
14551
+ if (blockers.length > 0) return { ok: false, blocked: true, slug, blockers };
14552
+ }
14553
+ if (opts.dryRun) {
14554
+ return { ok: true, dryRun: true, slug, wouldRemove: existingTargets.map((t) => t.repo) };
14555
+ }
14556
+ const removed = [];
14557
+ const failed = [];
14558
+ for (const folder of unresolvedDroppedMembers) {
14559
+ const droppedPath = path73.join(wsWorktreePath, folder);
14560
+ if (await fs53.pathExists(droppedPath)) {
14561
+ await fs53.remove(droppedPath);
14562
+ failed.push({ repo: folder, error: unresolvedDroppedFailureReason() });
14563
+ }
14564
+ }
14565
+ for (const target of existingTargets) {
14566
+ const result = await removeWorktree(target.ownerRepo, target.path);
14567
+ if (result.ok) removed.push(target.repo);
14568
+ else failed.push({ repo: target.repo, error: result.stderr ?? "unknown error" });
14569
+ await pruneWorktrees(target.ownerRepo);
14570
+ }
14571
+ return { ok: failed.length === 0, dryRun: false, slug, removed, failed };
14572
+ }
14573
+
14574
+ // src/workspace/worktree/list.ts
14575
+ import path74 from "node:path";
14576
+ import fs54 from "fs-extra";
14577
+ async function runList2() {
14578
+ const resolved = await resolveWorkspaceForWorktree();
14579
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
14580
+ const { rootInfo, workspaceRoot } = resolved;
14581
+ const dir = worktreesDir(workspaceRoot);
14582
+ if (!await fs54.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
14583
+ const entries = await fs54.readdir(dir, { withFileTypes: true });
14584
+ const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
14585
+ const configuredMembers = await readMembers(rootInfo);
14586
+ const worktrees = [];
14587
+ for (const slug of slugs) {
14588
+ const wtPath = path74.join(dir, slug);
14589
+ const state = await readWorktreeState(wtPath);
14590
+ const members = [];
14591
+ const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
14592
+ for (const m of source) {
14593
+ const memberPath = path74.join(wtPath, m.folder);
14594
+ const materialized = await fs54.pathExists(memberPath);
14595
+ const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
14596
+ members.push({
14597
+ id: m.id,
14598
+ folder: m.folder,
14599
+ expectedBranch: m.branch,
14600
+ materialized,
14601
+ actualBranch
14602
+ });
14603
+ }
14604
+ worktrees.push({
14605
+ slug,
14606
+ branch: state?.branch ?? await currentBranch(wtPath) ?? "unknown",
14607
+ createdAt: state?.createdAt,
14608
+ members
14609
+ });
14610
+ }
14611
+ return { ok: true, workspaceRoot, worktrees };
14612
+ }
14613
+
14614
+ // src/workspace/worktree/reclaim.ts
14615
+ import { existsSync as existsSync8 } from "node:fs";
14616
+ import path75 from "node:path";
14617
+ import fs55 from "fs-extra";
14618
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
14619
+ async function lastTouchedMs(worktreeRoot) {
14620
+ const candidates = [worktreeRoot, path75.join(worktreeRoot, ".haus-worktree.json")];
14621
+ try {
14622
+ const dotGit = await fs55.readFile(path75.join(worktreeRoot, ".git"), "utf8");
14623
+ const match = /^gitdir:\s*(.+)$/m.exec(dotGit);
14624
+ if (match?.[1]) {
14625
+ const gitdir = match[1].trim();
14626
+ candidates.push(path75.join(gitdir, "HEAD"), path75.join(gitdir, "index"));
14627
+ }
14628
+ } catch {
14629
+ }
14630
+ let newest = 0;
14631
+ for (const candidate of candidates) {
14632
+ const stat = await fs55.stat(candidate).catch(() => void 0);
14633
+ if (stat && stat.mtimeMs > newest) newest = stat.mtimeMs;
14634
+ }
14635
+ return newest;
14636
+ }
14637
+ async function runReclaim(opts = {}) {
14638
+ const resolved = await resolveWorkspaceForWorktree();
14639
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
14640
+ const { rootInfo, workspaceRoot } = resolved;
14641
+ const config = await readWorkspaceConfig(workspaceRoot);
14642
+ const olderThanDays = opts.olderThanDays ?? config?.worktree?.reclaimAfterDays;
14643
+ if (olderThanDays === void 0) {
14644
+ return {
14645
+ ok: false,
14646
+ error: "No reclamation age configured. Pass `--older-than-days <n>`, or set `worktree.reclaimAfterDays` in haus.workspace.yaml."
14647
+ };
14648
+ }
14649
+ if (!Number.isFinite(olderThanDays) || olderThanDays <= 0) {
14650
+ return {
14651
+ ok: false,
14652
+ error: `--older-than-days must be a number greater than 0 (got ${String(olderThanDays)}).`
14653
+ };
14654
+ }
14655
+ const dir = worktreesDir(workspaceRoot);
14656
+ if (!existsSync8(dir)) {
14657
+ return { ok: true, dryRun: !opts.write, olderThanDays, candidates: [], reclaimed: 0 };
14658
+ }
14659
+ const allEntries = (await fs55.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
14660
+ const entries = allEntries.filter((name) => isValidWorktreeSlug(name));
14661
+ const unaddressable = allEntries.filter((name) => !isValidWorktreeSlug(name));
14662
+ const members = await readMembers(rootInfo);
14663
+ const currentSlug = rootInfo.isLinkedWorktree ? slugFromWorktreePath(workspaceRoot, rootInfo.repoRoot) : void 0;
14664
+ const candidates = [];
14665
+ let reclaimed = 0;
14666
+ for (const name of unaddressable) {
14667
+ candidates.push({
14668
+ slug: name,
14669
+ ageDays: (Date.now() - await lastTouchedMs(path75.join(dir, name))) / MS_PER_DAY,
14670
+ materializedMembers: 0,
14671
+ action: "skip",
14672
+ reason: "directory name is not a valid worktree slug \u2014 no haus worktree command can address it; remove it by hand if it is debris"
14673
+ });
14674
+ }
14675
+ for (const slug of entries) {
14676
+ const wtPath = path75.join(dir, slug);
14677
+ const state = await readWorktreeState(wtPath);
14678
+ const stateMembers = normalizeStateMembers(state, members);
14679
+ const materialized = stateMembers.filter((m) => existsSync8(path75.join(wtPath, m.folder)));
14680
+ const ageDays = (Date.now() - await lastTouchedMs(wtPath)) / MS_PER_DAY;
14681
+ const base = { slug, ageDays, materializedMembers: materialized.length };
14682
+ if (slug === currentSlug) {
14683
+ candidates.push({ ...base, action: "skip", reason: "this is the current worktree" });
14684
+ continue;
14685
+ }
14686
+ if (materialized.length === 0) {
14687
+ candidates.push({ ...base, action: "skip", reason: "nothing materialized to reclaim" });
14688
+ continue;
14689
+ }
14690
+ const lock = await readHydrateLock(workspaceRoot, slug).catch(() => ({ held: false }));
14691
+ if (lock.held) {
14692
+ candidates.push({ ...base, action: "skip", reason: "a hydration is running for it" });
14693
+ continue;
14694
+ }
14695
+ if (ageDays < olderThanDays) {
14696
+ candidates.push({
14697
+ ...base,
14698
+ action: "skip",
14699
+ reason: `touched ${ageDays.toFixed(1)} days ago (threshold ${olderThanDays})`
14700
+ });
14701
+ continue;
14702
+ }
14703
+ const outcome = await runDematerialize({ slug, dryRun: !opts.write });
14704
+ if (!outcome.ok && "blocked" in outcome) {
14705
+ candidates.push({ ...base, action: "blocked", blockers: outcome.blockers });
14706
+ continue;
14707
+ }
14708
+ if (!outcome.ok && "error" in outcome) {
14709
+ candidates.push({ ...base, action: "failed", reason: outcome.error });
14710
+ continue;
14711
+ }
14712
+ if ("dryRun" in outcome && outcome.dryRun) {
14713
+ candidates.push({ ...base, action: "reclaim", members: outcome.wouldRemove });
14714
+ continue;
14715
+ }
14716
+ if ("removed" in outcome) {
14717
+ if (outcome.failed.length > 0) {
14718
+ candidates.push({
14719
+ ...base,
14720
+ action: "failed",
14721
+ members: outcome.removed,
14722
+ reason: outcome.failed.map((f) => `${f.repo}: ${f.error}`).join("; ")
14723
+ });
13822
14724
  continue;
13823
14725
  }
13824
- const unpushed = await hasUnpushedWork(target.path);
13825
- if (unpushed.unpushed) blockers.push({ repo: target.repo, reason: unpushed.reason });
14726
+ reclaimed += 1;
14727
+ candidates.push({ ...base, action: "reclaim", members: outcome.removed });
13826
14728
  }
14729
+ }
14730
+ return {
14731
+ ok: candidates.every((c) => c.action !== "failed"),
14732
+ dryRun: !opts.write,
14733
+ olderThanDays,
14734
+ candidates,
14735
+ reclaimed
14736
+ };
14737
+ }
14738
+
14739
+ // src/workspace/worktree/remove.ts
14740
+ import fs56 from "fs-extra";
14741
+ async function runRemove2(opts) {
14742
+ const resolved = await resolveWorkspaceForWorktree();
14743
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
14744
+ const { rootInfo, workspaceRoot } = resolved;
14745
+ const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
14746
+ if (!await fs56.pathExists(wsWorktreePath)) {
14747
+ return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
14748
+ }
14749
+ const state = await readWorktreeState(wsWorktreePath);
14750
+ const allMembers = await readMembers(rootInfo);
14751
+ const stateMembers = normalizeStateMembers(state, allMembers);
14752
+ const { targets: memberOnlyTargets, unresolvedDroppedMembers } = resolveMemberTargets(
14753
+ wsWorktreePath,
14754
+ stateMembers,
14755
+ allMembers
14756
+ );
14757
+ const targets = [
14758
+ { repo: "(workspace)", ownerRepo: workspaceRoot, path: wsWorktreePath },
14759
+ ...memberOnlyTargets
14760
+ ];
14761
+ const existingTargets = await filterExistingTargets(targets);
14762
+ if (!opts.force) {
14763
+ const blockers = unresolvedDroppedMembers.map((folder) => ({
14764
+ repo: folder,
14765
+ reason: unresolvedDroppedBlockerReason()
14766
+ }));
14767
+ blockers.push(
14768
+ ...await findWorkBlockers(
14769
+ existingTargets,
14770
+ (target) => (
14771
+ // The workspace worktree root always contains content that is untracked by
14772
+ // design and must not read as "uncommitted work": our own state file, and
14773
+ // every member folder (each a separate nested repo, so it shows as `?? forms/`
14774
+ // whether or not that member is clean). Members are dirty-checked
14775
+ // independently as their own targets.
14776
+ target.repo === "(workspace)" ? [WORKTREE_STATE_FILE, ...stateMembers.map((m) => m.folder)] : []
14777
+ )
14778
+ )
14779
+ );
13827
14780
  if (blockers.length > 0) return { ok: false, blocked: true, blockers };
13828
14781
  }
13829
14782
  if (opts.dryRun) {
@@ -13837,10 +14790,7 @@ async function runRemove2(opts) {
13837
14790
  const removed = [];
13838
14791
  const failed = [];
13839
14792
  for (const folder of unresolvedDroppedMembers) {
13840
- failed.push({
13841
- repo: folder,
13842
- error: "directory removed, but its git worktree registration could not be unregistered (no recorded absPath) \u2014 run `git worktree prune` in its original owning repo"
13843
- });
14793
+ failed.push({ repo: folder, error: unresolvedDroppedFailureReason() });
13844
14794
  }
13845
14795
  const memberTargets = existingTargets.filter((t) => t.repo !== "(workspace)");
13846
14796
  for (const target of memberTargets) {
@@ -13861,8 +14811,8 @@ async function runRemove2(opts) {
13861
14811
  }
13862
14812
  await pruneWorktrees(workspaceRoot);
13863
14813
  }
13864
- if (wsRemoveOk && await fs51.pathExists(wsWorktreePath)) {
13865
- await fs51.remove(wsWorktreePath);
14814
+ if (wsRemoveOk && await fs56.pathExists(wsWorktreePath)) {
14815
+ await fs56.remove(wsWorktreePath);
13866
14816
  }
13867
14817
  return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
13868
14818
  }
@@ -13878,6 +14828,23 @@ function emitMemberLine(hydration, line2) {
13878
14828
  if (hydration?.install.ran && !hydration.install.ok) warn(line2);
13879
14829
  else log(line2);
13880
14830
  }
14831
+ function countMemberFailures(members) {
14832
+ let materialize = 0;
14833
+ let install = 0;
14834
+ for (const m of members) {
14835
+ if (m.status === "failed") materialize += 1;
14836
+ else if (installFailed(m.hydration?.install)) install += 1;
14837
+ }
14838
+ return { materialize, install };
14839
+ }
14840
+ function describeMemberFailures(members) {
14841
+ const { materialize, install } = countMemberFailures(members);
14842
+ const parts = [];
14843
+ if (materialize > 0) parts.push(`${materialize} member(s) failed to materialize`);
14844
+ if (install > 0) parts.push(`${install} member(s) materialized but their install FAILED`);
14845
+ const summary = parts.length > 0 ? parts.join(" and ") : "the run did not complete cleanly";
14846
+ return `${summary} \u2014 see the per-member detail above for exactly what exists.`;
14847
+ }
13881
14848
  async function handleAdd(opts) {
13882
14849
  if (!opts.slug) {
13883
14850
  if (opts.json) emitJson({ ok: false, error: "Usage: haus workspace worktree add <slug>" });
@@ -13930,10 +14897,7 @@ async function handleAdd(opts) {
13930
14897
  }
13931
14898
  }
13932
14899
  if (!result.ok) {
13933
- const failedCount = result.members.filter((m) => m.status === "failed").length;
13934
- error(
13935
- `${failedCount} member(s) failed to materialize \u2014 the worktree is partially built. See the per-member detail above for exactly what exists.`
13936
- );
14900
+ error(`The worktree is partially built: ${describeMemberFailures(result.members)}`);
13937
14901
  process.exitCode = 1;
13938
14902
  }
13939
14903
  }
@@ -13976,7 +14940,10 @@ async function handleHydrate(opts) {
13976
14940
  warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
13977
14941
  }
13978
14942
  }
13979
- if (!result.ok) process.exitCode = 1;
14943
+ if (!result.ok) {
14944
+ error(describeMemberFailures(result.results));
14945
+ process.exitCode = 1;
14946
+ }
13980
14947
  }
13981
14948
  async function handleRemove(opts) {
13982
14949
  if (!opts.slug) {
@@ -14119,6 +15086,168 @@ async function handleDoctor(opts) {
14119
15086
  }
14120
15087
  }
14121
15088
  }
15089
+ function describeAutoHydrate(result) {
15090
+ const lines = [];
15091
+ if (result.lastRunFailure) {
15092
+ lines.push(
15093
+ `The previous automatic hydration of "${result.lastRunFailure.slug}" FAILED (${result.lastRunFailure.finishedAt}): ${result.lastRunFailure.failures.join("; ")}. Re-run \`haus workspace worktree hydrate --materialize\` and read the error before trusting the member repos.`
15094
+ );
15095
+ }
15096
+ switch (result.status) {
15097
+ case "started":
15098
+ lines.push(
15099
+ `The member repos are missing in this worktree and are being hydrated in the background now (${result.missingMembers?.join(", ")}). This takes minutes, not seconds. Wait until ${result.lockPath} disappears before reading any file in a member repo \u2014 \`haus workspace worktree doctor\` turns green before the work is finished. Log: ${result.logPath}`
15100
+ );
15101
+ break;
15102
+ case "in-progress":
15103
+ lines.push(
15104
+ `The member repos in this worktree are already being hydrated by another session${result.ownerPid !== void 0 ? ` (pid ${result.ownerPid})` : ""}. Wait until ${result.lockPath} disappears before reading files in the member repos.`
15105
+ );
15106
+ break;
15107
+ case "planned":
15108
+ lines.push(
15109
+ `[dry-run] Would hydrate ${result.missingMembers?.join(", ")} in the background for "${result.slug}". Log would be ${result.logPath}.`
15110
+ );
15111
+ break;
15112
+ case "error":
15113
+ lines.push(`Automatic hydration could not start: ${result.reason ?? "unknown reason"}`);
15114
+ break;
15115
+ case "disabled":
15116
+ case "up-to-date":
15117
+ case "not-a-workspace-worktree":
15118
+ break;
15119
+ }
15120
+ return lines;
15121
+ }
15122
+ async function handleAutoHydrate(opts) {
15123
+ if (opts.detachedChild) {
15124
+ const outcome = await runDetachedHydration();
15125
+ if (opts.json) emitJson(outcome);
15126
+ else if (!outcome.ok) for (const f of outcome.failures) error(f);
15127
+ if (!outcome.ok) process.exitCode = 1;
15128
+ return;
15129
+ }
15130
+ let result;
15131
+ try {
15132
+ result = await runAutoHydrate({
15133
+ dryRun: resolveWriteMode(opts, "mutate-by-default").kind === "preview"
15134
+ });
15135
+ } catch (err) {
15136
+ const message2 = err instanceof Error ? err.message : String(err);
15137
+ if (opts.fromHook) return;
15138
+ if (opts.json) emitJson({ status: "error", reason: message2 });
15139
+ else error(message2);
15140
+ process.exitCode = 1;
15141
+ return;
15142
+ }
15143
+ const lines = describeAutoHydrate(result);
15144
+ if (opts.fromHook) {
15145
+ if (lines.length > 0) emitFromHookContext(lines.join(" "));
15146
+ return;
15147
+ }
15148
+ if (opts.json) {
15149
+ emitJson(result);
15150
+ if (result.status === "error") process.exitCode = 1;
15151
+ return;
15152
+ }
15153
+ if (lines.length === 0) {
15154
+ log(
15155
+ result.status === "disabled" ? `Automatic hydration is off: ${result.reason ?? "not enabled"}.` : "Nothing to hydrate."
15156
+ );
15157
+ return;
15158
+ }
15159
+ for (const line2 of lines) log(line2);
15160
+ if (result.status === "error") process.exitCode = 1;
15161
+ }
15162
+ async function handleDematerialize(opts) {
15163
+ const dematOpts = {
15164
+ ...opts.slug !== void 0 ? { slug: opts.slug } : {},
15165
+ force: Boolean(opts.force),
15166
+ dryRun: resolveWriteMode(opts, "mutate-by-default").kind === "preview"
15167
+ };
15168
+ const result = await runDematerialize(dematOpts);
15169
+ if (opts.json) {
15170
+ emitJson(result);
15171
+ if (!result.ok || "removed" in result && result.failed.length > 0) process.exitCode = 1;
15172
+ return;
15173
+ }
15174
+ if (!result.ok && "blocked" in result) {
15175
+ error(`Refusing to dematerialize "${result.slug}" \u2014 uncommitted or unpushed work found:`);
15176
+ for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
15177
+ error("Re-run with --force to dematerialize anyway.");
15178
+ process.exitCode = 1;
15179
+ return;
15180
+ }
15181
+ if (!result.ok && "error" in result) {
15182
+ error(result.error);
15183
+ process.exitCode = 1;
15184
+ return;
15185
+ }
15186
+ if ("dryRun" in result && result.dryRun) {
15187
+ log(`[dry-run] Would dematerialize: ${result.wouldRemove.join(", ") || "(nothing)"}`);
15188
+ return;
15189
+ }
15190
+ if ("removed" in result) {
15191
+ log(
15192
+ `Dematerialized "${result.slug}": ${result.removed.join(", ") || "(nothing)"}. The workspace worktree and its branch are untouched \u2014 run \`haus workspace worktree hydrate --materialize\` to bring the members back.`
15193
+ );
15194
+ if (result.failed.length > 0) {
15195
+ for (const f of result.failed) error(` Failed to dematerialize ${f.repo}: ${f.error}`);
15196
+ process.exitCode = 1;
15197
+ }
15198
+ }
15199
+ }
15200
+ async function handleReclaim(opts) {
15201
+ const result = await runReclaim({
15202
+ ...opts.olderThanDays !== void 0 ? { olderThanDays: opts.olderThanDays } : {},
15203
+ write: resolveWriteMode(opts, "preview-by-default").kind === "write"
15204
+ });
15205
+ if (opts.json) {
15206
+ emitJson(result);
15207
+ if (!result.ok) process.exitCode = 1;
15208
+ return;
15209
+ }
15210
+ if (!result.ok && "error" in result) {
15211
+ error(result.error);
15212
+ process.exitCode = 1;
15213
+ return;
15214
+ }
15215
+ if (!("candidates" in result)) return;
15216
+ if (result.candidates.length === 0) {
15217
+ log("No workspace worktrees under .claude/worktrees/.");
15218
+ return;
15219
+ }
15220
+ for (const c of result.candidates) {
15221
+ const age = `${c.ageDays.toFixed(1)}d`;
15222
+ switch (c.action) {
15223
+ case "reclaim":
15224
+ log(
15225
+ dryRunPrefix(
15226
+ result.dryRun,
15227
+ `${result.dryRun ? "Would reclaim" : "Reclaimed"} ${c.slug} (${age}): ${(c.members ?? []).join(", ")}`
15228
+ )
15229
+ );
15230
+ break;
15231
+ case "blocked":
15232
+ warn(`Blocked ${c.slug} (${age}) \u2014 uncommitted or unpushed work:`);
15233
+ for (const b of c.blockers ?? []) warn(` ${b.repo}: ${b.reason}`);
15234
+ break;
15235
+ case "failed":
15236
+ error(`FAILED ${c.slug} (${age}): ${c.reason ?? "unknown error"}`);
15237
+ break;
15238
+ case "skip":
15239
+ log(`Keeping ${c.slug} (${age}) \u2014 ${c.reason ?? "not a candidate"}`);
15240
+ break;
15241
+ }
15242
+ }
15243
+ if (result.dryRun) {
15244
+ const would = result.candidates.filter((c) => c.action === "reclaim").length;
15245
+ log(
15246
+ would > 0 ? `Re-run with --write to reclaim ${would} worktree(s).` : "Nothing to reclaim at this age."
15247
+ );
15248
+ }
15249
+ if (!result.ok) process.exitCode = 1;
15250
+ }
14122
15251
  async function runWorktree(action, opts = {}) {
14123
15252
  switch (action) {
14124
15253
  case "add":
@@ -14133,12 +15262,18 @@ async function runWorktree(action, opts = {}) {
14133
15262
  return handleDoctor(opts);
14134
15263
  case "prune":
14135
15264
  return handlePrune(opts);
15265
+ case "autohydrate":
15266
+ return handleAutoHydrate(opts);
15267
+ case "dematerialize":
15268
+ return handleDematerialize(opts);
15269
+ case "reclaim":
15270
+ return handleReclaim(opts);
14136
15271
  }
14137
15272
  }
14138
15273
 
14139
15274
  // src/commands/workspace.ts
14140
- import { existsSync as existsSync11, statSync as statSync3 } from "node:fs";
14141
- import path80 from "node:path";
15275
+ import { existsSync as existsSync12, statSync as statSync3 } from "node:fs";
15276
+ import path84 from "node:path";
14142
15277
  init_fs();
14143
15278
  init_logger();
14144
15279
 
@@ -14196,8 +15331,8 @@ ${summaries.map(
14196
15331
  }
14197
15332
 
14198
15333
  // src/commands/workspace/doctor.ts
14199
- import { existsSync as existsSync8 } from "node:fs";
14200
- import path73 from "node:path";
15334
+ import { existsSync as existsSync9 } from "node:fs";
15335
+ import path77 from "node:path";
14201
15336
  init_logger();
14202
15337
  init_paths();
14203
15338
 
@@ -14205,14 +15340,14 @@ init_paths();
14205
15340
  init_fs();
14206
15341
  init_paths();
14207
15342
  import { readFileSync as readFileSync4 } from "node:fs";
14208
- import path72 from "node:path";
15343
+ import path76 from "node:path";
14209
15344
  var MANIFEST_FILE2 = "workspace.manifest.json";
14210
15345
  function manifestPath(workspaceRoot) {
14211
15346
  return hausPath(workspaceRoot, MANIFEST_FILE2);
14212
15347
  }
14213
15348
  function hausVersion() {
14214
15349
  try {
14215
- const pkg = JSON.parse(readFileSync4(path72.join(packageRoot(), "package.json"), "utf8"));
15350
+ const pkg = JSON.parse(readFileSync4(path76.join(packageRoot(), "package.json"), "utf8"));
14216
15351
  return pkg.version ?? "0.0.0";
14217
15352
  } catch {
14218
15353
  return "0.0.0";
@@ -14304,7 +15439,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14304
15439
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
14305
15440
  const catalogRefByRepo = [];
14306
15441
  for (const repo of config.repos) {
14307
- const repoRoot = path73.resolve(workspaceRoot, repo.path);
15442
+ const repoRoot = path77.resolve(workspaceRoot, repo.path);
14308
15443
  const entry = manifestByName.get(repo.name);
14309
15444
  if (!entry) {
14310
15445
  flag({
@@ -14329,7 +15464,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14329
15464
  detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
14330
15465
  });
14331
15466
  }
14332
- if (!existsSync8(claudePath(repoRoot))) {
15467
+ if (!existsSync9(claudePath(repoRoot))) {
14333
15468
  flag({
14334
15469
  repo: repo.name,
14335
15470
  kind: "missing-claude",
@@ -14338,7 +15473,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14338
15473
  }
14339
15474
  const lock = await checkLock(repoRoot);
14340
15475
  if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
14341
- if (!existsSync8(hausPath(repoRoot, "haus.lock.json"))) {
15476
+ if (!existsSync9(hausPath(repoRoot, "haus.lock.json"))) {
14342
15477
  flag({
14343
15478
  repo: repo.name,
14344
15479
  kind: "missing-lock",
@@ -14369,7 +15504,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14369
15504
  const memberById = new Map(members.map((m) => [m.id, m]));
14370
15505
  for (const entry of manifest.linkedContext) {
14371
15506
  const member = memberById.get(entry.repo);
14372
- if (!member || !existsSync8(member.absPath)) {
15507
+ if (!member || !existsSync9(member.absPath)) {
14373
15508
  flag({
14374
15509
  repo: entry.repo,
14375
15510
  kind: "missing-linked-context-source",
@@ -14377,7 +15512,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14377
15512
  });
14378
15513
  continue;
14379
15514
  }
14380
- if (!existsSync8(path73.join(workspaceRoot, entry.path))) {
15515
+ if (!existsSync9(path77.join(workspaceRoot, entry.path))) {
14381
15516
  flag({
14382
15517
  repo: entry.repo,
14383
15518
  kind: "missing-linked-context-copy",
@@ -14423,18 +15558,18 @@ function emit(args) {
14423
15558
  init_logger();
14424
15559
 
14425
15560
  // src/workspace/link-context/link.ts
14426
- import path76 from "node:path";
14427
- import fs54 from "fs-extra";
15561
+ import path80 from "node:path";
15562
+ import fs59 from "fs-extra";
14428
15563
  init_fs();
14429
15564
  init_logger();
14430
15565
 
14431
15566
  // src/workspace/link-context/plan.ts
14432
- import path75 from "node:path";
14433
- import fs53 from "fs-extra";
15567
+ import path79 from "node:path";
15568
+ import fs58 from "fs-extra";
14434
15569
 
14435
15570
  // src/workspace/link-context/scan-source.ts
14436
- import path74 from "node:path";
14437
- import fs52 from "fs-extra";
15571
+ import path78 from "node:path";
15572
+ import fs57 from "fs-extra";
14438
15573
  var ASSET_SUBDIR = {
14439
15574
  skill: "skills",
14440
15575
  agent: "agents",
@@ -14442,36 +15577,36 @@ var ASSET_SUBDIR = {
14442
15577
  };
14443
15578
  async function hasLinkableContext(member) {
14444
15579
  for (const subdir of Object.values(ASSET_SUBDIR)) {
14445
- if (await fs52.pathExists(path74.join(member.absPath, ".claude", subdir))) return true;
15580
+ if (await fs57.pathExists(path78.join(member.absPath, ".claude", subdir))) return true;
14446
15581
  }
14447
15582
  return false;
14448
15583
  }
14449
15584
  async function collectSkills(member) {
14450
- const dir = path74.join(member.absPath, ".claude", "skills");
14451
- if (!await fs52.pathExists(dir)) return [];
14452
- const names = await fs52.readdir(dir);
15585
+ const dir = path78.join(member.absPath, ".claude", "skills");
15586
+ if (!await fs57.pathExists(dir)) return [];
15587
+ const names = await fs57.readdir(dir);
14453
15588
  const out = [];
14454
15589
  for (const name of [...names].sort()) {
14455
- const abs = path74.join(dir, name);
14456
- const stat = await fs52.lstat(abs);
15590
+ const abs = path78.join(dir, name);
15591
+ const stat = await fs57.lstat(abs);
14457
15592
  if (!stat.isDirectory()) continue;
14458
- if (!await fs52.pathExists(path74.join(abs, "SKILL.md"))) continue;
14459
- out.push({ type: "skill", name, sourceRelPath: path74.posix.join(".claude", "skills", name) });
15593
+ if (!await fs57.pathExists(path78.join(abs, "SKILL.md"))) continue;
15594
+ out.push({ type: "skill", name, sourceRelPath: path78.posix.join(".claude", "skills", name) });
14460
15595
  }
14461
15596
  return out;
14462
15597
  }
14463
15598
  async function collectFlatMarkdown(member, type) {
14464
15599
  const subdir = ASSET_SUBDIR[type];
14465
- const dir = path74.join(member.absPath, ".claude", subdir);
14466
- if (!await fs52.pathExists(dir)) return [];
14467
- const names = await fs52.readdir(dir);
15600
+ const dir = path78.join(member.absPath, ".claude", subdir);
15601
+ if (!await fs57.pathExists(dir)) return [];
15602
+ const names = await fs57.readdir(dir);
14468
15603
  const out = [];
14469
15604
  for (const fileName of [...names].sort()) {
14470
15605
  if (!fileName.endsWith(".md")) continue;
14471
- const abs = path74.join(dir, fileName);
14472
- if (!(await fs52.lstat(abs)).isFile()) continue;
15606
+ const abs = path78.join(dir, fileName);
15607
+ if (!(await fs57.lstat(abs)).isFile()) continue;
14473
15608
  const name = fileName.slice(0, -".md".length);
14474
- out.push({ type, name, sourceRelPath: path74.posix.join(".claude", subdir, fileName) });
15609
+ out.push({ type, name, sourceRelPath: path78.posix.join(".claude", subdir, fileName) });
14475
15610
  }
14476
15611
  return out;
14477
15612
  }
@@ -14486,9 +15621,9 @@ async function collectSourceAssets(member) {
14486
15621
 
14487
15622
  // src/workspace/link-context/plan.ts
14488
15623
  function repoFolderPrefix(member) {
14489
- const base = path75.posix.basename(member.folder);
15624
+ const base = path79.posix.basename(member.folder);
14490
15625
  if (base && base !== "." && base !== "/") return base;
14491
- const fromAbs = path75.basename(member.absPath);
15626
+ const fromAbs = path79.basename(member.absPath);
14492
15627
  return fromAbs || member.id;
14493
15628
  }
14494
15629
  function assetKey(type, destKey) {
@@ -14503,7 +15638,7 @@ async function buildLinkPlan(members) {
14503
15638
  const byKey = /* @__PURE__ */ new Map();
14504
15639
  const membersById = new Map(members.map((m) => [m.id, m]));
14505
15640
  for (const member of members) {
14506
- if (!await fs53.pathExists(member.absPath)) {
15641
+ if (!await fs58.pathExists(member.absPath)) {
14507
15642
  skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
14508
15643
  continue;
14509
15644
  }
@@ -14545,7 +15680,7 @@ async function buildLinkPlan(members) {
14545
15680
  name: asset.name,
14546
15681
  destKey,
14547
15682
  sourceRelPath: asset.sourceRelPath,
14548
- sourceAbsPath: path75.join(member.absPath, asset.sourceRelPath),
15683
+ sourceAbsPath: path79.join(member.absPath, asset.sourceRelPath),
14549
15684
  sourceHash
14550
15685
  });
14551
15686
  }
@@ -14556,7 +15691,7 @@ async function buildLinkPlan(members) {
14556
15691
  function destRelPathFor(type, destKey) {
14557
15692
  const subdir = `${type}s`;
14558
15693
  const leaf = type === "skill" ? destKey : `${destKey}.md`;
14559
- return path76.posix.join(".claude", subdir, leaf);
15694
+ return path80.posix.join(".claude", subdir, leaf);
14560
15695
  }
14561
15696
  async function runLinkContext(workspaceRoot, opts = {}) {
14562
15697
  const write = resolveWriteMode(opts, "preview-by-default").kind === "write";
@@ -14582,17 +15717,17 @@ async function runLinkContext(workspaceRoot, opts = {}) {
14582
15717
  const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
14583
15718
  for (const entry of plan.entries) {
14584
15719
  const destRel = destRelPathFor(entry.type, entry.destKey);
14585
- const destAbs = path76.join(workspaceRoot, destRel);
15720
+ const destAbs = path80.join(workspaceRoot, destRel);
14586
15721
  if (write) {
14587
- await fs54.ensureDir(path76.dirname(destAbs));
14588
- if (await fs54.pathExists(destAbs)) await fs54.remove(destAbs);
14589
- await fs54.copy(entry.sourceAbsPath, destAbs, {
15722
+ await fs59.ensureDir(path80.dirname(destAbs));
15723
+ if (await fs59.pathExists(destAbs)) await fs59.remove(destAbs);
15724
+ await fs59.copy(entry.sourceAbsPath, destAbs, {
14590
15725
  overwrite: true,
14591
15726
  errorOnExist: false,
14592
15727
  dereference: false,
14593
15728
  filter: (s) => {
14594
15729
  try {
14595
- return !fs54.lstatSync(s).isSymbolicLink();
15730
+ return !fs59.lstatSync(s).isSymbolicLink();
14596
15731
  } catch {
14597
15732
  return false;
14598
15733
  }
@@ -14621,7 +15756,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
14621
15756
  warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
14622
15757
  continue;
14623
15758
  }
14624
- if (await fs54.pathExists(abs)) await fs54.remove(abs);
15759
+ if (await fs59.pathExists(abs)) await fs59.remove(abs);
14625
15760
  }
14626
15761
  }
14627
15762
  if (write) {
@@ -14672,12 +15807,12 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
14672
15807
  }
14673
15808
 
14674
15809
  // src/commands/workspace/setup.ts
14675
- import { existsSync as existsSync9, statSync } from "node:fs";
14676
- import path78 from "node:path";
15810
+ import { existsSync as existsSync10, statSync } from "node:fs";
15811
+ import path82 from "node:path";
14677
15812
 
14678
15813
  // src/claude/write-workspace-claude-md.ts
14679
- import path77 from "node:path";
14680
- import fs55 from "fs-extra";
15814
+ import path81 from "node:path";
15815
+ import fs60 from "fs-extra";
14681
15816
  init_fs();
14682
15817
  init_logger();
14683
15818
  init_paths();
@@ -14702,11 +15837,11 @@ ${WORKSPACE_BLOCK_END}`;
14702
15837
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
14703
15838
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
14704
15839
  const dryRun = opts.dryRun ?? false;
14705
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path77.join(workspaceRoot, "CLAUDE.md");
15840
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path81.join(workspaceRoot, "CLAUDE.md");
14706
15841
  const say = opts.quiet ? () => {
14707
15842
  } : log;
14708
- const existed = await fs55.pathExists(filePath);
14709
- const prev = existed ? await fs55.readFile(filePath, "utf8") : "";
15843
+ const existed = await fs60.pathExists(filePath);
15844
+ const prev = existed ? await fs60.readFile(filePath, "utf8") : "";
14710
15845
  const next = opts.collision ? `${block}
14711
15846
  ` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
14712
15847
  const printable = displayPath(workspaceRoot, filePath);
@@ -14735,20 +15870,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
14735
15870
  init_fs();
14736
15871
  init_logger();
14737
15872
  function resolveWorkspaceRoot(start = process.cwd()) {
14738
- let dir = path78.resolve(start);
15873
+ let dir = path82.resolve(start);
14739
15874
  for (; ; ) {
14740
- if (existsSync9(path78.join(dir, WORKSPACE_FILE))) return dir;
14741
- const parent = path78.dirname(dir);
14742
- if (parent === dir) return path78.resolve(start);
15875
+ if (existsSync10(path82.join(dir, WORKSPACE_FILE))) return dir;
15876
+ const parent = path82.dirname(dir);
15877
+ if (parent === dir) return path82.resolve(start);
14743
15878
  dir = parent;
14744
15879
  }
14745
15880
  }
14746
15881
  function isRootRepo(workspaceRoot, repoPath) {
14747
- return path78.resolve(workspaceRoot, repoPath) === path78.resolve(workspaceRoot);
15882
+ return path82.resolve(workspaceRoot, repoPath) === path82.resolve(workspaceRoot);
14748
15883
  }
14749
15884
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
14750
15885
  const apply = options.write ?? false;
14751
- const configText = await readText(path78.join(workspaceRoot, WORKSPACE_FILE));
15886
+ const configText = await readText(path82.join(workspaceRoot, WORKSPACE_FILE));
14752
15887
  if (!configText) {
14753
15888
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
14754
15889
  process.exitCode = 1;
@@ -14775,12 +15910,12 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
14775
15910
  const statuses = [];
14776
15911
  const aggregateInputs = [];
14777
15912
  for (const repo of repos) {
14778
- const repoRoot = path78.resolve(workspaceRoot, repo.path);
15913
+ const repoRoot = path82.resolve(workspaceRoot, repo.path);
14779
15914
  say(`
14780
15915
  \u2192 ${repo.name} (${repo.path})`);
14781
15916
  let res;
14782
15917
  try {
14783
- if (!existsSync9(repoRoot) || !statSync(repoRoot).isDirectory()) {
15918
+ if (!existsSync10(repoRoot) || !statSync(repoRoot).isDirectory()) {
14784
15919
  throw new Error(`Repo path is not a directory: ${repo.path}`);
14785
15920
  }
14786
15921
  res = await runSetupCore(repoRoot, {
@@ -14880,7 +16015,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
14880
16015
  if (apply && !options.dryRun) {
14881
16016
  const statusByName = new Map(statuses.map((s) => [s.name, s]));
14882
16017
  const prior = await readManifest2(workspaceRoot);
14883
- if (!prior && existsSync9(manifestPath(workspaceRoot))) {
16018
+ if (!prior && existsSync10(manifestPath(workspaceRoot))) {
14884
16019
  warn(
14885
16020
  "Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
14886
16021
  );
@@ -14891,7 +16026,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
14891
16026
  const status = statusByName.get(repo.name);
14892
16027
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
14893
16028
  if (status?.status === "ok") {
14894
- const lock = await checkLock(path78.resolve(workspaceRoot, repo.path));
16029
+ const lock = await checkLock(path82.resolve(workspaceRoot, repo.path));
14895
16030
  manifestRepos.push({
14896
16031
  name: repo.name,
14897
16032
  path: repo.path,
@@ -14947,7 +16082,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
14947
16082
  try {
14948
16083
  const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
14949
16084
  if (linkResult.ok) {
14950
- written.push(...linkResult.added.map((rel) => path78.join(workspaceRoot, rel)));
16085
+ written.push(...linkResult.added.map((rel) => path82.join(workspaceRoot, rel)));
14951
16086
  if (!options.json) {
14952
16087
  say(
14953
16088
  `Linked ${linkResult.linked.length} cross-repo skill/agent/command copy(ies) into .claude/ (${linkResult.added.length} new, ${linkResult.removed.length} removed)${linkResult.skipped.length > 0 ? ` \u2014 ${linkResult.skipped.length} member(s) skipped, see above` : ""}.`
@@ -14979,9 +16114,9 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
14979
16114
  }
14980
16115
 
14981
16116
  // src/commands/workspace/undo.ts
14982
- import { existsSync as existsSync10, statSync as statSync2 } from "node:fs";
14983
- import path79 from "node:path";
14984
- import fs56 from "fs-extra";
16117
+ import { existsSync as existsSync11, statSync as statSync2 } from "node:fs";
16118
+ import path83 from "node:path";
16119
+ import fs61 from "fs-extra";
14985
16120
  init_fs();
14986
16121
  init_logger();
14987
16122
  init_paths();
@@ -15011,11 +16146,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15011
16146
  }
15012
16147
  if (options.dryRun) {
15013
16148
  for (const repo of config.repos) {
15014
- const repoRoot = path79.resolve(workspaceRoot, repo.path);
16149
+ const repoRoot = path83.resolve(workspaceRoot, repo.path);
15015
16150
  log(`
15016
16151
  \u2192 ${repo.name} (${repo.path})`);
15017
16152
  try {
15018
- if (!existsSync10(repoRoot) || !statSync2(repoRoot).isDirectory()) {
16153
+ if (!existsSync11(repoRoot) || !statSync2(repoRoot).isDirectory()) {
15019
16154
  throw new Error(`Repo path is not a directory: ${repo.path}`);
15020
16155
  }
15021
16156
  await runUndo({ dryRun: true, root: repoRoot });
@@ -15032,7 +16167,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15032
16167
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
15033
16168
  continue;
15034
16169
  }
15035
- if (await fs56.pathExists(abs)) {
16170
+ if (await fs61.pathExists(abs)) {
15036
16171
  log(`[dry-run] Would remove ${entry.path} (linked ${entry.type} from ${entry.repo})`);
15037
16172
  }
15038
16173
  }
@@ -15042,12 +16177,12 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15042
16177
  hausPath(workspaceRoot, "WORKSPACE.md")
15043
16178
  ];
15044
16179
  for (const abs of workspaceArtifacts) {
15045
- if (await fs56.pathExists(abs)) {
15046
- log(`[dry-run] Would remove ${path79.relative(workspaceRoot, abs)}`);
16180
+ if (await fs61.pathExists(abs)) {
16181
+ log(`[dry-run] Would remove ${path83.relative(workspaceRoot, abs)}`);
15047
16182
  }
15048
16183
  }
15049
- if (!await fs56.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
15050
- const claudeMd = await readText(path79.join(workspaceRoot, "CLAUDE.md"));
16184
+ if (!await fs61.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
16185
+ const claudeMd = await readText(path83.join(workspaceRoot, "CLAUDE.md"));
15051
16186
  if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
15052
16187
  log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
15053
16188
  }
@@ -15066,11 +16201,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15066
16201
  }
15067
16202
  let anyRepoFailed = false;
15068
16203
  for (const repo of config.repos) {
15069
- const repoRoot = path79.resolve(workspaceRoot, repo.path);
16204
+ const repoRoot = path83.resolve(workspaceRoot, repo.path);
15070
16205
  log(`
15071
16206
  \u2192 ${repo.name} (${repo.path})`);
15072
16207
  try {
15073
- if (!existsSync10(repoRoot) || !statSync2(repoRoot).isDirectory()) {
16208
+ if (!existsSync11(repoRoot) || !statSync2(repoRoot).isDirectory()) {
15074
16209
  throw new Error(`Repo path is not a directory: ${repo.path}`);
15075
16210
  }
15076
16211
  await runUndo({ yes: true, root: repoRoot });
@@ -15086,30 +16221,30 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15086
16221
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
15087
16222
  continue;
15088
16223
  }
15089
- if (await fs56.pathExists(abs)) {
15090
- await fs56.remove(abs);
16224
+ if (await fs61.pathExists(abs)) {
16225
+ await fs61.remove(abs);
15091
16226
  log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
15092
16227
  }
15093
16228
  }
15094
16229
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
15095
16230
  const abs = hausPath(workspaceRoot, rel);
15096
- if (await fs56.pathExists(abs)) {
15097
- await fs56.remove(abs);
15098
- log(`Removed ${path79.relative(workspaceRoot, abs)}`);
16231
+ if (await fs61.pathExists(abs)) {
16232
+ await fs61.remove(abs);
16233
+ log(`Removed ${path83.relative(workspaceRoot, abs)}`);
15099
16234
  }
15100
16235
  }
15101
16236
  const manifestFile = manifestPath(workspaceRoot);
15102
- if (await fs56.pathExists(manifestFile)) {
15103
- await fs56.remove(manifestFile);
15104
- log(`Removed ${path79.relative(workspaceRoot, manifestFile)}`);
16237
+ if (await fs61.pathExists(manifestFile)) {
16238
+ await fs61.remove(manifestFile);
16239
+ log(`Removed ${path83.relative(workspaceRoot, manifestFile)}`);
15105
16240
  }
15106
16241
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
15107
- if (await fs56.pathExists(workspaceMdPath)) {
15108
- await fs56.remove(workspaceMdPath);
15109
- log(`Removed ${path79.relative(workspaceRoot, workspaceMdPath)}`);
16242
+ if (await fs61.pathExists(workspaceMdPath)) {
16243
+ await fs61.remove(workspaceMdPath);
16244
+ log(`Removed ${path83.relative(workspaceRoot, workspaceMdPath)}`);
15110
16245
  } else {
15111
- const claudeMdPath = path79.join(workspaceRoot, "CLAUDE.md");
15112
- if (existsSync10(claudeMdPath)) {
16246
+ const claudeMdPath = path83.join(workspaceRoot, "CLAUDE.md");
16247
+ if (existsSync11(claudeMdPath)) {
15113
16248
  const prev = await readText(claudeMdPath);
15114
16249
  if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
15115
16250
  const next = stripHausBlock(prev, {
@@ -15117,19 +16252,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15117
16252
  end: WORKSPACE_BLOCK_END
15118
16253
  });
15119
16254
  if (next.length === 0) {
15120
- await fs56.remove(claudeMdPath);
16255
+ await fs61.remove(claudeMdPath);
15121
16256
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
15122
16257
  } else {
15123
- await fs56.writeFile(claudeMdPath, next, "utf8");
16258
+ await fs61.writeFile(claudeMdPath, next, "utf8");
15124
16259
  log("Removed haus import block from CLAUDE.md (user content preserved).");
15125
16260
  }
15126
16261
  } else if (prev && hasLegacyWorkspaceBlock(prev)) {
15127
16262
  const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
15128
16263
  if (next.length === 0) {
15129
- await fs56.remove(claudeMdPath);
16264
+ await fs61.remove(claudeMdPath);
15130
16265
  log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
15131
16266
  } else {
15132
- await fs56.writeFile(claudeMdPath, next, "utf8");
16267
+ await fs61.writeFile(claudeMdPath, next, "utf8");
15133
16268
  log(
15134
16269
  "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
15135
16270
  );
@@ -15155,7 +16290,7 @@ function normalizeMaxDepth(maxDepth) {
15155
16290
  return void 0;
15156
16291
  }
15157
16292
  async function initWorkspace(options = {}) {
15158
- if (existsSync11(WORKSPACE_FILE) && !options.yes) {
16293
+ if (existsSync12(WORKSPACE_FILE) && !options.yes) {
15159
16294
  const ok = await confirm(
15160
16295
  `${WORKSPACE_FILE} already exists \u2014 overwrite with a fresh placeholder? Existing client/repos/relationships will be lost. Run \`haus workspace discover\` instead to merge in newly found repos.`
15161
16296
  );
@@ -15177,7 +16312,7 @@ relationships: []
15177
16312
  log("Workspace initialized.");
15178
16313
  }
15179
16314
  async function scanWorkspace(workspaceRoot, opts) {
15180
- const configText = await readText(path80.join(workspaceRoot, WORKSPACE_FILE));
16315
+ const configText = await readText(path84.join(workspaceRoot, WORKSPACE_FILE));
15181
16316
  if (!configText) {
15182
16317
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
15183
16318
  process.exitCode = 1;
@@ -15199,9 +16334,9 @@ async function scanWorkspace(workspaceRoot, opts) {
15199
16334
  const inputs = [];
15200
16335
  const failures = [];
15201
16336
  for (const repo of config.repos) {
15202
- const repoRoot = path80.resolve(workspaceRoot, repo.path);
16337
+ const repoRoot = path84.resolve(workspaceRoot, repo.path);
15203
16338
  try {
15204
- if (!existsSync11(repoRoot) || !statSync3(repoRoot).isDirectory()) {
16339
+ if (!existsSync12(repoRoot) || !statSync3(repoRoot).isDirectory()) {
15205
16340
  throw new Error("path is not a directory");
15206
16341
  }
15207
16342
  const result = await scanProject(repoRoot);
@@ -15268,7 +16403,7 @@ init_logger();
15268
16403
  init_paths();
15269
16404
  function cliVersion() {
15270
16405
  try {
15271
- const pkgPath = path81.join(packageRoot(), "package.json");
16406
+ const pkgPath = path85.join(packageRoot(), "package.json");
15272
16407
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
15273
16408
  return pkg.version ?? "0.0.0";
15274
16409
  } catch {
@@ -15278,7 +16413,7 @@ function cliVersion() {
15278
16413
  var program = new Command();
15279
16414
  function validateRuntimeNodeVersion() {
15280
16415
  try {
15281
- const pkgPath = path81.join(packageRoot(), "package.json");
16416
+ const pkgPath = path85.join(packageRoot(), "package.json");
15282
16417
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
15283
16418
  const requiredRange = pkg.engines?.node;
15284
16419
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -15438,6 +16573,35 @@ worktree.command("prune").description(
15438
16573
  ).option("--dry-run", "Preview which registrations would be pruned").option("--json", "Emit the structured prune result as JSON").action(
15439
16574
  (opts) => runWorktree("prune", { dryRun: opts.dryRun, json: opts.json })
15440
16575
  );
16576
+ worktree.command("autohydrate").description(
16577
+ "Materialize + hydrate this worktree's missing member repos in the background when `worktree.autoHydrate` is enabled (the acting half of the flag; see ADR-0074)"
16578
+ ).option("--from-hook", "Emit for the SessionStart hook \u2014 always exits 0").option("--dry-run", "Report what would be hydrated without forking anything").option("--json", "Emit the structured autohydrate result as JSON").addOption(new Option("--detached-child", "internal: run the forked hydration").hideHelp()).action(
16579
+ (opts) => runWorktree("autohydrate", {
16580
+ fromHook: opts.fromHook,
16581
+ dryRun: opts.dryRun,
16582
+ json: opts.json,
16583
+ detachedChild: opts.detachedChild
16584
+ })
16585
+ );
16586
+ worktree.command("dematerialize [slug]").description(
16587
+ "Drop a workspace worktree's member worktrees while keeping the workspace worktree, its branch and its state (the other end of `hydrate --materialize`; see ADR-0075)"
16588
+ ).option("--force", "Bypass the uncommitted/unpushed-work refusal").option("--dry-run", "Preview which members would be dropped").option("--json", "Emit the structured dematerialize result as JSON").action(
16589
+ (slug, opts) => runWorktree("dematerialize", {
16590
+ slug,
16591
+ force: opts.force,
16592
+ dryRun: opts.dryRun,
16593
+ json: opts.json
16594
+ })
16595
+ );
16596
+ worktree.command("reclaim").description(
16597
+ "Dematerialize workspace worktrees untouched for longer than `worktree.reclaimAfterDays` \u2014 previews unless --write (ADR-0075)"
16598
+ ).option("--older-than-days <n>", "Override worktree.reclaimAfterDays", Number.parseFloat).option("--write", "Perform the reclamation (default previews only)").option("--json", "Emit the structured reclaim result as JSON").action(
16599
+ (opts) => runWorktree("reclaim", {
16600
+ olderThanDays: opts.olderThanDays,
16601
+ write: opts.write,
16602
+ json: opts.json
16603
+ })
16604
+ );
15441
16605
  worktree.command("doctor").description("Fast, side-effect-free health check for the current workspace worktree").option("--from-hook", "Emit for the SessionStart hook \u2014 always exits 0").action((opts) => runWorktree("doctor", { fromHook: opts.fromHook }));
15442
16606
  program.parseAsync(process.argv).catch((err) => {
15443
16607
  const message2 = err instanceof Error ? err.message : String(err);