@haus-tech/haus-workflow 4.6.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();
@@ -8898,6 +9045,17 @@ var MD_PREFIX = "<!-- HAUS-MANAGED";
8898
9045
  var MD_SUFFIX = " -->";
8899
9046
  var FM_FENCE = "---";
8900
9047
  var FM_KEY = "haus_managed";
9048
+ var JS_PREFIX = "// HAUS-MANAGED";
9049
+ var JS_EXT_RE = /\.[mc]?js$/i;
9050
+ var MD_EXT_RE = /\.(md|markdown)$/i;
9051
+ function stampableMarkerForm(destPath) {
9052
+ if (JS_EXT_RE.test(destPath)) return "js";
9053
+ if (MD_EXT_RE.test(destPath)) return "markdown";
9054
+ return void 0;
9055
+ }
9056
+ function isShebang(line2) {
9057
+ return (line2 ?? "").startsWith("#!");
9058
+ }
8901
9059
  function parseAttrs(raw) {
8902
9060
  const attrs = parseHausManagedAttrs(raw) ?? parseHausManagedAttrs(`<!-- HAUS-MANAGED ${raw.trim()} -->`);
8903
9061
  if (!attrs?.id || !attrs.v || !attrs.source) return void 0;
@@ -8920,8 +9078,11 @@ function frontmatterMarkerValue(content2) {
8920
9078
  return void 0;
8921
9079
  }
8922
9080
  function parseMarkdownHeader(content2) {
8923
- const firstLine = content2.split("\n")[0] ?? "";
9081
+ const lines = content2.split("\n");
9082
+ const firstLine = lines[0] ?? "";
8924
9083
  if (firstLine.startsWith(MD_PREFIX)) return parseAttrs(firstLine);
9084
+ const jsLine = isShebang(firstLine) ? lines[1] ?? "" : firstLine;
9085
+ if (jsLine.startsWith(JS_PREFIX)) return parseAttrs(jsLine.slice(JS_PREFIX.length));
8925
9086
  const fmValue = frontmatterMarkerValue(content2);
8926
9087
  if (fmValue !== void 0) return parseAttrs(fmValue);
8927
9088
  return void 0;
@@ -8956,7 +9117,18 @@ function stampFrontmatter(content2, h) {
8956
9117
  ];
8957
9118
  return rebuilt.join("\n");
8958
9119
  }
8959
- function stampMarkdown(content2, h) {
9120
+ function buildJsHeader(h) {
9121
+ return `${JS_PREFIX} ${buildMarkerValue(h)}`;
9122
+ }
9123
+ function stampJs(content2, h) {
9124
+ const lines = content2.split("\n");
9125
+ const at = isShebang(lines[0]) ? 1 : 0;
9126
+ if ((lines[at] ?? "").startsWith(JS_PREFIX)) lines[at] = buildJsHeader(h);
9127
+ else lines.splice(at, 0, buildJsHeader(h));
9128
+ return lines.join("\n");
9129
+ }
9130
+ function stampMarkdown(content2, h, destPath) {
9131
+ if (destPath !== void 0 && stampableMarkerForm(destPath) === "js") return stampJs(content2, h);
8960
9132
  if (hasFrontmatter(content2)) return stampFrontmatter(content2, h);
8961
9133
  const header = buildMarkdownHeader(h);
8962
9134
  const firstLine = content2.split("\n")[0] ?? "";
@@ -8973,7 +9145,7 @@ ${content2}`;
8973
9145
  // src/install/apply.ts
8974
9146
  var SCHEMA_VERSION2 = "1";
8975
9147
  function hashContent(content2) {
8976
- return `sha256-${crypto2.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
9148
+ return `sha256-${crypto3.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
8977
9149
  }
8978
9150
  function destFileMissing(destPath) {
8979
9151
  try {
@@ -9010,6 +9182,21 @@ function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
9010
9182
  }
9011
9183
  return entries;
9012
9184
  }
9185
+ function collectFlatChannel(srcDir, claudeDir, dirName, idPrefix) {
9186
+ const entries = [];
9187
+ const channelDir = path47.join(srcDir, dirName);
9188
+ if (!fs40.pathExistsSync(channelDir)) return entries;
9189
+ for (const fileName of fs40.readdirSync(channelDir)) {
9190
+ if (!fileName.endsWith(".md")) continue;
9191
+ const name = fileName.slice(0, -".md".length);
9192
+ entries.push({
9193
+ stableId: `${idPrefix}.${name}`,
9194
+ srcRelPath: path47.join("library", "global", dirName, fileName),
9195
+ destPath: path47.join(claudeDir, dirName, fileName)
9196
+ });
9197
+ }
9198
+ return entries;
9199
+ }
9013
9200
  function collectSourceFiles(srcDir, claudeDir) {
9014
9201
  const entries = [];
9015
9202
  const skillsDir = path47.join(srcDir, "skills");
@@ -9026,18 +9213,8 @@ function collectSourceFiles(srcDir, claudeDir) {
9026
9213
  }
9027
9214
  }
9028
9215
  }
9029
- const commandsDir = path47.join(srcDir, "commands");
9030
- if (fs40.pathExistsSync(commandsDir)) {
9031
- for (const fileName of fs40.readdirSync(commandsDir)) {
9032
- if (!fileName.endsWith(".md")) continue;
9033
- const commandName = fileName.slice(0, -".md".length);
9034
- entries.push({
9035
- stableId: `command.${commandName}`,
9036
- srcRelPath: path47.join("library", "global", "commands", fileName),
9037
- destPath: path47.join(claudeDir, "commands", fileName)
9038
- });
9039
- }
9040
- }
9216
+ entries.push(...collectFlatChannel(srcDir, claudeDir, "commands", "command"));
9217
+ entries.push(...collectFlatChannel(srcDir, claudeDir, "agents", "agent"));
9041
9218
  return entries;
9042
9219
  }
9043
9220
  async function applyInstall(options = {}) {
@@ -9072,11 +9249,19 @@ async function applyInstall(options = {}) {
9072
9249
  warn(`Source file not found: ${entry.srcRelPath}`);
9073
9250
  continue;
9074
9251
  }
9075
- const stamped = stampMarkdown(rawContent, {
9076
- stableId: entry.stableId,
9077
- schemaVersion: SCHEMA_VERSION2,
9078
- source
9079
- });
9252
+ if (stampableMarkerForm(entry.destPath) === void 0)
9253
+ throw new Error(
9254
+ `No safe ownership-marker form for ${entry.srcRelPath} \u2014 only .md and .js/.mjs/.cjs aux files can ship in library/global (see ADR-0072)`
9255
+ );
9256
+ const stamped = stampMarkdown(
9257
+ rawContent,
9258
+ {
9259
+ stableId: entry.stableId,
9260
+ schemaVersion: SCHEMA_VERSION2,
9261
+ source
9262
+ },
9263
+ entry.destPath
9264
+ );
9080
9265
  const newHash = hashContent(stamped);
9081
9266
  const existing = manifestByDest.get(entry.destPath);
9082
9267
  if (check) {
@@ -10307,20 +10492,246 @@ async function runFetchRefs(options) {
10307
10492
  }
10308
10493
  }
10309
10494
 
10310
- // src/security/guard-bash.ts
10311
- function matchesDenyToken(command, denyPhrase) {
10312
- if (denyPhrase === "sudo") {
10313
- 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;
10314
10574
  }
10315
- const normalizedCommand = command.replace(/["'`]/g, "").toLowerCase().replace(/\s+/g, " ").trim();
10316
- return normalizedCommand.includes(denyPhrase.toLowerCase());
10575
+ endSegment();
10576
+ return segments;
10317
10577
  }
10318
- function guardBash(command) {
10319
- const matched = DENY_COMMANDS.find((denyPhrase) => matchesDenyToken(command, denyPhrase));
10320
- 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
+ }
10321
10725
  return void 0;
10322
10726
  }
10323
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
+
10324
10735
  // src/security/guard-file-access.ts
10325
10736
  function guardFileAccess(candidate) {
10326
10737
  const normalized = normalizePathForMatch(candidate);
@@ -10339,7 +10750,7 @@ async function runGuard(kind, _options) {
10339
10750
  }
10340
10751
  const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
10341
10752
  if (kind === "file-access") {
10342
- const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
10753
+ const candidate = String(toolInput.notebook_path ?? toolInput.path ?? toolInput.file_path ?? "");
10343
10754
  const reason2 = guardFileAccess(candidate);
10344
10755
  if (reason2) {
10345
10756
  emitPreToolUseDeny(reason2);
@@ -10589,7 +11000,7 @@ async function recommend(root, context, opts = {}) {
10589
11000
  const tagMatch = item.tags.find((t) => stackSet.has(t.toLowerCase()));
10590
11001
  if (tagMatch) push("stack-match", "stack/dependency match", stackSignal(tagMatch));
10591
11002
  const pm = context.packageManager;
10592
- 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);
10593
11004
  if (pmVersionedMatch) {
10594
11005
  push(
10595
11006
  "package-manager-match",
@@ -11355,7 +11766,7 @@ async function runInstall(options, deps = {}) {
11355
11766
  if (result.created.length) parts.push(`${result.created.length} file(s) added`);
11356
11767
  if (result.updated.length) parts.push(`${result.updated.length} file(s) updated`);
11357
11768
  log(
11358
- parts.length ? ` \u2022 ${parts.join(", ")} in ${claudeConfigDirLabel()} (skills, slash commands)` : " \u2022 already up to date \u2014 no files changed"
11769
+ parts.length ? ` \u2022 ${parts.join(", ")} in ${claudeConfigDirLabel()} (skills, agents, slash commands)` : " \u2022 already up to date \u2014 no files changed"
11359
11770
  );
11360
11771
  log(
11361
11772
  ` \u2022 ensured hooks + security rules are present in ${claudeConfigDirLabel()}/settings.json`
@@ -11949,17 +12360,19 @@ async function runRemove(ids, options = {}) {
11949
12360
  for (const id of found) {
11950
12361
  const entry = byId.get(id);
11951
12362
  const relPaths = entry.paths ?? [];
11952
- const hasUnsafePath = relPaths.some((rel) => resolveContainedPath(root, rel) === null);
11953
- if (hasUnsafePath) {
11954
- for (const rel of relPaths) {
11955
- 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") {
11956
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}`);
11957
12370
  }
11958
12371
  }
11959
12372
  report.skipped.push({ id, reason: "unsafe-path" });
11960
12373
  continue;
11961
12374
  }
11962
- const existing = await resolveExistingTrackedPaths(root, id, relPaths, warn);
12375
+ const existing = await resolveExistingTrackedPaths(root, id, relPaths, warn, unsafeByRel);
11963
12376
  if (existing.length === 0) {
11964
12377
  if (relPaths.length === 0) {
11965
12378
  warn(`${id} has no recorded paths \u2014 leaving its lock entry in place.`);
@@ -12135,7 +12548,12 @@ async function collectLockTrackedPaths(root) {
12135
12548
  const preserved = [];
12136
12549
  for (const row of lock ?? []) {
12137
12550
  const id = row.id ?? "(unknown item)";
12138
- 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
+ }
12139
12557
  if (existing.length === 0) continue;
12140
12558
  const gate = await hashGateEntry(root, { hash: row.hash }, existing);
12141
12559
  if (!gate.ok) {
@@ -12267,7 +12685,7 @@ User-owned .claude/ files will be preserved.`
12267
12685
  init_fs();
12268
12686
  init_logger();
12269
12687
  init_paths();
12270
- import crypto3 from "node:crypto";
12688
+ import crypto4 from "node:crypto";
12271
12689
  import path57 from "node:path";
12272
12690
  import fs44 from "fs-extra";
12273
12691
  async function runUninstall(options = {}) {
@@ -12316,7 +12734,7 @@ async function runUninstall(options = {}) {
12316
12734
  result.skipped.push(entry.destPath);
12317
12735
  continue;
12318
12736
  }
12319
- const currentHash = `sha256-${crypto3.createHash("sha256").update(content2).digest("hex")}`;
12737
+ const currentHash = `sha256-${crypto4.createHash("sha256").update(content2).digest("hex")}`;
12320
12738
  if (currentHash !== entry.hash && !force) {
12321
12739
  warn(
12322
12740
  `Skipping user-edited haus file (hash mismatch): ${entry.destPath} \u2014 use --force to delete`
@@ -12523,6 +12941,22 @@ function auditManifestStructure(manifestVersion2, items) {
12523
12941
  if (item.version && !ITEM_SEMVER_RE.test(item.version)) {
12524
12942
  failures.push(`${item.id}: version "${item.version}" is not valid semver (expected X.Y.Z)`);
12525
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
+ }
12526
12960
  const prev = seenIds.get(item.id);
12527
12961
  if (prev !== void 0) {
12528
12962
  failures.push(`${item.id}: duplicate id (first at index ${prev})`);
@@ -13107,6 +13541,9 @@ function summarizeInstall(install) {
13107
13541
  const details = [...envNotes, ...tail.length > 0 ? tail.split("\n") : []];
13108
13542
  return details.length > 0 ? [head, ...details].join("\n ") : head;
13109
13543
  }
13544
+ function installFailed(install) {
13545
+ return install?.ran === true && install.ok === false;
13546
+ }
13110
13547
  function withMissingEnv(result, missingEnvVars) {
13111
13548
  return missingEnvVars.length > 0 ? { ...result, missingEnvVars } : result;
13112
13549
  }
@@ -13164,6 +13601,9 @@ async function runInstallPlan(dir, plan, deps = {}) {
13164
13601
 
13165
13602
  // src/workspace/worktree/hydrate.ts
13166
13603
  var HYDRATION_TARGETS = ["node_modules", "vendor", "obj", "bin"];
13604
+ function memberOutcomeOk(member) {
13605
+ return member.status !== "failed" && !installFailed(member.hydration?.install);
13606
+ }
13167
13607
  async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
13168
13608
  const start = Date.now();
13169
13609
  const targets = [];
@@ -13266,6 +13706,13 @@ function worktreePath(mainRoot, slug) {
13266
13706
  }
13267
13707
  return path65.join(worktreesDir(mainRoot), slug);
13268
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
+ }
13269
13716
  async function readWorktreeState(worktreeRoot) {
13270
13717
  const state = await readJson(path65.join(worktreeRoot, WORKTREE_STATE_FILE));
13271
13718
  if (state == null || typeof state !== "object" || !Array.isArray(state.members)) return void 0;
@@ -13356,7 +13803,7 @@ async function runAdd(opts) {
13356
13803
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
13357
13804
  members: stateMembers
13358
13805
  });
13359
- const anyFailed = memberResults.some((m) => m.status === "failed");
13806
+ const anyFailed = memberResults.some((m) => !memberOutcomeOk(m));
13360
13807
  return {
13361
13808
  ok: !anyFailed,
13362
13809
  dryRun: false,
@@ -13401,27 +13848,135 @@ async function runDryRun(args) {
13401
13848
  };
13402
13849
  }
13403
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
+
13404
13857
  // src/workspace/worktree/doctor.ts
13405
13858
  import { existsSync as existsSync6 } from "node:fs";
13406
- 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
+ }
13407
13962
 
13408
13963
  // src/workspace/worktree/orphans.ts
13409
13964
  import { existsSync as existsSync5 } from "node:fs";
13410
- import path67 from "node:path";
13965
+ import path68 from "node:path";
13411
13966
  var WORKSPACE_REPO_ID = "(workspace)";
13412
13967
  async function findOrphanedRegistrations(repos, wsWorktreesDir) {
13413
13968
  const orphans = [];
13414
13969
  for (const repo of repos) {
13415
13970
  const entries = await listWorktrees(repo.absPath);
13416
13971
  for (const entry of entries) {
13417
- const entryPath = path67.normalize(entry.path);
13418
- 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}`;
13419
13974
  const idx = entryPath.indexOf(marker);
13420
13975
  if (idx === -1) continue;
13421
13976
  const rest = entryPath.slice(idx + marker.length);
13422
- const slug = rest.split(path67.sep)[0];
13977
+ const slug = rest.split(path68.sep)[0];
13423
13978
  if (!slug) continue;
13424
- if (existsSync5(path67.join(wsWorktreesDir, slug))) continue;
13979
+ if (existsSync5(path68.join(wsWorktreesDir, slug))) continue;
13425
13980
  orphans.push({
13426
13981
  repoId: repo.id,
13427
13982
  ownerRepo: repo.absPath,
@@ -13436,7 +13991,7 @@ async function findOrphanedRegistrations(repos, wsWorktreesDir) {
13436
13991
  // src/workspace/worktree/doctor.ts
13437
13992
  var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
13438
13993
  function looksHydrated(memberPath) {
13439
- return HYDRATION_MARKERS.some((marker) => existsSync6(path68.join(memberPath, marker)));
13994
+ return HYDRATION_MARKERS.some((marker) => existsSync6(path69.join(memberPath, marker)));
13440
13995
  }
13441
13996
  async function runDoctor2() {
13442
13997
  const rootInfo = await resolveRoots();
@@ -13448,11 +14003,12 @@ async function runDoctor2() {
13448
14003
  orphans: [],
13449
14004
  problems: ["Not inside a git repository."],
13450
14005
  remedies: [],
13451
- autoHydrate: false
14006
+ autoHydrate: false,
14007
+ hydrationInProgress: false
13452
14008
  };
13453
14009
  }
13454
14010
  const workspaceRoot = rootInfo.mainRoot;
13455
- 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));
13456
14012
  if (!isWorkspace) {
13457
14013
  return {
13458
14014
  isWorkspace: false,
@@ -13463,11 +14019,12 @@ async function runDoctor2() {
13463
14019
  `Not a haus workspace \u2014 no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
13464
14020
  ],
13465
14021
  remedies: [],
13466
- autoHydrate: false
14022
+ autoHydrate: false,
14023
+ hydrationInProgress: false
13467
14024
  };
13468
14025
  }
13469
14026
  const wsWorktreesDir = worktreesDir(workspaceRoot);
13470
- const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path68.sep}`);
14027
+ const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path69.sep}`);
13471
14028
  const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
13472
14029
  const workspaceConfig = await readWorkspaceConfig(workspaceRoot);
13473
14030
  const autoHydrate = workspaceConfig?.worktree?.autoHydrate === true;
@@ -13476,12 +14033,14 @@ async function runDoctor2() {
13476
14033
  const remedies = [];
13477
14034
  const memberChecks = [];
13478
14035
  let slug;
14036
+ let hydrationInProgress = false;
14037
+ let hydrationOwnerPid;
13479
14038
  if (inWorkspaceWorktree) {
13480
14039
  const wtPath = rootInfo.repoRoot;
13481
- slug = rootInfo.worktreeName ?? path68.basename(wtPath);
14040
+ slug = rootInfo.worktreeName ?? path69.basename(wtPath);
13482
14041
  const state = await readWorktreeState(wtPath);
13483
14042
  for (const member of members) {
13484
- const memberPath = path68.join(wtPath, member.folder);
14043
+ const memberPath = path69.join(wtPath, member.folder);
13485
14044
  const materialized = existsSync6(memberPath);
13486
14045
  const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
13487
14046
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
@@ -13507,9 +14066,14 @@ async function runDoctor2() {
13507
14066
  );
13508
14067
  }
13509
14068
  }
14069
+ const lock = await readHydrateLock(workspaceRoot, slug).catch(() => ({ held: false }));
14070
+ if (lock.held) {
14071
+ hydrationInProgress = true;
14072
+ hydrationOwnerPid = lock.ownerPid;
14073
+ }
13510
14074
  if (memberChecks.some((m) => !m.materialized)) {
13511
14075
  remedies.push(
13512
- "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."
13513
14077
  );
13514
14078
  }
13515
14079
  }
@@ -13521,7 +14085,7 @@ async function runDoctor2() {
13521
14085
  wsWorktreesDir
13522
14086
  );
13523
14087
  const orphans = orphanScan.map(
13524
- (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.`
13525
14089
  );
13526
14090
  if (orphans.length > 0) {
13527
14091
  problems.push(...orphans);
@@ -13535,14 +14099,16 @@ async function runDoctor2() {
13535
14099
  orphans,
13536
14100
  problems,
13537
14101
  remedies,
13538
- autoHydrate
14102
+ autoHydrate,
14103
+ hydrationInProgress,
14104
+ ...hydrationOwnerPid !== void 0 ? { hydrationOwnerPid } : {}
13539
14105
  };
13540
14106
  }
13541
14107
 
13542
14108
  // src/workspace/worktree/hydrate-command.ts
13543
14109
  init_fs();
13544
14110
  import { existsSync as existsSync7 } from "node:fs";
13545
- import path69 from "node:path";
14111
+ import path70 from "node:path";
13546
14112
  async function runHydrateWorktree(opts) {
13547
14113
  const rootInfo = await resolveRoots();
13548
14114
  if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
@@ -13553,7 +14119,7 @@ async function runHydrateWorktree(opts) {
13553
14119
  }
13554
14120
  const workspaceRoot = rootInfo.mainRoot;
13555
14121
  const wtPath = rootInfo.repoRoot;
13556
- if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path69.sep}`)) {
14122
+ if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path70.sep}`)) {
13557
14123
  return {
13558
14124
  ok: false,
13559
14125
  error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
@@ -13565,13 +14131,13 @@ async function runHydrateWorktree(opts) {
13565
14131
  return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
13566
14132
  }
13567
14133
  const state = await readWorktreeState(wtPath);
13568
- const slug = state?.slug ?? path69.basename(wtPath);
14134
+ const slug = state?.slug ?? path70.basename(wtPath);
13569
14135
  const detectedBranch = await currentBranch(wtPath);
13570
14136
  const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
13571
14137
  if (opts.dryRun) {
13572
14138
  const results2 = await Promise.all(
13573
14139
  selected.map(async (member) => {
13574
- const memberPath = path69.join(wtPath, member.folder);
14140
+ const memberPath = path70.join(wtPath, member.folder);
13575
14141
  if (existsSync7(memberPath)) {
13576
14142
  return { member: member.id, folder: member.folder, status: "planned" };
13577
14143
  }
@@ -13592,7 +14158,7 @@ async function runHydrateWorktree(opts) {
13592
14158
  const results = await mapWithConcurrency(
13593
14159
  selected,
13594
14160
  async (member) => {
13595
- const memberPath = path69.join(wtPath, member.folder);
14161
+ const memberPath = path70.join(wtPath, member.folder);
13596
14162
  let branchAction;
13597
14163
  if (!existsSync7(memberPath)) {
13598
14164
  if (!opts.materialize) {
@@ -13651,47 +14217,7 @@ async function runHydrateWorktree(opts) {
13651
14217
  members: Array.from(byId.values())
13652
14218
  });
13653
14219
  }
13654
- return { ok: results.every((r) => r.status !== "failed"), dryRun: false, slug, results };
13655
- }
13656
-
13657
- // src/workspace/worktree/list.ts
13658
- import path70 from "node:path";
13659
- import fs50 from "fs-extra";
13660
- async function runList2() {
13661
- const resolved = await resolveWorkspaceForWorktree();
13662
- if (!resolved.ok) return { ok: false, error: resolved.reason };
13663
- const { rootInfo, workspaceRoot } = resolved;
13664
- const dir = worktreesDir(workspaceRoot);
13665
- if (!await fs50.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
13666
- const entries = await fs50.readdir(dir, { withFileTypes: true });
13667
- const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
13668
- const configuredMembers = await readMembers(rootInfo);
13669
- const worktrees = [];
13670
- for (const slug of slugs) {
13671
- const wtPath = path70.join(dir, slug);
13672
- const state = await readWorktreeState(wtPath);
13673
- const members = [];
13674
- const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
13675
- for (const m of source) {
13676
- const memberPath = path70.join(wtPath, m.folder);
13677
- const materialized = await fs50.pathExists(memberPath);
13678
- const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
13679
- members.push({
13680
- id: m.id,
13681
- folder: m.folder,
13682
- expectedBranch: m.branch,
13683
- materialized,
13684
- actualBranch
13685
- });
13686
- }
13687
- worktrees.push({
13688
- slug,
13689
- branch: state?.branch ?? await currentBranch(wtPath) ?? "unknown",
13690
- createdAt: state?.createdAt,
13691
- members
13692
- });
13693
- }
13694
- return { ok: true, workspaceRoot, worktrees };
14220
+ return { ok: results.every(memberOutcomeOk), dryRun: false, slug, results };
13695
14221
  }
13696
14222
 
13697
14223
  // src/workspace/worktree/prune.ts
@@ -13734,58 +14260,523 @@ async function runPrune2(opts) {
13734
14260
  return { ok: results.every((r) => r.pruned), dryRun: false, results };
13735
14261
  }
13736
14262
 
13737
- // src/workspace/worktree/remove.ts
13738
- import path71 from "node:path";
13739
- import fs51 from "fs-extra";
13740
- async function runRemove2(opts) {
13741
- const resolved = await resolveWorkspaceForWorktree();
13742
- if (!resolved.ok) return { ok: false, error: resolved.reason };
13743
- const { rootInfo, workspaceRoot } = resolved;
13744
- const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
13745
- if (!await fs51.pathExists(wsWorktreePath)) {
13746
- 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);
13747
14278
  }
13748
- const state = await readWorktreeState(wsWorktreePath);
13749
- const allMembers = await readMembers(rootInfo);
13750
- 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 }));
13751
- const targets = [
13752
- { repo: "(workspace)", ownerRepo: workspaceRoot, path: wsWorktreePath }
13753
- ];
13754
- const unresolvedDroppedMembers = [];
13755
- for (const stateMember of stateMembers) {
13756
- const configMember = allMembers.find((m) => m.folder === stateMember.folder);
13757
- const ownerRepo = configMember?.absPath ?? stateMember.absPath;
13758
- if (!ownerRepo) {
13759
- if (!configMember) unresolvedDroppedMembers.push(stateMember.folder);
13760
- 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
+ `);
14287
+ }
14288
+ fsSync.appendFileSync(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${line2}
14289
+ `);
14290
+ } catch {
14291
+ }
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;
14300
+ }
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);
14337
+ }
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;
13761
14469
  }
13762
14470
  targets.push({
13763
14471
  repo: configMember?.id ?? stateMember.id,
13764
14472
  ownerRepo,
13765
- path: path71.join(wsWorktreePath, stateMember.folder)
14473
+ path: path72.join(wsWorktreePath, stateMember.folder)
13766
14474
  });
13767
14475
  }
13768
- const existingTargets = [];
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 = [];
13769
14493
  for (const target of targets) {
13770
- if (await fs51.pathExists(target.path)) existingTargets.push(target);
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}.` };
13771
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);
13772
14545
  if (!opts.force) {
13773
- const blockers = [];
13774
- for (const folder of unresolvedDroppedMembers) {
13775
- blockers.push({
13776
- repo: folder,
13777
- 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)"
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})`
13778
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;
13779
14707
  }
13780
- for (const target of existingTargets) {
13781
- const ignorePaths = target.repo === "(workspace)" ? [WORKTREE_STATE_FILE, ...stateMembers.map((m) => m.folder)] : [];
13782
- if (await hasUncommittedChanges(target.path, ignorePaths)) {
13783
- blockers.push({ repo: target.repo, reason: "uncommitted changes" });
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
+ });
13784
14724
  continue;
13785
14725
  }
13786
- const unpushed = await hasUnpushedWork(target.path);
13787
- if (unpushed.unpushed) blockers.push({ repo: target.repo, reason: unpushed.reason });
14726
+ reclaimed += 1;
14727
+ candidates.push({ ...base, action: "reclaim", members: outcome.removed });
13788
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
+ );
13789
14780
  if (blockers.length > 0) return { ok: false, blocked: true, blockers };
13790
14781
  }
13791
14782
  if (opts.dryRun) {
@@ -13799,10 +14790,7 @@ async function runRemove2(opts) {
13799
14790
  const removed = [];
13800
14791
  const failed = [];
13801
14792
  for (const folder of unresolvedDroppedMembers) {
13802
- failed.push({
13803
- repo: folder,
13804
- 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"
13805
- });
14793
+ failed.push({ repo: folder, error: unresolvedDroppedFailureReason() });
13806
14794
  }
13807
14795
  const memberTargets = existingTargets.filter((t) => t.repo !== "(workspace)");
13808
14796
  for (const target of memberTargets) {
@@ -13823,8 +14811,8 @@ async function runRemove2(opts) {
13823
14811
  }
13824
14812
  await pruneWorktrees(workspaceRoot);
13825
14813
  }
13826
- if (wsRemoveOk && await fs51.pathExists(wsWorktreePath)) {
13827
- await fs51.remove(wsWorktreePath);
14814
+ if (wsRemoveOk && await fs56.pathExists(wsWorktreePath)) {
14815
+ await fs56.remove(wsWorktreePath);
13828
14816
  }
13829
14817
  return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
13830
14818
  }
@@ -13840,6 +14828,23 @@ function emitMemberLine(hydration, line2) {
13840
14828
  if (hydration?.install.ran && !hydration.install.ok) warn(line2);
13841
14829
  else log(line2);
13842
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
+ }
13843
14848
  async function handleAdd(opts) {
13844
14849
  if (!opts.slug) {
13845
14850
  if (opts.json) emitJson({ ok: false, error: "Usage: haus workspace worktree add <slug>" });
@@ -13892,10 +14897,7 @@ async function handleAdd(opts) {
13892
14897
  }
13893
14898
  }
13894
14899
  if (!result.ok) {
13895
- const failedCount = result.members.filter((m) => m.status === "failed").length;
13896
- error(
13897
- `${failedCount} member(s) failed to materialize \u2014 the worktree is partially built. See the per-member detail above for exactly what exists.`
13898
- );
14900
+ error(`The worktree is partially built: ${describeMemberFailures(result.members)}`);
13899
14901
  process.exitCode = 1;
13900
14902
  }
13901
14903
  }
@@ -13938,7 +14940,10 @@ async function handleHydrate(opts) {
13938
14940
  warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
13939
14941
  }
13940
14942
  }
13941
- if (!result.ok) process.exitCode = 1;
14943
+ if (!result.ok) {
14944
+ error(describeMemberFailures(result.results));
14945
+ process.exitCode = 1;
14946
+ }
13942
14947
  }
13943
14948
  async function handleRemove(opts) {
13944
14949
  if (!opts.slug) {
@@ -14081,6 +15086,168 @@ async function handleDoctor(opts) {
14081
15086
  }
14082
15087
  }
14083
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
+ }
14084
15251
  async function runWorktree(action, opts = {}) {
14085
15252
  switch (action) {
14086
15253
  case "add":
@@ -14095,12 +15262,18 @@ async function runWorktree(action, opts = {}) {
14095
15262
  return handleDoctor(opts);
14096
15263
  case "prune":
14097
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);
14098
15271
  }
14099
15272
  }
14100
15273
 
14101
15274
  // src/commands/workspace.ts
14102
- import { existsSync as existsSync11, statSync as statSync3 } from "node:fs";
14103
- import path80 from "node:path";
15275
+ import { existsSync as existsSync12, statSync as statSync3 } from "node:fs";
15276
+ import path84 from "node:path";
14104
15277
  init_fs();
14105
15278
  init_logger();
14106
15279
 
@@ -14158,8 +15331,8 @@ ${summaries.map(
14158
15331
  }
14159
15332
 
14160
15333
  // src/commands/workspace/doctor.ts
14161
- import { existsSync as existsSync8 } from "node:fs";
14162
- import path73 from "node:path";
15334
+ import { existsSync as existsSync9 } from "node:fs";
15335
+ import path77 from "node:path";
14163
15336
  init_logger();
14164
15337
  init_paths();
14165
15338
 
@@ -14167,14 +15340,14 @@ init_paths();
14167
15340
  init_fs();
14168
15341
  init_paths();
14169
15342
  import { readFileSync as readFileSync4 } from "node:fs";
14170
- import path72 from "node:path";
15343
+ import path76 from "node:path";
14171
15344
  var MANIFEST_FILE2 = "workspace.manifest.json";
14172
15345
  function manifestPath(workspaceRoot) {
14173
15346
  return hausPath(workspaceRoot, MANIFEST_FILE2);
14174
15347
  }
14175
15348
  function hausVersion() {
14176
15349
  try {
14177
- const pkg = JSON.parse(readFileSync4(path72.join(packageRoot(), "package.json"), "utf8"));
15350
+ const pkg = JSON.parse(readFileSync4(path76.join(packageRoot(), "package.json"), "utf8"));
14178
15351
  return pkg.version ?? "0.0.0";
14179
15352
  } catch {
14180
15353
  return "0.0.0";
@@ -14266,7 +15439,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14266
15439
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
14267
15440
  const catalogRefByRepo = [];
14268
15441
  for (const repo of config.repos) {
14269
- const repoRoot = path73.resolve(workspaceRoot, repo.path);
15442
+ const repoRoot = path77.resolve(workspaceRoot, repo.path);
14270
15443
  const entry = manifestByName.get(repo.name);
14271
15444
  if (!entry) {
14272
15445
  flag({
@@ -14291,7 +15464,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14291
15464
  detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
14292
15465
  });
14293
15466
  }
14294
- if (!existsSync8(claudePath(repoRoot))) {
15467
+ if (!existsSync9(claudePath(repoRoot))) {
14295
15468
  flag({
14296
15469
  repo: repo.name,
14297
15470
  kind: "missing-claude",
@@ -14300,7 +15473,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14300
15473
  }
14301
15474
  const lock = await checkLock(repoRoot);
14302
15475
  if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
14303
- if (!existsSync8(hausPath(repoRoot, "haus.lock.json"))) {
15476
+ if (!existsSync9(hausPath(repoRoot, "haus.lock.json"))) {
14304
15477
  flag({
14305
15478
  repo: repo.name,
14306
15479
  kind: "missing-lock",
@@ -14331,7 +15504,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14331
15504
  const memberById = new Map(members.map((m) => [m.id, m]));
14332
15505
  for (const entry of manifest.linkedContext) {
14333
15506
  const member = memberById.get(entry.repo);
14334
- if (!member || !existsSync8(member.absPath)) {
15507
+ if (!member || !existsSync9(member.absPath)) {
14335
15508
  flag({
14336
15509
  repo: entry.repo,
14337
15510
  kind: "missing-linked-context-source",
@@ -14339,7 +15512,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
14339
15512
  });
14340
15513
  continue;
14341
15514
  }
14342
- if (!existsSync8(path73.join(workspaceRoot, entry.path))) {
15515
+ if (!existsSync9(path77.join(workspaceRoot, entry.path))) {
14343
15516
  flag({
14344
15517
  repo: entry.repo,
14345
15518
  kind: "missing-linked-context-copy",
@@ -14385,18 +15558,18 @@ function emit(args) {
14385
15558
  init_logger();
14386
15559
 
14387
15560
  // src/workspace/link-context/link.ts
14388
- import path76 from "node:path";
14389
- import fs54 from "fs-extra";
15561
+ import path80 from "node:path";
15562
+ import fs59 from "fs-extra";
14390
15563
  init_fs();
14391
15564
  init_logger();
14392
15565
 
14393
15566
  // src/workspace/link-context/plan.ts
14394
- import path75 from "node:path";
14395
- import fs53 from "fs-extra";
15567
+ import path79 from "node:path";
15568
+ import fs58 from "fs-extra";
14396
15569
 
14397
15570
  // src/workspace/link-context/scan-source.ts
14398
- import path74 from "node:path";
14399
- import fs52 from "fs-extra";
15571
+ import path78 from "node:path";
15572
+ import fs57 from "fs-extra";
14400
15573
  var ASSET_SUBDIR = {
14401
15574
  skill: "skills",
14402
15575
  agent: "agents",
@@ -14404,36 +15577,36 @@ var ASSET_SUBDIR = {
14404
15577
  };
14405
15578
  async function hasLinkableContext(member) {
14406
15579
  for (const subdir of Object.values(ASSET_SUBDIR)) {
14407
- 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;
14408
15581
  }
14409
15582
  return false;
14410
15583
  }
14411
15584
  async function collectSkills(member) {
14412
- const dir = path74.join(member.absPath, ".claude", "skills");
14413
- if (!await fs52.pathExists(dir)) return [];
14414
- 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);
14415
15588
  const out = [];
14416
15589
  for (const name of [...names].sort()) {
14417
- const abs = path74.join(dir, name);
14418
- const stat = await fs52.lstat(abs);
15590
+ const abs = path78.join(dir, name);
15591
+ const stat = await fs57.lstat(abs);
14419
15592
  if (!stat.isDirectory()) continue;
14420
- if (!await fs52.pathExists(path74.join(abs, "SKILL.md"))) continue;
14421
- 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) });
14422
15595
  }
14423
15596
  return out;
14424
15597
  }
14425
15598
  async function collectFlatMarkdown(member, type) {
14426
15599
  const subdir = ASSET_SUBDIR[type];
14427
- const dir = path74.join(member.absPath, ".claude", subdir);
14428
- if (!await fs52.pathExists(dir)) return [];
14429
- 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);
14430
15603
  const out = [];
14431
15604
  for (const fileName of [...names].sort()) {
14432
15605
  if (!fileName.endsWith(".md")) continue;
14433
- const abs = path74.join(dir, fileName);
14434
- if (!(await fs52.lstat(abs)).isFile()) continue;
15606
+ const abs = path78.join(dir, fileName);
15607
+ if (!(await fs57.lstat(abs)).isFile()) continue;
14435
15608
  const name = fileName.slice(0, -".md".length);
14436
- out.push({ type, name, sourceRelPath: path74.posix.join(".claude", subdir, fileName) });
15609
+ out.push({ type, name, sourceRelPath: path78.posix.join(".claude", subdir, fileName) });
14437
15610
  }
14438
15611
  return out;
14439
15612
  }
@@ -14448,9 +15621,9 @@ async function collectSourceAssets(member) {
14448
15621
 
14449
15622
  // src/workspace/link-context/plan.ts
14450
15623
  function repoFolderPrefix(member) {
14451
- const base = path75.posix.basename(member.folder);
15624
+ const base = path79.posix.basename(member.folder);
14452
15625
  if (base && base !== "." && base !== "/") return base;
14453
- const fromAbs = path75.basename(member.absPath);
15626
+ const fromAbs = path79.basename(member.absPath);
14454
15627
  return fromAbs || member.id;
14455
15628
  }
14456
15629
  function assetKey(type, destKey) {
@@ -14465,7 +15638,7 @@ async function buildLinkPlan(members) {
14465
15638
  const byKey = /* @__PURE__ */ new Map();
14466
15639
  const membersById = new Map(members.map((m) => [m.id, m]));
14467
15640
  for (const member of members) {
14468
- if (!await fs53.pathExists(member.absPath)) {
15641
+ if (!await fs58.pathExists(member.absPath)) {
14469
15642
  skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
14470
15643
  continue;
14471
15644
  }
@@ -14507,7 +15680,7 @@ async function buildLinkPlan(members) {
14507
15680
  name: asset.name,
14508
15681
  destKey,
14509
15682
  sourceRelPath: asset.sourceRelPath,
14510
- sourceAbsPath: path75.join(member.absPath, asset.sourceRelPath),
15683
+ sourceAbsPath: path79.join(member.absPath, asset.sourceRelPath),
14511
15684
  sourceHash
14512
15685
  });
14513
15686
  }
@@ -14518,7 +15691,7 @@ async function buildLinkPlan(members) {
14518
15691
  function destRelPathFor(type, destKey) {
14519
15692
  const subdir = `${type}s`;
14520
15693
  const leaf = type === "skill" ? destKey : `${destKey}.md`;
14521
- return path76.posix.join(".claude", subdir, leaf);
15694
+ return path80.posix.join(".claude", subdir, leaf);
14522
15695
  }
14523
15696
  async function runLinkContext(workspaceRoot, opts = {}) {
14524
15697
  const write = resolveWriteMode(opts, "preview-by-default").kind === "write";
@@ -14544,17 +15717,17 @@ async function runLinkContext(workspaceRoot, opts = {}) {
14544
15717
  const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
14545
15718
  for (const entry of plan.entries) {
14546
15719
  const destRel = destRelPathFor(entry.type, entry.destKey);
14547
- const destAbs = path76.join(workspaceRoot, destRel);
15720
+ const destAbs = path80.join(workspaceRoot, destRel);
14548
15721
  if (write) {
14549
- await fs54.ensureDir(path76.dirname(destAbs));
14550
- if (await fs54.pathExists(destAbs)) await fs54.remove(destAbs);
14551
- 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, {
14552
15725
  overwrite: true,
14553
15726
  errorOnExist: false,
14554
15727
  dereference: false,
14555
15728
  filter: (s) => {
14556
15729
  try {
14557
- return !fs54.lstatSync(s).isSymbolicLink();
15730
+ return !fs59.lstatSync(s).isSymbolicLink();
14558
15731
  } catch {
14559
15732
  return false;
14560
15733
  }
@@ -14583,7 +15756,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
14583
15756
  warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
14584
15757
  continue;
14585
15758
  }
14586
- if (await fs54.pathExists(abs)) await fs54.remove(abs);
15759
+ if (await fs59.pathExists(abs)) await fs59.remove(abs);
14587
15760
  }
14588
15761
  }
14589
15762
  if (write) {
@@ -14634,12 +15807,12 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
14634
15807
  }
14635
15808
 
14636
15809
  // src/commands/workspace/setup.ts
14637
- import { existsSync as existsSync9, statSync } from "node:fs";
14638
- import path78 from "node:path";
15810
+ import { existsSync as existsSync10, statSync } from "node:fs";
15811
+ import path82 from "node:path";
14639
15812
 
14640
15813
  // src/claude/write-workspace-claude-md.ts
14641
- import path77 from "node:path";
14642
- import fs55 from "fs-extra";
15814
+ import path81 from "node:path";
15815
+ import fs60 from "fs-extra";
14643
15816
  init_fs();
14644
15817
  init_logger();
14645
15818
  init_paths();
@@ -14664,11 +15837,11 @@ ${WORKSPACE_BLOCK_END}`;
14664
15837
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
14665
15838
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
14666
15839
  const dryRun = opts.dryRun ?? false;
14667
- 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");
14668
15841
  const say = opts.quiet ? () => {
14669
15842
  } : log;
14670
- const existed = await fs55.pathExists(filePath);
14671
- const prev = existed ? await fs55.readFile(filePath, "utf8") : "";
15843
+ const existed = await fs60.pathExists(filePath);
15844
+ const prev = existed ? await fs60.readFile(filePath, "utf8") : "";
14672
15845
  const next = opts.collision ? `${block}
14673
15846
  ` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
14674
15847
  const printable = displayPath(workspaceRoot, filePath);
@@ -14697,20 +15870,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
14697
15870
  init_fs();
14698
15871
  init_logger();
14699
15872
  function resolveWorkspaceRoot(start = process.cwd()) {
14700
- let dir = path78.resolve(start);
15873
+ let dir = path82.resolve(start);
14701
15874
  for (; ; ) {
14702
- if (existsSync9(path78.join(dir, WORKSPACE_FILE))) return dir;
14703
- const parent = path78.dirname(dir);
14704
- 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);
14705
15878
  dir = parent;
14706
15879
  }
14707
15880
  }
14708
15881
  function isRootRepo(workspaceRoot, repoPath) {
14709
- return path78.resolve(workspaceRoot, repoPath) === path78.resolve(workspaceRoot);
15882
+ return path82.resolve(workspaceRoot, repoPath) === path82.resolve(workspaceRoot);
14710
15883
  }
14711
15884
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
14712
15885
  const apply = options.write ?? false;
14713
- const configText = await readText(path78.join(workspaceRoot, WORKSPACE_FILE));
15886
+ const configText = await readText(path82.join(workspaceRoot, WORKSPACE_FILE));
14714
15887
  if (!configText) {
14715
15888
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
14716
15889
  process.exitCode = 1;
@@ -14737,12 +15910,12 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
14737
15910
  const statuses = [];
14738
15911
  const aggregateInputs = [];
14739
15912
  for (const repo of repos) {
14740
- const repoRoot = path78.resolve(workspaceRoot, repo.path);
15913
+ const repoRoot = path82.resolve(workspaceRoot, repo.path);
14741
15914
  say(`
14742
15915
  \u2192 ${repo.name} (${repo.path})`);
14743
15916
  let res;
14744
15917
  try {
14745
- if (!existsSync9(repoRoot) || !statSync(repoRoot).isDirectory()) {
15918
+ if (!existsSync10(repoRoot) || !statSync(repoRoot).isDirectory()) {
14746
15919
  throw new Error(`Repo path is not a directory: ${repo.path}`);
14747
15920
  }
14748
15921
  res = await runSetupCore(repoRoot, {
@@ -14842,7 +16015,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
14842
16015
  if (apply && !options.dryRun) {
14843
16016
  const statusByName = new Map(statuses.map((s) => [s.name, s]));
14844
16017
  const prior = await readManifest2(workspaceRoot);
14845
- if (!prior && existsSync9(manifestPath(workspaceRoot))) {
16018
+ if (!prior && existsSync10(manifestPath(workspaceRoot))) {
14846
16019
  warn(
14847
16020
  "Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
14848
16021
  );
@@ -14853,7 +16026,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
14853
16026
  const status = statusByName.get(repo.name);
14854
16027
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
14855
16028
  if (status?.status === "ok") {
14856
- const lock = await checkLock(path78.resolve(workspaceRoot, repo.path));
16029
+ const lock = await checkLock(path82.resolve(workspaceRoot, repo.path));
14857
16030
  manifestRepos.push({
14858
16031
  name: repo.name,
14859
16032
  path: repo.path,
@@ -14909,7 +16082,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
14909
16082
  try {
14910
16083
  const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
14911
16084
  if (linkResult.ok) {
14912
- written.push(...linkResult.added.map((rel) => path78.join(workspaceRoot, rel)));
16085
+ written.push(...linkResult.added.map((rel) => path82.join(workspaceRoot, rel)));
14913
16086
  if (!options.json) {
14914
16087
  say(
14915
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` : ""}.`
@@ -14941,9 +16114,9 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
14941
16114
  }
14942
16115
 
14943
16116
  // src/commands/workspace/undo.ts
14944
- import { existsSync as existsSync10, statSync as statSync2 } from "node:fs";
14945
- import path79 from "node:path";
14946
- 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";
14947
16120
  init_fs();
14948
16121
  init_logger();
14949
16122
  init_paths();
@@ -14973,11 +16146,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
14973
16146
  }
14974
16147
  if (options.dryRun) {
14975
16148
  for (const repo of config.repos) {
14976
- const repoRoot = path79.resolve(workspaceRoot, repo.path);
16149
+ const repoRoot = path83.resolve(workspaceRoot, repo.path);
14977
16150
  log(`
14978
16151
  \u2192 ${repo.name} (${repo.path})`);
14979
16152
  try {
14980
- if (!existsSync10(repoRoot) || !statSync2(repoRoot).isDirectory()) {
16153
+ if (!existsSync11(repoRoot) || !statSync2(repoRoot).isDirectory()) {
14981
16154
  throw new Error(`Repo path is not a directory: ${repo.path}`);
14982
16155
  }
14983
16156
  await runUndo({ dryRun: true, root: repoRoot });
@@ -14994,7 +16167,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
14994
16167
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
14995
16168
  continue;
14996
16169
  }
14997
- if (await fs56.pathExists(abs)) {
16170
+ if (await fs61.pathExists(abs)) {
14998
16171
  log(`[dry-run] Would remove ${entry.path} (linked ${entry.type} from ${entry.repo})`);
14999
16172
  }
15000
16173
  }
@@ -15004,12 +16177,12 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15004
16177
  hausPath(workspaceRoot, "WORKSPACE.md")
15005
16178
  ];
15006
16179
  for (const abs of workspaceArtifacts) {
15007
- if (await fs56.pathExists(abs)) {
15008
- 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)}`);
15009
16182
  }
15010
16183
  }
15011
- if (!await fs56.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
15012
- 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"));
15013
16186
  if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
15014
16187
  log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
15015
16188
  }
@@ -15028,11 +16201,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15028
16201
  }
15029
16202
  let anyRepoFailed = false;
15030
16203
  for (const repo of config.repos) {
15031
- const repoRoot = path79.resolve(workspaceRoot, repo.path);
16204
+ const repoRoot = path83.resolve(workspaceRoot, repo.path);
15032
16205
  log(`
15033
16206
  \u2192 ${repo.name} (${repo.path})`);
15034
16207
  try {
15035
- if (!existsSync10(repoRoot) || !statSync2(repoRoot).isDirectory()) {
16208
+ if (!existsSync11(repoRoot) || !statSync2(repoRoot).isDirectory()) {
15036
16209
  throw new Error(`Repo path is not a directory: ${repo.path}`);
15037
16210
  }
15038
16211
  await runUndo({ yes: true, root: repoRoot });
@@ -15048,30 +16221,30 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15048
16221
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
15049
16222
  continue;
15050
16223
  }
15051
- if (await fs56.pathExists(abs)) {
15052
- await fs56.remove(abs);
16224
+ if (await fs61.pathExists(abs)) {
16225
+ await fs61.remove(abs);
15053
16226
  log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
15054
16227
  }
15055
16228
  }
15056
16229
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
15057
16230
  const abs = hausPath(workspaceRoot, rel);
15058
- if (await fs56.pathExists(abs)) {
15059
- await fs56.remove(abs);
15060
- log(`Removed ${path79.relative(workspaceRoot, abs)}`);
16231
+ if (await fs61.pathExists(abs)) {
16232
+ await fs61.remove(abs);
16233
+ log(`Removed ${path83.relative(workspaceRoot, abs)}`);
15061
16234
  }
15062
16235
  }
15063
16236
  const manifestFile = manifestPath(workspaceRoot);
15064
- if (await fs56.pathExists(manifestFile)) {
15065
- await fs56.remove(manifestFile);
15066
- log(`Removed ${path79.relative(workspaceRoot, manifestFile)}`);
16237
+ if (await fs61.pathExists(manifestFile)) {
16238
+ await fs61.remove(manifestFile);
16239
+ log(`Removed ${path83.relative(workspaceRoot, manifestFile)}`);
15067
16240
  }
15068
16241
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
15069
- if (await fs56.pathExists(workspaceMdPath)) {
15070
- await fs56.remove(workspaceMdPath);
15071
- log(`Removed ${path79.relative(workspaceRoot, workspaceMdPath)}`);
16242
+ if (await fs61.pathExists(workspaceMdPath)) {
16243
+ await fs61.remove(workspaceMdPath);
16244
+ log(`Removed ${path83.relative(workspaceRoot, workspaceMdPath)}`);
15072
16245
  } else {
15073
- const claudeMdPath = path79.join(workspaceRoot, "CLAUDE.md");
15074
- if (existsSync10(claudeMdPath)) {
16246
+ const claudeMdPath = path83.join(workspaceRoot, "CLAUDE.md");
16247
+ if (existsSync11(claudeMdPath)) {
15075
16248
  const prev = await readText(claudeMdPath);
15076
16249
  if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
15077
16250
  const next = stripHausBlock(prev, {
@@ -15079,19 +16252,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
15079
16252
  end: WORKSPACE_BLOCK_END
15080
16253
  });
15081
16254
  if (next.length === 0) {
15082
- await fs56.remove(claudeMdPath);
16255
+ await fs61.remove(claudeMdPath);
15083
16256
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
15084
16257
  } else {
15085
- await fs56.writeFile(claudeMdPath, next, "utf8");
16258
+ await fs61.writeFile(claudeMdPath, next, "utf8");
15086
16259
  log("Removed haus import block from CLAUDE.md (user content preserved).");
15087
16260
  }
15088
16261
  } else if (prev && hasLegacyWorkspaceBlock(prev)) {
15089
16262
  const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
15090
16263
  if (next.length === 0) {
15091
- await fs56.remove(claudeMdPath);
16264
+ await fs61.remove(claudeMdPath);
15092
16265
  log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
15093
16266
  } else {
15094
- await fs56.writeFile(claudeMdPath, next, "utf8");
16267
+ await fs61.writeFile(claudeMdPath, next, "utf8");
15095
16268
  log(
15096
16269
  "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
15097
16270
  );
@@ -15117,7 +16290,7 @@ function normalizeMaxDepth(maxDepth) {
15117
16290
  return void 0;
15118
16291
  }
15119
16292
  async function initWorkspace(options = {}) {
15120
- if (existsSync11(WORKSPACE_FILE) && !options.yes) {
16293
+ if (existsSync12(WORKSPACE_FILE) && !options.yes) {
15121
16294
  const ok = await confirm(
15122
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.`
15123
16296
  );
@@ -15139,7 +16312,7 @@ relationships: []
15139
16312
  log("Workspace initialized.");
15140
16313
  }
15141
16314
  async function scanWorkspace(workspaceRoot, opts) {
15142
- const configText = await readText(path80.join(workspaceRoot, WORKSPACE_FILE));
16315
+ const configText = await readText(path84.join(workspaceRoot, WORKSPACE_FILE));
15143
16316
  if (!configText) {
15144
16317
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
15145
16318
  process.exitCode = 1;
@@ -15161,9 +16334,9 @@ async function scanWorkspace(workspaceRoot, opts) {
15161
16334
  const inputs = [];
15162
16335
  const failures = [];
15163
16336
  for (const repo of config.repos) {
15164
- const repoRoot = path80.resolve(workspaceRoot, repo.path);
16337
+ const repoRoot = path84.resolve(workspaceRoot, repo.path);
15165
16338
  try {
15166
- if (!existsSync11(repoRoot) || !statSync3(repoRoot).isDirectory()) {
16339
+ if (!existsSync12(repoRoot) || !statSync3(repoRoot).isDirectory()) {
15167
16340
  throw new Error("path is not a directory");
15168
16341
  }
15169
16342
  const result = await scanProject(repoRoot);
@@ -15230,7 +16403,7 @@ init_logger();
15230
16403
  init_paths();
15231
16404
  function cliVersion() {
15232
16405
  try {
15233
- const pkgPath = path81.join(packageRoot(), "package.json");
16406
+ const pkgPath = path85.join(packageRoot(), "package.json");
15234
16407
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
15235
16408
  return pkg.version ?? "0.0.0";
15236
16409
  } catch {
@@ -15240,7 +16413,7 @@ function cliVersion() {
15240
16413
  var program = new Command();
15241
16414
  function validateRuntimeNodeVersion() {
15242
16415
  try {
15243
- const pkgPath = path81.join(packageRoot(), "package.json");
16416
+ const pkgPath = path85.join(packageRoot(), "package.json");
15244
16417
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
15245
16418
  const requiredRange = pkg.engines?.node;
15246
16419
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -15400,6 +16573,35 @@ worktree.command("prune").description(
15400
16573
  ).option("--dry-run", "Preview which registrations would be pruned").option("--json", "Emit the structured prune result as JSON").action(
15401
16574
  (opts) => runWorktree("prune", { dryRun: opts.dryRun, json: opts.json })
15402
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
+ );
15403
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 }));
15404
16606
  program.parseAsync(process.argv).catch((err) => {
15405
16607
  const message2 = err instanceof Error ? err.message : String(err);