@haus-tech/haus-workflow 6.4.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli.js +1179 -599
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -509,9 +509,12 @@ async function writeJson(file, value, opts = {}) {
509
509
  }
510
510
  async function pruneEmptyDir(dir) {
511
511
  try {
512
- const entries = await fs2.readdir(dir);
513
- if (entries.length === 0) await fs2.remove(dir);
512
+ if (!(await fs2.lstat(dir)).isDirectory()) return false;
513
+ if ((await fs2.readdir(dir)).length > 0) return false;
514
+ await fs2.remove(dir);
515
+ return true;
514
516
  } catch {
517
+ return false;
515
518
  }
516
519
  }
517
520
  async function readText(file) {
@@ -715,7 +718,7 @@ function checkManifestFormat(value, sourceLabel) {
715
718
  return {
716
719
  ok: false,
717
720
  kind: "invalid",
718
- error: `${sourceLabel} declares an invalid manifestFormat (${JSON.stringify(value)}) \u2014 expected a positive integer.`
721
+ error: `${sourceLabel} declares an invalid manifestFormat (${JSON.stringify(value)}): expected a positive integer.`
719
722
  };
720
723
  }
721
724
  if (value > MAX_SUPPORTED_MANIFEST_FORMAT) {
@@ -941,7 +944,7 @@ function formatGithubRateLimitMessage(info) {
941
944
  "Fix: export GITHUB_TOKEN=$(gh auth token) # or HAUS_GITHUB_TOKEN=<pat>",
942
945
  " # also: gh auth login if gh isn't logged in",
943
946
  "Then: retry the command (e.g. haus update)",
944
- `Or wait until ${when} and retry once (token still recommended \u2014 wait alone can re-hit the limit).`
947
+ `Or wait until ${when} and retry once (token still recommended: waiting alone can re-hit the limit).`
945
948
  ].join("\n");
946
949
  }
947
950
  var hit;
@@ -980,7 +983,7 @@ function formatPrivateCatalogMessage(info) {
980
983
  return [
981
984
  `The catalog could not be read (HTTP ${info.status}) because haus is not signed in to GitHub.`,
982
985
  "The catalog lives in a private repository, and GitHub answers a signed-out read of",
983
- "a private repository with 404 \u2014 so this is an access problem, not a missing file.",
986
+ "a private repository with 404, so this is an access problem, not a missing file.",
984
987
  "Fix: gh auth login # sign in once, then retry",
985
988
  " export GITHUB_TOKEN=$(gh auth token) # or HAUS_GITHUB_TOKEN=<pat>",
986
989
  "Then: retry the command (e.g. haus update)"
@@ -1084,15 +1087,15 @@ function describeCatalogPin(pin, latestTag) {
1084
1087
  if (pin.kind === "invalid") {
1085
1088
  return {
1086
1089
  level: "advisory",
1087
- line: `- CATALOG PIN: ${PROJECT_CONFIG_REL} ${pin.reason} \u2014 the pin is ignored`,
1090
+ line: `- CATALOG PIN: ${PROJECT_CONFIG_REL} ${pin.reason}, the pin is ignored`,
1088
1091
  sentence: `${PROJECT_CONFIG_REL} cannot be read as a catalog pin`,
1089
- fix: 'fix or remove it \u2014 it must be JSON shaped { "catalogRef": "v3.2.0" }'
1092
+ fix: 'fix or remove it: it must be JSON shaped { "catalogRef": "v3.2.0" }'
1090
1093
  };
1091
1094
  }
1092
1095
  const behind = latestTag !== null && isCatalogRefOlderThan(pin.ref, latestTag);
1093
1096
  return {
1094
1097
  level: "advisory",
1095
- line: behind ? `- CATALOG PIN: ${pin.ref} (from ${PROJECT_CONFIG_REL}) \u2014 latest release is ${latestTag}` : `- CATALOG PIN: ${pin.ref} (from ${PROJECT_CONFIG_REL})`,
1098
+ line: behind ? `- CATALOG PIN: ${pin.ref} (from ${PROJECT_CONFIG_REL}), latest release is ${latestTag}` : `- CATALOG PIN: ${pin.ref} (from ${PROJECT_CONFIG_REL})`,
1096
1099
  sentence: behind ? `The catalog is pinned to ${pin.ref}; ${latestTag} is available` : `The catalog is pinned to ${pin.ref}`,
1097
1100
  fix: `remove "catalogRef" from ${PROJECT_CONFIG_REL} to track the latest release`
1098
1101
  };
@@ -1142,7 +1145,7 @@ function getResolvedCatalogRef() {
1142
1145
  const resolved = cachedCatalogRef?.ref ?? process.env["HAUS_CATALOG_REF"] ?? getBundledCatalogRef();
1143
1146
  if (!resolved) {
1144
1147
  warn(
1145
- "Could not determine catalog ref from cache, env, or bundled snapshot \u2014 falling back to main (moving target)."
1148
+ "Could not determine catalog ref from cache, env, or bundled snapshot, falling back to main (moving target)."
1146
1149
  );
1147
1150
  return "main";
1148
1151
  }
@@ -1196,7 +1199,7 @@ async function resolveCatalogRefWithSource(opts) {
1196
1199
  if (env["HAUS_CATALOG_REF"]) {
1197
1200
  if (env["HAUS_CATALOG_REF"] === "main") {
1198
1201
  warn(
1199
- "HAUS_CATALOG_REF=main is set \u2014 fetching from the moving main branch. Pin to a release tag for reproducible installs."
1202
+ "HAUS_CATALOG_REF=main is set, fetching from the moving main branch. Pin to a release tag for reproducible installs."
1200
1203
  );
1201
1204
  }
1202
1205
  return { ref: env["HAUS_CATALOG_REF"], source: "env" };
@@ -1205,7 +1208,7 @@ async function resolveCatalogRefWithSource(opts) {
1205
1208
  const pin = readProjectCatalogPin(opts.projectRoot);
1206
1209
  if (pin.kind === "pinned") return { ref: pin.ref, source: "pin" };
1207
1210
  if (pin.kind === "invalid") {
1208
- warn(`${PROJECT_CONFIG_REL} ${pin.reason} \u2014 ignoring the pin and resolving normally.`);
1211
+ warn(`${PROJECT_CONFIG_REL} ${pin.reason}, ignoring the pin and resolving normally.`);
1209
1212
  }
1210
1213
  }
1211
1214
  const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
@@ -1352,7 +1355,7 @@ async function fetchGitHubRecursiveBlobPaths(ref) {
1352
1355
  }
1353
1356
  const tree = await treeRes.json();
1354
1357
  if (tree.truncated) {
1355
- warn("Catalog GitHub tree listing was truncated \u2014 refusing partial cache sync");
1358
+ warn("Catalog GitHub tree listing was truncated, refusing partial cache sync");
1356
1359
  return null;
1357
1360
  }
1358
1361
  return tree.tree.filter((e) => e.type === "blob").map((e) => e.path);
@@ -1619,7 +1622,7 @@ function parseManifest(json) {
1619
1622
  kept.push(clause);
1620
1623
  } else {
1621
1624
  warnings.push(
1622
- `${item.id}: dropping unrecognized requiresAny clause (catalog schema evolved ahead of this CLI version) \u2014 ${JSON.stringify(clause)}`
1625
+ `${item.id}: dropping unrecognized requiresAny clause (catalog schema evolved ahead of this CLI version): ${JSON.stringify(clause)}`
1623
1626
  );
1624
1627
  }
1625
1628
  }
@@ -1921,7 +1924,7 @@ async function syncSuperpowersShared(base) {
1921
1924
  async function syncOneItem(item, base) {
1922
1925
  if (!KNOWN_ITEM_TYPES.has(item.type)) {
1923
1926
  warn(
1924
- `Skipping ${item.id}: type "${item.type}" is unknown to this haus version \u2014 upgrade to use it`
1927
+ `Skipping ${item.id}: type "${item.type}" is unknown to this haus version. Upgrade to use it`
1925
1928
  );
1926
1929
  return "failed";
1927
1930
  }
@@ -1971,7 +1974,7 @@ function buildCacheManifest(input2) {
1971
1974
  }
1972
1975
  async function syncRemoteCatalog(projectRoot) {
1973
1976
  if (isOffline()) {
1974
- warn("Offline \u2014 skipping catalog sync; using cached or bundled content.");
1977
+ warn("Offline: skipping catalog sync; using cached or bundled content.");
1975
1978
  return { newItems: [], refreshed: [], unchanged: 0, failed: [], skipped: true };
1976
1979
  }
1977
1980
  _resetBlobPathCacheForNewSync();
@@ -1989,7 +1992,7 @@ async function syncRemoteCatalog(projectRoot) {
1989
1992
  ...getPrivateCatalogDenial() ? { accessDenied: getPrivateCatalogDenial() } : {}
1990
1993
  };
1991
1994
  }
1992
- warn("Remote catalog fetch failed \u2014 using bundled catalog");
1995
+ warn("Remote catalog fetch failed, using bundled catalog");
1993
1996
  return {
1994
1997
  newItems: [],
1995
1998
  refreshed: [],
@@ -2013,7 +2016,7 @@ async function syncRemoteCatalog(projectRoot) {
2013
2016
  // the tier `apply` actually loads from, and `loadCatalogContext` reads a missing
2014
2017
  // field as format 1. Dropping it here would mean a synced cache always claims
2015
2018
  // format 1 whatever the catalog declared, so the cache-tier half of the format
2016
- // gate could never fire — the remote fetch would be the only thing standing
2019
+ // gate could never fire, the remote fetch would be the only thing standing
2017
2020
  // between a too-new catalog and an install.
2018
2021
  //
2019
2022
  // catalogRef binds this cache to the ref that populated it. The cache directory is
@@ -2030,7 +2033,7 @@ async function syncRemoteCatalog(projectRoot) {
2030
2033
  );
2031
2034
  } catch (err) {
2032
2035
  warn(
2033
- `Catalog cache not writable (${cacheDir}) \u2014 skipping cache sync: ${err instanceof Error ? err.message : String(err)}`
2036
+ `Catalog cache not writable (${cacheDir}), skipping cache sync: ${err instanceof Error ? err.message : String(err)}`
2034
2037
  );
2035
2038
  return { newItems: [], refreshed: [], unchanged: 0, failed: [] };
2036
2039
  }
@@ -2115,15 +2118,46 @@ init_paths();
2115
2118
  import path4 from "node:path";
2116
2119
 
2117
2120
  // src/catalog/install-mapping.ts
2118
- init_paths();
2119
- init_path_safety();
2120
2121
  import path3 from "node:path";
2122
+
2123
+ // src/claude/install-root.ts
2124
+ import { posix } from "node:path";
2121
2125
  var FLATTEN_DIR_BY_TYPE = {
2122
2126
  skill: "skills",
2123
2127
  agent: "agents",
2124
2128
  template: "templates",
2125
2129
  command: "commands"
2126
2130
  };
2131
+ var INSTALL_ROOT_PREFIXES = [".claude/"];
2132
+ var NEVER_INSTALLED = {
2133
+ files: [
2134
+ ".claude/settings.json",
2135
+ ".claude/settings.local.json",
2136
+ ".claude/CLAUDE.md",
2137
+ ".claude/rules/haus.md"
2138
+ ],
2139
+ subtrees: [".claude/worktrees", ".claude/rules/project", ".claude/agent-memory"]
2140
+ };
2141
+ function installTypeFolders() {
2142
+ return [.../* @__PURE__ */ new Set([...Object.values(FLATTEN_DIR_BY_TYPE), "rules"])].map(
2143
+ (folder) => `.claude/${folder}`
2144
+ );
2145
+ }
2146
+ function holdsPosix(a, b) {
2147
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
2148
+ }
2149
+ function insideInstallRoot(recorded) {
2150
+ const rel = posix.normalize(recorded).replace(/\/+$/, "");
2151
+ if (!INSTALL_ROOT_PREFIXES.some((prefix) => rel.startsWith(prefix))) return false;
2152
+ const lower = rel.toLowerCase();
2153
+ const equalsAny = (entries) => entries.some((entry) => entry.toLowerCase() === lower);
2154
+ if (equalsAny(NEVER_INSTALLED.files) || equalsAny(installTypeFolders())) return false;
2155
+ return !NEVER_INSTALLED.subtrees.some((tree) => holdsPosix(lower, tree.toLowerCase()));
2156
+ }
2157
+
2158
+ // src/catalog/install-mapping.ts
2159
+ init_paths();
2160
+ init_path_safety();
2127
2161
  var MIRRORED_TYPES = /* @__PURE__ */ new Set(["rule", "asset"]);
2128
2162
  function upstreamRelative(itemPath) {
2129
2163
  const match = itemPath.replace(/\\/g, "/").match(/^sources\/[^/]+\/(.+)$/);
@@ -2144,7 +2178,7 @@ function installTargetFor(item) {
2144
2178
  return {
2145
2179
  ok: false,
2146
2180
  code: "unknown-type",
2147
- reason: `type "${type}" is unknown to this haus version \u2014 upgrade the CLI to use it`
2181
+ reason: `type "${type}" is unknown to this haus version. Upgrade the CLI to use it`
2148
2182
  };
2149
2183
  }
2150
2184
  const itemPath = typeof item.path === "string" ? item.path : "";
@@ -2156,11 +2190,7 @@ function installTargetFor(item) {
2156
2190
  };
2157
2191
  }
2158
2192
  if (flattenDir) {
2159
- return {
2160
- ok: true,
2161
- layout: "flatten",
2162
- relPath: `${flattenDir}/${path3.posix.basename(itemPath)}`
2163
- };
2193
+ return admitted("flatten", `${flattenDir}/${path3.posix.basename(itemPath)}`);
2164
2194
  }
2165
2195
  const rel = upstreamRelative(itemPath);
2166
2196
  if (rel === null) {
@@ -2177,7 +2207,18 @@ function installTargetFor(item) {
2177
2207
  reason: `path "${itemPath}" escapes its own sources/<source>/ prefix`
2178
2208
  };
2179
2209
  }
2180
- return { ok: true, layout: "mirror", relPath: rel };
2210
+ return admitted("mirror", rel);
2211
+ }
2212
+ function admitted(layout, relPath) {
2213
+ const target = path3.posix.normalize(`.claude/${relPath}`);
2214
+ if (!insideInstallRoot(target)) {
2215
+ return {
2216
+ ok: false,
2217
+ code: "never-installed",
2218
+ reason: `install path "${target}" is one haus never installs to (only into an item's own path below .claude/), so haus leaves the item out; the catalog has to move the item`
2219
+ };
2220
+ }
2221
+ return { ok: true, layout, relPath };
2181
2222
  }
2182
2223
  function installDestinationFor(root, item) {
2183
2224
  const target = installTargetFor(item);
@@ -2587,9 +2628,22 @@ var EDITED_REASON = {
2587
2628
  "no-hash": "has no lock hash",
2588
2629
  modified: "was modified locally"
2589
2630
  };
2590
- async function unsafeTrackedPath(root, rel) {
2631
+ var REPLACE_LINK_RULES = { installRoot: false, linkedParent: false };
2632
+ async function realPathInsideRoot(root, abs) {
2633
+ const [realAbs, realRoot] = await Promise.all([fs4.realpath(abs), fs4.realpath(root)]);
2634
+ return insideReal(realAbs, realRoot);
2635
+ }
2636
+ function insideReal(realAbs, realRoot) {
2637
+ const prefix = realRoot.endsWith(path8.sep) ? realRoot : realRoot + path8.sep;
2638
+ return realAbs === realRoot || realAbs.startsWith(prefix);
2639
+ }
2640
+ async function unsafeTrackedPath(root, rel, rules = {}) {
2591
2641
  const abs = resolveContainedPath(root, recordedRel(rel));
2592
2642
  if (abs === null) return "escapes-root";
2643
+ if (rules.installRoot !== false) {
2644
+ const normalised = path8.relative(path8.resolve(root), abs).split(path8.sep).join("/");
2645
+ if (!insideInstallRoot(normalised)) return "outside-install-root";
2646
+ }
2593
2647
  if (!await fs4.pathExists(abs)) {
2594
2648
  try {
2595
2649
  if ((await fs4.lstat(abs)).isSymbolicLink()) return "symlink";
@@ -2599,9 +2653,16 @@ async function unsafeTrackedPath(root, rel) {
2599
2653
  return null;
2600
2654
  }
2601
2655
  if ((await fs4.lstat(abs)).isSymbolicLink()) return "symlink";
2602
- const [realAbs, realRoot] = await Promise.all([fs4.realpath(abs), fs4.realpath(root)]);
2603
- const prefix = realRoot.endsWith(path8.sep) ? realRoot : realRoot + path8.sep;
2604
- if (realAbs !== realRoot && !realAbs.startsWith(prefix)) return "symlink";
2656
+ const [realAbs, realRoot, realParent] = await Promise.all([
2657
+ fs4.realpath(abs),
2658
+ fs4.realpath(root),
2659
+ fs4.realpath(path8.dirname(abs))
2660
+ ]);
2661
+ if (!insideReal(realAbs, realRoot)) return "symlink";
2662
+ if (rules.linkedParent !== false) {
2663
+ const lexicalParent = path8.relative(path8.resolve(root), path8.dirname(abs));
2664
+ if (realParent !== path8.join(realRoot, lexicalParent)) return "symlink";
2665
+ }
2605
2666
  return null;
2606
2667
  }
2607
2668
  async function hasProtectedUnsafePath(root, relPaths, unsafeByRel) {
@@ -2609,14 +2670,39 @@ async function hasProtectedUnsafePath(root, relPaths, unsafeByRel) {
2609
2670
  const unsafe = unsafeByRel.get(rel) ?? null;
2610
2671
  if (unsafe === "symlink") return true;
2611
2672
  if (unsafe === "escapes-root" && await fs4.pathExists(path8.resolve(root, rel))) return true;
2673
+ if (unsafe === "outside-install-root" && await lstatExists(path8.resolve(root, rel))) {
2674
+ return true;
2675
+ }
2612
2676
  }
2613
2677
  return false;
2614
2678
  }
2615
- async function unsafeReasonsFor(root, relPaths) {
2679
+ async function lstatExists(abs) {
2680
+ try {
2681
+ await fs4.lstat(abs);
2682
+ return true;
2683
+ } catch {
2684
+ return false;
2685
+ }
2686
+ }
2687
+ function unsafePathLine(id, rel, reason) {
2688
+ if (reason === "escapes-root") {
2689
+ return `Refusing to touch ${id}'s recorded path outside the project root: ${rel}`;
2690
+ }
2691
+ if (reason === "symlink")
2692
+ return `Refusing to touch ${id}'s recorded path through a symlink: ${rel}`;
2693
+ return `Refusing to touch ${id}'s recorded path ${rel}: haus never installs there (only into an item's own path below .claude/), so it leaves it. Delete the row from .haus-workflow/haus.lock.json by hand.`;
2694
+ }
2695
+ function holdsPath(target, held) {
2696
+ const a = path8.resolve(target);
2697
+ const b = path8.resolve(held);
2698
+ const below = (folder) => folder.endsWith(path8.sep) ? folder : `${folder}${path8.sep}`;
2699
+ return a === b || b.startsWith(below(a)) || a.startsWith(below(b));
2700
+ }
2701
+ async function unsafeReasonsFor(root, relPaths, rules = {}) {
2616
2702
  const reasons = /* @__PURE__ */ new Map();
2617
2703
  for (const rel of relPaths.map(recordedRel)) {
2618
2704
  if (reasons.has(rel)) continue;
2619
- reasons.set(rel, await unsafeTrackedPath(root, rel));
2705
+ reasons.set(rel, await unsafeTrackedPath(root, rel, rules));
2620
2706
  }
2621
2707
  return reasons;
2622
2708
  }
@@ -2624,12 +2710,8 @@ async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn, unsaf
2624
2710
  const existing = [];
2625
2711
  for (const rel of new Set(relPaths.map(recordedRel))) {
2626
2712
  const unsafe = unsafeByRel?.has(rel) ? unsafeByRel.get(rel) ?? null : await unsafeTrackedPath(root, rel);
2627
- if (unsafe === "escapes-root") {
2628
- warnFn(`Refusing to touch ${itemId}'s recorded path outside the project root: ${rel}`);
2629
- continue;
2630
- }
2631
- if (unsafe === "symlink") {
2632
- warnFn(`Refusing to touch ${itemId}'s recorded path through a symlink: ${rel}`);
2713
+ if (unsafe !== null) {
2714
+ warnFn(unsafePathLine(itemId, rel, unsafe));
2633
2715
  continue;
2634
2716
  }
2635
2717
  const abs = path8.resolve(root, rel);
@@ -2790,7 +2872,7 @@ function recordedPathSet(rows2) {
2790
2872
  }
2791
2873
  async function recordedPathsOnDisk(root, relPaths) {
2792
2874
  const rels = [...new Set(relPaths.map(recordedRel))];
2793
- const unsafeByRel = await unsafeReasonsFor(root, rels);
2875
+ const unsafeByRel = await unsafeReasonsFor(root, rels, REPLACE_LINK_RULES);
2794
2876
  let anyPresent = false;
2795
2877
  const refused = [];
2796
2878
  for (const rel of rels) {
@@ -2809,7 +2891,8 @@ async function recordedPathsOnDisk(root, relPaths) {
2809
2891
  }
2810
2892
  var UNSAFE_ON_DISK = {
2811
2893
  symlink: "is a symlink (or resolves through one), which haus does not copy or replace",
2812
- "escapes-root": "is outside the project root, which haus does not touch"
2894
+ "escapes-root": "is outside the project root, which haus does not touch",
2895
+ "outside-install-root": "is where haus never installs (only into an item's own path below .claude/), which haus does not copy or replace"
2813
2896
  };
2814
2897
  function unsafeOnDiskReason(rel, reason) {
2815
2898
  return `${recordedRel(rel)} ${UNSAFE_ON_DISK[reason]}`;
@@ -3013,6 +3096,10 @@ function inUseLine(rel, id, holder, dryRun = false) {
3013
3096
  const verb = dryRun ? "[dry-run] would keep (in use)" : "kept (in use)";
3014
3097
  return `${verb} ${recordedRel(rel)} ${bracketedId(id)}: ${holder} also records it, so haus leaves it on disk.`;
3015
3098
  }
3099
+ function excludedLine(rel, id, dryRun = false) {
3100
+ const verb = dryRun ? "[dry-run] would exclude" : "excluded";
3101
+ return `${verb} ${recordedRel(rel)} ${bracketedId(id)}: haus leaves the file as it is and reports nothing about it. Take ${id} out of excludedIds in .haus-workflow/haus.config.json to hand it back to haus.`;
3102
+ }
3016
3103
  function underFileLine(paths, id, blocking, dryRun = false) {
3017
3104
  const verb = dryRun ? "[dry-run] would refuse" : "refused";
3018
3105
  return `${verb} ${posixPathsOnce(paths).join(", ")} ${bracketedId(id)}: ${recordedRel(blocking)} is a file where a folder belongs. haus left both as they were; move the file aside by hand and run again.`;
@@ -3381,7 +3468,7 @@ async function loadCatalogContext(root) {
3381
3468
  const wantedRef = expectedCatalogRef(root);
3382
3469
  if (!cacheRefMatches(cacheData.catalogRef, wantedRef)) {
3383
3470
  warn(
3384
- `Catalog cache was populated from ${String(cacheData.catalogRef)} but this project resolves to ${wantedRef} \u2014 ignoring the cache. Run \`haus update\` to re-sync it.`
3471
+ `Catalog cache was populated from ${String(cacheData.catalogRef)} but this project resolves to ${wantedRef}, ignoring the cache. Run \`haus update\` to re-sync it.`
3385
3472
  );
3386
3473
  } else {
3387
3474
  return {
@@ -3530,7 +3617,7 @@ async function reconcileIndex(root, move, dryRun, notify) {
3530
3617
  if (tracked.length === 0) return;
3531
3618
  if (dryRun) {
3532
3619
  notify(
3533
- move.keepTracked ? `Would stage the move of ${move.from} to ${move.to} in git (${tracked.length} tracked file(s)).` : `Would untrack ${tracked.length} committed file(s) under ${move.from} from git \u2014 request pricing material should never have been committed.`
3620
+ move.keepTracked ? `Would stage the move of ${move.from} to ${move.to} in git (${tracked.length} tracked file(s)).` : `Would untrack ${tracked.length} committed file(s) under ${move.from} from git: request pricing material should never have been committed.`
3534
3621
  );
3535
3622
  return;
3536
3623
  }
@@ -3543,12 +3630,12 @@ async function reconcileIndex(root, move, dryRun, notify) {
3543
3630
  if (move.keepTracked) {
3544
3631
  await runGitStep(root, ["add", "--", move.to], notify);
3545
3632
  notify(
3546
- `Staged the move of ${move.from} to ${move.to} in git \u2014 it is board configuration and stays tracked. Run \`git status\` to review before committing this change.`
3633
+ `Staged the move of ${move.from} to ${move.to} in git: it is board configuration and stays tracked. Run \`git status\` to review before committing this change.`
3547
3634
  );
3548
3635
  return;
3549
3636
  }
3550
3637
  notify(
3551
- `Untracked ${tracked.length} file(s) under ${move.from} from git \u2014 request pricing material should never have been committed, and \`${move.to}\` is gitignored from here on. Run \`git status\` to review before committing this change.`
3638
+ `Untracked ${tracked.length} file(s) under ${move.from} from git: request pricing material should never have been committed, and \`${move.to}\` is gitignored from here on. Run \`git status\` to review before committing this change.`
3552
3639
  );
3553
3640
  }
3554
3641
  async function removeEmptyLegacyDir(root, previewedMoves, dryRun, notify) {
@@ -3568,7 +3655,7 @@ async function removeEmptyLegacyDir(root, previewedMoves, dryRun, notify) {
3568
3655
  notify(`Removed the now-empty ${LEGACY_REQUEST_DIR}/ directory.`);
3569
3656
  } catch (error2) {
3570
3657
  notify(
3571
- `Could not remove the empty ${LEGACY_REQUEST_DIR}/ directory: ${error2 instanceof Error ? error2.message : String(error2)}. The request state was migrated successfully \u2014 delete ${LEGACY_REQUEST_DIR}/ by hand.`
3658
+ `Could not remove the empty ${LEGACY_REQUEST_DIR}/ directory: ${error2 instanceof Error ? error2.message : String(error2)}. The request state was migrated successfully. Delete ${LEGACY_REQUEST_DIR}/ by hand.`
3572
3659
  );
3573
3660
  }
3574
3661
  }
@@ -3601,7 +3688,7 @@ async function migrateRequestState(root, dryRun, notify) {
3601
3688
  if (await exists2(toAbs)) {
3602
3689
  conflicts.push({ ...move, reason: "destination-exists" });
3603
3690
  notify(
3604
- `Refused to migrate ${move.from} to ${move.to}: ${move.to} already exists. Two locations hold request state \u2014 reconcile them by hand, then remove ${move.from} so this migration can finish.`
3691
+ `Refused to migrate ${move.from} to ${move.to}: ${move.to} already exists. Two locations hold request state. Reconcile them by hand, then remove ${move.from} so this migration can finish.`
3605
3692
  );
3606
3693
  continue;
3607
3694
  }
@@ -3619,13 +3706,13 @@ async function migrateRequestState(root, dryRun, notify) {
3619
3706
  const message2 = error2 instanceof Error ? error2.message : String(error2);
3620
3707
  failures.push({ ...move, message: message2 });
3621
3708
  notify(
3622
- `Could not migrate ${move.from} to ${move.to}: ${message2}. ${move.from} was left in place \u2014 fix the cause and re-run \`haus apply --write\`.`
3709
+ `Could not migrate ${move.from} to ${move.to}: ${message2}. ${move.from} was left in place. Fix the cause and re-run \`haus apply --write\`.`
3623
3710
  );
3624
3711
  continue;
3625
3712
  }
3626
3713
  moved.push(move);
3627
3714
  notify(
3628
- `Moved ${move.from} to ${move.to} \u2014 request state now lives under \`${HAUS_DIR}/\`, where haus's gitignore rules cover it (ADR-0103).`
3715
+ `Moved ${move.from} to ${move.to}: request state now lives under \`${HAUS_DIR}/\`, where haus's gitignore rules cover it (ADR-0103).`
3629
3716
  );
3630
3717
  if (trackedBefore.length > 0) await reconcileIndex(root, move, false, notify);
3631
3718
  }
@@ -3716,7 +3803,7 @@ function resolveGlobalPlugins(manifest, deps = {}) {
3716
3803
  const declared = manifest?.globalPlugins;
3717
3804
  if (declared === void 0) return [...DEFAULT_GLOBAL_PLUGINS];
3718
3805
  if (!Array.isArray(declared)) {
3719
- warn2("Catalog `globalPlugins` is not an array \u2014 falling back to the bundled pin.");
3806
+ warn2("Catalog `globalPlugins` is not an array, falling back to the bundled pin.");
3720
3807
  return [...DEFAULT_GLOBAL_PLUGINS];
3721
3808
  }
3722
3809
  const pins = [];
@@ -3729,13 +3816,13 @@ function resolveGlobalPlugins(manifest, deps = {}) {
3729
3816
  const activation = entry.activation ?? "always";
3730
3817
  if (entry.ref !== void 0 && !isImmutableRef(entry.ref)) {
3731
3818
  warn2(
3732
- `Refusing catalog pin ${entry.id}: ref "${entry.ref}" is not a release tag \u2014 haus only installs immutable, reviewed plugin content.`
3819
+ `Refusing catalog pin ${entry.id}: ref "${entry.ref}" is not a release tag: haus only installs immutable, reviewed plugin content.`
3733
3820
  );
3734
3821
  continue;
3735
3822
  }
3736
3823
  if (activation === "project" && !(entry.requiresAny && entry.requiresAny.length > 0)) {
3737
3824
  warn2(
3738
- `Refusing catalog pin ${entry.id}: activation "project" needs a non-empty requiresAny \u2014 without a signal no project could ever enable it.`
3825
+ `Refusing catalog pin ${entry.id}: activation "project" needs a non-empty requiresAny: without a signal no project could ever enable it.`
3739
3826
  );
3740
3827
  continue;
3741
3828
  }
@@ -3825,7 +3912,7 @@ function planBuiltin(pin, state) {
3825
3912
  pin,
3826
3913
  kind: "marketplace-missing",
3827
3914
  steps: [],
3828
- reason: `built-in marketplace ${pin.marketplace} is not listed by this Claude Code \u2014 haus does not add it`
3915
+ reason: `built-in marketplace ${pin.marketplace} is not listed by this Claude Code, and haus does not add it`
3829
3916
  };
3830
3917
  }
3831
3918
  const installed = findManagedInstall(state, pin);
@@ -3845,7 +3932,7 @@ function planBuiltin(pin, state) {
3845
3932
  pin,
3846
3933
  kind: "re-enable",
3847
3934
  steps: ["plugin-enable"],
3848
- reason: `plugin ${id} is disabled \u2014 haus workflows depend on it`
3935
+ reason: `plugin ${id} is disabled, and haus workflows depend on it`
3849
3936
  };
3850
3937
  }
3851
3938
  if (!wantEnabled && enabled) {
@@ -3853,7 +3940,7 @@ function planBuiltin(pin, state) {
3853
3940
  pin,
3854
3941
  kind: "disable-at-user-scope",
3855
3942
  steps: ["plugin-disable"],
3856
- reason: `plugin ${id} is enabled at ${MANAGED_SCOPE} scope \u2014 it loads into every session instead of only the projects that use it`
3943
+ reason: `plugin ${id} is enabled at ${MANAGED_SCOPE} scope, so it loads into every session instead of only the projects that use it`
3857
3944
  };
3858
3945
  }
3859
3946
  return {
@@ -3905,7 +3992,7 @@ function planPinned(pin, state) {
3905
3992
  pin,
3906
3993
  kind: "re-enable",
3907
3994
  steps: ["plugin-enable"],
3908
- reason: `plugin ${pluginId(pin)} is disabled \u2014 haus workflows depend on it`
3995
+ reason: `plugin ${pluginId(pin)} is disabled, and haus workflows depend on it`
3909
3996
  };
3910
3997
  }
3911
3998
  return { pin, kind: "up-to-date", steps: [], reason: `pinned to ${ref}` };
@@ -4574,7 +4661,7 @@ async function readProjectConfigObject(root) {
4574
4661
  } catch (err) {
4575
4662
  if (err.code === "ENOENT") return null;
4576
4663
  warn(
4577
- `${PROJECT_CONFIG_REL} could not be read (${err instanceof Error ? err.message : String(err)}) \u2014 treating project intent as unset`
4664
+ `${PROJECT_CONFIG_REL} could not be read (${err instanceof Error ? err.message : String(err)}), treating project intent as unset`
4578
4665
  );
4579
4666
  return null;
4580
4667
  }
@@ -4713,7 +4800,7 @@ async function readProjectProfile(root) {
4713
4800
  const { profile, malformed } = parseProfile(data?.profile);
4714
4801
  if (malformed) {
4715
4802
  warn(
4716
- `${PROJECT_CONFIG_REL}: unknown profile ${JSON.stringify(data?.profile)} \u2014 expected one of ${PROFILE_IDS.join(", ")}; treating as ${DEFAULT_PROFILE}`
4803
+ `${PROJECT_CONFIG_REL}: unknown profile ${JSON.stringify(data?.profile)}: expected one of ${PROFILE_IDS.join(", ")}; treating as ${DEFAULT_PROFILE}`
4717
4804
  );
4718
4805
  }
4719
4806
  return profile;
@@ -4740,7 +4827,7 @@ function warnOnce(message2) {
4740
4827
  function stringList(value, label) {
4741
4828
  if (value === void 0) return [];
4742
4829
  if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
4743
- warnOnce(`${label} is not a string array \u2014 ignoring it`);
4830
+ warnOnce(`${label} is not a string array, ignoring it`);
4744
4831
  return [];
4745
4832
  }
4746
4833
  return value;
@@ -4755,7 +4842,7 @@ function parseProfileRules(raw, profile, knownIds) {
4755
4842
  for (const key of Object.keys(clauses)) {
4756
4843
  if (!KNOWN_CLAUSES.includes(key)) {
4757
4844
  warnOnce(
4758
- `profiles.${profile}.exclude.${key}: unknown clause (catalog schema evolved ahead of this CLI version) \u2014 ignoring it`
4845
+ `profiles.${profile}.exclude.${key}: unknown clause (catalog schema evolved ahead of this CLI version), ignoring it`
4759
4846
  );
4760
4847
  }
4761
4848
  }
@@ -4765,7 +4852,7 @@ function parseProfileRules(raw, profile, knownIds) {
4765
4852
  ).filter((id) => {
4766
4853
  if (knownIds !== void 0 && !knownIds.has(id)) {
4767
4854
  warnOnce(
4768
- `profiles.${profile}.keepIds names an id the catalog does not have (${id}) \u2014 ignoring it`
4855
+ `profiles.${profile}.keepIds names an id the catalog does not have (${id}), ignoring it`
4769
4856
  );
4770
4857
  return false;
4771
4858
  }
@@ -5451,8 +5538,8 @@ function computeDetectionStatus(roles, stacks, unsupportedSignals) {
5451
5538
  }
5452
5539
  function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
5453
5540
  const markers = unsupportedSignals.join(", ");
5454
- const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
5455
- const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree \u2014 sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
5541
+ const base = markers ? `Stack not recognised: detected ${markers}, which haus does not support.` : "Stack not recognised: no supported framework detected.";
5542
+ const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree: sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
5456
5543
  return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
5457
5544
  }
5458
5545
  function blocked(rel) {
@@ -6018,17 +6105,13 @@ function describeRequiresAny(clauses) {
6018
6105
  function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
6019
6106
  const markers = context.unsupportedSignals?.join(", ");
6020
6107
  const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
6021
- `Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
6108
+ `Partially supported: found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
6022
6109
  ] : [];
6023
6110
  const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
6024
6111
  return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
6025
6112
  }
6026
6113
 
6027
6114
  // src/claude/item-admission.ts
6028
- var CORE_MANAGED_CLAUDE_PATHS = /* @__PURE__ */ new Set(["settings.json", "rules/haus.md"]);
6029
- function isCoreManagedClaudePath(relPath) {
6030
- return CORE_MANAGED_CLAUDE_PATHS.has(relPath.toLowerCase());
6031
- }
6032
6115
  var RETIREMENT_REFUSALS = /* @__PURE__ */ new Set([
6033
6116
  "deprecated",
6034
6117
  "plugin-superseded"
@@ -6106,19 +6189,12 @@ async function computeAdmission({ root, contentRoot, manifestById, installPlugin
6106
6189
  warning: `Skipping ${item.id}: ${placement.reason}`
6107
6190
  };
6108
6191
  }
6109
- if (isCoreManagedClaudePath(placement.relPath)) {
6110
- return {
6111
- ok: false,
6112
- refusal: "core-managed-path",
6113
- warning: `Skipping ${item.id}: .claude/${placement.relPath} is written and owned by haus itself`
6114
- };
6115
- }
6116
6192
  const sourcePath = catalogItemContentPath(contentRoot, manifestItem);
6117
6193
  if (!await fs19.pathExists(sourcePath)) {
6118
6194
  return {
6119
6195
  ok: false,
6120
6196
  refusal: "source-missing",
6121
- warning: `Skipping ${item.id}: source not found at ${sourcePath} \u2014 run \`haus update\` to populate catalog cache`
6197
+ warning: `Skipping ${item.id}: source not found at ${sourcePath}. Run \`haus update\` to populate catalog cache`
6122
6198
  };
6123
6199
  }
6124
6200
  return { ok: true, manifestItem, destination: placement.destination, sourcePath };
@@ -6140,7 +6216,7 @@ async function sourceValidationFailure(item, manifestItem, sourcePath) {
6140
6216
  mdContent
6141
6217
  );
6142
6218
  if (!mdValidation.ok) {
6143
- return `Skipping ${item.id}: pre-copy validation failed (${relFile}) \u2014 ${mdValidation.reason}`;
6219
+ return `Skipping ${item.id}: pre-copy validation failed (${relFile}): ${mdValidation.reason}`;
6144
6220
  }
6145
6221
  }
6146
6222
  return void 0;
@@ -6154,7 +6230,7 @@ async function sourceValidationFailure(item, manifestItem, sourcePath) {
6154
6230
  },
6155
6231
  await fs19.readFile(sourcePath, "utf8")
6156
6232
  );
6157
- return validation.ok ? void 0 : `Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`;
6233
+ return validation.ok ? void 0 : `Skipping ${item.id}: pre-copy validation failed: ${validation.reason}`;
6158
6234
  }
6159
6235
  function assertNeverRenameVerdict(verdict) {
6160
6236
  throw new Error(`Unhandled RenameTargetVerdict: ${JSON.stringify(verdict)}`);
@@ -6194,8 +6270,6 @@ function describeAdmissionRefusal(refusal2) {
6194
6270
  return "config items are not written into .claude/";
6195
6271
  case "placement-refused":
6196
6272
  return "haus cannot place it under .claude/";
6197
- case "core-managed-path":
6198
- return "its destination is a file haus writes itself";
6199
6273
  case "source-missing":
6200
6274
  return "its content is missing from the catalog cache";
6201
6275
  }
@@ -6247,17 +6321,13 @@ async function holdsKnownStub(abs, stub) {
6247
6321
  `, `${stub}\r
6248
6322
  `].some((known) => found.equals(Buffer.from(known)));
6249
6323
  }
6250
- async function throughLinkedFolder(root, rel) {
6251
- let dir = root;
6252
- for (const part of path32.posix.dirname(rel).split("/")) {
6253
- dir = path32.join(dir, part);
6254
- try {
6255
- if ((await fs20.lstat(dir)).isSymbolicLink()) return true;
6256
- } catch {
6257
- return true;
6258
- }
6324
+ async function keptForLinkedFolder(root, rel, unsafeByRel) {
6325
+ if (unsafeByRel.get(rel) !== "symlink") return false;
6326
+ try {
6327
+ return await realPathInsideRoot(root, path32.join(root, rel));
6328
+ } catch {
6329
+ return false;
6259
6330
  }
6260
- return false;
6261
6331
  }
6262
6332
  function legacyKeptLine(rel, kind) {
6263
6333
  if (kind === "linked") {
@@ -6270,20 +6340,27 @@ function legacyKeptLine(rel, kind) {
6270
6340
  }
6271
6341
  var CI_GATE_COMMIT_HINT = 'Commit this with an [adr-skip] line in the commit body (e.g. "[adr-skip] haus moved its gate from CI to Lefthook, ADR-0161"): removing a workflow is a decision trigger.';
6272
6342
  var CI_GATE_REL = CI_GATE_WORKFLOW_REL.split(path32.sep).join("/");
6343
+ var CI_GATE_RULES = { installRoot: false };
6273
6344
  async function findCiGateWorkflow(root) {
6274
6345
  if (await legacyFileState(path32.join(root, CI_GATE_REL)) !== "file") return void 0;
6275
- const [tracked] = await resolveExistingTrackedPaths(root, "haus-gate", [CI_GATE_REL], () => {
6276
- });
6277
- if (tracked === void 0) return void 0;
6346
+ const unsafeByRel = await unsafeReasonsFor(root, [CI_GATE_REL], CI_GATE_RULES);
6347
+ const [tracked] = await resolveExistingTrackedPaths(
6348
+ root,
6349
+ "haus-gate",
6350
+ [CI_GATE_REL],
6351
+ () => {
6352
+ },
6353
+ unsafeByRel
6354
+ );
6355
+ const linked = tracked === void 0 && await keptForLinkedFolder(root, CI_GATE_REL, unsafeByRel);
6356
+ if (tracked === void 0 && !linked) return void 0;
6278
6357
  let content2;
6279
6358
  try {
6280
- content2 = await fs20.readFile(tracked.abs);
6359
+ content2 = await fs20.readFile(tracked?.abs ?? path32.join(root, CI_GATE_REL));
6281
6360
  } catch {
6282
6361
  return void 0;
6283
6362
  }
6284
- if (isKnownHausCiGate(content2)) {
6285
- return await throughLinkedFolder(root, CI_GATE_REL) ? { kind: "linked" } : { kind: "known" };
6286
- }
6363
+ if (isKnownHausCiGate(content2)) return linked ? { kind: "linked" } : { kind: "known" };
6287
6364
  return isHausWrittenCiGate(content2.toString("utf8")) ? { kind: "edited" } : void 0;
6288
6365
  }
6289
6366
  async function findLegacyStubPaths(root) {
@@ -6291,10 +6368,13 @@ async function findLegacyStubPaths(root) {
6291
6368
  for (const stub of LEGACY_STUBS) {
6292
6369
  const state = await legacyFileState(path32.join(root, stub.rel));
6293
6370
  if (state === "absent") continue;
6371
+ const unsafeByRel = state === "file" ? await unsafeReasonsFor(root, [stub.rel]) : /* @__PURE__ */ new Map();
6294
6372
  const existing = state === "file" ? await resolveExistingTrackedPaths(root, stub.id, [stub.rel], () => {
6295
- }) : [];
6373
+ }, unsafeByRel) : [];
6296
6374
  const tracked = existing[0];
6297
- const kind = tracked === void 0 || !await holdsKnownStub(tracked.abs, stub.text) ? "other" : await throughLinkedFolder(root, stub.rel) ? "linked" : "stub";
6375
+ const linked = tracked === void 0 && await keptForLinkedFolder(root, stub.rel, unsafeByRel);
6376
+ const readAt = tracked?.abs ?? (linked ? path32.join(root, stub.rel) : void 0);
6377
+ const kind = readAt === void 0 || !await holdsKnownStub(readAt, stub.text) ? "other" : linked ? "linked" : "stub";
6298
6378
  found.push({ id: stub.id, rel: stub.rel, kind });
6299
6379
  }
6300
6380
  return found;
@@ -6315,8 +6395,14 @@ async function removeLegacyHausFiles(root, dryRun, say, opts = {}) {
6315
6395
  if (found.kind === "linked") warn(legacyKeptLine(found.rel, "linked"));
6316
6396
  }
6317
6397
  const ciGate = opts.ciGateReplaced ? await findCiGateWorkflow(root) : void 0;
6318
- const [ciGateTracked] = ciGate?.kind === "known" ? await resolveExistingTrackedPaths(root, "haus-gate", [CI_GATE_REL], () => {
6319
- }) : [];
6398
+ const [ciGateTracked] = ciGate?.kind === "known" ? await resolveExistingTrackedPaths(
6399
+ root,
6400
+ "haus-gate",
6401
+ [CI_GATE_REL],
6402
+ () => {
6403
+ },
6404
+ await unsafeReasonsFor(root, [CI_GATE_REL], CI_GATE_RULES)
6405
+ ) : [];
6320
6406
  if (ciGateTracked !== void 0) {
6321
6407
  matched.push({
6322
6408
  id: "haus-gate",
@@ -6382,6 +6468,10 @@ async function removeLegacyHausFiles(root, dryRun, say, opts = {}) {
6382
6468
  init_paths();
6383
6469
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
6384
6470
  var RULES_HAUS_ID = "core.rules-haus";
6471
+ var HEADER_FILE_RELS = {
6472
+ "template.workflow": ".haus-workflow/WORKFLOW.md",
6473
+ [RULES_HAUS_ID]: ".claude/rules/haus.md"
6474
+ };
6385
6475
  var PROJECT_MANAGED_HAUS_REL = [];
6386
6476
  function coreManagedAbsolutePaths(root) {
6387
6477
  const claude = PROJECT_MANAGED_CLAUDE_REL.map((rel) => claudePath(root, rel));
@@ -6797,7 +6887,7 @@ async function readProjectExcludedIds(root) {
6797
6887
  } catch (err) {
6798
6888
  if (err.code === "ENOENT") return [];
6799
6889
  warn(
6800
- `${PROJECT_CONFIG_REL} could not be read for excludedIds (${err instanceof Error ? err.message : String(err)}) \u2014 treating as none`
6890
+ `${PROJECT_CONFIG_REL} could not be read for excludedIds (${err instanceof Error ? err.message : String(err)}), treating as none`
6801
6891
  );
6802
6892
  return [];
6803
6893
  }
@@ -6979,7 +7069,7 @@ function skillDepInstallCommand(manager) {
6979
7069
  case "yarn":
6980
7070
  return {
6981
7071
  ok: false,
6982
- reason: "yarn resolves installs from the nearest lockfile up the tree, so it cannot install a standalone skill directory without reaching the project root \u2014 install it manually if the skill scripts are needed"
7072
+ reason: "yarn resolves installs from the nearest lockfile up the tree, so it cannot install a standalone skill directory without reaching the project root. Install it manually if the skill scripts are needed"
6983
7073
  };
6984
7074
  default:
6985
7075
  return { ok: false, reason: "no package manager detected for this project" };
@@ -7039,7 +7129,7 @@ async function installSkillDependencies(opts, deps = {}) {
7039
7129
  case "inconclusive":
7040
7130
  return {
7041
7131
  status: "skipped",
7042
- reason: `could not determine whether ${command} is on PATH (${probe.reason}) \u2014 dependency install skipped`
7132
+ reason: `could not determine whether ${command} is on PATH (${probe.reason}), dependency install skipped`
7043
7133
  };
7044
7134
  default:
7045
7135
  return assertNeverCommandProbe(probe);
@@ -7076,10 +7166,10 @@ async function findStrayFiles(root, items, contentRoot, lockRows, excludedIds, i
7076
7166
  if (reported.has(relPath) || recorded.has(relPath)) continue;
7077
7167
  if (isItemExcluded(item, excludedIds) || trackedIds.has(item.id)) continue;
7078
7168
  if (!await anyRecordedPathOnDisk(root, [relPath])) continue;
7079
- const admitted = await admission(item).catch(() => void 0);
7080
- if (admitted === void 0 || !admitted.ok) continue;
7081
- if (await sourceIsInvalid(item, admitted.manifestItem, admitted.sourcePath)) continue;
7082
- const verdict = await verdictFor(root, admitted.manifestItem, contentRoot, relPath);
7169
+ const admitted2 = await admission(item).catch(() => void 0);
7170
+ if (admitted2 === void 0 || !admitted2.ok) continue;
7171
+ if (await sourceIsInvalid(item, admitted2.manifestItem, admitted2.sourcePath)) continue;
7172
+ const verdict = await verdictFor(root, admitted2.manifestItem, contentRoot, relPath);
7083
7173
  if (verdict.kind === "absent") continue;
7084
7174
  reported.add(relPath);
7085
7175
  strays.push(
@@ -7390,7 +7480,7 @@ async function writeManagedText(root, filePath, nextText, dryRun, quiet = false,
7390
7480
  // src/claude/write-decisions-seed.ts
7391
7481
  var SEED_README = `# Architecture Decision Records (ADRs)
7392
7482
 
7393
- Why we chose X \u2014 short, dated, write-once decision log for humans and agents.
7483
+ Why we chose X: a short, dated, write-once decision log for humans and agents.
7394
7484
 
7395
7485
  | ADR | Title | Why (one line) | Status |
7396
7486
  | --- | ----- | -------------- | ------ |
@@ -7706,13 +7796,53 @@ var DEPLOY_SIGNALS = [
7706
7796
  /\bscp\b/,
7707
7797
  /\bssh-agent\b/
7708
7798
  ];
7709
- function classifyEnvironmentName(name) {
7799
+ function environmentNameClass(name) {
7710
7800
  const lowered = name.toLowerCase();
7711
7801
  if (/\bpreview\b/.test(lowered)) return "preview";
7712
7802
  if (/github-pages|\bdocs?\b|docusaurus|github\.io/.test(lowered)) return "customer-visible";
7713
7803
  if (/\bprod|\bproduction\b|\blive\b/.test(lowered)) return "production";
7714
- if (/\bstag(?:e|ing)\b|\btest\b|\bqa\b|\buat\b|\bdev\b|\bdevelop/.test(lowered)) return "staging";
7715
- return "production";
7804
+ if (/\bstag(?:e|ing)\b|\btest(?:ing)?\b|\bqa\b|\buat\b|\bdev\b|\bdevelop/.test(lowered)) {
7805
+ return "staging";
7806
+ }
7807
+ return void 0;
7808
+ }
7809
+ function classifyEnvironmentName(name) {
7810
+ return environmentNameClass(name) ?? "production";
7811
+ }
7812
+ var TARGET_WORD = "(?:prod(?:uction)?|live|staging)";
7813
+ var STAGE_WORD = "(?:test(?:ing)?|dev(?:elop(?:ment)?)?|stage|qa|uat)";
7814
+ var DEPLOY_WORD = "(?:deploy(?:ment)?|rollout)";
7815
+ var SEP = "[-_ ./]";
7816
+ var NAMED_TARGET = [
7817
+ new RegExp(`\\b${DEPLOY_WORD}${SEP}*(?:to${SEP}+)?${TARGET_WORD}\\b`),
7818
+ new RegExp(`\\b${TARGET_WORD}${SEP}+${DEPLOY_WORD}`),
7819
+ new RegExp(`\\b${DEPLOY_WORD}${SEP}*(?:to${SEP}+)?${STAGE_WORD}$`)
7820
+ ];
7821
+ var ENVIRONMENT_WORD = new RegExp(`\\b(?:${TARGET_WORD}|${STAGE_WORD})\\b`, "g");
7822
+ function namedTargetClass(doc, file) {
7823
+ const groupOf = (value) => typeof value === "string" ? value : isPlainObject(value) && typeof value.group === "string" ? value.group : "";
7824
+ const names4 = [file.replace(/\.ya?ml$/, ""), textOf(doc.name), groupOf(doc.concurrency)];
7825
+ const jobs = isPlainObject(doc.jobs) ? doc.jobs : {};
7826
+ for (const job of Object.values(jobs)) {
7827
+ if (isPlainObject(job)) names4.push(groupOf(job.concurrency));
7828
+ }
7829
+ let found;
7830
+ for (const name of names4) {
7831
+ if (!name || name.length > MAX_TARGET_NAME || name.includes("${{")) continue;
7832
+ const lowered = withoutTrailingSeparators(name.toLowerCase());
7833
+ if (!NAMED_TARGET.some((pattern) => pattern.test(lowered))) continue;
7834
+ for (const [word] of lowered.matchAll(ENVIRONMENT_WORD)) {
7835
+ const value = environmentNameClass(word);
7836
+ if (value && value !== "preview") found = found ? maxClass(found, value) : value;
7837
+ }
7838
+ }
7839
+ return found;
7840
+ }
7841
+ var MAX_TARGET_NAME = 200;
7842
+ function withoutTrailingSeparators(name) {
7843
+ let end = name.length;
7844
+ while (end > 0 && "-_ ./".includes(name[end - 1])) end -= 1;
7845
+ return name.slice(0, end);
7716
7846
  }
7717
7847
  function toList(value) {
7718
7848
  if (typeof value === "string") return [value];
@@ -7730,26 +7860,54 @@ function triggers(doc) {
7730
7860
  }
7731
7861
  return isPlainObject(on) ? on : {};
7732
7862
  }
7863
+ function staticEnvironment(job) {
7864
+ const env = job.environment;
7865
+ const name = typeof env === "string" ? env : isPlainObject(env) && typeof env.name === "string" ? env.name : void 0;
7866
+ return name && !name.includes("${{") ? name : void 0;
7867
+ }
7868
+ function isPagesDeploy(doc, text, file) {
7869
+ const named = `${file} ${typeof doc.name === "string" ? doc.name : ""}`.toLowerCase();
7870
+ return /github\.io|docusaurus|gh-pages|deploy-pages|\bdocs?\b/.test(named) || /github\.io|deploy-pages|gh-pages/.test(text);
7871
+ }
7733
7872
  function workflowClass(doc, text, file) {
7734
- let found;
7873
+ let declared;
7874
+ const raise = (value) => {
7875
+ declared = declared ? maxClass(declared, value) : value;
7876
+ };
7877
+ const unnamed = [];
7878
+ const deploying = [];
7879
+ let anyJobDeploys = false;
7735
7880
  const jobs = isPlainObject(doc.jobs) ? doc.jobs : {};
7736
- for (const job of Object.values(jobs)) {
7881
+ for (const [id, job] of Object.entries(jobs)) {
7737
7882
  if (!isPlainObject(job)) continue;
7738
- const env = job.environment;
7739
- const name = typeof env === "string" ? env : isPlainObject(env) && typeof env.name === "string" ? env.name : void 0;
7740
- if (!name || name.includes("${{")) continue;
7741
- const value = classifyEnvironmentName(name);
7742
- if (value !== "preview") found = found ? maxClass(found, value) : value;
7883
+ const deploys = DEPLOY_SIGNALS.some((signal) => signal.test(jobText(id, job)));
7884
+ anyJobDeploys ||= deploys;
7885
+ const name = staticEnvironment(job);
7886
+ if (name !== void 0) {
7887
+ const value = classifyEnvironmentName(name);
7888
+ if (value !== "preview") raise(value);
7889
+ } else {
7890
+ unnamed.push([id, job]);
7891
+ if (deploys) deploying.push([id, job]);
7892
+ }
7743
7893
  }
7744
- if (found) return found;
7745
- const named = `${file} ${typeof doc.name === "string" ? doc.name : ""}`.toLowerCase();
7746
- if (/github\.io|docusaurus|gh-pages|deploy-pages|\bdocs?\b/.test(named) || /github\.io|deploy-pages|gh-pages/.test(text)) {
7747
- return "customer-visible";
7894
+ if (!anyJobDeploys) {
7895
+ deploying.push(
7896
+ ...unnamed.filter(
7897
+ ([id, job]) => !SUPPORT_JOB.test(`${id} ${textOf(job.name)}`.toLowerCase())
7898
+ )
7899
+ );
7748
7900
  }
7749
- if (/\bprod|production/.test(text)) return "production";
7750
- if (/\bstag(?:e|ing)\b/.test(text)) return "staging";
7751
- return "production";
7901
+ const target = namedTargetClass(doc, file);
7902
+ if (target) raise(target);
7903
+ const pages = isPagesDeploy(doc, text, file);
7904
+ if (pages) raise("customer-visible");
7905
+ if (declared && (deploying.length === 0 || target || pages)) return declared;
7906
+ const words = declared ? JSON.stringify(Object.fromEntries(deploying)).toLowerCase() : text;
7907
+ const guessed = /\bprod|production/.test(words) ? "production" : /\bstag(?:e|ing)\b/.test(words) ? "staging" : "production";
7908
+ return declared ? maxClass(declared, guessed) : guessed;
7752
7909
  }
7910
+ var SUPPORT_JOB = /^(?:build|test|tests|unit|e2e|lint|check|checks|ci|typecheck|types|format|audit|scan|coverage|report|notify|notification|setup|install|cache|verify)(?:[-_ ]|$)/;
7753
7911
  function textOf(value) {
7754
7912
  if (typeof value === "string") return value;
7755
7913
  if (isPlainObject(value) && typeof value.name === "string") return value.name;
@@ -7759,19 +7917,71 @@ var REPORTING_ACTION = /test|coverage|report|lint|codecov|sonar/;
7759
7917
  function deployText(doc, file) {
7760
7918
  const parts = [file, textOf(doc.name)];
7761
7919
  const jobs = isPlainObject(doc.jobs) ? doc.jobs : {};
7920
+ for (const [id, job] of Object.entries(jobs)) {
7921
+ if (isPlainObject(job)) parts.push(jobText(id, job));
7922
+ }
7923
+ return parts.join("\n").toLowerCase();
7924
+ }
7925
+ function jobText(id, job) {
7926
+ const parts = [id, textOf(job.name), textOf(job.environment), textOf(job.uses)];
7927
+ if (isPlainObject(job.with)) {
7928
+ for (const value of Object.values(job.with)) {
7929
+ if (typeof value === "string") parts.push(value);
7930
+ }
7931
+ }
7932
+ for (const step of Array.isArray(job.steps) ? job.steps : []) {
7933
+ if (!isPlainObject(step)) continue;
7934
+ const uses = textOf(step.uses);
7935
+ if (REPORTING_ACTION.test(uses.toLowerCase())) continue;
7936
+ parts.push(uses, textOf(step.run), textOf(step.name));
7937
+ }
7938
+ return parts.join("\n").toLowerCase();
7939
+ }
7940
+ var SCRIPT_PATH = /(?:^|[\s"'=(;&|])((?:\.\/)?[\w.][\w./-]*\.(?:sh|bash|zsh|mjs|cjs|js|ts|py|rb))(?=$|[\s"';)&|])/gm;
7941
+ var MAX_SCRIPTS = 10;
7942
+ var MAX_SCRIPT_BYTES = 256 * 1024;
7943
+ async function commandText(root, doc) {
7944
+ const texts = [];
7945
+ const rels = /* @__PURE__ */ new Set();
7946
+ const jobs = isPlainObject(doc.jobs) ? doc.jobs : {};
7762
7947
  for (const [id, job] of Object.entries(jobs)) {
7763
7948
  if (!isPlainObject(job)) continue;
7764
- parts.push(id, textOf(job.name), textOf(job.environment), textOf(job.uses));
7949
+ if (isPlainObject(job.with) && DEPLOY_SIGNALS.some((signal) => signal.test(jobText(id, job)))) {
7950
+ for (const value of Object.values(job.with)) {
7951
+ if (typeof value === "string") texts.push(value.toLowerCase());
7952
+ }
7953
+ }
7765
7954
  for (const step of Array.isArray(job.steps) ? job.steps : []) {
7766
- if (!isPlainObject(step)) continue;
7767
- const uses = textOf(step.uses);
7768
- if (REPORTING_ACTION.test(uses.toLowerCase())) continue;
7769
- parts.push(uses, textOf(step.run), textOf(step.name));
7955
+ if (!isPlainObject(step) || typeof step.run !== "string") continue;
7956
+ const run = step.run.toLowerCase();
7957
+ if (!DEPLOY_SIGNALS.some((signal) => signal.test(run))) continue;
7958
+ texts.push(run);
7959
+ for (const match of step.run.matchAll(SCRIPT_PATH)) {
7960
+ const rel = path35.posix.normalize(match[1]);
7961
+ if (!rel.startsWith("..") && !path35.isAbsolute(rel)) rels.add(rel);
7962
+ }
7770
7963
  }
7771
7964
  }
7772
- return parts.join("\n").toLowerCase();
7965
+ let realRoot;
7966
+ try {
7967
+ realRoot = await fs24.realpath(root);
7968
+ } catch {
7969
+ return texts.join("\n");
7970
+ }
7971
+ for (const rel of [...rels].slice(0, MAX_SCRIPTS)) {
7972
+ try {
7973
+ const real = await fs24.realpath(path35.join(root, rel));
7974
+ if (!real.startsWith(`${realRoot}${path35.sep}`)) continue;
7975
+ const stat2 = await fs24.stat(real);
7976
+ if (!stat2.isFile() || stat2.size > MAX_SCRIPT_BYTES) continue;
7977
+ const lines = (await fs24.readFile(real, "utf8")).toLowerCase().split("\n");
7978
+ texts.push(lines.filter((line2) => !/^\s*(?:#|\/\/)/.test(line2)).join("\n"));
7979
+ } catch {
7980
+ }
7981
+ }
7982
+ return texts.join("\n");
7773
7983
  }
7774
- async function workflowEvidence(root, defaultBranch2) {
7984
+ async function workflowEvidence(root, defaultBranch2, mentions) {
7775
7985
  const dir = path35.join(root, ".github", "workflows");
7776
7986
  let names4;
7777
7987
  try {
@@ -7793,6 +8003,7 @@ async function workflowEvidence(root, defaultBranch2) {
7793
8003
  const signals = deployText(doc, name);
7794
8004
  if (!DEPLOY_SIGNALS.some((signal) => signal.test(signals))) continue;
7795
8005
  const deploys = workflowClass(doc, text, name);
8006
+ let mention;
7796
8007
  const on = triggers(doc);
7797
8008
  const sources = [];
7798
8009
  if ("push" in on) sources.push({ event: "push", filter: isPlainObject(on.push) ? on.push : {} });
@@ -7822,7 +8033,7 @@ async function workflowEvidence(root, defaultBranch2) {
7822
8033
  if ("workflow_dispatch" in on) {
7823
8034
  evidence.push({
7824
8035
  source: "workflow",
7825
- deploys: "production",
8036
+ deploys,
7826
8037
  dispatch: rel,
7827
8038
  detail: `${rel}: deploys on workflow_dispatch`
7828
8039
  });
@@ -7845,6 +8056,10 @@ async function workflowEvidence(root, defaultBranch2) {
7845
8056
  ...pathsIgnore.length > 0 ? { ignorePaths: pathsIgnore } : {},
7846
8057
  detail: `${rel}: ${event} to ${branch}${pathNote} deploys ${deploys}`
7847
8058
  });
8059
+ if (paths.length === 0) {
8060
+ mention ??= await commandText(root, doc);
8061
+ mentions.set(branch, [...mentions.get(branch) ?? [], mention]);
8062
+ }
7848
8063
  }
7849
8064
  for (const tag of tags) {
7850
8065
  evidence.push({
@@ -7858,31 +8073,96 @@ async function workflowEvidence(root, defaultBranch2) {
7858
8073
  }
7859
8074
  return evidence;
7860
8075
  }
7861
- async function vercelEvidence(root, defaultBranch2) {
7862
- const configFile = path35.join(root, "vercel.json");
7863
- const linked = await fs24.pathExists(path35.join(root, ".vercel", "project.json"));
8076
+ async function gitIgnores(root, rel, git) {
8077
+ if (!git) return false;
8078
+ try {
8079
+ const result = await git(["check-ignore", "-q", "--", rel], { cwd: root, timeoutMs: 5e3 });
8080
+ return result.exitCode === 0;
8081
+ } catch {
8082
+ return false;
8083
+ }
8084
+ }
8085
+ async function vercelFileEvidence(root, rel, defaultBranch2) {
7864
8086
  let config;
7865
- if (await fs24.pathExists(configFile)) {
8087
+ try {
8088
+ config = JSON.parse(await fs24.readFile(path35.join(root, rel), "utf8"));
8089
+ } catch {
8090
+ config = {};
8091
+ }
8092
+ const git = isPlainObject(config) && isPlainObject(config.git) ? config.git : void 0;
8093
+ const enabled = git?.deploymentEnabled;
8094
+ if (enabled === false) return void 0;
8095
+ if (isPlainObject(enabled) && enabled[defaultBranch2] === false) return void 0;
8096
+ return {
8097
+ source: "vercel",
8098
+ deploys: "production",
8099
+ branch: defaultBranch2,
8100
+ detail: `${rel}: Vercel deploys ${defaultBranch2} to production`
8101
+ };
8102
+ }
8103
+ var NOT_AN_APP = /^(?:node_modules|examples?|samples?|fixtures?|tests?|__tests__|templates?)$/;
8104
+ var skippedFolder = (name) => name.startsWith(".") || name === "node_modules";
8105
+ function isNestedVercelFile(rel) {
8106
+ const parts = rel.split("/");
8107
+ if (parts.length < 2 || parts.length > 3 || parts[parts.length - 1] !== "vercel.json")
8108
+ return false;
8109
+ const folders = parts.slice(0, -1);
8110
+ return !NOT_AN_APP.test(folders[0]) && !folders.some(skippedFolder);
8111
+ }
8112
+ async function nestedVercelFiles(root, git) {
8113
+ if (git) {
7866
8114
  try {
7867
- config = JSON.parse(await fs24.readFile(configFile, "utf8"));
8115
+ const listed = await git(["ls-files", "-z", "--", "*/vercel.json"], {
8116
+ cwd: root,
8117
+ timeoutMs: 5e3
8118
+ });
8119
+ if (listed.exitCode === 0) {
8120
+ return listed.stdout.split("\0").filter(isNestedVercelFile).sort();
8121
+ }
7868
8122
  } catch {
7869
- config = {};
7870
8123
  }
7871
- } else if (!linked) {
7872
- return [];
7873
8124
  }
7874
- const git = isPlainObject(config) && isPlainObject(config.git) ? config.git : void 0;
7875
- const enabled = git?.deploymentEnabled;
7876
- if (enabled === false) return [];
7877
- if (isPlainObject(enabled) && enabled[defaultBranch2] === false) return [];
7878
- return [
7879
- {
7880
- source: "vercel",
7881
- deploys: "production",
7882
- branch: defaultBranch2,
7883
- detail: `${linked ? ".vercel/project.json" : "vercel.json"}: Vercel deploys ${defaultBranch2} to production`
8125
+ const children = async (rel) => {
8126
+ try {
8127
+ const entries = await fs24.readdir(path35.join(root, rel), { withFileTypes: true });
8128
+ return entries.filter((entry) => entry.isDirectory() && !skippedFolder(entry.name)).map((entry) => rel ? `${rel}/${entry.name}` : entry.name).sort();
8129
+ } catch {
8130
+ return [];
7884
8131
  }
7885
- ];
8132
+ };
8133
+ const found = [];
8134
+ for (const first of await children("")) {
8135
+ if (NOT_AN_APP.test(first)) continue;
8136
+ for (const dir of [first, ...await children(first)]) {
8137
+ const rel = `${dir}/vercel.json`;
8138
+ if (isNestedVercelFile(rel) && await fs24.pathExists(path35.join(root, rel))) found.push(rel);
8139
+ }
8140
+ }
8141
+ return found;
8142
+ }
8143
+ async function vercelEvidence(root, defaultBranch2, git) {
8144
+ const evidence = [];
8145
+ if (await fs24.pathExists(path35.join(root, "vercel.json"))) {
8146
+ const item = await vercelFileEvidence(root, "vercel.json", defaultBranch2);
8147
+ if (item) evidence.push(item);
8148
+ } else {
8149
+ const link = ".vercel/project.json";
8150
+ if (await fs24.pathExists(path35.join(root, link))) {
8151
+ const ignored = await gitIgnores(root, link, git);
8152
+ evidence.push({
8153
+ source: "vercel",
8154
+ deploys: "production",
8155
+ branch: defaultBranch2,
8156
+ ...ignored ? { tracedTo: "vercel" } : {},
8157
+ detail: `${link}${ignored ? " (git-ignored)" : ""}: Vercel deploys ${defaultBranch2} to production`
8158
+ });
8159
+ }
8160
+ }
8161
+ for (const rel of await nestedVercelFiles(root, git)) {
8162
+ const item = await vercelFileEvidence(root, rel, defaultBranch2);
8163
+ if (item) evidence.push(item);
8164
+ }
8165
+ return evidence;
7886
8166
  }
7887
8167
  async function fetchDeployments(deps, errors) {
7888
8168
  const { gh, slug } = deps;
@@ -7943,6 +8223,7 @@ async function fetchDeployments(deps, errors) {
7943
8223
  }
7944
8224
  return rows2;
7945
8225
  }
8226
+ var SERVICE_NAME = /^[a-z0-9]{2,}(?:[-_][a-z0-9]{2,})+$/i;
7946
8227
  function deploymentEvidence(rows2, deps) {
7947
8228
  const now = (deps.now ?? /* @__PURE__ */ new Date()).getTime();
7948
8229
  const cutoff = now - DEPLOYMENT_WINDOW_DAYS * 24 * 60 * 60 * 1e3;
@@ -7950,7 +8231,8 @@ function deploymentEvidence(rows2, deps) {
7950
8231
  const seen = /* @__PURE__ */ new Map();
7951
8232
  for (const row of rows2) {
7952
8233
  if (Date.parse(row.created_at) < cutoff) continue;
7953
- const deploys = classifyEnvironmentName(row.environment);
8234
+ const named = environmentNameClass(row.environment);
8235
+ const deploys = named ?? "production";
7954
8236
  if (deploys === "preview") continue;
7955
8237
  if (/^v?\d+(?:\.\d+)+/.test(row.ref)) {
7956
8238
  const pattern = row.ref.startsWith("v") ? "v*" : "*.*";
@@ -7974,6 +8256,9 @@ function deploymentEvidence(rows2, deps) {
7974
8256
  source: "deployments",
7975
8257
  deploys,
7976
8258
  branch,
8259
+ // Only a service-shaped name (`shop-server`) is traced: a plain word (`backend`, `web`)
8260
+ // could stand for anything in a script, so it is never explained.
8261
+ ...named === void 0 && SERVICE_NAME.test(row.environment) ? { tracedTo: row.environment.toLowerCase() } : {},
7977
8262
  detail: `GitHub deployments: ${branch} deployed to ${row.environment} (last ${row.created_at.slice(0, 10)})`
7978
8263
  });
7979
8264
  }
@@ -8019,15 +8304,23 @@ function configFromEvidence(evidence) {
8019
8304
  async function detectDeployConfig(root, deps = {}) {
8020
8305
  const defaultBranch2 = deps.defaultBranch ?? "main";
8021
8306
  const errors = [];
8022
- const evidence = [];
8307
+ const found = [];
8308
+ const mentions = /* @__PURE__ */ new Map();
8023
8309
  if (root) {
8024
- evidence.push(...await workflowEvidence(root, defaultBranch2));
8025
- evidence.push(...await vercelEvidence(root, defaultBranch2));
8310
+ found.push(...await workflowEvidence(root, defaultBranch2, mentions));
8311
+ found.push(...await vercelEvidence(root, defaultBranch2, deps.git));
8026
8312
  }
8027
8313
  const rows2 = await fetchDeployments(deps, errors);
8028
- if (rows2) evidence.push(...deploymentEvidence(rows2, { ...deps, defaultBranch: defaultBranch2 }));
8314
+ if (rows2) found.push(...deploymentEvidence(rows2, { ...deps, defaultBranch: defaultBranch2 }));
8315
+ const evidence = found.filter((item) => !explainedBy(item, mentions));
8029
8316
  return { config: configFromEvidence(evidence), evidence, errors };
8030
8317
  }
8318
+ function explainedBy(item, mentions) {
8319
+ if (!item.tracedTo || !item.branch) return false;
8320
+ const escaped = item.tracedTo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8321
+ const word = new RegExp(`(?<![\\w-])${escaped}(?![\\w-])`);
8322
+ return (mentions.get(item.branch) ?? []).some((text) => word.test(text));
8323
+ }
8031
8324
 
8032
8325
  // src/deploy/repo.ts
8033
8326
  init_exec();
@@ -8134,6 +8427,7 @@ async function writeDeployConfig(root, dryRun, opts = {}) {
8134
8427
  const gh = opts.gh ?? (deployDetectOffline() ? void 0 : realGh());
8135
8428
  const result = await detectDeployConfig(root, {
8136
8429
  gh,
8430
+ git,
8137
8431
  slug: context?.slug,
8138
8432
  defaultBranch: branch,
8139
8433
  now: opts.now,
@@ -8296,7 +8590,7 @@ function prettierIgnoreProtects(lines, dir) {
8296
8590
  function buildPrettierIgnoreBlock() {
8297
8591
  return [
8298
8592
  PRETTIERIGNORE_BEGIN,
8299
- "# haus-owned files \u2014 do not reformat (keeps tamper/lock hashes stable)",
8593
+ "# haus-owned files, do not reformat (keeps tamper/lock hashes stable)",
8300
8594
  ...IGNORED_PATHS,
8301
8595
  PRETTIERIGNORE_END
8302
8596
  ].join("\n");
@@ -8498,7 +8792,7 @@ function line(f) {
8498
8792
  function buildWorkflowConfig(v) {
8499
8793
  const f = productionFields(v);
8500
8794
  const byPrefix = (p) => line(f.find((x) => x.prefix === p));
8501
- return "# How this project works (workflow methodology bindings)\n\n> The few project-specific values the workflow standard (WORKFLOW.md) binds to:\n> where the source-of-truth docs live, the test commands the TDD/verification gate\n> runs, the highest-stakes logic, and the pre-commit tool. This file is yours to\n> edit and haus will not overwrite it.\n>\n> Everyday commands (dev, build, lint, typecheck, format) and project documentation\n> live in `CLAUDE.md` + `docs/` \u2014 run **`/docs`** to generate/refresh them.\n\n## Source-of-truth documents\n" + byPrefix("- Spec: ") + "\n" + byPrefix("- Design: ") + "\n" + byPrefix("- UX flows: ") + "\n\n## Test commands (TDD / verification gate)\n" + byPrefix("- Test (unit + integration): ") + "\n" + byPrefix("- Test (E2E): ") + "\n\n## Highest-stakes logic\n<!-- fill in domain areas requiring TDD-only treatment, e.g. payment flows, auth, medical data -->\n\n## Pre-commit tool\n" + byPrefix("- Tool: ") + "\n";
8795
+ return "# How this project works (workflow methodology bindings)\n\n> The few project-specific values the workflow standard (WORKFLOW.md) binds to:\n> where the source-of-truth docs live, the test commands the TDD/verification gate\n> runs, the highest-stakes logic, and the pre-commit tool. This file is yours to\n> edit and haus will not overwrite it.\n>\n> Everyday commands (dev, build, lint, typecheck, format) and project documentation\n> live in `CLAUDE.md` + `docs/`. Run **`/docs`** to generate/refresh them.\n\n## Source-of-truth documents\n" + byPrefix("- Spec: ") + "\n" + byPrefix("- Design: ") + "\n" + byPrefix("- UX flows: ") + "\n\n## Test commands (TDD / verification gate)\n" + byPrefix("- Test (unit + integration): ") + "\n" + byPrefix("- Test (E2E): ") + "\n\n## Highest-stakes logic\n<!-- fill in domain areas requiring TDD-only treatment, e.g. payment flows, auth, medical data -->\n\n## Pre-commit tool\n" + byPrefix("- Tool: ") + "\n";
8502
8796
  }
8503
8797
  function buildPocWorkflowConfig(v) {
8504
8798
  const f = pocFields(v);
@@ -8693,6 +8987,10 @@ var APPLY_FORCE = "haus apply --write --force";
8693
8987
  var APPLY_WRITE = "haus apply --write";
8694
8988
  async function writeWorkflowReport(root, pkgVersion, opts) {
8695
8989
  const { dryRun } = opts;
8990
+ if (opts.marked === true) {
8991
+ const left = await leaveMarkedFile(root, WORKFLOW_REL, STABLE_ID, opts.force === true);
8992
+ if (left !== null) return left;
8993
+ }
8696
8994
  const templateContent = await readWorkflowTemplate({
8697
8995
  dryRun,
8698
8996
  projectRoot: root,
@@ -8700,7 +8998,7 @@ async function writeWorkflowReport(root, pkgVersion, opts) {
8700
8998
  });
8701
8999
  if (templateContent === null) {
8702
9000
  warn(
8703
- isOffline() ? `Workflow template is not in the local catalog cache and this run is offline \u2014 run \`haus catalog bundle import <file>\` (or drop \`--offline\`), then re-run \`haus apply --write\`` : `Workflow template could not be fetched from the catalog \u2014 check your network, then re-run \`haus apply --write\` (or \`haus update\`)`
9001
+ isOffline() ? `Workflow template is not in the local catalog cache and this run is offline. Run \`haus catalog bundle import <file>\` (or drop \`--offline\`), then re-run \`haus apply --write\`` : `Workflow template could not be fetched from the catalog. Check your network, then re-run \`haus apply --write\` (or \`haus update\`)`
8704
9002
  );
8705
9003
  return null;
8706
9004
  }
@@ -8712,9 +9010,19 @@ async function writeWorkflowReport(root, pkgVersion, opts) {
8712
9010
  force: opts.force === true,
8713
9011
  quiet: opts.quiet === true,
8714
9012
  forceCommand: opts.forceCommand ?? APPLY_FORCE,
8715
- writeCommand: opts.writeCommand ?? APPLY_WRITE
9013
+ writeCommand: opts.writeCommand ?? APPLY_WRITE,
9014
+ marked: opts.marked === true
8716
9015
  });
8717
9016
  }
9017
+ var MARK_SET_ASIDE = " (excludedIds marks it as the project's own; --force replaces it anyway)";
9018
+ function leavesMarkedFile(onDisk, force) {
9019
+ return !force || onDisk.kind === "absent" || onDisk.kind === "other";
9020
+ }
9021
+ async function leaveMarkedFile(root, relPath, id, force) {
9022
+ const file = path43.join(root, relPath);
9023
+ if (!leavesMarkedFile(await inspectHeaderHashedFile(file), force)) return null;
9024
+ return { id, relPath, path: file, outcome: "kept", marked: true };
9025
+ }
8718
9026
  function headerFileItem(result) {
8719
9027
  return {
8720
9028
  id: result.id,
@@ -8805,6 +9113,10 @@ async function writeHeaderHashedFile(root, relPath, opts) {
8805
9113
  const file = path43.join(root, relPath);
8806
9114
  const printable = displayPath(root, file);
8807
9115
  const onDisk = await inspectHeaderHashedFile(file);
9116
+ const marked = opts.marked === true;
9117
+ if (marked && leavesMarkedFile(onDisk, opts.force)) {
9118
+ return { id: opts.id, relPath, path: file, outcome: "kept", marked: true };
9119
+ }
8808
9120
  const next = `${hausManagedHeader(opts.id, opts.pkgVersion, hashText(normaliseLF(opts.body)))}
8809
9121
  ${opts.body}`;
8810
9122
  const named = { id: opts.id, relPath, path: file };
@@ -8818,8 +9130,9 @@ ${opts.body}`;
8818
9130
  ...outcome === "refused" && notAFile !== void 0 ? { notAFile } : {}
8819
9131
  }
8820
9132
  );
8821
- if (outcomeStream(outcome) === "warn") warn(line2);
8822
- else say(line2);
9133
+ const said = marked && outcome === "replaced" ? `${line2}${MARK_SET_ASIDE}` : line2;
9134
+ if (outcomeStream(outcome) === "warn") warn(said);
9135
+ else say(said);
8823
9136
  const before = onDisk.kind === "file" ? onDisk.text : onDisk.kind === "absent" ? "" : void 0;
8824
9137
  if (opts.dryRun && before !== void 0 && isOwnedAfter(outcome) && outcome !== "unchanged") {
8825
9138
  say(createUnifiedDiff(printable, before, next));
@@ -8889,7 +9202,7 @@ async function reportSkillDependencyInstall(root, itemId, sourceDir, skillDir, m
8889
9202
  case "none":
8890
9203
  return;
8891
9204
  case "skipped":
8892
- warn(`${itemId}: skipping dependency install \u2014 ${outcome.reason}`);
9205
+ warn(`${itemId}: skipping dependency install: ${outcome.reason}`);
8893
9206
  return;
8894
9207
  case "would-install":
8895
9208
  say(
@@ -8903,7 +9216,7 @@ async function reportSkillDependencyInstall(root, itemId, sourceDir, skillDir, m
8903
9216
  return;
8904
9217
  case "failed":
8905
9218
  warn(
8906
- `${itemId}: dependency install failed (${outcome.command} exited ${outcome.exitCode}) \u2014 the skill is installed but its scripts may not run${outcome.stderr ? `: ${outcome.stderr.trim().split("\n").slice(-1)[0]}` : ""}`
9219
+ `${itemId}: dependency install failed (${outcome.command} exited ${outcome.exitCode}): the skill is installed but its scripts may not run${outcome.stderr ? `: ${outcome.stderr.trim().split("\n").slice(-1)[0]}` : ""}`
8907
9220
  );
8908
9221
  return;
8909
9222
  }
@@ -9069,12 +9382,15 @@ async function writeClaudeFilesReport(root, dryRun, selectedIds, opts = {}) {
9069
9382
  }
9070
9383
  const rootClaudeMdPath = rootClaudeMd.status === "written" ? rootClaudeMd.path : null;
9071
9384
  const decisionsSeedPath = profileSpec.writesDecisionsSeed ? await writeDecisionsSeed(root, dryRun, opts.quiet) : null;
9385
+ const excludedIds = await readProjectExcludedIdsIfReadable(root);
9386
+ const isMarked = (id) => excludedIds?.includes(id) === true;
9072
9387
  const workflow = await writeWorkflowReport(root, hausVersion2, {
9073
9388
  dryRun,
9074
9389
  force: opts.force,
9075
9390
  quiet: opts.quiet,
9076
9391
  templateRel: profileSpec.workflowTemplateRel,
9077
- forceCommand: APPLY_FORCE2
9392
+ forceCommand: APPLY_FORCE2,
9393
+ marked: isMarked(WORKFLOW_ID)
9078
9394
  });
9079
9395
  if (workflow?.backupFailed) exitCode = 1;
9080
9396
  const workflowPath = workflow !== null && isOwnedAfter(workflow.outcome) ? workflow.path : null;
@@ -9149,10 +9465,11 @@ async function writeClaudeFilesReport(root, dryRun, selectedIds, opts = {}) {
9149
9465
  force: opts.force === true,
9150
9466
  quiet: opts.quiet === true,
9151
9467
  forceCommand: APPLY_FORCE2,
9152
- writeCommand: APPLY_WRITE2
9468
+ writeCommand: APPLY_WRITE2,
9469
+ marked: isMarked(RULES_HAUS_ID)
9153
9470
  });
9154
9471
  if (rulesHaus.backupFailed) exitCode = 1;
9155
- const headerItems = [...workflow === null ? [] : [workflow], rulesHaus].map(headerFileItem);
9472
+ const headerItems = [...workflow === null ? [] : [workflow], rulesHaus].filter((result) => result.marked !== true).map(headerFileItem);
9156
9473
  const coreFiles = [
9157
9474
  ...settingsRefused ? [] : [claudePath(root, "settings.json")],
9158
9475
  ...isOwnedAfter(rulesHaus.outcome) ? [rulesHaus.path] : []
@@ -9503,11 +9820,10 @@ async function writeClaudeFilesReport(root, dryRun, selectedIds, opts = {}) {
9503
9820
  }
9504
9821
  if (curatedReviewStatusSkips > 1) {
9505
9822
  warn(
9506
- `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
9823
+ `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved (possible catalog field rename upstream)`
9507
9824
  );
9508
9825
  }
9509
9826
  const adoptedStrays = [];
9510
- const excludedIds = await readProjectExcludedIdsIfReadable(root);
9511
9827
  if (excludedIds === void 0) {
9512
9828
  warn(
9513
9829
  `Not adopting stray files: ${PROJECT_CONFIG_REL} cannot be read as JSON, so haus cannot tell which ids it excludes. Fix the file and run again.`
@@ -9555,7 +9871,7 @@ async function writeClaudeFilesReport(root, dryRun, selectedIds, opts = {}) {
9555
9871
  if (opts.prune) {
9556
9872
  if (refusePruneWithoutRecommendation) {
9557
9873
  warn(
9558
- "recommendation.json is missing or unreadable \u2014 refusing --prune (every installed item would look orphaned). Run `haus recommend` first, or pass --force to override."
9874
+ "recommendation.json is missing or unreadable, refusing --prune (every installed item would look orphaned). Run `haus recommend` first, or pass --force to override."
9559
9875
  );
9560
9876
  } else {
9561
9877
  const notOrphaned = new Set(rec.recommended.map((r) => r.id));
@@ -9875,7 +10191,7 @@ async function targetShapeMismatch(root, target, itemType) {
9875
10191
  for (const rel of target) {
9876
10192
  let stat2;
9877
10193
  try {
9878
- if (await unsafeTrackedPath(root, rel) !== null) continue;
10194
+ if (await unsafeTrackedPath(root, rel, REPLACE_LINK_RULES) !== null) continue;
9879
10195
  stat2 = await fs28.lstat(path44.resolve(root, recordedRel(rel)));
9880
10196
  } catch {
9881
10197
  continue;
@@ -9936,18 +10252,14 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
9936
10252
  unsafeByRel
9937
10253
  );
9938
10254
  if (await hasProtectedUnsafePath(root, relPaths, unsafeByRel)) {
9939
- if (isMove) {
9940
- if (samePath2.force) {
9941
- const reasons = [...unsafeByRel].flatMap(
9942
- ([rel, reason]) => reason === null ? [] : [unsafeOnDiskReason(rel, reason)]
9943
- );
9944
- warn(
9945
- dryRun ? `[dry-run] would refuse ${posixPathsOnce(relPaths).join(", ")} (${migration.oldId}): ${reasons.join("; ")}` : backupFailedLine(relPaths, migration.oldId, reasons.join("; "))
9946
- );
9947
- containmentFailed = true;
9948
- } else {
9949
- warn(movedKeptLine(migration.oldId, relPaths, dryRun));
9950
- }
10255
+ if (isMove && samePath2.force) {
10256
+ const reasons = [...unsafeByRel].flatMap(
10257
+ ([rel, reason]) => reason === null ? [] : [unsafeOnDiskReason(rel, reason)]
10258
+ );
10259
+ warn(
10260
+ dryRun ? `[dry-run] would refuse ${posixPathsOnce(relPaths).join(", ")} (${migration.oldId}): ${reasons.join("; ")}` : backupFailedLine(relPaths, migration.oldId, reasons.join("; "))
10261
+ );
10262
+ containmentFailed = true;
9951
10263
  }
9952
10264
  refusedIds.add(migration.oldId);
9953
10265
  continue;
@@ -9995,11 +10307,11 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
9995
10307
  if (!gate.ok) {
9996
10308
  if (gate.reason === "no-hash") {
9997
10309
  warn(
9998
- `Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
10310
+ `Former catalog item ${migration.oldId} has no lock hash, leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
9999
10311
  );
10000
10312
  } else {
10001
10313
  warn(
10002
- `Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
10314
+ `Former catalog item ${migration.oldId} was modified locally, leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
10003
10315
  );
10004
10316
  }
10005
10317
  refusedIds.add(migration.oldId);
@@ -10158,11 +10470,11 @@ async function cleanupStaleCatalogItems(root, prevLock, manifestById, dryRun, qu
10158
10470
  if (!gate.ok) {
10159
10471
  if (gate.reason === "no-hash") {
10160
10472
  warn(
10161
- `${label} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
10473
+ `${label} catalog item ${entry.id} has no lock hash, leaving in place: ${existing.map((e) => e.rel).join(", ")}`
10162
10474
  );
10163
10475
  } else {
10164
10476
  warn(
10165
- `${label} catalog item ${entry.id} was modified locally \u2014 leaving in place${superseded ? " (the global plugin provides it; delete the copy manually if you no longer want it)" : ""}: ${existing.map((e) => e.rel).join(", ")}`
10477
+ `${label} catalog item ${entry.id} was modified locally, leaving in place${superseded ? " (the global plugin provides it; delete the copy manually if you no longer want it)" : ""}: ${existing.map((e) => e.rel).join(", ")}`
10166
10478
  );
10167
10479
  }
10168
10480
  refusedIds.add(entry.id);
@@ -10172,7 +10484,7 @@ async function cleanupStaleCatalogItems(root, prevLock, manifestById, dryRun, qu
10172
10484
  const isShared = recordedRel(rel) === LEGACY_SUPERPOWERS_SHARED_REL_PATH;
10173
10485
  if (isShared && keepShared) {
10174
10486
  say(
10175
- `Keeping ${displayPath(root, path44.join(root, rel))} \u2014 a locally-edited superpowers skill still uses it`
10487
+ `Keeping ${displayPath(root, path44.join(root, rel))}: a locally-edited superpowers skill still uses it`
10176
10488
  );
10177
10489
  result.heldBack.set(entry.id, [...result.heldBack.get(entry.id) ?? [], rel]);
10178
10490
  return false;
@@ -10272,7 +10584,7 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
10272
10584
  if (!gate.ok) {
10273
10585
  const detail = gate.reason === "no-hash" ? "has no lock hash" : "was modified locally";
10274
10586
  warn(
10275
- `Orphaned catalog item ${entry.id} ${detail} \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
10587
+ `Orphaned catalog item ${entry.id} ${detail}, leaving in place: ${existing.map((e) => e.rel).join(", ")}`
10276
10588
  );
10277
10589
  refusedIds.add(entry.id);
10278
10590
  continue;
@@ -10367,8 +10679,8 @@ var GITIGNORED_ARTIFACT_PATHS = [
10367
10679
  // process state and machine-local diagnostics, meaningless in another checkout.
10368
10680
  ".haus-workflow/locks/",
10369
10681
  ".haus-workflow/logs/",
10370
- // The request pipeline's archive (#472, ADR-0103): per-request pricing material —
10371
- // specs, impact analyses, scores — written by the request skill, not this CLI. It
10682
+ // The request pipeline's archive (#472, ADR-0103): per-request pricing material,
10683
+ // specs, impact analyses, scores, written by the request skill, not this CLI. It
10372
10684
  // lived at `.haus/requests/` behind a hand-added ignore line OUTSIDE this block,
10373
10685
  // so no client repo ever received the rule; `migrate-request-state.ts` moves it
10374
10686
  // here precisely so this block can own it. Note `.haus-workflow/request.json`
@@ -10408,7 +10720,7 @@ function buildBlockFor(existing, beginMarker, endMarker, comment, patterns) {
10408
10720
  if (needed.length === 0) return null;
10409
10721
  return [beginMarker, comment, ...needed, endMarker].join("\n");
10410
10722
  }
10411
- var ARTIFACT_BLOCK_COMMENT = "# haus machine-local artifacts \u2014 must never be committed (see ADR-0025, ADR-0058, ADR-0009)";
10723
+ var ARTIFACT_BLOCK_COMMENT = "# haus machine-local artifacts, must never be committed (see ADR-0025, ADR-0058, ADR-0009)";
10412
10724
  function injectBlockFor(existing, beginMarker, endMarker, comment, patterns) {
10413
10725
  const range = findBlockRangeFor(existing, beginMarker, endMarker);
10414
10726
  if (!range && findLineMarker(existing, beginMarker)) return { refused: "lone-begin" };
@@ -10436,7 +10748,7 @@ var LINKED_CONTEXT_GITIGNORE_PATTERNS = [
10436
10748
  ".claude/agents/*--*.md",
10437
10749
  ".claude/commands/*--*.md"
10438
10750
  ];
10439
- var LINK_CONTEXT_BLOCK_COMMENT = "# haus workspace link-context copies \u2014 derived cross-repo skill/agent/command copies, never committed";
10751
+ var LINK_CONTEXT_BLOCK_COMMENT = "# haus workspace link-context copies: derived cross-repo skill/agent/command copies, never committed";
10440
10752
  function injectLinkContextGitignoreBlock(existing) {
10441
10753
  return injectBlockFor(
10442
10754
  existing,
@@ -10504,7 +10816,7 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
10504
10816
  for (const rel of tracked) {
10505
10817
  if (dryRun) {
10506
10818
  notify(
10507
- `Would untrack ${rel} from git \u2014 it is machine-local haus output and should never have been committed.`
10819
+ `Would untrack ${rel} from git: it is machine-local haus output and should never have been committed.`
10508
10820
  );
10509
10821
  continue;
10510
10822
  }
@@ -10512,7 +10824,7 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
10512
10824
  if (result.exitCode === 0) {
10513
10825
  untracked.push(rel);
10514
10826
  notify(
10515
- `Untracked ${rel} from git \u2014 it is machine-local haus output and should never have been committed. Run \`git status\` to review before committing this change.`
10827
+ `Untracked ${rel} from git: it is machine-local haus output and should never have been committed. Run \`git status\` to review before committing this change.`
10516
10828
  );
10517
10829
  } else {
10518
10830
  notify(`Could not untrack ${rel} from git: ${result.stderr.trim() || result.stdout.trim()}`);
@@ -10669,7 +10981,7 @@ async function probePluginEnv(deps = {}) {
10669
10981
  return { status: "absent" };
10670
10982
  case "inconclusive":
10671
10983
  warn2(
10672
- `Could not determine whether the \`claude\` CLI is on PATH (${probe.reason}) \u2014 leaving global plugins untouched this run.`
10984
+ `Could not determine whether the \`claude\` CLI is on PATH (${probe.reason}), leaving global plugins untouched this run.`
10673
10985
  );
10674
10986
  return { status: "inconclusive", reason: probe.reason };
10675
10987
  default:
@@ -10698,7 +11010,7 @@ async function probePluginEnv(deps = {}) {
10698
11010
  ]);
10699
11011
  if (listed.exitCode !== 0 || markets.exitCode !== 0) {
10700
11012
  warn2(
10701
- `Could not read Claude Code plugin state (${listed.exitCode !== 0 ? "plugin list" : "marketplace list"} exited ${listed.exitCode !== 0 ? listed.exitCode : markets.exitCode}) \u2014 leaving global plugins untouched.`
11013
+ `Could not read Claude Code plugin state (${listed.exitCode !== 0 ? "plugin list" : "marketplace list"} exited ${listed.exitCode !== 0 ? listed.exitCode : markets.exitCode}), leaving global plugins untouched.`
10702
11014
  );
10703
11015
  return unread(
10704
11016
  "query-failed",
@@ -10709,7 +11021,7 @@ async function probePluginEnv(deps = {}) {
10709
11021
  const installed = parseInstalled(listed.stdout);
10710
11022
  const marketplaces = await parseMarketplaces(markets.stdout, configDir);
10711
11023
  if (installed === null || marketplaces === null) {
10712
- warn2("Claude Code returned unreadable plugin state \u2014 leaving global plugins untouched.");
11024
+ warn2("Claude Code returned unreadable plugin state, leaving global plugins untouched.");
10713
11025
  return unread("unreadable");
10714
11026
  }
10715
11027
  const userEnabledPlugins = await readUserEnabledPlugins(configDir);
@@ -10783,7 +11095,7 @@ async function ensureGlobalPlugins(pins, deps = {}) {
10783
11095
  for (const action of actions) {
10784
11096
  if (action.kind === "unavailable") {
10785
11097
  warn2(
10786
- `Claude Code CLI not found \u2014 cannot manage the ${action.pin.id} plugin. Install it manually:`
11098
+ `Claude Code CLI not found, cannot manage the ${action.pin.id} plugin. Install it manually:`
10787
11099
  );
10788
11100
  manualCommands(action.pin).forEach((cmd) => warn2(` ${cmd}`));
10789
11101
  continue;
@@ -10801,7 +11113,7 @@ async function ensureGlobalPlugins(pins, deps = {}) {
10801
11113
  }
10802
11114
  if (action.overriddenRef !== void 0 && action.overriddenRef !== action.pin.ref) {
10803
11115
  warn2(
10804
- `Re-pointing marketplace ${action.pin.marketplace} from ${action.overriddenRef} to the haus-pinned ${action.pin.ref} \u2014 haus manages this plugin's version.`
11116
+ `Re-pointing marketplace ${action.pin.marketplace} from ${action.overriddenRef} to the haus-pinned ${action.pin.ref}: haus manages this plugin's version.`
10805
11117
  );
10806
11118
  }
10807
11119
  if (dryRun) {
@@ -10849,7 +11161,7 @@ async function removeGlobalPlugins(plugins, deps = {}) {
10849
11161
  const state = await probePluginEnv(deps);
10850
11162
  if (!state.available) {
10851
11163
  result.unavailable = true;
10852
- warn2("Claude Code CLI not found \u2014 leaving the haus-managed global plugin(s) in place.");
11164
+ warn2("Claude Code CLI not found, leaving the haus-managed global plugin(s) in place.");
10853
11165
  return result;
10854
11166
  }
10855
11167
  const scopeKey = (id, scope) => `${id}@@${scope ?? "user"}`;
@@ -11110,7 +11422,7 @@ async function resolveWorkspaceForWorktree(start) {
11110
11422
  if (!hasYaml && !hasManifest) {
11111
11423
  return {
11112
11424
  ok: false,
11113
- reason: `No workspace found at ${workspaceRoot} \u2014 expected ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE}. Run \`haus workspace discover --write\` or \`haus workspace init\` first.`
11425
+ reason: `No workspace found at ${workspaceRoot}: expected ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE}. Run \`haus workspace discover --write\` or \`haus workspace init\` first.`
11114
11426
  };
11115
11427
  }
11116
11428
  return { ok: true, rootInfo, workspaceRoot };
@@ -11131,7 +11443,7 @@ function isValidWorktreeSlug(slug) {
11131
11443
  function worktreePath(mainRoot, slug) {
11132
11444
  if (!isValidWorktreeSlug(slug)) {
11133
11445
  throw new Error(
11134
- `Invalid worktree slug ${JSON.stringify(slug)} \u2014 slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
11446
+ `Invalid worktree slug ${JSON.stringify(slug)}: slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
11135
11447
  );
11136
11448
  }
11137
11449
  return path50.join(worktreesDir(mainRoot), slug);
@@ -12531,14 +12843,14 @@ async function applyRun(options) {
12531
12843
  const skippedIds = new Set((rec2?.skipped ?? []).map((s) => s.id));
12532
12844
  for (const id of explicitIds.filter((id2) => !installableIds.has(id2))) {
12533
12845
  if (configIds.has(id)) {
12534
- warn(`--ids: "${id}" is a config item \u2014 install it with \`haus configs ${id}\`. Ignoring.`);
12846
+ warn(`--ids: "${id}" is a config item. Install it with \`haus configs ${id}\`. Ignoring.`);
12535
12847
  } else if (skippedIds.has(id)) {
12536
12848
  warn(
12537
- `--ids: "${id}" is not currently recommended (skipped) \u2014 add it with \`haus recommend --include ${id}\` first. Ignoring.`
12849
+ `--ids: "${id}" is not currently recommended (skipped). Add it with \`haus recommend --include ${id}\` first. Ignoring.`
12538
12850
  );
12539
12851
  } else {
12540
12852
  warn(
12541
- `--ids: "${id}" is not in recommendation.json \u2014 run \`haus recommend\` (or \`--include ${id}\`) first. Ignoring.`
12853
+ `--ids: "${id}" is not in recommendation.json. Run \`haus recommend\` (or \`--include ${id}\`) first. Ignoring.`
12542
12854
  );
12543
12855
  }
12544
12856
  }
@@ -12553,7 +12865,7 @@ async function applyRun(options) {
12553
12865
  }
12554
12866
  const rec2 = await readJson(hausPath(root, "recommendation.json"));
12555
12867
  if (!rec2) {
12556
- log("No recommendation.json found \u2014 run `haus recommend` first. Writing core files only.");
12868
+ log("No recommendation.json found. Run `haus recommend` first. Writing core files only.");
12557
12869
  selectedIds = [];
12558
12870
  } else {
12559
12871
  const items = installableRecommendedItems(rec2.recommended);
@@ -12562,7 +12874,7 @@ async function applyRun(options) {
12562
12874
  selectedIds = [];
12563
12875
  } else {
12564
12876
  const choices = items.map((item) => ({
12565
- name: `${item.id} [${item.selectionMode}] \u2014 ${item.reason}`,
12877
+ name: `${item.id} [${item.selectionMode}]: ${item.reason}`,
12566
12878
  value: item.id,
12567
12879
  checked: true
12568
12880
  }));
@@ -12608,7 +12920,7 @@ async function applyRun(options) {
12608
12920
  const message2 = "No catalog content found. Run `haus update` first.";
12609
12921
  if (!options.allowEmptyCache) {
12610
12922
  if (isDryRun) {
12611
- warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message2}`);
12923
+ warn(`Catalog cache is empty: dry-run will skip catalog items. ${message2}`);
12612
12924
  } else {
12613
12925
  error(message2);
12614
12926
  process.exitCode = 1;
@@ -12630,7 +12942,7 @@ async function applyRun(options) {
12630
12942
  const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
12631
12943
  if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
12632
12944
  warn(
12633
- `Catalog cache is ${cacheAgeDays} days old \u2014 run \`haus update\` for the latest content.`
12945
+ `Catalog cache is ${cacheAgeDays} days old. Run \`haus update\` for the latest content.`
12634
12946
  );
12635
12947
  }
12636
12948
  }
@@ -12667,7 +12979,7 @@ async function applyRun(options) {
12667
12979
  if (isOffline()) {
12668
12980
  const refCount = collectLlmsTxtUrls(installedItems).length;
12669
12981
  if (refCount > 0)
12670
- log(`Offline \u2014 skipping ${refCount} llms.txt reference fetch(es); using the cache as-is.`);
12982
+ log(`Offline: skipping ${refCount} llms.txt reference fetch(es); using the cache as-is.`);
12671
12983
  } else {
12672
12984
  const summary = await fetchRefsForItems(installedItems, cacheDir);
12673
12985
  if (summary.fetched > 0)
@@ -12694,11 +13006,11 @@ async function applyRun(options) {
12694
13006
  const dirList = dirs.join(" and ");
12695
13007
  const verb = dirs.length === 1 ? "is" : "are";
12696
13008
  warn(
12697
- `${dirList} ${verb} gitignored \u2014 installed skills/agents/commands will not be visible to anything relying on git-tracked state. Remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored).`
13009
+ `${dirList} ${verb} gitignored: installed skills/agents/commands will not be visible to anything relying on git-tracked state. Remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored).`
12698
13010
  );
12699
13011
  }
12700
13012
  if (isDryRun) {
12701
- log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
13013
+ log(`Dry-run complete: ${files.length} file(s) planned, none written. Run --write to apply.`);
12702
13014
  return { report: report2, dryRun: isDryRun };
12703
13015
  }
12704
13016
  log("Applied files:");
@@ -12712,7 +13024,7 @@ async function applyRun(options) {
12712
13024
  log(`${installedWantedCount} of ${countedIds.length} catalog items installed (${tier} tier).`);
12713
13025
  if (installedWantedCount === 0) {
12714
13026
  error(
12715
- "No catalog items were installed \u2014 only core files were written. Run `haus update` with network access, or import a catalog bundle."
13027
+ "No catalog items were installed, only core files were written. Run `haus update` with network access, or import a catalog bundle."
12716
13028
  );
12717
13029
  process.exitCode = 1;
12718
13030
  }
@@ -12760,7 +13072,7 @@ async function confirm(question, io) {
12760
13072
  async function input(question, io = defaultIO()) {
12761
13073
  if (!io.isTTY) {
12762
13074
  throw new Error(
12763
- `Cannot prompt for "${question}" without a TTY \u2014 pass the value as a flag/argument.`
13075
+ `Cannot prompt for "${question}" without a TTY. Pass the value as a flag/argument.`
12764
13076
  );
12765
13077
  }
12766
13078
  for (let i = 0; i < MAX_PROMPT_RETRIES; i++) {
@@ -12772,7 +13084,7 @@ async function input(question, io = defaultIO()) {
12772
13084
  async function select(question, choices, io = defaultIO()) {
12773
13085
  if (!io.isTTY) {
12774
13086
  throw new Error(
12775
- `Cannot prompt for "${question}" without a TTY \u2014 pass the value as a flag/argument.`
13087
+ `Cannot prompt for "${question}" without a TTY. Pass the value as a flag/argument.`
12776
13088
  );
12777
13089
  }
12778
13090
  const menu = choices.map((c, i) => ` ${i + 1}. ${c.label}`).join("\n");
@@ -13133,7 +13445,7 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
13133
13445
  const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
13134
13446
  if (skippedLinks.length > 0) {
13135
13447
  warn(
13136
- `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path57.relative(entry.absPath, abs)).join(", ")}`
13448
+ `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id}, never followed: ${skippedLinks.map((abs) => path57.relative(entry.absPath, abs)).join(", ")}`
13137
13449
  );
13138
13450
  }
13139
13451
  if (skippedOther.length > 0) {
@@ -13164,7 +13476,7 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
13164
13476
  }
13165
13477
  if (unsafeTargets.length > 0) {
13166
13478
  warn(
13167
- `Skipped ${unsafeTargets.length} restore target(s) that are a symlink (or under one) rather than a plain path \u2014 never written through: ${unsafeTargets.join(", ")}`
13479
+ `Skipped ${unsafeTargets.length} restore target(s) that are a symlink (or under one) rather than a plain path, never written through: ${unsafeTargets.join(", ")}`
13168
13480
  );
13169
13481
  }
13170
13482
  if (safeFiles.length === 0 && lockInBackup === null) {
@@ -13482,7 +13794,7 @@ async function runBackups(action, opts = {}) {
13482
13794
  return;
13483
13795
  }
13484
13796
  if (opts.olderThan === void 0 && opts.keep === void 0) {
13485
- const message2 = "Specify --older-than <days> or --keep <n> \u2014 refusing to prune with no bound.";
13797
+ const message2 = "Specify --older-than <days> or --keep <n>. Refusing to prune with no bound.";
13486
13798
  if (json) log(JSON.stringify({ ok: false, error: message2 }));
13487
13799
  error(message2);
13488
13800
  process.exitCode = 1;
@@ -13492,7 +13804,7 @@ async function runBackups(action, opts = {}) {
13492
13804
  if (opts.olderThan !== void 0) {
13493
13805
  const n = Number(opts.olderThan);
13494
13806
  if (!Number.isFinite(n) || n < 0) {
13495
- const message2 = `Invalid --older-than value "${opts.olderThan}" \u2014 expected a non-negative number of days.`;
13807
+ const message2 = `Invalid --older-than value "${opts.olderThan}": expected a non-negative number of days.`;
13496
13808
  if (json) log(JSON.stringify({ ok: false, error: message2 }));
13497
13809
  error(message2);
13498
13810
  process.exitCode = 1;
@@ -13504,7 +13816,7 @@ async function runBackups(action, opts = {}) {
13504
13816
  if (opts.keep !== void 0) {
13505
13817
  const n = Number(opts.keep);
13506
13818
  if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
13507
- const message2 = `Invalid --keep value "${opts.keep}" \u2014 expected a non-negative integer.`;
13819
+ const message2 = `Invalid --keep value "${opts.keep}": expected a non-negative integer.`;
13508
13820
  if (json) log(JSON.stringify({ ok: false, error: message2 }));
13509
13821
  error(message2);
13510
13822
  process.exitCode = 1;
@@ -13578,7 +13890,7 @@ async function runBundleExport(options) {
13578
13890
  const cacheDir = getCacheDir();
13579
13891
  const manifest = await readJson(path58.join(cacheDir, MANIFEST_FILE));
13580
13892
  if (!manifest?.items?.length) {
13581
- error("Catalog cache is empty \u2014 run `haus update` on a connected machine first.");
13893
+ error("Catalog cache is empty. Run `haus update` on a connected machine first.");
13582
13894
  process.exitCode = 1;
13583
13895
  return;
13584
13896
  }
@@ -13724,9 +14036,9 @@ async function runBundleImport(file, options = {}) {
13724
14036
  cwd: staging,
13725
14037
  // Refuse anything that would land outside the staging dir. node-tar's own
13726
14038
  // extraction already refuses `..`, absolute paths, and symlink escapes by
13727
- // default (preservePaths is left unset) — this filter is a second, independent
14039
+ // default (preservePaths is left unset), this filter is a second, independent
13728
14040
  // check using the same path-safety module the network sync uses. Both this
13729
- // filter's refusals and node-tar's own built-in ones are logged — a tampered
14041
+ // filter's refusals and node-tar's own built-in ones are logged, a tampered
13730
14042
  // bundle's dropped entries must be visible, not silently absorbed.
13731
14043
  filter: (entryPath) => {
13732
14044
  const safe = isSafeCatalogPath(entryPath);
@@ -13741,7 +14053,7 @@ async function runBundleImport(file, options = {}) {
13741
14053
  }
13742
14054
  const manifest = await readJson(path58.join(staging, MANIFEST_FILE));
13743
14055
  if (!manifest?.items?.length) {
13744
- error("Bundle contains no catalog manifest \u2014 refusing to import.");
14056
+ error("Bundle contains no catalog manifest, refusing to import.");
13745
14057
  process.exitCode = 1;
13746
14058
  return;
13747
14059
  }
@@ -14811,7 +15123,7 @@ async function runDecisionsCheck(root, opts = {}) {
14811
15123
  return {
14812
15124
  triggered: false,
14813
15125
  satisfied: true,
14814
- reasons: ["catalog-sync branch \u2014 exempt"],
15126
+ reasons: ["catalog-sync branch, exempt"],
14815
15127
  securityTriggered: false,
14816
15128
  ...noRecordFailure()
14817
15129
  };
@@ -14895,7 +15207,7 @@ async function auditDecisionsLayout(root, profile) {
14895
15207
  if (PROFILES[profile].writesDecisionsSeed) {
14896
15208
  findings.push({
14897
15209
  level: "advisory",
14898
- line: `- DECISIONS: legacy ${DECISIONS_TRIGGERS.legacyDecisionsDir}/ present \u2014 migrate to docs/decisions/`,
15210
+ line: `- DECISIONS: legacy ${DECISIONS_TRIGGERS.legacyDecisionsDir}/ present, migrate to docs/decisions/`,
14899
15211
  sentence: "Decision records still live under the legacy docs/adr/ path",
14900
15212
  fix: `git mv ${DECISIONS_TRIGGERS.legacyDecisionsDir} ${DECISIONS_TRIGGERS.decisionsDir}`
14901
15213
  });
@@ -15219,7 +15531,7 @@ function printConfigDirMigration(result) {
15219
15531
  if (!result.planned) log("Rewrote the install manifest to the new location.");
15220
15532
  }
15221
15533
  for (const conflict of result.conflicts) {
15222
- warn(`Left in place \u2014 a different file already exists at the destination: ${conflict}`);
15534
+ warn(`Left in place: a different file already exists at the destination: ${conflict}`);
15223
15535
  }
15224
15536
  for (const failure2 of result.failures) {
15225
15537
  warn(`Could not move ${failure2.path}: ${failure2.reason}`);
@@ -15304,12 +15616,12 @@ async function checkPrereqs(deps = {}) {
15304
15616
  function formatPrereqReport(report2) {
15305
15617
  const lines = report2.checks.map((check) => {
15306
15618
  const label = check.status === "ok" ? "OK " : check.status === "missing" ? "MISSING" : "UNKNOWN";
15307
- const head = `- ${label} ${check.name} \u2014 ${check.detail}`;
15619
+ const head = `- ${label} ${check.name}: ${check.detail}`;
15308
15620
  return check.fix ? `${head}
15309
15621
  fix: ${check.fix}` : head;
15310
15622
  });
15311
15623
  const unknown = report2.checks.filter((check) => check.status === "unknown");
15312
- const verdict = report2.ok ? unknown.length > 0 ? `This machine looks ready, though ${unknown.length} check(s) could not complete.` : "This machine is ready to run haus." : "Some prerequisites are missing \u2014 the fixes are listed above.";
15624
+ const verdict = report2.ok ? unknown.length > 0 ? `This machine looks ready, though ${unknown.length} check(s) could not complete.` : "This machine is ready to run haus." : "Some prerequisites are missing. The fixes are listed above.";
15313
15625
  return [...lines, "", verdict].join("\n");
15314
15626
  }
15315
15627
 
@@ -15350,6 +15662,11 @@ async function fetchNpmVersionStatus(currentVersion) {
15350
15662
  init_fs();
15351
15663
  init_logger();
15352
15664
  init_paths();
15665
+ function joinFix(sentence, label, fix) {
15666
+ const trimmed = sentence.replace(/[:;]+$/, "");
15667
+ const closed = /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
15668
+ return `${closed} ${label}: ${fix}`;
15669
+ }
15353
15670
  async function runDoctor(options, deps = {}) {
15354
15671
  const root = process.cwd();
15355
15672
  if (options?.prereqs) {
@@ -15397,7 +15714,7 @@ async function runDoctor(options, deps = {}) {
15397
15714
  };
15398
15715
  const SETTINGS_HAND_FIX = "fix .claude/settings.json by hand, then run haus apply --write";
15399
15716
  const UNCLOSED_BLOCK_FIX = "close the block by hand (put the HAUS:END line back, or delete the HAUS:BEGIN line), then run haus apply --write";
15400
- const notOwnedHeaderFile = (rel, refusal2) => {
15717
+ const notOwnedHeaderFile = (rel, id, refusal2) => {
15401
15718
  if (refusal2.kind === "not-a-file") {
15402
15719
  const what = notAFileDescription(refusal2.what);
15403
15720
  suggest(
@@ -15410,9 +15727,15 @@ async function runDoctor(options, deps = {}) {
15410
15727
  suggest(
15411
15728
  `- ${rel}: not owned (no haus header). haus refuses it; haus apply --write --force replaces it after a backup`,
15412
15729
  `${rel} has no haus header and is not a version haus wrote`,
15413
- "keep it as your own (apply keeps refusing it), or replace it with haus apply --write --force (backed up first)"
15730
+ `keep it as your own (haus remove ${id} marks it, so apply and doctor stop naming it), or replace it with haus apply --write --force (backed up first)`
15414
15731
  );
15415
15732
  };
15733
+ const headerMarks = await readProjectExcludedIdsIfReadable(root) ?? [];
15734
+ const markedHeaderRels = new Set(
15735
+ headerMarks.flatMap(
15736
+ (id) => Object.hasOwn(HEADER_FILE_RELS, id) ? [HEADER_FILE_RELS[id]] : []
15737
+ )
15738
+ );
15416
15739
  ok2(`Repo: ${context.repoName}`);
15417
15740
  ok2(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
15418
15741
  if (context.detectionStatus === "unknown") {
@@ -15427,7 +15750,7 @@ async function runDoctor(options, deps = {}) {
15427
15750
  } else {
15428
15751
  ok2("Recommended items: none recorded yet (run `haus recommend`)");
15429
15752
  suggest(
15430
- "- CATALOG ITEMS: no recommendation.json \u2014 the missing-item drift and orphaned-item checks did not run",
15753
+ "- CATALOG ITEMS: no recommendation.json, so the missing-item drift and orphaned-item checks did not run",
15431
15754
  "No recommendation has been computed for this project yet, so two of the checks were skipped",
15432
15755
  "haus recommend (or `haus apply --write` to set the project up)"
15433
15756
  );
@@ -15440,7 +15763,7 @@ async function runDoctor(options, deps = {}) {
15440
15763
  if (hooks.skipped) {
15441
15764
  flag(
15442
15765
  `- HOOKS: (skipped) ${hooks.message}`,
15443
- "No Claude Code settings.json \u2014 haus security hooks are not installed",
15766
+ "No Claude Code settings.json, so the haus security hooks are not installed",
15444
15767
  "haus apply --write"
15445
15768
  );
15446
15769
  } else if (!hooks.ok) {
@@ -15520,6 +15843,7 @@ async function runDoctor(options, deps = {}) {
15520
15843
  const resolved = path67.resolve(root, target);
15521
15844
  const relFromRoot = path67.relative(root, resolved);
15522
15845
  const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path67.sep}`) || path67.isAbsolute(relFromRoot);
15846
+ if (!escapesRoot && markedHeaderRels.has(relFromRoot.split(path67.sep).join("/"))) continue;
15523
15847
  if (escapesRoot || !await fs45.pathExists(resolved)) {
15524
15848
  flag(
15525
15849
  `- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
@@ -15539,7 +15863,8 @@ async function runDoctor(options, deps = {}) {
15539
15863
  }
15540
15864
  const workflowPath = hausPath(root, "WORKFLOW.md");
15541
15865
  const workflowOnDisk = await inspectHeaderHashedFile(workflowPath);
15542
- if (workflowOnDisk.kind === "absent") {
15866
+ if (headerMarks.includes(WORKFLOW_ID)) {
15867
+ } else if (workflowOnDisk.kind === "absent") {
15543
15868
  flag(
15544
15869
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
15545
15870
  "The workflow standard file is missing",
@@ -15549,7 +15874,7 @@ async function runDoctor(options, deps = {}) {
15549
15874
  const onDisk = workflowOnDisk;
15550
15875
  const workflowContent = onDisk.kind === "unreadable" ? null : verdictText(onDisk);
15551
15876
  if (workflowContent !== null && typeof workflowContent !== "string") {
15552
- notOwnedHeaderFile(".haus-workflow/WORKFLOW.md", workflowContent);
15877
+ notOwnedHeaderFile(".haus-workflow/WORKFLOW.md", WORKFLOW_ID, workflowContent);
15553
15878
  } else if (workflowContent !== null) {
15554
15879
  const firstLine3 = workflowContent.split("\n")[0] ?? "";
15555
15880
  const bodyContent = parseHausManagedHeader(firstLine3) === null ? workflowContent : workflowContent.slice(firstLine3.length + 1);
@@ -15583,7 +15908,7 @@ async function runDoctor(options, deps = {}) {
15583
15908
  );
15584
15909
  } else if (verdict.outcome === "installed") {
15585
15910
  suggest(
15586
- "- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
15911
+ "- .haus-workflow/WORKFLOW.md: stale (template updated, run `haus apply --write`)",
15587
15912
  "The workflow standard is out of date",
15588
15913
  "haus apply --write"
15589
15914
  );
@@ -15592,14 +15917,14 @@ async function runDoctor(options, deps = {}) {
15592
15917
  } else if (verdict.refusal?.kind === "another-id") {
15593
15918
  ok2("- .haus-workflow/WORKFLOW.md: OK (user-owned)");
15594
15919
  } else if (verdict.refusal?.kind === "not-owned") {
15595
- notOwnedHeaderFile(".haus-workflow/WORKFLOW.md", verdict.refusal);
15920
+ notOwnedHeaderFile(".haus-workflow/WORKFLOW.md", WORKFLOW_ID, verdict.refusal);
15596
15921
  } else {
15597
15922
  ok2("- .haus-workflow/WORKFLOW.md: OK");
15598
15923
  }
15599
15924
  }
15600
15925
  }
15601
15926
  const rulesOnDisk = await inspectHeaderHashedFile(claudePath(root, "rules", "haus.md"));
15602
- const rulesHaus = rulesOnDisk.kind === "unreadable" ? void 0 : headerFileVerdict(verdictText(rulesOnDisk), {
15927
+ const rulesHaus = rulesOnDisk.kind === "unreadable" || headerMarks.includes(RULES_HAUS_ID) ? void 0 : headerFileVerdict(verdictText(rulesOnDisk), {
15603
15928
  id: RULES_HAUS_ID,
15604
15929
  body: RULES_HAUS_BODY,
15605
15930
  knownBodies: KNOWN_RULES_HAUS_BODIES,
@@ -15609,10 +15934,10 @@ async function runDoctor(options, deps = {}) {
15609
15934
  suggest(
15610
15935
  "- .claude/rules/haus.md: edited (header hash does not match). haus keeps it; haus apply --write --force replaces it after a backup",
15611
15936
  "The haus rule (.claude/rules/haus.md) was edited after haus wrote it, so haus keeps it as it is",
15612
- "keep it, or replace it with haus apply --write --force (backed up first)"
15937
+ `keep it (haus remove ${RULES_HAUS_ID} marks it as your own, so this stops), or replace it with haus apply --write --force (backed up first)`
15613
15938
  );
15614
15939
  } else if (rulesHaus?.refusal?.kind === "not-owned" || rulesHaus?.refusal?.kind === "not-a-file") {
15615
- notOwnedHeaderFile(".claude/rules/haus.md", rulesHaus.refusal);
15940
+ notOwnedHeaderFile(".claude/rules/haus.md", RULES_HAUS_ID, rulesHaus.refusal);
15616
15941
  }
15617
15942
  const workflowConfigPath = hausPath(root, "workflow-config.md");
15618
15943
  const workflowConfigExists = await fs45.pathExists(workflowConfigPath);
@@ -15640,6 +15965,10 @@ async function runDoctor(options, deps = {}) {
15640
15965
  ok2(finding.line);
15641
15966
  continue;
15642
15967
  }
15968
+ if (finding.level === "attention") {
15969
+ flag(finding.line, finding.sentence, finding.fix);
15970
+ continue;
15971
+ }
15643
15972
  suggest(finding.line, finding.sentence, finding.fix);
15644
15973
  for (const line2 of finding.details ?? []) detail.push({ stream: "warn", text: ` ${line2}` });
15645
15974
  }
@@ -15651,7 +15980,7 @@ async function runDoctor(options, deps = {}) {
15651
15980
  ok2("- PROJECT DOCS: OK (docs/SUMMARY.md present)");
15652
15981
  } else if (PROFILES[profile].runsDocsPass) {
15653
15982
  suggest(
15654
- "- PROJECT DOCS: docs/SUMMARY.md missing \u2014 run the writing-documentation skill (`/docs` in Claude Code) to generate the project docs",
15983
+ "- PROJECT DOCS: docs/SUMMARY.md missing. Run the writing-documentation skill (`/docs` in Claude Code) to generate the project docs",
15655
15984
  "The project docs index (docs/SUMMARY.md) has not been generated yet",
15656
15985
  "run `/docs` in Claude Code (the writing-documentation skill)"
15657
15986
  );
@@ -15741,7 +16070,7 @@ async function runDoctor(options, deps = {}) {
15741
16070
  const dirList = dirs.join(" and ");
15742
16071
  const verb = dirs.length === 1 ? "is" : "are";
15743
16072
  flag(
15744
- `- GITIGNORE (haus content): ${dirList} ${verb} gitignored \u2014 installed skills/agents/commands will not be visible to anything relying on git-tracked state`,
16073
+ `- GITIGNORE (haus content): ${dirList} ${verb} gitignored: installed skills/agents/commands will not be visible to anything relying on git-tracked state`,
15745
16074
  `${dirList} ${verb} gitignored, so haus-installed content isn't visible to git-tracked state`,
15746
16075
  "remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored)"
15747
16076
  );
@@ -15762,7 +16091,7 @@ async function runDoctor(options, deps = {}) {
15762
16091
  flag(
15763
16092
  `- CATALOG: ${err.message}`,
15764
16093
  err.kind === "too-new" ? "The catalog this project uses needs a newer haus than the one installed" : "The catalog this project uses declares a manifestFormat that is not a valid format number",
15765
- err.kind === "too-new" ? "npm install -g @haus-tech/haus-workflow@latest" : "fix `manifestFormat` in the catalog manifest \u2014 it must be a positive integer (upgrading haus will not help)"
16094
+ err.kind === "too-new" ? "npm install -g @haus-tech/haus-workflow@latest" : "fix `manifestFormat` in the catalog manifest: it must be a positive integer (upgrading haus will not help)"
15766
16095
  );
15767
16096
  }
15768
16097
  const profileRules = catalogCtx ? parseProfileRules(
@@ -15772,7 +16101,7 @@ async function runDoctor(options, deps = {}) {
15772
16101
  ) : null;
15773
16102
  if (catalogCtx && profileRules === null) {
15774
16103
  flag(
15775
- `- PROFILE: the ${describeCatalogTier(catalogCtx)} catalog defines no "${profile}" profile \u2014 \`haus apply\` refuses until it does`,
16104
+ `- PROFILE: the ${describeCatalogTier(catalogCtx)} catalog defines no "${profile}" profile, so \`haus apply\` refuses until it does`,
15776
16105
  `This project is set to the ${profile} profile, but the catalog it uses defines no such profile (needs haus-workflow-catalog >= 6.5.0)`,
15777
16106
  'haus update (or set "profile" back to "production" in .haus-workflow/haus.config.json)'
15778
16107
  );
@@ -15805,7 +16134,7 @@ async function runDoctor(options, deps = {}) {
15805
16134
  suggest(
15806
16135
  `- GLOBAL PLUGINS: ${marketplaceMissing.length} built-in marketplace not listed (${marketplaceMissing.map((a) => `${a.pin.id}: ${a.reason}`).join("; ")})`,
15807
16136
  `${marketplaceMissing.length} haus-managed plugin(s) sit on a built-in marketplace this Claude Code does not list`,
15808
- "update Claude Code (the claude-plugins-official marketplace is built in) \u2014 or: claude plugin marketplace add anthropics/claude-plugins-official"
16137
+ "update Claude Code (the claude-plugins-official marketplace is built in), or: claude plugin marketplace add anthropics/claude-plugins-official"
15809
16138
  );
15810
16139
  }
15811
16140
  if (otherPending.length > 0) {
@@ -15911,7 +16240,7 @@ async function runDoctor(options, deps = {}) {
15911
16240
  suggest(
15912
16241
  `- GLOBAL PLUGINS: ${untrackedDuplicates.length} project skill folder(s) duplicate the global plugin but are not lock-tracked (${untrackedDuplicates.slice(0, 3).join(", ")}${untrackedDuplicates.length > 3 ? ", \u2026" : ""})`,
15913
16242
  `${untrackedDuplicates.length} skill folder(s) load twice in every session; haus does not track them, so no command will remove them`,
15914
- `delete .claude/skills/{${untrackedDuplicates.join(",")}} \u2014 the global plugin already provides them`
16243
+ `delete .claude/skills/{${untrackedDuplicates.join(",")}} (the global plugin already provides them)`
15915
16244
  );
15916
16245
  }
15917
16246
  }
@@ -16037,8 +16366,8 @@ async function runDoctor(options, deps = {}) {
16037
16366
  }).join("; ");
16038
16367
  suggest(
16039
16368
  `- AGENT REFS: ${gatedOut.length} reference(s) name ${referent(gatedOut)} the catalog gates out of this project's stack (${unmet}) (${gatedOut.slice(0, 3).map(describeAgent).join("; ")}${gatedOut.length > 3 ? "; \u2026" : ""})`,
16040
- `${gatedOut.length} rule/agent reference(s) ${routedTo(gatedOut, names4)}, which the catalog does not offer a project with this stack (${unmet}) \u2014 the reference is upstream prose, not a missing install`,
16041
- `nothing to install here: correct or drop the reference upstream (catalog-owned prose) \u2014 the catalog gates ${names4.join(", ")} out of this stack on purpose`
16369
+ `${gatedOut.length} rule/agent reference(s) ${routedTo(gatedOut, names4)}, which the catalog does not offer a project with this stack (${unmet}): the reference is upstream prose, not a missing install`,
16370
+ `nothing to install here: correct or drop the reference upstream (catalog-owned prose); the catalog gates ${names4.join(", ")} out of this stack on purpose`
16042
16371
  );
16043
16372
  }
16044
16373
  if (unknown.length > 0) {
@@ -16079,7 +16408,7 @@ async function runDoctor(options, deps = {}) {
16079
16408
  if (drifted.length > 0) {
16080
16409
  suggest(
16081
16410
  `- ELIGIBILITY DRIFT: ${drifted.length} installed item(s) are no longer recommended by the current signals (${drifted.slice(0, 3).join(", ")}${drifted.length > 3 ? ", \u2026" : ""})`,
16082
- `${drifted.length} installed item(s) no longer match anything haus can detect in this repo \u2014 often a role that only \`deep-context.json\` knew about, which is not tracked in git`,
16411
+ `${drifted.length} installed item(s) no longer match anything haus can detect in this repo, often a role that only \`deep-context.json\` knew about, which is not tracked in git`,
16083
16412
  "declare the role with `haus recommend --declare-role <role>` or the stack in .haus-workflow/haus.config.json (declaredStacks), re-run the docs pass to regenerate deep-context.json, or `haus remove <id>` if the item is genuinely no longer wanted"
16084
16413
  );
16085
16414
  }
@@ -16089,7 +16418,7 @@ async function runDoctor(options, deps = {}) {
16089
16418
  ok2(`- CLAUDE CONFIG DIR: ${configMigration.to} (CLAUDE_CONFIG_DIR)`);
16090
16419
  if (configMigration.status === "pending") {
16091
16420
  suggest(
16092
- `- CLAUDE CONFIG DIR: ${configMigration.moved.length} haus file(s) still under ${configMigration.from} \u2014 \`haus install\` moves them`,
16421
+ `- CLAUDE CONFIG DIR: ${configMigration.moved.length} haus file(s) still under ${configMigration.from}; \`haus install\` moves them`,
16093
16422
  "Your haus global install predates CLAUDE_CONFIG_DIR and still sits in ~/.claude",
16094
16423
  "haus install"
16095
16424
  );
@@ -16113,18 +16442,18 @@ async function runDoctor(options, deps = {}) {
16113
16442
  const tierLabel = `${catalogTier}${catalogVersion ? ` (v${catalogVersion})` : ""}`;
16114
16443
  if (catalogTier === "bundled") {
16115
16444
  suggest(
16116
- `- CATALOG TIER: ${tierLabel} \u2014 frozen at publish time, metadata only (${installableItems} installable item(s))`,
16117
- `Catalog is being served from the snapshot bundled with this CLI (${tierLabel}), frozen at publish time and carrying metadata only \u2014 ${installableItems} of its items have installable content, so \`haus apply\` will write core files only`,
16445
+ `- CATALOG TIER: ${tierLabel}: frozen at publish time, metadata only (${installableItems} installable item(s))`,
16446
+ `Catalog is being served from the snapshot bundled with this CLI (${tierLabel}), frozen at publish time and carrying metadata only: ${installableItems} of its items have installable content, so \`haus apply\` will write core files only`,
16118
16447
  "haus update (with network access), or `haus catalog bundle import` on a disconnected machine"
16119
16448
  );
16120
16449
  } else if (installableItems === 0) {
16121
16450
  suggest(
16122
- `- CATALOG TIER: ${tierLabel} \u2014 0 installable items (\`haus apply\` will write core files only)`,
16451
+ `- CATALOG TIER: ${tierLabel}: 0 installable items (\`haus apply\` will write core files only)`,
16123
16452
  `The ${catalogTier} catalog tier has 0 installable items`,
16124
16453
  "haus update (or `haus catalog bundle import` on a disconnected machine)"
16125
16454
  );
16126
16455
  } else {
16127
- ok2(`- CATALOG TIER: ${tierLabel} \u2014 ${installableItems} installable item(s)`);
16456
+ ok2(`- CATALOG TIER: ${tierLabel}: ${installableItems} installable item(s)`);
16128
16457
  }
16129
16458
  }
16130
16459
  const cacheAgeMs = await getCacheManifestAge();
@@ -16138,7 +16467,7 @@ async function runDoctor(options, deps = {}) {
16138
16467
  const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
16139
16468
  if (cacheAgeDays >= 7) {
16140
16469
  suggest(
16141
- `- CATALOG CACHE: stale (${cacheAgeDays}d old \u2014 run \`haus update\`)`,
16470
+ `- CATALOG CACHE: stale (${cacheAgeDays}d old, run \`haus update\`)`,
16142
16471
  `The catalog cache is ${cacheAgeDays} days old`,
16143
16472
  "haus update"
16144
16473
  );
@@ -16155,7 +16484,7 @@ async function runDoctor(options, deps = {}) {
16155
16484
  if (recommendation) {
16156
16485
  if (!Array.isArray(recommendation.recommended)) {
16157
16486
  suggest(
16158
- `- CATALOG ITEMS: recommendation.json's "recommended" field is not an array \u2014 skipping orphaned-item check`,
16487
+ `- CATALOG ITEMS: recommendation.json's "recommended" field is not an array, skipping orphaned-item check`,
16159
16488
  'recommendation.json looks malformed (its "recommended" field is not an array)',
16160
16489
  "run `haus recommend` to regenerate it"
16161
16490
  );
@@ -16282,9 +16611,9 @@ async function runDoctor(options, deps = {}) {
16282
16611
  if (orphans.length > 0) {
16283
16612
  const paths = orphans.map((orphan) => orphan.relPath);
16284
16613
  suggest(
16285
- `- CATALOG RENAMES: ${orphans.length} directory/file left by a rename is not tracked in haus.lock.json (${orphans.map((orphan) => `${orphan.relPath} \u2014 renamed to ${orphan.currentId}`).join("; ")})`,
16614
+ `- CATALOG RENAMES: ${orphans.length} directory/file left by a rename is not tracked in haus.lock.json (${orphans.map((orphan) => `${orphan.relPath}, renamed to ${orphan.currentId}`).join("; ")})`,
16286
16615
  `${orphans.length} leftover item(s) from a catalog rename are still in .claude/ and haus cannot remove them`,
16287
- `nothing in haus.lock.json records them, so haus leaves them alone \u2014 delete them by hand once you have checked for local edits: rm -rf ${paths.join(" ")}`
16616
+ `nothing in haus.lock.json records them, so haus leaves them alone. Delete them by hand once you have checked for local edits: rm -rf ${paths.join(" ")}`
16288
16617
  );
16289
16618
  }
16290
16619
  } catch (err) {
@@ -16377,7 +16706,7 @@ async function runDoctor(options, deps = {}) {
16377
16706
  if (lingering.length > 0) {
16378
16707
  const idList = `${lingering.slice(0, 3).join(", ")}${lingering.length > 3 ? ", \u2026" : ""}`;
16379
16708
  suggest(
16380
- `- CATALOG ITEMS: ${lingering.length} installed item(s) are removed or deprecated in the current catalog (${idList}) \u2014 pre-0.30.0 CLIs never pruned these`,
16709
+ `- CATALOG ITEMS: ${lingering.length} installed item(s) are removed or deprecated in the current catalog (${idList}); pre-0.30.0 CLIs never pruned these`,
16381
16710
  `${lingering.length} installed item(s) no longer exist in the catalog (removed or deprecated)`,
16382
16711
  "haus update (prunes them when unmodified; see docs/runbook.md for locally-edited copies)"
16383
16712
  );
@@ -16454,11 +16783,11 @@ async function runDoctor(options, deps = {}) {
16454
16783
  log("\u2705 Your project is set up and healthy.");
16455
16784
  } else {
16456
16785
  log(`\u26A0\uFE0F ${attention.length} thing(s) need attention:`);
16457
- for (const a of attention) log(` \u2022 ${a.sentence} \u2014 fix: ${a.fix}`);
16786
+ for (const a of attention) log(` \u2022 ${joinFix(a.sentence, "Fix", a.fix)}`);
16458
16787
  }
16459
16788
  if (suggestions.length > 0) {
16460
16789
  log(`\u{1F4A1} ${suggestions.length} suggestion(s):`);
16461
- for (const s of suggestions) log(` \u2022 ${s.sentence} \u2014 try: ${s.fix}`);
16790
+ for (const s of suggestions) log(` \u2022 ${joinFix(s.sentence, "Try", s.fix)}`);
16462
16791
  }
16463
16792
  log("Haus Doctor");
16464
16793
  for (const line2 of detail) {
@@ -16488,6 +16817,7 @@ function deployDisagreements(config, evidence) {
16488
16817
  groups.set(key, group);
16489
16818
  };
16490
16819
  for (const item of evidence) {
16820
+ if (item.dispatch && !needsHumanClass(item.deploys)) continue;
16491
16821
  if (item.dispatch) {
16492
16822
  const listed = config.dispatchDeploys.includes(item.dispatch);
16493
16823
  add(
@@ -16531,16 +16861,57 @@ function deployDisagreements(config, evidence) {
16531
16861
  subject: group.subject,
16532
16862
  detected,
16533
16863
  fileSays: group.file === void 0 ? group.noRow : `says ${group.file}`,
16864
+ ...group.file === void 0 ? {} : { file: group.file },
16534
16865
  details: [...new Set(higher.map((item) => item.detail))]
16535
16866
  });
16536
16867
  }
16537
16868
  return out;
16538
16869
  }
16870
+ function topDetected(result) {
16871
+ let top;
16872
+ const consider = (subject, deploys) => {
16873
+ if (!top || classRank(deploys) > classRank(top.deploys)) top = { subject, deploys };
16874
+ };
16875
+ for (const [branch, row] of Object.entries(result.config.branches)) {
16876
+ consider(branch, row.deploys);
16877
+ for (const [glob, value] of Object.entries(row.pathDeploys ?? {})) {
16878
+ consider(`a change to ${glob} on ${branch}`, value);
16879
+ }
16880
+ }
16881
+ for (const [tag, value] of Object.entries(result.config.tags)) consider(`tag ${tag}`, value);
16882
+ for (const item of result.evidence) {
16883
+ if (item.dispatch) consider(`${item.dispatch}, when dispatched,`, item.deploys);
16884
+ }
16885
+ return top;
16886
+ }
16539
16887
  async function auditDeployConfig(root, workflowConfigPath, deps) {
16540
16888
  const rel = DEPLOY_CONFIG_REL;
16541
16889
  const fallback2 = `the deploy gate guesses from detection on every merge, push and deploy until it is fixed`;
16890
+ const git = realGit();
16891
+ const detect = async () => {
16892
+ const context = await repoContext(root, git);
16893
+ const branch = context ? await defaultBranch(git, context.root) : void 0;
16894
+ return detectDeployConfig(root, {
16895
+ gh: deps.deployGh ?? (deployDetectOffline() ? void 0 : realGh()),
16896
+ git,
16897
+ slug: context?.slug,
16898
+ defaultBranch: branch,
16899
+ cache: true
16900
+ });
16901
+ };
16542
16902
  const found = await readTextDetailed(path67.join(root, rel));
16543
16903
  if (found.status === "missing") {
16904
+ const top = topDetected(await detect());
16905
+ if (top && isGatedClass(top.deploys)) {
16906
+ return [
16907
+ {
16908
+ level: "attention",
16909
+ line: `- DEPLOY TARGETS: ${rel} missing while detection says ${top.subject} deploys ${top.deploys} (run \`haus apply --write\`, then set confirmedBy); ${fallback2}`,
16910
+ sentence: `${rel} is missing while detection says ${top.subject} deploys ${top.deploys}, so the deploy gate guesses on every merge, push and deploy`,
16911
+ fix: "run haus apply --write to write it, then check what each branch deploys and set confirmedBy"
16912
+ }
16913
+ ];
16914
+ }
16544
16915
  return [
16545
16916
  {
16546
16917
  level: "suggest",
@@ -16613,15 +16984,7 @@ async function auditDeployConfig(root, workflowConfigPath, deps) {
16613
16984
  fix: `set branches.<branch>.localCheck in ${rel} to the command that runs the change locally (the app, a dev server, a build or a docs preview)`
16614
16985
  });
16615
16986
  }
16616
- const git = realGit();
16617
- const context = await repoContext(root, git);
16618
- const branch = context ? await defaultBranch(git, context.root) : void 0;
16619
- const detection = await detectDeployConfig(root, {
16620
- gh: deps.deployGh ?? (deployDetectOffline() ? void 0 : realGh()),
16621
- slug: context?.slug,
16622
- defaultBranch: branch,
16623
- cache: true
16624
- });
16987
+ const detection = await detect();
16625
16988
  for (const message2 of detection.errors) {
16626
16989
  findings.push({
16627
16990
  level: "ok",
@@ -16630,11 +16993,12 @@ async function auditDeployConfig(root, workflowConfigPath, deps) {
16630
16993
  }
16631
16994
  const disagreements = deployDisagreements(config, detection.evidence);
16632
16995
  for (const d of disagreements) {
16996
+ const nameIt = d.file === void 0 ? "" : "; if the file is right, name the target in the workflow (a job environment:, or a concurrency group or file name such as deploy-test) so detection agrees";
16633
16997
  findings.push({
16634
16998
  level: "suggest",
16635
16999
  line: `- DEPLOY TARGETS: detection says ${d.subject} deploys ${d.detected}, but ${rel} ${d.fileSays} (${d.details.join("; ")})`,
16636
17000
  sentence: `Detection says ${d.subject} deploys ${d.detected} (${d.details.join("; ")}), but ${rel} ${d.fileSays}`,
16637
- fix: `check it, and raise the class in ${rel} if detection is right (a higher class than detection's is fine)`
17001
+ fix: `check it, and raise the class in ${rel} if detection is right (a higher class than detection's is fine)${nameIt}`
16638
17002
  });
16639
17003
  }
16640
17004
  const configText = await readText(workflowConfigPath);
@@ -16914,12 +17278,7 @@ async function pruneEmptyDirsUpward(dirs, stopAt) {
16914
17278
  for (const start of ordered) {
16915
17279
  let dir = start;
16916
17280
  while (dir.startsWith(stopAt) && dir !== stopAt) {
16917
- try {
16918
- if ((await fs48.readdir(dir)).length > 0) break;
16919
- await fs48.remove(dir);
16920
- } catch {
16921
- break;
16922
- }
17281
+ if (!await pruneEmptyDir(dir)) break;
16923
17282
  dir = path70.dirname(dir);
16924
17283
  }
16925
17284
  }
@@ -17036,7 +17395,7 @@ async function applyInstall(options = {}) {
17036
17395
  }
17037
17396
  if (stampableMarkerForm(entry.destPath) === void 0)
17038
17397
  throw new Error(
17039
- `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)`
17398
+ `No safe ownership-marker form for ${entry.srcRelPath}: only .md and .js/.mjs/.cjs aux files can ship in library/global (see ADR-0072)`
17040
17399
  );
17041
17400
  const stamped = stampMarkdown(
17042
17401
  rawContent,
@@ -17069,7 +17428,7 @@ async function applyInstall(options = {}) {
17069
17428
  continue;
17070
17429
  }
17071
17430
  if (existing && hashContent(currentContent) !== existing.hash && !force) {
17072
- warn(`User edited haus file (skipping): ${entry.destPath} \u2014 use --force to overwrite`);
17431
+ warn(`User edited haus file (skipping): ${entry.destPath}. Use --force to overwrite`);
17073
17432
  result.skipped.push(entry.destPath);
17074
17433
  result.userEdited.push(entry.destPath);
17075
17434
  manifestFiles.push(existing);
@@ -17136,7 +17495,7 @@ async function applyInstall(options = {}) {
17136
17495
  emptiedDirs.add(path70.dirname(entry.destPath));
17137
17496
  result.deleted.push(entry.destPath);
17138
17497
  } else {
17139
- warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
17498
+ warn(`Orphaned file ${entry.destPath} was user-modified, leaving in place`);
17140
17499
  result.skipped.push(entry.destPath);
17141
17500
  }
17142
17501
  }
@@ -17168,7 +17527,7 @@ function printInternalChannel(result, prefix) {
17168
17527
  }
17169
17528
  if (!enabled && result.deleted.some((p) => p.includes("request") || p.includes("slack-cowork"))) {
17170
17529
  log(
17171
- `${prefix}Internal channel is off \u2014 the request skills and slack-cowork were removed. Bring them back with \`haus install --internal\`.`
17530
+ `${prefix}Internal channel is off: the request skills and slack-cowork were removed. Bring them back with \`haus install --internal\`.`
17172
17531
  );
17173
17532
  }
17174
17533
  }
@@ -17197,7 +17556,7 @@ function printApplyResult(result, dryRun) {
17197
17556
  }
17198
17557
  printInternalChannel(result, prefix);
17199
17558
  if (result.drift) {
17200
- warn("Install drift detected \u2014 run `haus install` to sync.");
17559
+ warn("Install drift detected. Run `haus install` to sync.");
17201
17560
  }
17202
17561
  }
17203
17562
 
@@ -17328,7 +17687,7 @@ What changed in ${npmStatus.latest}:`);
17328
17687
  const notAProject = await projectSkipReason(root);
17329
17688
  if (notAProject !== null) warn(`Skipped the project refresh: ${root} ${notAProject}.`);
17330
17689
  if (notAProject === null && await hasLocalOverrides(root)) {
17331
- log("Existing .claude/settings.json \u2014 haus rules will be merged, not replaced.");
17690
+ log("Existing .claude/settings.json: haus rules will be merged, not replaced.");
17332
17691
  }
17333
17692
  const lockBefore = await readLockRaw(root);
17334
17693
  log("Syncing remote catalog...");
@@ -17423,7 +17782,7 @@ async function refreshProjectFiles(root) {
17423
17782
  try {
17424
17783
  const result = await refreshProjectApply(root);
17425
17784
  if (!result.setUp) {
17426
- warn("No prior haus project setup detected \u2014 skipped project re-apply.");
17785
+ warn("No prior haus project setup detected, skipped project re-apply.");
17427
17786
  return null;
17428
17787
  }
17429
17788
  if (result.report.refusedFiles.includes(LOCK_FILE)) return result.report;
@@ -17731,10 +18090,10 @@ async function runClone(url, opts = {}) {
17731
18090
  if (existsSync4(target) && !await isEmptyDirectory(target)) {
17732
18091
  const check = await checkExistingTarget(target, url);
17733
18092
  if (check.kind === "matches") {
17734
- log(`\u2022 ${path72.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
18093
+ log(`\u2022 ${path72.basename(target)} already cloned here, matches ${url}, skipped`);
17735
18094
  return;
17736
18095
  }
17737
- const message2 = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}) \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
18096
+ const message2 = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository, refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}), refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
17738
18097
  if (opts.dryRun) {
17739
18098
  warn(`[dry-run] ${message2}`);
17740
18099
  return;
@@ -18505,9 +18864,143 @@ var INERT_COMMANDS = /* @__PURE__ */ new Set([
18505
18864
  "wc"
18506
18865
  ]);
18507
18866
  var INERT_GIT_SUBCOMMANDS = /* @__PURE__ */ new Set(["add", "diff", "log", "status"]);
18508
- var GIT_CONTROL_FILE = /(?:^|\/)(?:\.git(?:\/|$)|\.git(?:attributes|config|modules)$|git\/[^/]+$|config$|hooks\/|info\/attributes$)/;
18509
- function runsNothing(segment, callTargets) {
18510
- if (segment.some((token) => !token.quoted && ENV_ASSIGNMENT.test(token.value))) return false;
18867
+ var GIT_CONTROL_FILE = /(?:^|\/)(?:\.git(?:\/|$)|\.git(?:attributes|config|modules)$|git\/[^/]+$|config$|hooks\/|info\/attributes$)/i;
18868
+ var GH_BODY_FILE_FLAGS = /* @__PURE__ */ new Map([
18869
+ [
18870
+ "pr",
18871
+ /* @__PURE__ */ new Map([
18872
+ ["create", ["--body-file", "-F"]],
18873
+ ["edit", ["--body-file", "-F"]],
18874
+ ["comment", ["--body-file", "-F"]]
18875
+ ])
18876
+ ],
18877
+ [
18878
+ "issue",
18879
+ /* @__PURE__ */ new Map([
18880
+ ["create", ["--body-file", "-F"]],
18881
+ ["edit", ["--body-file", "-F"]],
18882
+ ["comment", ["--body-file", "-F"]]
18883
+ ])
18884
+ ],
18885
+ [
18886
+ "release",
18887
+ /* @__PURE__ */ new Map([
18888
+ ["create", ["--notes-file", "-F"]],
18889
+ ["edit", ["--notes-file", "-F"]]
18890
+ ])
18891
+ ]
18892
+ ]);
18893
+ var GH_CONTROL_FILE = /(?:^|\/)gh\/|(?:^|\/)(?:config|hosts)\.ya?ml$/i;
18894
+ var BARE_GH_PREFIXES = /* @__PURE__ */ new Set(["command", "exec"]);
18895
+ var SUBSTITUTION = /\$\(|`|<\(|>\(/;
18896
+ var UNREAD_PATH = /[*?[\]$`~{}]/;
18897
+ var WRITES_ONLY_WHERE_SENT = /* @__PURE__ */ new Set([
18898
+ "cat",
18899
+ "echo",
18900
+ "grep",
18901
+ "head",
18902
+ "jq",
18903
+ "printf",
18904
+ "tail",
18905
+ "tee",
18906
+ "wc"
18907
+ ]);
18908
+ function plainPath(path137) {
18909
+ return path137.replace(/^(?:\.\/+)+/, "");
18910
+ }
18911
+ function folded(path137) {
18912
+ return path137.normalize("NFKC").toLowerCase();
18913
+ }
18914
+ var NOT_PRINTABLE_ASCII = /[^ -~]/;
18915
+ function mayBeControlFile(path137) {
18916
+ const plain2 = folded(path137);
18917
+ return NOT_PRINTABLE_ASCII.test(path137) || GIT_CONTROL_FILE.test(plain2) || GH_CONTROL_FILE.test(plain2);
18918
+ }
18919
+ var NOTE_EXTENSION = /\.(?:md|markdown|txt)$/;
18920
+ function everyWrittenFile(segment) {
18921
+ const embedded = segment.filter((token) => !token.quoted && token.value.includes(">")).filter((token) => !OUTPUT_REDIRECTION.test(token.value)).map((token) => token.value);
18922
+ const values = withoutRedirections(segment).map((token) => token.value);
18923
+ const tee = values.findIndex((value) => commandBaseName(value) === "tee");
18924
+ const teeOperands = tee === -1 ? [] : values.slice(tee + 1).filter((value) => !value.startsWith("-"));
18925
+ const git = trustedName(commandWords(withoutRedirections(segment))[0] ?? "") === "git";
18926
+ const gitOutput = git ? values.filter((value) => value.startsWith("--out")) : [];
18927
+ return [...writtenFiles(segment), ...embedded, ...teeOperands, ...gitOutput];
18928
+ }
18929
+ function safeNotePath(file) {
18930
+ const plain2 = plainPath(file);
18931
+ if (UNREAD_PATH.test(plain2) || mayBeControlFile(plain2)) return false;
18932
+ if (!NOTE_EXTENSION.test(folded(plain2))) return false;
18933
+ const directories = folded(plain2).split("/").slice(0, -1);
18934
+ return !directories.some((part) => part.startsWith(".") && part !== ".");
18935
+ }
18936
+ function writesOnlyWhereSent(segment) {
18937
+ const bare = withoutRedirections(segment);
18938
+ if (bare.length === 0) return true;
18939
+ const name = trustedName(commandWords(bare)[0] ?? "");
18940
+ return name !== void 0 && WRITES_ONLY_WHERE_SENT.has(name);
18941
+ }
18942
+ function callWrites(commands, written) {
18943
+ return {
18944
+ targets: written.flatMap((body) => body.targets),
18945
+ files: commands.flatMap(({ segment }) => everyWrittenFile(segment)),
18946
+ hiddenWriter: written.some((body) => !body.writers.every(writesOnlyWhereSent))
18947
+ };
18948
+ }
18949
+ function attachedBodyFile(word, flags) {
18950
+ for (const flag of flags) {
18951
+ if (flag.startsWith("--")) {
18952
+ if (word.startsWith(`${flag}=`)) return word.slice(flag.length + 1);
18953
+ } else if (word.startsWith(flag) && word.length > flag.length) {
18954
+ return word.slice(flag.length).replace(/^=/, "");
18955
+ }
18956
+ }
18957
+ return void 0;
18958
+ }
18959
+ function ghSendsOnlyAsBody(segment, words, call, bodyTargets) {
18960
+ const flags = GH_BODY_FILE_FLAGS.get(words[1] ?? "")?.get(words[2] ?? "");
18961
+ if (flags === void 0 || bodyTargets.length === 0 || call.hiddenWriter) return false;
18962
+ if (segment.some((token) => SUBSTITUTION.test(token.value))) return false;
18963
+ const values = withoutRedirections(segment).map((token) => token.value);
18964
+ if (values.some((value) => commandBaseName(value) === "xargs")) return false;
18965
+ const writes = [...call.targets, ...call.files];
18966
+ if (writes.some(mayBeControlFile)) {
18967
+ return false;
18968
+ }
18969
+ let at = 0;
18970
+ while (at < values.length && BARE_GH_PREFIXES.has(values[at])) at += 1;
18971
+ if (values[at] !== words[0] || values[at + 1] !== words[1] || values[at + 2] !== words[2]) {
18972
+ return false;
18973
+ }
18974
+ const names4 = writes.filter((file) => plainPath(file) !== "/dev/null").map((file) => commandBaseName(file)).filter(Boolean);
18975
+ const bodyFiles = [];
18976
+ let bodyFlags = 0;
18977
+ for (let i = 0; i < values.length; i += 1) {
18978
+ const value = values[i];
18979
+ if (i > at + 2 && flags.includes(value)) {
18980
+ bodyFlags += 1;
18981
+ if (i + 1 < values.length) bodyFiles.push(values[i + 1]);
18982
+ i += 1;
18983
+ continue;
18984
+ }
18985
+ const attached = i > at + 2 ? attachedBodyFile(value, flags) : void 0;
18986
+ if (attached !== void 0) {
18987
+ bodyFlags += 1;
18988
+ bodyFiles.push(attached);
18989
+ continue;
18990
+ }
18991
+ if (names4.some((name) => value.toLowerCase().includes(name))) return false;
18992
+ }
18993
+ if (bodyFlags !== 1 || bodyFiles.length !== 1 || !safeNotePath(bodyFiles[0])) return false;
18994
+ const note = plainPath(bodyFiles[0]);
18995
+ const isNote = (file) => !UNREAD_PATH.test(file) && plainPath(file) === note;
18996
+ return bodyTargets.every(isNote) && writes.every((file) => plainPath(file) === "/dev/null" || isNote(file));
18997
+ }
18998
+ function runsNothing(segment, call, bodyTargets) {
18999
+ const viaEnv = segment.some((token) => commandBaseName(token.value) === "env");
19000
+ const assigns = segment.some(
19001
+ (token) => (!token.quoted || viaEnv) && ENV_ASSIGNMENT.test(token.value)
19002
+ );
19003
+ if (assigns) return false;
18511
19004
  const bare = withoutRedirections(segment);
18512
19005
  if (bare.length === 0) return true;
18513
19006
  const words = commandWords(bare);
@@ -18515,19 +19008,20 @@ function runsNothing(segment, callTargets) {
18515
19008
  const name = trustedName(words[0]);
18516
19009
  if (name === void 0) return false;
18517
19010
  if (name === "git") {
18518
- return INERT_GIT_SUBCOMMANDS.has(words[1] ?? "") && !callTargets.some((target) => GIT_CONTROL_FILE.test(target));
19011
+ return INERT_GIT_SUBCOMMANDS.has(words[1] ?? "") && !call.targets.some((target) => GIT_CONTROL_FILE.test(folded(target)));
18519
19012
  }
19013
+ if (name === "gh") return ghSendsOnlyAsBody(segment, words, call, bodyTargets);
18520
19014
  return INERT_COMMANDS.has(name);
18521
19015
  }
18522
19016
  function writtenBodiesThatRun(commands, written, sawGrouping) {
18523
19017
  const writers = written.flatMap((body) => body.writers);
18524
- const allTargets = written.flatMap((body) => body.targets);
19018
+ const call = callWrites(commands, written);
18525
19019
  return written.filter(({ targets }) => {
18526
19020
  const names4 = targets.map((target) => commandBaseName(target)).filter(Boolean);
18527
19021
  const mentionsFile = (value) => names4.some((name) => value.toLowerCase().includes(name));
18528
19022
  return commands.some(({ segment }) => {
18529
19023
  if (writers.includes(segment)) return false;
18530
- if (!runsNothing(segment, allTargets)) return true;
19024
+ if (!runsNothing(segment, call, targets)) return true;
18531
19025
  const words = commandWords(withoutRedirections(segment));
18532
19026
  if (words.length > 0 && names4.includes(commandBaseName(words[0]))) return true;
18533
19027
  const copies = segment.some(
@@ -18913,7 +19407,7 @@ async function guardRepository(root) {
18913
19407
  if (result.triggered && !result.satisfied) {
18914
19408
  const rel = relativeDecisionPath(await resolveDecisionsDir(root), root);
18915
19409
  emitPreToolUseDeny(
18916
- result.securityTriggered ? `I didn't run that: this change touches a security path, so an ADR is required before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under ${rel}/, then retry.` : `I didn't run that \u2014 this change needs an ADR before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under ${rel}/, then retry, or put [adr-skip] on its own line in a commit body for non-security changes.`
19410
+ result.securityTriggered ? `I didn't run that: this change touches a security path, so an ADR is required before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under ${rel}/, then retry.` : `I didn't run that: this change needs an ADR before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under ${rel}/, then retry, or put [adr-skip] on its own line in a commit body for non-security changes.`
18917
19411
  );
18918
19412
  return true;
18919
19413
  }
@@ -18960,11 +19454,11 @@ _(One clear sentence: what we chose.)_
18960
19454
 
18961
19455
  ## Motivation (why)
18962
19456
 
18963
- _(Why this over alternatives \u2014 the line agents read for "why".)_
19457
+ _(Why this over alternatives: the line agents read for "why".)_
18964
19458
 
18965
19459
  ## Alternatives considered
18966
19460
 
18967
- - _(Alternative)_ \u2014 rejected because _(reason)_.
19461
+ - _(Alternative)_: rejected because _(reason)_.
18968
19462
 
18969
19463
  ## Consequences
18970
19464
 
@@ -20358,6 +20852,7 @@ async function loadDeployConfig(context, options) {
20358
20852
  const detected = await detectDeployConfig(context.root, {
20359
20853
  // `HAUS_DEPLOY_DETECT_OFFLINE=1` keeps detection off the network (the test runner sets it).
20360
20854
  gh: process.env.HAUS_DEPLOY_DETECT_OFFLINE === "1" || options.detectOnline === false ? void 0 : options.gh,
20855
+ git: options.git,
20361
20856
  slug: context.slug,
20362
20857
  defaultBranch: await defaultName(),
20363
20858
  cache: options.cacheDetection ?? true,
@@ -20367,13 +20862,15 @@ async function loadDeployConfig(context, options) {
20367
20862
  return {
20368
20863
  config: strictestOf(detected.config, local.config),
20369
20864
  source: `${local.where} (no copy on ${remoteRef} yet, so read with detection)`,
20370
- errors: [...errors, ...detected.errors]
20865
+ errors: [...errors, ...detected.errors],
20866
+ detected: true
20371
20867
  };
20372
20868
  }
20373
20869
  return {
20374
20870
  config: detected.config,
20375
20871
  source: "detection (no deploy.json yet)",
20376
- errors: [...errors, ...detected.errors]
20872
+ errors: [...errors, ...detected.errors],
20873
+ detected: true
20377
20874
  };
20378
20875
  }
20379
20876
  async function loadRemoteDeployConfig(slug, branch, options) {
@@ -20404,6 +20901,7 @@ async function loadRemoteDeployConfig(slug, branch, options) {
20404
20901
  config: detected.config,
20405
20902
  source: `detection for ${slug} (no deploy.json)`,
20406
20903
  errors: [...errors, ...detected.errors],
20904
+ detected: true,
20407
20905
  ...unreadable2 ? { unreadable: unreadable2 } : {}
20408
20906
  };
20409
20907
  }
@@ -20433,9 +20931,15 @@ var Freezes = class {
20433
20931
  }
20434
20932
  }
20435
20933
  };
20934
+ function detectionHint(change) {
20935
+ if (!change.branch || !needsHumanClass(change.deploys) || !change.detected) return "";
20936
+ const branch = change.branch;
20937
+ return ` If ${branch} deploys a test or staging environment, ask the human: only they may set branches.${branch}.deploys and confirmedBy in ${DEPLOY_CONFIG_REL} (haus apply --write creates it), and the gate reads it once it is on origin/${branch}.`;
20938
+ }
20436
20939
  async function verdictFor2(change, freezes) {
20437
20940
  const where = change.surface ? ` (${change.surface})` : "";
20438
20941
  const summary = `${change.what} deploys ${change.deploys}${where}: ${change.why}; read from ${change.source}`;
20942
+ const hint = detectionHint(change);
20439
20943
  const known = await freezes.get();
20440
20944
  if (known instanceof Error) {
20441
20945
  return deny(
@@ -20451,19 +20955,19 @@ async function verdictFor2(change, freezes) {
20451
20955
  }
20452
20956
  if (needsHumanClass(change.deploys) && change.auto) {
20453
20957
  return deny(
20454
- `haus: ${summary}. Auto-merge would land it later with no one confirming the deploy. Wait for the checks yourself, then merge without auto so the human confirms.`
20958
+ `haus: ${summary}. Auto-merge would land it later with no one confirming the deploy. Wait for the checks yourself, then merge without auto so the human confirms.${hint}`
20455
20959
  );
20456
20960
  }
20457
20961
  if (!change.hasRecord) {
20458
20962
  return deny(
20459
- `haus: ${summary}. No local-verification record. ${HOW_TO_RECORD} ${change.recordHint}`
20963
+ `haus: ${summary}. No local-verification record. ${HOW_TO_RECORD} ${change.recordHint}${hint}`
20460
20964
  );
20461
20965
  }
20462
20966
  if (needsHumanClass(change.deploys)) {
20463
20967
  return {
20464
20968
  decision: "ask",
20465
20969
  gated: true,
20466
- reason: `haus: ${summary}. A local run is recorded. This is a ${change.deploys} deploy: confirm only if you want it live now.`
20970
+ reason: `haus: ${summary}. A local run is recorded. This is a ${change.deploys} deploy: confirm only if you want it live now.${hint}`
20467
20971
  };
20468
20972
  }
20469
20973
  return { gated: true };
@@ -20621,7 +21125,8 @@ async function evaluateMerge(action, hookCwd, deps, freezes) {
20621
21125
  auto: action.auto,
20622
21126
  hasRecord,
20623
21127
  recordHint: prRecordHint(`#${pr2.number}`, pr2.baseRefName, classification.entry?.localCheck),
20624
- source: loaded.source
21128
+ source: loaded.source,
21129
+ detected: loaded.detected === true
20625
21130
  },
20626
21131
  freezes
20627
21132
  );
@@ -20773,6 +21278,7 @@ async function evaluatePush(action, deps, freezes) {
20773
21278
  let deploys = "none";
20774
21279
  let classification;
20775
21280
  let source = overview.source;
21281
+ let detected = overview.detected === true;
20776
21282
  if (target.kind === "branch") {
20777
21283
  const loaded = await loadDeployConfig(context, {
20778
21284
  git: deps.git,
@@ -20782,6 +21288,7 @@ async function evaluatePush(action, deps, freezes) {
20782
21288
  detectOnline
20783
21289
  });
20784
21290
  source = loaded.source;
21291
+ detected = loaded.detected === true;
20785
21292
  const files = await changedFilesSince(deps.git, context.root, `${remote}/${target.name}`, sha2);
20786
21293
  classification = classifyBranchChange(loaded.config, target.name, files);
20787
21294
  deploys = classification.deploys;
@@ -20808,7 +21315,8 @@ async function evaluatePush(action, deps, freezes) {
20808
21315
  auto: false,
20809
21316
  hasRecord: await hasReceipt([repo], sha2, localCheck, deps.verifyDir),
20810
21317
  recordHint: shaRecordHint(checkBranch, localCheck),
20811
- source
21318
+ source,
21319
+ detected
20812
21320
  },
20813
21321
  freezes
20814
21322
  )
@@ -20868,7 +21376,8 @@ async function evaluateRelease(action, deps, freezes) {
20868
21376
  auto: false,
20869
21377
  hasRecord: await hasReceipt([slug], sha2, localCheck, deps.verifyDir),
20870
21378
  recordHint: context ? shaRecordHint(branch, localCheck) : "from a checkout of that repository, by running haus verify local on the commit the tag lands on.",
20871
- source: loaded.source
21379
+ source: loaded.source,
21380
+ detected: loaded.detected === true
20872
21381
  },
20873
21382
  freezes
20874
21383
  );
@@ -20907,7 +21416,8 @@ async function evaluateDirectDeploy(what, cwd, ref, deploysOverride, deps, freez
20907
21416
  auto: false,
20908
21417
  hasRecord: await hasReceipt([repo], sha2, row?.localCheck, deps.verifyDir),
20909
21418
  recordHint: shaRecordHint(branch, row?.localCheck),
20910
- source: loaded.source
21419
+ source: loaded.source,
21420
+ detected: loaded.detected === true
20911
21421
  },
20912
21422
  freezes
20913
21423
  );
@@ -21287,12 +21797,11 @@ function resolveOnDisk(candidate) {
21287
21797
  }
21288
21798
  function guardFileAccess(candidate) {
21289
21799
  if (matchesDenyTier(candidate))
21290
- return `I didn't open ${candidate} \u2014 it looks like it holds secrets or sensitive data`;
21800
+ return `I didn't open ${candidate}: it looks like it holds secrets or sensitive data`;
21291
21801
  const { resolved, unverifiable } = resolveOnDisk(candidate);
21292
- if (unverifiable)
21293
- return `I didn't open ${candidate} \u2014 it is a link whose target I couldn't verify`;
21802
+ if (unverifiable) return `I didn't open ${candidate}: it is a link whose target I couldn't verify`;
21294
21803
  if (resolved && matchesDenyTier(resolved))
21295
- return `I didn't open ${candidate} \u2014 it leads to a file that looks like it holds secrets or sensitive data`;
21804
+ return `I didn't open ${candidate}: it leads to a file that looks like it holds secrets or sensitive data`;
21296
21805
  return void 0;
21297
21806
  }
21298
21807
 
@@ -22212,7 +22721,7 @@ function guardBash(command) {
22212
22721
  if (shape === UNGATED_MERGE_CHAIN) {
22213
22722
  return `I didn't run that: the merge would run even if the CI wait before it fails, because they are joined by a semicolon, a newline, || or a pipe. Join the wait and the merge with && and no pipe, so a red check stops the merge: ${command.replace(/`/g, "'")}`;
22214
22723
  }
22215
- return `I didn't run that \u2014 it looks like ${shape}, which can permanently change or delete things: ${command}`;
22724
+ return `I didn't run that: it looks like ${shape}, which can permanently change or delete things: ${command}`;
22216
22725
  }
22217
22726
  function allowBash(command, cwd, options = {}) {
22218
22727
  if (matchDangerousCommandShape(command)) return void 0;
@@ -23278,7 +23787,7 @@ async function runDiscover(workspaceRoot, opts = {}) {
23278
23787
  }
23279
23788
 
23280
23789
  // src/scanner/sibling-repos.ts
23281
- var SIBLING_REPO_WARNING = 'Multiple sibling repos detected under this root \u2014 consider "haus workspace discover" instead of running init/scan per repo.';
23790
+ var SIBLING_REPO_WARNING = 'Multiple sibling repos detected under this root. Consider "haus workspace discover" instead of running init/scan per repo.';
23282
23791
  async function hasMultipleSiblingRepos(root) {
23283
23792
  try {
23284
23793
  const repoRoots = await findRepoRoots(root);
@@ -23398,7 +23907,7 @@ async function runSetupCore(root, opts) {
23398
23907
  const supersededCount = recommendation.skipped.filter(
23399
23908
  (s) => s.skipReasons?.some((r) => r.code === "superseded-by-global-plugin")
23400
23909
  ).length;
23401
- const message2 = supersededCount > 0 ? `No catalog items matched this project after ${supersededCount} item(s) were gated as already provided by an installed global plugin \u2014 refusing to write recommendation.json/haus.lock.json. Those skills are available globally; re-run with \`haus init --force\` to write anyway.` : "No catalog items matched this project at all (zero signal) \u2014 refusing to write recommendation.json/haus.lock.json. Re-run with `haus init --force` to write anyway.";
23910
+ const message2 = supersededCount > 0 ? `No catalog items matched this project after ${supersededCount} item(s) were gated as already provided by an installed global plugin, refusing to write recommendation.json/haus.lock.json. Those skills are available globally; re-run with \`haus init --force\` to write anyway.` : "No catalog items matched this project at all (zero signal), refusing to write recommendation.json/haus.lock.json. Re-run with `haus init --force` to write anyway.";
23402
23911
  warn(message2);
23403
23912
  process.exitCode = 1;
23404
23913
  const warnings = [...warningLines2, message2];
@@ -23419,7 +23928,7 @@ async function runSetupCore(root, opts) {
23419
23928
  warnings,
23420
23929
  hooksOk: false,
23421
23930
  written: [],
23422
- // Pre-existing zero-signal guard, not the #325 catalog-install honesty check —
23931
+ // Pre-existing zero-signal guard, not the #325 catalog-install honesty check,
23423
23932
  // `catalogInstallOk` stays true; `setupOk` is false so workspace setup cannot
23424
23933
  // record this repo as ok (issue #330).
23425
23934
  catalogInstallOk: true,
@@ -23526,7 +24035,7 @@ async function runSetupCore(root, opts) {
23526
24035
  const message2 = "No catalog content found. Run `haus update` first.";
23527
24036
  if (!opts.allowEmptyCache) {
23528
24037
  if (isPreview) {
23529
- warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message2}`);
24038
+ warn(`Catalog cache is empty: dry-run will skip catalog items. ${message2}`);
23530
24039
  } else {
23531
24040
  error(message2);
23532
24041
  process.exitCode = 1;
@@ -23582,7 +24091,7 @@ async function runSetupCore(root, opts) {
23582
24091
  say(`${installedWantedCount} of ${countedIds.length} catalog items installed (${tier} tier).`);
23583
24092
  if (installedWantedCount === 0) {
23584
24093
  error(
23585
- "No catalog items were installed \u2014 only core files were written. Run `haus update` with network access, or import a catalog bundle."
24094
+ "No catalog items were installed, only core files were written. Run `haus update` with network access, or import a catalog bundle."
23586
24095
  );
23587
24096
  process.exitCode = 1;
23588
24097
  catalogInstallOk = false;
@@ -23648,7 +24157,7 @@ var COMPANION_TOOLS = [
23648
24157
  {
23649
24158
  bin: "caveman",
23650
24159
  label: "Caveman",
23651
- blurb: "Ultra-compressed Claude Code responses \u2014 cuts output tokens ~75%.",
24160
+ blurb: "Ultra-compressed Claude Code responses; cuts output tokens ~75%.",
23652
24161
  installCmds: [
23653
24162
  "curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash"
23654
24163
  ],
@@ -23657,13 +24166,13 @@ var COMPANION_TOOLS = [
23657
24166
  {
23658
24167
  bin: "rtk",
23659
24168
  label: "RTK (Rust Token Killer)",
23660
- blurb: "Token-optimized CLI proxy \u2014 60-90% savings on dev operations.",
24169
+ blurb: "Token-optimized CLI proxy, 60-90% savings on dev operations.",
23661
24170
  installCmds: [
23662
24171
  "brew install rtk",
23663
24172
  "cargo install --git https://github.com/rtk-ai/rtk # if Homebrew is unavailable"
23664
24173
  ],
23665
24174
  url: "https://github.com/rtk-ai/rtk",
23666
- note: 'Another crate named "rtk" (Rust Type Kit) exists on crates.io \u2014 use the commands above, not a bare `cargo install rtk`.'
24175
+ note: 'Another crate named "rtk" (Rust Type Kit) exists on crates.io. Use the commands above, not a bare `cargo install rtk`.'
23667
24176
  }
23668
24177
  ];
23669
24178
  function buildCompanionSuggestions(tools, isInstalled) {
@@ -23671,7 +24180,7 @@ function buildCompanionSuggestions(tools, isInstalled) {
23671
24180
  if (missing.length === 0) return [];
23672
24181
  const lines = ["", "Optional token-saving tools you can add:"];
23673
24182
  for (const tool of missing) {
23674
- lines.push("", ` ${tool.label} \u2014 ${tool.blurb}`);
24183
+ lines.push("", ` ${tool.label}: ${tool.blurb}`);
23675
24184
  for (const cmd of tool.installCmds) {
23676
24185
  lines.push(` ${cmd}`);
23677
24186
  }
@@ -23751,7 +24260,7 @@ async function runInstall(options, deps = {}) {
23751
24260
  if (result.created.length) parts.push(`${result.created.length} file(s) added`);
23752
24261
  if (result.updated.length) parts.push(`${result.updated.length} file(s) updated`);
23753
24262
  log(
23754
- parts.length ? ` \u2022 ${parts.join(", ")} in ${claudeConfigDirLabel()} (skills, agents, slash commands)` : " \u2022 already up to date \u2014 no files changed"
24263
+ parts.length ? ` \u2022 ${parts.join(", ")} in ${claudeConfigDirLabel()} (skills, agents, slash commands)` : " \u2022 already up to date, no files changed"
23755
24264
  );
23756
24265
  log(
23757
24266
  ` \u2022 ensured hooks + security rules are present in ${claudeConfigDirLabel()}/settings.json`
@@ -23797,20 +24306,20 @@ async function safeExec(exec, command, args, options) {
23797
24306
  function inconclusive(result, what) {
23798
24307
  if (!result.killed) return void 0;
23799
24308
  const reason = result.stderr.trim() || "the probe did not complete";
23800
- return `Could not confirm ${what}: ${reason}. This is usually transient load \u2014 try again, or use --no-github.`;
24309
+ return `Could not confirm ${what}: ${reason}. This is usually transient load. Try again, or use --no-github.`;
23801
24310
  }
23802
24311
  async function checkGh(exec = runCommand) {
23803
24312
  const version = await safeExec(exec, "gh", ["--version"], { timeout: PROBE_TIMEOUT_MS2 });
23804
24313
  const versionUnknown = inconclusive(version, "the gh CLI is installed");
23805
24314
  if (versionUnknown) return { ok: false, reason: versionUnknown };
23806
24315
  if (version.exitCode !== 0) {
23807
- return { ok: false, reason: "gh CLI not found \u2014 install it or re-run with --no-github" };
24316
+ return { ok: false, reason: "gh CLI not found. Install it or re-run with --no-github" };
23808
24317
  }
23809
24318
  const auth = await safeExec(exec, "gh", ["auth", "status"], { timeout: PROBE_TIMEOUT_MS2 });
23810
24319
  const authUnknown = inconclusive(auth, "gh is authenticated");
23811
24320
  if (authUnknown) return { ok: false, reason: authUnknown };
23812
24321
  if (auth.exitCode !== 0) {
23813
- return { ok: false, reason: "gh is not authenticated \u2014 run `gh auth login` or use --no-github" };
24322
+ return { ok: false, reason: "gh is not authenticated. Run `gh auth login` or use --no-github" };
23814
24323
  }
23815
24324
  return { ok: true };
23816
24325
  }
@@ -23819,7 +24328,7 @@ async function checkOrg(org, exec = runCommand) {
23819
24328
  if (!GITHUB_LOGIN.test(org)) {
23820
24329
  return {
23821
24330
  ok: false,
23822
- reason: `"${org}" is not a GitHub login \u2014 expected letters, digits and hyphens (e.g. acme or octocat).`
24331
+ reason: `"${org}" is not a GitHub login: expected letters, digits and hyphens (e.g. acme or octocat).`
23823
24332
  };
23824
24333
  }
23825
24334
  const res = await safeExec(exec, "gh", ["api", `users/${org}`], { timeout: PROBE_TIMEOUT_MS2 });
@@ -23843,13 +24352,13 @@ async function createRepo(opts, exec = runCommand) {
23843
24352
  if (result.exitCode !== 0) {
23844
24353
  const stderr = result.stderr.trim();
23845
24354
  const repoAlreadyExists = /already exists/i.test(stderr);
23846
- const resumeHint = result.killed ? `Repo creation timed out (${stderr || "no output before the timeout"}). It may or may not have been created \u2014 check first, then finish:
24355
+ const resumeHint = result.killed ? `Repo creation timed out (${stderr || "no output before the timeout"}). It may or may not have been created. Check first, then finish:
23847
24356
  gh repo view ${slug}
23848
24357
  # if it exists: git remote get-url origin || git remote add origin <url>
23849
24358
  # git push -u origin main
23850
24359
  # if it does not: gh ${args.join(" ")}` : repoAlreadyExists ? `Repo creation failed (${stderr || "unknown error"}). GitHub reports "${slug}" already exists. If this run created it, the push is all that is left; if it existed before, check what is in it before pushing over it:
23851
24360
  gh repo view ${slug}
23852
- git push -u origin main` : `Repo creation failed (${stderr || "unknown error"}). Your project is intact \u2014 resume with:
24361
+ git push -u origin main` : `Repo creation failed (${stderr || "unknown error"}). Your project is intact. Resume with:
23853
24362
  gh ${args.join(" ")}
23854
24363
  git push -u origin main`;
23855
24364
  return { ok: false, resumeHint };
@@ -23957,7 +24466,7 @@ var defaultDeps = {
23957
24466
  loadCatalog: loadCatalogContext,
23958
24467
  overlay: scaffoldConfigItems,
23959
24468
  setup: runSetupCore,
23960
- // persist:false (scan-project.ts #101): the role check is a probe — the chained
24469
+ // persist:false (scan-project.ts #101): the role check is a probe, the chained
23961
24470
  // init already wrote the scan artifacts; re-persisting here would clobber them.
23962
24471
  scan: (root) => scanProject(root, { persist: false })
23963
24472
  };
@@ -23978,7 +24487,7 @@ async function recordPackageManager(projectPath, pm, deps) {
23978
24487
  const field = `${pm}@${version}`;
23979
24488
  if (detectPackageManager(projectPath, field) !== pm) {
23980
24489
  return {
23981
- note: `installed ${pm} is ${version}, which haus does not support \u2014 the package manager was not recorded, so the scanner will report it as unknown until you upgrade`
24490
+ note: `installed ${pm} is ${version}, which haus does not support: the package manager was not recorded, so the scanner will report it as unknown until you upgrade`
23982
24491
  };
23983
24492
  }
23984
24493
  await fs55.writeJson(pkgPath, { ...pkg, packageManager: field }, { spaces: 2 });
@@ -24041,7 +24550,7 @@ async function createProject(opts, deps = defaultDeps) {
24041
24550
  if (!parentStat?.isDirectory()) {
24042
24551
  return fail3(
24043
24552
  "preflight",
24044
- `Parent directory ${opts.parentDir} is not an existing directory \u2014 pass an existing one to --dir.`
24553
+ `Parent directory ${opts.parentDir} is not an existing directory. Pass an existing one to --dir.`
24045
24554
  );
24046
24555
  }
24047
24556
  if (!NAME_RE2.test(opts.name) || opts.name.includes("/") || opts.name.includes("\\") || path88.dirname(projectPath) !== path88.resolve(opts.parentDir)) {
@@ -24051,7 +24560,7 @@ async function createProject(opts, deps = defaultDeps) {
24051
24560
  );
24052
24561
  }
24053
24562
  if (await fs55.pathExists(projectPath)) {
24054
- return fail3("preflight", `Target ${projectPath} already exists \u2014 refusing to write into it.`);
24563
+ return fail3("preflight", `Target ${projectPath} already exists, refusing to write into it.`);
24055
24564
  }
24056
24565
  const scaffoldArgs = stack.scaffold.args.map(
24057
24566
  (a) => a.replaceAll("{name}", opts.name).replaceAll("{pm}", opts.pm)
@@ -24103,7 +24612,7 @@ async function createProject(opts, deps = defaultDeps) {
24103
24612
  if (inconclusiveTools.length > 0) {
24104
24613
  return fail3(
24105
24614
  "preflight",
24106
- `Could not confirm tool availability on PATH: ${inconclusiveTools.join("; ")} \u2014 the probe did not complete (likely transient load). Try again.`
24615
+ `Could not confirm tool availability on PATH: ${inconclusiveTools.join("; ")}: the probe did not complete (likely transient load). Try again.`
24107
24616
  );
24108
24617
  }
24109
24618
  if (opts.github) {
@@ -24125,8 +24634,8 @@ async function createProject(opts, deps = defaultDeps) {
24125
24634
  timeout: SCAFFOLD_TIMEOUT_MS
24126
24635
  });
24127
24636
  if (scaffold.exitCode !== 0) {
24128
- const why = scaffold.killed ? `${pinned} did not complete: ${scaffold.stderr.trim() || `timed out after ${SCAFFOLD_TIMEOUT_MS}ms`}. Scaffolders that stall this way are usually waiting on a prompt despite --yes \u2014 run it yourself to see: npx ${npxArgs.join(" ")}` : `${pinned} exited ${scaffold.exitCode}.`;
24129
- const leftBehind = await fs55.pathExists(projectPath) ? ` The directory was left for inspection \u2014 review it and remove it manually if unwanted (haus never auto-deletes).` : "";
24637
+ const why = scaffold.killed ? `${pinned} did not complete: ${scaffold.stderr.trim() || `timed out after ${SCAFFOLD_TIMEOUT_MS}ms`}. Scaffolders that stall this way are usually waiting on a prompt despite --yes. Run it yourself to see: npx ${npxArgs.join(" ")}` : `${pinned} exited ${scaffold.exitCode}.`;
24638
+ const leftBehind = await fs55.pathExists(projectPath) ? ` The directory was left for inspection. Review it and remove it manually if unwanted (haus never auto-deletes).` : "";
24130
24639
  return fail3("scaffold", `${why}${leftBehind}`);
24131
24640
  }
24132
24641
  steps.push({ step: "scaffold", status: "done" });
@@ -24189,7 +24698,7 @@ async function createProject(opts, deps = defaultDeps) {
24189
24698
  steps.push({
24190
24699
  step: "init",
24191
24700
  status: "failed",
24192
- detail: `haus init threw (${err instanceof Error ? err.message : String(err)}). Project and repo are intact \u2014 cd ${opts.name} && haus init${profileFlag} to retry.`
24701
+ detail: `haus init threw (${err instanceof Error ? err.message : String(err)}). Project and repo are intact. Run cd ${opts.name} && haus init${profileFlag} to retry.`
24193
24702
  });
24194
24703
  return { ok: false, projectPath, repoUrl, installHint, steps };
24195
24704
  }
@@ -24218,7 +24727,7 @@ async function createProject(opts, deps = defaultDeps) {
24218
24727
  steps.push({
24219
24728
  step: "init",
24220
24729
  status: "skipped",
24221
- detail: `setup was declined \u2014 no Claude files were written. Finish it with: ${retryCmd}`
24730
+ detail: `setup was declined, no Claude files were written. Finish it with: ${retryCmd}`
24222
24731
  });
24223
24732
  return { ok: false, projectPath, repoUrl, installHint, steps, ...applied };
24224
24733
  }
@@ -24233,7 +24742,7 @@ async function createProject(opts, deps = defaultDeps) {
24233
24742
  steps.push({
24234
24743
  step: "init",
24235
24744
  status: "failed",
24236
- detail: `haus init reported failure. Project and repo are intact \u2014 ${retryCmd} to retry.`
24745
+ detail: `haus init reported failure. Project and repo are intact. Run ${retryCmd} to retry.`
24237
24746
  });
24238
24747
  return { ok: false, projectPath, repoUrl, installHint, steps, ...applied };
24239
24748
  }
@@ -24290,12 +24799,12 @@ async function createProject(opts, deps = defaultDeps) {
24290
24799
  const add2 = await deps.git(["add", "-A"], gitOptions);
24291
24800
  if (add2.exitCode !== 0) {
24292
24801
  landedFully = false;
24293
- detail += `; follow-up git add failed (${add2.stderr.trim()}) \u2014 ${resumeHint}`;
24802
+ detail += `; follow-up git add failed (${add2.stderr.trim()}): ${resumeHint}`;
24294
24803
  } else {
24295
24804
  const st = await deps.git(["status", "--porcelain"], gitOptions);
24296
24805
  if (st.exitCode !== 0) {
24297
24806
  landedFully = false;
24298
- detail += `; follow-up git status failed (${st.stderr.trim()}) \u2014 ${resumeHint}`;
24807
+ detail += `; follow-up git status failed (${st.stderr.trim()}): ${resumeHint}`;
24299
24808
  } else if (st.stdout.trim() !== "") {
24300
24809
  const commit = await deps.git(
24301
24810
  ["commit", "-m", "chore: add haus config files"],
@@ -24303,12 +24812,12 @@ async function createProject(opts, deps = defaultDeps) {
24303
24812
  );
24304
24813
  if (commit.exitCode !== 0) {
24305
24814
  landedFully = false;
24306
- detail += `; follow-up commit failed (${commit.stderr.trim()}) \u2014 commit manually`;
24815
+ detail += `; follow-up commit failed (${commit.stderr.trim()}), commit manually`;
24307
24816
  } else if (opts.github) {
24308
24817
  const push = await deps.git(["push", "-u", "origin", "main"], gitOptions);
24309
24818
  if (push.exitCode !== 0) {
24310
24819
  landedFully = false;
24311
- detail += "; push failed \u2014 run `git push -u origin main`";
24820
+ detail += "; push failed, run `git push -u origin main`";
24312
24821
  }
24313
24822
  }
24314
24823
  }
@@ -24316,7 +24825,7 @@ async function createProject(opts, deps = defaultDeps) {
24316
24825
  } catch (err) {
24317
24826
  landedFully = false;
24318
24827
  const reason = err instanceof Error ? err.message : String(err);
24319
- detail += `; follow-up commit/push failed (${reason}) \u2014 files are on disk but uncommitted, resume with: cd ${opts.name} && git add -A && git commit -m "chore: add haus config files"` + (opts.github ? " && git push -u origin main" : "");
24828
+ detail += `; follow-up commit/push failed (${reason}): files are on disk but uncommitted, resume with: cd ${opts.name} && git add -A && git commit -m "chore: add haus config files"` + (opts.github ? " && git push -u origin main" : "");
24320
24829
  }
24321
24830
  if (!landedFully) overlayIncomplete = true;
24322
24831
  steps.push({ step: "overlay", status: landedFully ? "done" : "partial", detail });
@@ -24329,7 +24838,7 @@ async function createProject(opts, deps = defaultDeps) {
24329
24838
  steps.push({ step: "role-check", status: "done" });
24330
24839
  } else {
24331
24840
  warn(
24332
- `Scanner did not detect role "${stack.expectedRole}" in the new project \u2014 the upstream scaffolder output may have changed. Review the project layout.`
24841
+ `Scanner did not detect role "${stack.expectedRole}" in the new project. The upstream scaffolder output may have changed. Review the project layout.`
24333
24842
  );
24334
24843
  steps.push({
24335
24844
  step: "role-check",
@@ -24346,7 +24855,7 @@ async function createProject(opts, deps = defaultDeps) {
24346
24855
  steps.push({
24347
24856
  step: phase,
24348
24857
  status: "failed",
24349
- detail: `Unexpected error during ${phase}: ${err instanceof Error ? err.message : String(err)}. Nothing was auto-deleted \u2014 inspect ${projectPath} and resume manually.`
24858
+ detail: `Unexpected error during ${phase}: ${err instanceof Error ? err.message : String(err)}. Nothing was auto-deleted. Inspect ${projectPath} and resume manually.`
24350
24859
  });
24351
24860
  return { ok: false, projectPath, repoUrl, installHint, steps, ...applied };
24352
24861
  }
@@ -24379,10 +24888,10 @@ function resolveNewOptions(stackArg, nameArg, flags, cwd) {
24379
24888
  const parentDir = flags.dir === void 0 ? cwd : path89.resolve(cwd, flags.dir);
24380
24889
  const pm = flags.pm ?? "yarn";
24381
24890
  if (!PMS.includes(pm)) {
24382
- return { ok: false, error: `Unsupported --pm "${pm}" \u2014 use yarn, npm, or pnpm.` };
24891
+ return { ok: false, error: `Unsupported --pm "${pm}". Use yarn, npm, or pnpm.` };
24383
24892
  }
24384
24893
  if (flags.profile !== void 0 && !isProfileId(flags.profile)) {
24385
- return { ok: false, error: `Unsupported --profile "${flags.profile}" \u2014 use production or poc.` };
24894
+ return { ok: false, error: `Unsupported --profile "${flags.profile}". Use production or poc.` };
24386
24895
  }
24387
24896
  if (stackArg === void 0 || nameArg === void 0) {
24388
24897
  return { ok: false, error: "missing-args" };
@@ -24409,7 +24918,7 @@ async function runNew(stackArg, nameArg, flags) {
24409
24918
  if (flags.list) {
24410
24919
  const stacks = STACKS.map(({ id, title }) => ({ id, title }));
24411
24920
  log(
24412
- flags.json ? JSON.stringify({ stacks }, null, 2) : stacks.map((s) => `${s.id} \u2014 ${s.title}`).join("\n")
24921
+ flags.json ? JSON.stringify({ stacks }, null, 2) : stacks.map((s) => `${s.id}: ${s.title}`).join("\n")
24413
24922
  );
24414
24923
  return;
24415
24924
  }
@@ -24419,7 +24928,7 @@ async function runNew(stackArg, nameArg, flags) {
24419
24928
  try {
24420
24929
  stack ??= await select(
24421
24930
  "Which stack?",
24422
- STACKS.map((s) => ({ value: s.id, label: `${s.id} \u2014 ${s.title}` }))
24931
+ STACKS.map((s) => ({ value: s.id, label: `${s.id}: ${s.title}` }))
24423
24932
  );
24424
24933
  name ??= await input("Project name?");
24425
24934
  } catch (err) {
@@ -24430,7 +24939,7 @@ async function runNew(stackArg, nameArg, flags) {
24430
24939
  }
24431
24940
  const resolved = resolveNewOptions(stack, name, flags, process.cwd());
24432
24941
  if (!resolved.ok) {
24433
- const message2 = resolved.error === "missing-args" ? process.stdin.isTTY ? "Missing stack/name \u2014 refusing to prompt under --json; run `haus new <stack> <name>` (see --list)." : "Missing stack/name and no TTY to prompt \u2014 run `haus new <stack> <name>` (see --list)." : resolved.error;
24942
+ const message2 = resolved.error === "missing-args" ? process.stdin.isTTY ? "Missing stack/name, refusing to prompt under --json; run `haus new <stack> <name>` (see --list)." : "Missing stack/name and no TTY to prompt. Run `haus new <stack> <name>` (see --list)." : resolved.error;
24434
24943
  error(message2);
24435
24944
  process.exitCode = 1;
24436
24945
  return;
@@ -24441,7 +24950,7 @@ async function runNew(stackArg, nameArg, flags) {
24441
24950
  } else {
24442
24951
  for (const s of result.steps) {
24443
24952
  log(
24444
- `${s.status === "done" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.step}${s.detail ? ` \u2014 ${s.detail}` : ""}`
24953
+ `${s.status === "done" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.step}${s.detail ? `: ${s.detail}` : ""}`
24445
24954
  );
24446
24955
  }
24447
24956
  }
@@ -24880,6 +25389,8 @@ async function runRecommend(options) {
24880
25389
 
24881
25390
  // src/commands/remove.ts
24882
25391
  import path90 from "node:path";
25392
+ import fs56 from "fs-extra";
25393
+ init_fs();
24883
25394
  init_logger();
24884
25395
  init_paths();
24885
25396
  function blankReport(dryRun, missing = []) {
@@ -24888,6 +25399,7 @@ function blankReport(dryRun, missing = []) {
24888
25399
  forced: [],
24889
25400
  skipped: [],
24890
25401
  missing,
25402
+ excluded: [],
24891
25403
  backupDir: null,
24892
25404
  backupDirs: [],
24893
25405
  dryRun,
@@ -24910,6 +25422,44 @@ function reportLock(before, after, selfHealed, opts) {
24910
25422
  function emitReport(report2, json) {
24911
25423
  if (json) log(JSON.stringify(report2, null, 2));
24912
25424
  }
25425
+ function heldBy(item, others) {
25426
+ return item.existing.flatMap(({ rel, abs }) => {
25427
+ const other = others.find((o) => o.id !== item.id && holdsPath(abs, o.abs));
25428
+ return other === void 0 ? [] : [{ rel, holder: other.id }];
25429
+ });
25430
+ }
25431
+ function holdSharedPaths(root, lock, cleared, dryRun) {
25432
+ const goingIds = new Set(cleared.map(({ id }) => id));
25433
+ const recordedOnDisk = (row) => recordedPathsOf(row).flatMap((rel) => {
25434
+ const abs = resolveContainedPath(root, recordedRel(rel));
25435
+ if (abs === null) return [];
25436
+ try {
25437
+ fs56.lstatSync(abs);
25438
+ } catch (err) {
25439
+ const code2 = err.code;
25440
+ if (code2 === "ENOENT" || code2 === "ENOTDIR") return [];
25441
+ }
25442
+ return [{ abs, id: rowName(row) }];
25443
+ });
25444
+ const others = lock.filter((row) => !goingIds.has(rowName(row))).flatMap(recordedOnDisk);
25445
+ return holdUntilSettled(cleared, others, dryRun);
25446
+ }
25447
+ function holdUntilSettled(items, holders, dryRun) {
25448
+ const held = [];
25449
+ for (let changed = true; changed; ) {
25450
+ changed = false;
25451
+ for (const item of items) {
25452
+ if (held.includes(item)) continue;
25453
+ const holding = heldBy(item, holders);
25454
+ if (holding.length === 0) continue;
25455
+ for (const { rel, holder } of holding) warn(inUseLine(rel, item.id, holder, dryRun));
25456
+ held.push(item);
25457
+ holders.push(...item.existing.map(({ abs }) => ({ abs, id: item.id })));
25458
+ changed = true;
25459
+ }
25460
+ }
25461
+ return held;
25462
+ }
24913
25463
  async function runRemove(ids, options = {}) {
24914
25464
  const root = process.cwd();
24915
25465
  const dryRun = Boolean(options.dryRun);
@@ -24918,7 +25468,7 @@ async function runRemove(ids, options = {}) {
24918
25468
  } : log;
24919
25469
  const wanted = [...new Set(parseIdList(ids))];
24920
25470
  if (wanted.length === 0) {
24921
- error("Specify at least one catalog item id \u2014 see `haus doctor` for what is installed.");
25471
+ error("Specify at least one catalog item id. See `haus doctor` for what is installed.");
24922
25472
  process.exitCode = 1;
24923
25473
  emitReport(blankReport(dryRun), options.json);
24924
25474
  return;
@@ -24931,13 +25481,15 @@ async function runRemove(ids, options = {}) {
24931
25481
  }
24932
25482
  const lock = lockRead.rows;
24933
25483
  const byId = new Map(lock.map((entry) => [entry.id, entry]));
24934
- const missing = wanted.filter((id) => !byId.has(id));
24935
- const found = wanted.filter((id) => byId.has(id));
25484
+ const headerIds = wanted.filter((id) => Object.hasOwn(HEADER_FILE_RELS, id));
25485
+ const itemIds = wanted.filter((id) => !Object.hasOwn(HEADER_FILE_RELS, id));
25486
+ const missing = itemIds.filter((id) => !byId.has(id));
25487
+ const found = itemIds.filter((id) => byId.has(id));
24936
25488
  for (const id of missing) {
24937
25489
  warn(`Not installed (no lock entry): ${id}`);
24938
25490
  }
24939
- if (found.length === 0) {
24940
- error("No installed items matched \u2014 nothing to remove.");
25491
+ if (found.length === 0 && headerIds.length === 0) {
25492
+ error("No installed items matched, nothing to remove.");
24941
25493
  process.exitCode = 1;
24942
25494
  emitReport(blankReport(dryRun, missing), options.json);
24943
25495
  return;
@@ -24948,6 +25500,32 @@ async function runRemove(ids, options = {}) {
24948
25500
  return;
24949
25501
  }
24950
25502
  const report2 = blankReport(dryRun, missing);
25503
+ if (headerIds.length > 0) {
25504
+ if (found.length > 0 && options.json && !options.yes && !dryRun) {
25505
+ error(
25506
+ "`--json` requires `-y` (a destructive command will not prompt on a machine-readable stream)."
25507
+ );
25508
+ process.exitCode = 1;
25509
+ return;
25510
+ }
25511
+ for (const id of headerIds) say(excludedLine(HEADER_FILE_RELS[id] ?? id, id, dryRun));
25512
+ const marked = await addProjectExcludedIds(root, headerIds, { dryRun });
25513
+ if (marked !== null && "refused" in marked) {
25514
+ process.exitCode = 1;
25515
+ emitReport(report2, options.json);
25516
+ return;
25517
+ }
25518
+ if (marked !== null) {
25519
+ report2.records.push(marked);
25520
+ say(recordLine(marked, dryRun));
25521
+ }
25522
+ report2.excluded = headerIds;
25523
+ }
25524
+ if (found.length === 0) {
25525
+ if (report2.missing.length > 0) process.exitCode = 1;
25526
+ emitReport(report2, options.json);
25527
+ return;
25528
+ }
24951
25529
  const cleared = [];
24952
25530
  let unreadable2 = false;
24953
25531
  for (const id of found) {
@@ -24956,11 +25534,7 @@ async function runRemove(ids, options = {}) {
24956
25534
  const unsafeByRel = await unsafeReasonsFor(root, relPaths);
24957
25535
  if ([...unsafeByRel.values()].some((reason) => reason !== null)) {
24958
25536
  for (const [rel, reason] of unsafeByRel) {
24959
- if (reason === "escapes-root") {
24960
- warn(`Refusing to touch ${id}'s recorded path outside the project root: ${rel}`);
24961
- } else if (reason === "symlink") {
24962
- warn(`Refusing to touch ${id}'s recorded path through a symlink: ${rel}`);
24963
- }
25537
+ if (reason !== null) warn(unsafePathLine(id, rel, reason));
24964
25538
  }
24965
25539
  report2.skipped.push({ id, reason: "unsafe-path" });
24966
25540
  continue;
@@ -24968,7 +25542,7 @@ async function runRemove(ids, options = {}) {
24968
25542
  const existing = await resolveExistingTrackedPaths(root, id, relPaths, warn, unsafeByRel);
24969
25543
  if (existing.length === 0) {
24970
25544
  if (relPaths.length === 0) {
24971
- warn(`${id} has no recorded paths \u2014 leaving its lock entry in place.`);
25545
+ warn(`${id} has no recorded paths, leaving its lock entry in place.`);
24972
25546
  report2.skipped.push({ id, reason: "no-paths" });
24973
25547
  continue;
24974
25548
  }
@@ -24992,6 +25566,10 @@ async function runRemove(ids, options = {}) {
24992
25566
  }
24993
25567
  cleared.push({ id, paths: relPaths, existing, forced: gate.ok ? null : gate.reason });
24994
25568
  }
25569
+ for (const held of holdSharedPaths(root, lock, cleared, dryRun)) {
25570
+ cleared.splice(cleared.indexOf(held), 1);
25571
+ report2.skipped.push({ id: held.id, reason: "in-use" });
25572
+ }
24995
25573
  if (report2.missing.length > 0 || report2.skipped.length > 0 || unreadable2) {
24996
25574
  process.exitCode = 1;
24997
25575
  }
@@ -25034,7 +25612,7 @@ async function runRemove(ids, options = {}) {
25034
25612
  say(recordLine(exclusion2, true));
25035
25613
  }
25036
25614
  }
25037
- if (!options.json) log(`Dry-run complete \u2014 ${cleared.length} item(s) planned, nothing written.`);
25615
+ if (!options.json) log(`Dry-run complete: ${cleared.length} item(s) planned, nothing written.`);
25038
25616
  emitReport(report2, options.json);
25039
25617
  return;
25040
25618
  }
@@ -25046,15 +25624,19 @@ async function runRemove(ids, options = {}) {
25046
25624
  process.exitCode = 1;
25047
25625
  return;
25048
25626
  }
25049
- const pastGate = cleared.flatMap(
25050
- ({ id, forced }) => forced === null ? [] : [`${id} (${EDITED_REASON[forced]})`]
25627
+ const going2 = cleared.flatMap(
25628
+ ({ id, existing, forced }) => existing.map(
25629
+ ({ rel }) => forced === null ? `${rel} (${id})` : `${rel} (${id}): ${EDITED_REASON[forced]}; removed with --force`
25630
+ )
25051
25631
  );
25052
- const forceNote = pastGate.length > 0 ? ` --force includes ${pastGate.join(", ")}.` : "";
25632
+ const listed = going2.length > 0 ? `
25633
+ ${going2.join("\n ")}
25634
+ ` : " ";
25053
25635
  const ok2 = await confirm(
25054
- `Remove ${cleared.length} installed item(s) (${cleared.map((c) => c.id).join(", ")})? Files are backed up first.${forceNote}`
25636
+ `Remove ${cleared.length} installed item(s) (${cleared.map((c) => c.id).join(", ")})?${listed}Files are backed up first.`
25055
25637
  );
25056
25638
  if (!ok2) {
25057
- log("Aborted \u2014 nothing removed.");
25639
+ log("Aborted, nothing removed.");
25058
25640
  return;
25059
25641
  }
25060
25642
  }
@@ -25123,6 +25705,14 @@ async function runRemove(ids, options = {}) {
25123
25705
  }
25124
25706
  if (copyFailures.length > 0) process.exitCode = 1;
25125
25707
  const failedIds = new Set(copyFailures.map(({ item }) => item.id));
25708
+ const failedPaths = copyFailures.flatMap(
25709
+ ({ item }) => item.existing.map(({ abs }) => ({ abs, id: item.id }))
25710
+ );
25711
+ const copied = cleared.filter(({ id }) => !failedIds.has(id));
25712
+ for (const item of holdUntilSettled(copied, failedPaths, false)) {
25713
+ failedIds.add(item.id);
25714
+ report2.skipped.push({ id: item.id, reason: "in-use" });
25715
+ }
25126
25716
  const going = cleared.filter(({ id }) => !failedIds.has(id));
25127
25717
  report2.backupDirs = copyDirs;
25128
25718
  report2.backupDir = copyDirs.length === 1 ? copyDirs[0] ?? null : null;
@@ -25268,7 +25858,7 @@ async function runScan(options) {
25268
25858
 
25269
25859
  // src/commands/undo.ts
25270
25860
  import path91 from "node:path";
25271
- import fs56 from "fs-extra";
25861
+ import fs57 from "fs-extra";
25272
25862
  init_fs();
25273
25863
  init_logger();
25274
25864
  init_paths();
@@ -25276,7 +25866,8 @@ var FORCE_COMMAND = "haus undo --force";
25276
25866
  var FORCE_LINE = { force: FORCE_COMMAND, forceVerb: "removes" };
25277
25867
  var REFUSED_REASON = {
25278
25868
  symlink: "recorded path is a symlink or reaches through one",
25279
- "escapes-root": "recorded path is outside the project root"
25869
+ "escapes-root": "recorded path is outside the project root",
25870
+ "outside-install-root": "haus never installs there, so it leaves it"
25280
25871
  };
25281
25872
  var notRegularReason = (what) => what === void 0 ? "is not a regular file" : `is ${what}, not a regular file`;
25282
25873
  var notRegularLine = (rel) => `kept (not owned) ${rel} (${RULES_HAUS_ID}): it is not a regular file, so haus leaves it as it is; delete it by hand.`;
@@ -25380,7 +25971,7 @@ async function claudeMdBlock(root) {
25380
25971
  }
25381
25972
  async function stripProjectSettings(root) {
25382
25973
  const settingsPath = claudePath(root, "settings.json");
25383
- if (!await fs56.pathExists(settingsPath)) return false;
25974
+ if (!await fs57.pathExists(settingsPath)) return false;
25384
25975
  const read = await readSettingsOrRefuse(root);
25385
25976
  if (read === null) return false;
25386
25977
  const settings = stripHausHooks(
@@ -25392,7 +25983,7 @@ async function stripProjectSettings(root) {
25392
25983
  log(`Stripped haus rules from ${path91.relative(root, settingsPath)} (user settings preserved).`);
25393
25984
  return true;
25394
25985
  }
25395
- await fs56.remove(settingsPath);
25986
+ await fs57.remove(settingsPath);
25396
25987
  log(`Removed ${path91.relative(root, settingsPath)} (no user-owned settings remained).`);
25397
25988
  return true;
25398
25989
  }
@@ -25413,39 +26004,31 @@ async function stripRootClaudeMd(root) {
25413
26004
  }
25414
26005
  if (edit.text === prev) return false;
25415
26006
  if (edit.text.trim().length === 0) {
25416
- await fs56.remove(filePath);
26007
+ await fs57.remove(filePath);
25417
26008
  log("Removed CLAUDE.md (only contained haus import block).");
25418
26009
  } else {
25419
- await fs56.writeFile(filePath, edit.text, "utf8");
26010
+ await fs57.writeFile(filePath, edit.text, "utf8");
25420
26011
  log("Removed haus import block from CLAUDE.md (user content preserved).");
25421
26012
  }
25422
26013
  return true;
25423
26014
  }
25424
- async function pruneDirIfEmpty(dir) {
25425
- if (!await fs56.pathExists(dir)) return;
25426
- try {
25427
- const entries = await fs56.readdir(dir);
25428
- if (entries.length === 0) await fs56.remove(dir);
25429
- } catch {
25430
- }
25431
- }
25432
26015
  async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
25433
26016
  if (managedAbsPaths.length === 0) return null;
25434
26017
  const stamp = newBackupStamp();
25435
26018
  const backupRoot = hausPath(root, "backups", dirBackupDirName("undo", stamp));
25436
- const existedBefore = await fs56.pathExists(backupRoot);
26019
+ const existedBefore = await fs57.pathExists(backupRoot);
25437
26020
  let copied = 0;
25438
26021
  try {
25439
26022
  for (const abs of managedAbsPaths) {
25440
- if (!await fs56.pathExists(abs)) continue;
26023
+ if (!await fs57.pathExists(abs)) continue;
25441
26024
  const backupPath = path91.join(backupRoot, path91.relative(root, abs));
25442
- await fs56.ensureDir(path91.dirname(backupPath));
25443
- const dereference = (await fs56.lstat(abs)).isSymbolicLink();
25444
- await fs56.copy(abs, backupPath, { dereference });
26025
+ await fs57.ensureDir(path91.dirname(backupPath));
26026
+ const dereference = (await fs57.lstat(abs)).isSymbolicLink();
26027
+ await fs57.copy(abs, backupPath, { dereference });
25445
26028
  copied++;
25446
26029
  }
25447
26030
  } catch (err) {
25448
- if (!existedBefore) await fs56.remove(backupRoot).catch(() => void 0);
26031
+ if (!existedBefore) await fs57.remove(backupRoot).catch(() => void 0);
25449
26032
  throw err;
25450
26033
  }
25451
26034
  if (copied === 0) return null;
@@ -25482,13 +26065,7 @@ async function runUndo(options) {
25482
26065
  const keptPaths = [...keptItems, ...unreadableItems].flatMap(
25483
26066
  ({ item }) => item.existing.map((e) => path91.resolve(e.abs))
25484
26067
  );
25485
- const below = (folder) => folder.endsWith(path91.sep) ? folder : `${folder}${path91.sep}`;
25486
- const holdsKept = (abs) => {
25487
- const target = path91.resolve(abs);
25488
- return keptPaths.some(
25489
- (kept) => kept === target || kept.startsWith(below(target)) || target.startsWith(below(kept))
25490
- );
25491
- };
26068
+ const holdsKept = (abs) => keptPaths.some((kept) => holdsPath(abs, kept));
25492
26069
  const toDelete = tracked.filter(
25493
26070
  (item) => item.existing.length > 0 && item.unreadable === void 0 && (item.edited === null || force)
25494
26071
  ).map((item) => ({ item, paths: item.existing.filter((e) => !holdsKept(e.abs)) })).filter(({ paths }) => paths.length > 0);
@@ -25579,7 +26156,7 @@ async function runUndo(options) {
25579
26156
  if (!lockExisted) return;
25580
26157
  if (carried.length === 0) {
25581
26158
  if (dryRun) return;
25582
- await fs56.remove(lockFile);
26159
+ await fs57.remove(lockFile);
25583
26160
  log(`Removed ${LOCK_FILE}`);
25584
26161
  return;
25585
26162
  }
@@ -25661,7 +26238,7 @@ ${keeping}User-owned .claude/ files will be preserved.`
25661
26238
  return;
25662
26239
  }
25663
26240
  for (const plan of coreRemoved) {
25664
- await fs56.remove(plan.abs);
26241
+ await fs57.remove(plan.abs);
25665
26242
  log(`Removed ${relOf(plan.abs)}`);
25666
26243
  }
25667
26244
  const failed = /* @__PURE__ */ new Set();
@@ -25688,15 +26265,15 @@ ${keeping}User-owned .claude/ files will be preserved.`
25688
26265
  if (stripSettings) await stripProjectSettings(root);
25689
26266
  if (stripClaudeMd) await stripRootClaudeMd(root);
25690
26267
  await settleLock();
25691
- await pruneDirIfEmpty(claudePath(root));
25692
- await pruneDirIfEmpty(hausPath(root));
26268
+ await pruneEmptyDir(claudePath(root));
26269
+ await pruneEmptyDir(hausPath(root));
25693
26270
  if (failed.size > 0) return;
25694
26271
  log("haus undo complete. Scan artifacts under .haus-workflow/ were left in place.");
25695
26272
  }
25696
26273
 
25697
26274
  // src/install/uninstall.ts
25698
26275
  import path92 from "node:path";
25699
- import fs57 from "fs-extra";
26276
+ import fs58 from "fs-extra";
25700
26277
  init_fs();
25701
26278
  init_logger();
25702
26279
  init_paths();
@@ -25710,7 +26287,7 @@ async function runUninstall(options = {}) {
25710
26287
  pluginsRemoved: []
25711
26288
  };
25712
26289
  if (!manifest) {
25713
- warn("No install manifest found \u2014 nothing to uninstall.");
26290
+ warn("No install manifest found, nothing to uninstall.");
25714
26291
  return result;
25715
26292
  }
25716
26293
  await readSettings();
@@ -25718,7 +26295,7 @@ async function runUninstall(options = {}) {
25718
26295
  const malformed = recordedPlugins.filter((entry) => !isValidPin(entry)).length;
25719
26296
  if (malformed > 0) {
25720
26297
  warn(
25721
- `Ignoring ${malformed} malformed plugin record(s) in the install manifest \u2014 continuing with the rest of the uninstall.`
26298
+ `Ignoring ${malformed} malformed plugin record(s) in the install manifest, continuing with the rest of the uninstall.`
25722
26299
  );
25723
26300
  }
25724
26301
  const managedPlugins = recordedPlugins.filter(isValidPin).map(withDefaultActivation);
@@ -25727,11 +26304,11 @@ async function runUninstall(options = {}) {
25727
26304
  result.pluginsRemoved = pluginRemoval.removed;
25728
26305
  } catch (err) {
25729
26306
  warn(
25730
- `Could not remove global plugin(s): ${err instanceof Error ? err.message : String(err)} \u2014 continuing with the rest of the uninstall.`
26307
+ `Could not remove global plugin(s): ${err instanceof Error ? err.message : String(err)}. Continuing with the rest of the uninstall.`
25731
26308
  );
25732
26309
  }
25733
26310
  for (const entry of manifest.files) {
25734
- const exists3 = fs57.pathExistsSync(entry.destPath);
26311
+ const exists3 = fs58.pathExistsSync(entry.destPath);
25735
26312
  if (!exists3) continue;
25736
26313
  const content2 = await readText(entry.destPath);
25737
26314
  if (content2 === void 0) continue;
@@ -25744,12 +26321,12 @@ async function runUninstall(options = {}) {
25744
26321
  const currentHash = hashText(normaliseLF(content2));
25745
26322
  if (currentHash !== entry.hash && !force) {
25746
26323
  warn(
25747
- `Skipping user-edited haus file (hash mismatch): ${entry.destPath} \u2014 use --force to delete`
26324
+ `Skipping user-edited haus file (hash mismatch): ${entry.destPath}. Use --force to delete`
25748
26325
  );
25749
26326
  result.skipped.push(entry.destPath);
25750
26327
  continue;
25751
26328
  }
25752
- await fs57.remove(entry.destPath);
26329
+ await fs58.remove(entry.destPath);
25753
26330
  await pruneEmptyDir(path92.dirname(entry.destPath));
25754
26331
  result.deleted.push(entry.destPath);
25755
26332
  }
@@ -25761,14 +26338,14 @@ async function runUninstall(options = {}) {
25761
26338
  result.hooksStripped = true;
25762
26339
  const hausDir = path92.join(globalClaudeDir(), "haus");
25763
26340
  const manifestPath2 = hausManifestPath();
25764
- if (fs57.pathExistsSync(manifestPath2)) {
25765
- await fs57.remove(manifestPath2);
26341
+ if (fs58.pathExistsSync(manifestPath2)) {
26342
+ await fs58.remove(manifestPath2);
25766
26343
  }
25767
26344
  await removeInstallConfig();
25768
- if (fs57.pathExistsSync(hausDir)) {
26345
+ if (fs58.pathExistsSync(hausDir)) {
25769
26346
  try {
25770
- const remaining = await fs57.readdir(hausDir);
25771
- if (remaining.length === 0) await fs57.remove(hausDir);
26347
+ const remaining = await fs58.readdir(hausDir);
26348
+ if (remaining.length === 0) await fs58.remove(hausDir);
25772
26349
  } catch {
25773
26350
  }
25774
26351
  }
@@ -25800,7 +26377,7 @@ async function runUninstallCommand(options) {
25800
26377
  if (options.dryRun) {
25801
26378
  const manifest = await readManifest();
25802
26379
  if (!manifest) {
25803
- log("[dry-run] No install manifest found \u2014 nothing would be removed.");
26380
+ log("[dry-run] No install manifest found, nothing would be removed.");
25804
26381
  return;
25805
26382
  }
25806
26383
  if (manifest.files.length > 0) {
@@ -25844,7 +26421,7 @@ init_forbidden_content();
25844
26421
  init_manifest_item_fields();
25845
26422
  init_path_safety();
25846
26423
  init_validation_rules2();
25847
- import fs58 from "node:fs";
26424
+ import fs59 from "node:fs";
25848
26425
  import path93 from "node:path";
25849
26426
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
25850
26427
  var ITEM_ID_PATTERN = /^haus\./;
@@ -25954,7 +26531,7 @@ function auditManifestStructure(manifestVersion2, items) {
25954
26531
  if (typeof item.tokenEstimate === "number" && item.tokenEstimate > 0) {
25955
26532
  if (typeof item.tokenBudget !== "number") {
25956
26533
  failures.push(
25957
- `${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)})`
26534
+ `${item.id}: tokenEstimate ${item.tokenEstimate} but no tokenBudget: an item that loads into context needs a growth ceiling (estimate + ~30% headroom, so ${Math.ceil(item.tokenEstimate * 1.3)})`
25958
26535
  );
25959
26536
  } else if (item.tokenEstimate > item.tokenBudget) {
25960
26537
  failures.push(
@@ -26005,7 +26582,7 @@ function checkRequiredFrontmatter(text, label) {
26005
26582
  }
26006
26583
  function readForAudit(absPath, label, failures) {
26007
26584
  try {
26008
- return fs58.readFileSync(absPath, "utf8");
26585
+ return fs59.readFileSync(absPath, "utf8");
26009
26586
  } catch (err) {
26010
26587
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
26011
26588
  return null;
@@ -26037,7 +26614,7 @@ function auditShippedFiles(manifestDir, items) {
26037
26614
  const absPath = path93.join(manifestDir, item.path);
26038
26615
  if (item.type === "skill") {
26039
26616
  const skillMd = path93.join(absPath, "SKILL.md");
26040
- if (!fs58.existsSync(skillMd)) {
26617
+ if (!fs59.existsSync(skillMd)) {
26041
26618
  failures.push(`${item.id}: missing ${path93.relative(manifestDir, skillMd)}`);
26042
26619
  continue;
26043
26620
  }
@@ -26048,7 +26625,7 @@ function auditShippedFiles(manifestDir, items) {
26048
26625
  ...auditForbiddenTagsInText(text, `${item.id}: ${path93.relative(manifestDir, skillMd)}`)
26049
26626
  );
26050
26627
  } else if (item.type === "agent") {
26051
- if (!fs58.existsSync(absPath)) {
26628
+ if (!fs59.existsSync(absPath)) {
26052
26629
  failures.push(`${item.id}: missing agent file ${item.path}`);
26053
26630
  continue;
26054
26631
  }
@@ -26058,13 +26635,13 @@ function auditShippedFiles(manifestDir, items) {
26058
26635
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
26059
26636
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
26060
26637
  } else if (item.type === "template") {
26061
- if (!fs58.existsSync(absPath)) {
26638
+ if (!fs59.existsSync(absPath)) {
26062
26639
  failures.push(`${item.id}: missing template file ${item.path}`);
26063
26640
  continue;
26064
26641
  }
26065
26642
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
26066
26643
  } else if (item.type === "command") {
26067
- if (!fs58.existsSync(absPath)) {
26644
+ if (!fs59.existsSync(absPath)) {
26068
26645
  failures.push(`${item.id}: missing command file ${item.path}`);
26069
26646
  continue;
26070
26647
  }
@@ -26074,15 +26651,15 @@ function auditShippedFiles(manifestDir, items) {
26074
26651
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
26075
26652
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
26076
26653
  } else if (item.type === "config") {
26077
- if (!fs58.existsSync(absPath)) {
26654
+ if (!fs59.existsSync(absPath)) {
26078
26655
  failures.push(`${item.id}: missing config path ${item.path}`);
26079
26656
  }
26080
26657
  } else if (item.type === "rule") {
26081
- if (!fs58.existsSync(absPath)) {
26658
+ if (!fs59.existsSync(absPath)) {
26082
26659
  failures.push(`${item.id}: missing rule file ${item.path}`);
26083
26660
  }
26084
26661
  } else if (item.type === "asset") {
26085
- if (!fs58.existsSync(absPath)) {
26662
+ if (!fs59.existsSync(absPath)) {
26086
26663
  failures.push(`${item.id}: missing asset file ${item.path}`);
26087
26664
  }
26088
26665
  }
@@ -26117,7 +26694,7 @@ function resolveItemType(relPath, pathTypeMap) {
26117
26694
  function walkAllFiles(dir, fn, failures) {
26118
26695
  let entries;
26119
26696
  try {
26120
- entries = fs58.readdirSync(dir, { withFileTypes: true });
26697
+ entries = fs59.readdirSync(dir, { withFileTypes: true });
26121
26698
  } catch (err) {
26122
26699
  failures.push(
26123
26700
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -26151,7 +26728,7 @@ function auditDiskOrphans(manifestDir, items) {
26151
26728
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
26152
26729
  for (const dir of CONTENT_ROOTS) {
26153
26730
  const abs = path93.join(manifestDir, dir);
26154
- if (!fs58.existsSync(abs)) continue;
26731
+ if (!fs59.existsSync(abs)) continue;
26155
26732
  walkAllFiles(
26156
26733
  abs,
26157
26734
  (file) => {
@@ -26168,7 +26745,7 @@ function auditDiskOrphans(manifestDir, items) {
26168
26745
  function walkMd(dir, fn, failures) {
26169
26746
  let entries;
26170
26747
  try {
26171
- entries = fs58.readdirSync(dir, { withFileTypes: true });
26748
+ entries = fs59.readdirSync(dir, { withFileTypes: true });
26172
26749
  } catch (err) {
26173
26750
  failures.push(
26174
26751
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -26187,7 +26764,7 @@ function auditMarkdownContent(manifestDir, items) {
26187
26764
  const pathTypeMap = buildItemPathTypeMap(items);
26188
26765
  for (const dir of ["skills", "agents", "templates", "commands", "sources"]) {
26189
26766
  const abs = path93.join(manifestDir, dir);
26190
- if (!fs58.existsSync(abs)) continue;
26767
+ if (!fs59.existsSync(abs)) continue;
26191
26768
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
26192
26769
  walkMd(
26193
26770
  abs,
@@ -26451,12 +27028,12 @@ async function runVerifyLocal(options) {
26451
27028
  // src/commands/workspace/env.ts
26452
27029
  init_logger();
26453
27030
  import { existsSync as existsSync11 } from "node:fs";
26454
- import fs67 from "node:fs/promises";
27031
+ import fs68 from "node:fs/promises";
26455
27032
  import path105 from "node:path";
26456
27033
 
26457
27034
  // src/workspace/env/datastores/container.ts
26458
27035
  init_exec();
26459
- import fs59 from "node:fs/promises";
27036
+ import fs60 from "node:fs/promises";
26460
27037
  import path95 from "node:path";
26461
27038
 
26462
27039
  // src/workspace/env/datastores/names.ts
@@ -26687,18 +27264,18 @@ async function dumpToFile(ctx, target, argv, file, what) {
26687
27264
  await dumpCommandToFile(ctx, "docker", composeExecArgs(target, argv), {}, file, what);
26688
27265
  }
26689
27266
  async function dumpCommandToFile(ctx, cmd, args, opts, file, what) {
26690
- await fs59.mkdir(path95.dirname(file), { recursive: true });
27267
+ await fs60.mkdir(path95.dirname(file), { recursive: true });
26691
27268
  try {
26692
27269
  assertOk(await ctx.exec(cmd, args, { ...opts, stdoutFile: file }), what);
26693
27270
  await assertPrivateNonEmpty(file, what);
26694
27271
  } catch (err) {
26695
- await fs59.rm(file, { force: true });
27272
+ await fs60.rm(file, { force: true });
26696
27273
  throw err instanceof DatastoreError ? err : new DatastoreError(`${what} failed: ${err instanceof Error ? err.message : String(err)}`);
26697
27274
  }
26698
27275
  }
26699
27276
  async function assertPrivateNonEmpty(file, what) {
26700
- await fs59.chmod(file, 384);
26701
- const { size } = await fs59.stat(file);
27277
+ await fs60.chmod(file, 384);
27278
+ const { size } = await fs60.stat(file);
26702
27279
  if (size === 0) throw new DatastoreError(`${what} produced an empty file.`);
26703
27280
  }
26704
27281
 
@@ -27055,7 +27632,7 @@ var mariadbDriver = {
27055
27632
  };
27056
27633
 
27057
27634
  // src/workspace/env/datastores/mssql.ts
27058
- import fs60 from "node:fs/promises";
27635
+ import fs61 from "node:fs/promises";
27059
27636
  import path96 from "node:path";
27060
27637
  var MSSQL_TOOLS_IMAGE = "mcr.microsoft.com/mssql-tools:latest";
27061
27638
  var DETECT_SCRIPT = 'for p in /opt/mssql-tools18/bin/sqlcmd /opt/mssql-tools/bin/sqlcmd; do if [ -x "$p" ]; then echo "$p"; exit 0; fi; done; command -v sqlcmd || exit 3';
@@ -27351,7 +27928,7 @@ DROP DATABASE ${q(n.template)};`,
27351
27928
  const dir = dataDir(await databaseFiles(ctx, t.datastore, n.worktree));
27352
27929
  const bak = assertContainerPath(`${dir}/haus-dump-${n.worktree}.bak`);
27353
27930
  const what = `Dumping ${n.worktree}`;
27354
- await fs60.mkdir(path96.dirname(file), { recursive: true });
27931
+ await fs61.mkdir(path96.dirname(file), { recursive: true });
27355
27932
  try {
27356
27933
  await sqlcmd(
27357
27934
  ctx,
@@ -27369,7 +27946,7 @@ DROP DATABASE ${q(n.template)};`,
27369
27946
  assertOk(await ctx.exec("docker", ["cp", `${cid}:${bak}`, file]), `Copying ${bak} out`);
27370
27947
  await assertPrivateNonEmpty(file, what);
27371
27948
  } catch (err) {
27372
- await fs60.rm(file, { force: true });
27949
+ await fs61.rm(file, { force: true });
27373
27950
  throw err instanceof DatastoreError ? err : new DatastoreError(`${what} failed: ${err instanceof Error ? err.message : String(err)}`);
27374
27951
  } finally {
27375
27952
  await removeContainerFile(ctx, t.datastore, bak).catch(() => void 0);
@@ -27974,13 +28551,13 @@ function derivedEnvState(instance, base, worktreePath2) {
27974
28551
 
27975
28552
  // src/workspace/env/processes.ts
27976
28553
  import { spawn } from "node:child_process";
27977
- import fs62 from "node:fs";
28554
+ import fs63 from "node:fs";
27978
28555
  import http from "node:http";
27979
28556
  import path99 from "node:path";
27980
28557
 
27981
28558
  // src/workspace/env/orphans.ts
27982
28559
  import { execFile } from "node:child_process";
27983
- import fs61 from "node:fs";
28560
+ import fs62 from "node:fs";
27984
28561
  import path98 from "node:path";
27985
28562
  function parseLsofListeners(text) {
27986
28563
  const out = [];
@@ -28033,7 +28610,7 @@ function portOwnerOf(port, ctx) {
28033
28610
  function classifyListener(l, ctx) {
28034
28611
  const owner = portOwnerOf(l.port, ctx);
28035
28612
  if (!owner) return void 0;
28036
- const exists3 = ctx.exists ?? fs61.existsSync;
28613
+ const exists3 = ctx.exists ?? fs62.existsSync;
28037
28614
  const portOwner = owner.kind === "lease" ? owner.lease.slug : "main";
28038
28615
  const result = (cls, reason) => ({
28039
28616
  ...l,
@@ -28075,7 +28652,7 @@ async function resolveCwds(pids, exec = execText) {
28075
28652
  }
28076
28653
  function realOr(p) {
28077
28654
  try {
28078
- return fs61.realpathSync(p);
28655
+ return fs62.realpathSync(p);
28079
28656
  } catch {
28080
28657
  return p;
28081
28658
  }
@@ -28198,7 +28775,7 @@ async function anyHostHealthy(port, pathname) {
28198
28775
  }
28199
28776
  function tail(file) {
28200
28777
  try {
28201
- const lines = fs62.readFileSync(file, "utf8").trimEnd().split("\n");
28778
+ const lines = fs63.readFileSync(file, "utf8").trimEnd().split("\n");
28202
28779
  return lines.slice(-LOG_TAIL_LINES).join("\n");
28203
28780
  } catch {
28204
28781
  return "";
@@ -28225,7 +28802,7 @@ function prepare(ctx, name, state) {
28225
28802
  }
28226
28803
  const member = ctx.members.find((m) => m.id === spec.repo || m.folder === spec.repo);
28227
28804
  const cwd = member?.worktreePath;
28228
- if (!cwd || !fs62.existsSync(cwd)) {
28805
+ if (!cwd || !fs63.existsSync(cwd)) {
28229
28806
  return {
28230
28807
  ok: false,
28231
28808
  reason: ctx.mode === "single-repo" ? `Folder "${spec.repo}" does not exist in this worktree.` : `Repo "${spec.repo}" is not materialised in this worktree. Run \`haus workspace worktree hydrate --materialize\` first.`
@@ -28308,13 +28885,13 @@ async function startProcess(ctx, name, opts = {}) {
28308
28885
  }
28309
28886
  const logRel = logRelPath(name);
28310
28887
  const logAbs = path99.join(p.worktree, logRel);
28311
- fs62.mkdirSync(path99.dirname(logAbs), { recursive: true });
28312
- fs62.appendFileSync(
28888
+ fs63.mkdirSync(path99.dirname(logAbs), { recursive: true });
28889
+ fs63.appendFileSync(
28313
28890
  logAbs,
28314
28891
  `--- haus env start ${name} ${(/* @__PURE__ */ new Date()).toISOString()}: ${p.command}
28315
28892
  `
28316
28893
  );
28317
- const fd = fs62.openSync(logAbs, "a");
28894
+ const fd = fs63.openSync(logAbs, "a");
28318
28895
  let child;
28319
28896
  try {
28320
28897
  child = spawn(p.command, {
@@ -28325,7 +28902,7 @@ async function startProcess(ctx, name, opts = {}) {
28325
28902
  env: { ...process.env, HAUS_ENV_FILE: envStatePath(p.worktree) }
28326
28903
  });
28327
28904
  } finally {
28328
- fs62.closeSync(fd);
28905
+ fs63.closeSync(fd);
28329
28906
  }
28330
28907
  const pid = child.pid;
28331
28908
  if (pid === void 0) {
@@ -28420,12 +28997,12 @@ function errorText(err) {
28420
28997
 
28421
28998
  // src/workspace/env/sites/herd.ts
28422
28999
  import { existsSync as existsSync7, lstatSync, readlinkSync } from "node:fs";
28423
- import fs65 from "node:fs/promises";
29000
+ import fs66 from "node:fs/promises";
28424
29001
  import path102 from "node:path";
28425
29002
 
28426
29003
  // src/workspace/env/dotenv-writer.ts
28427
29004
  import { createHash as createHash4 } from "node:crypto";
28428
- import fs63 from "node:fs/promises";
29005
+ import fs64 from "node:fs/promises";
28429
29006
  var DOTENV_MODE = 384;
28430
29007
  var LINE_KEY_RE = /^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)(\s*)=/;
28431
29008
  function hashContent2(content2) {
@@ -28466,7 +29043,7 @@ function linesByKey(content2) {
28466
29043
  }
28467
29044
  async function readIfExists(file) {
28468
29045
  try {
28469
- return await fs63.readFile(file, "utf8");
29046
+ return await fs64.readFile(file, "utf8");
28470
29047
  } catch (err) {
28471
29048
  if (err.code === "ENOENT") return void 0;
28472
29049
  throw new Error(`Could not read ${file} (${err.code ?? "error"}).`);
@@ -28581,7 +29158,7 @@ function describeDotenvResult(r) {
28581
29158
 
28582
29159
  // src/workspace/env/sites/wp-config-override.ts
28583
29160
  import { existsSync as existsSync6 } from "node:fs";
28584
- import fs64 from "node:fs/promises";
29161
+ import fs65 from "node:fs/promises";
28585
29162
  import path101 from "node:path";
28586
29163
  init_paths();
28587
29164
  var WP_CONFIG_FILE = "wp-config.php";
@@ -28662,7 +29239,7 @@ async function patchMainWpConfig(mainDocroot, keys, opts) {
28662
29239
  const file = path101.join(mainDocroot, WP_CONFIG_FILE);
28663
29240
  if (!existsSync6(file)) return { status: "missing", file };
28664
29241
  const exec = opts.exec ?? defaultExec;
28665
- const source = await fs64.readFile(file, "utf8");
29242
+ const source = await fs65.readFile(file, "utf8");
28666
29243
  const patch = patchWpConfigSource(source, keys);
28667
29244
  if (patch.text === source) {
28668
29245
  return {
@@ -28680,11 +29257,11 @@ async function patchMainWpConfig(mainDocroot, keys, opts) {
28680
29257
  dirBackupDirName("worktreeEnv", newBackupStamp())
28681
29258
  );
28682
29259
  const backup = path101.join(backupDir, inside2 ? rel : WP_CONFIG_FILE);
28683
- const mode = (await fs64.stat(file)).mode & 511;
28684
- await fs64.mkdir(path101.dirname(backup), { recursive: true });
28685
- await fs64.copyFile(file, backup);
28686
- await fs64.chmod(backup, 384);
28687
- if (await fs64.readFile(backup, "utf8") !== source) {
29260
+ const mode = (await fs65.stat(file)).mode & 511;
29261
+ await fs65.mkdir(path101.dirname(backup), { recursive: true });
29262
+ await fs65.copyFile(file, backup);
29263
+ await fs65.chmod(backup, 384);
29264
+ if (await fs65.readFile(backup, "utf8") !== source) {
28688
29265
  throw new SiteConfigError(
28689
29266
  `The backup of ${file} does not match the original; wp-config.php was not changed.`
28690
29267
  );
@@ -28692,8 +29269,8 @@ async function patchMainWpConfig(mainDocroot, keys, opts) {
28692
29269
  await writeFileAtomic(file, patch.text, { mode });
28693
29270
  const lint = await exec(opts.php ?? "php", ["-l", file]);
28694
29271
  if (lint.exitCode !== 0) {
28695
- await fs64.copyFile(backup, file);
28696
- await fs64.chmod(file, mode);
29272
+ await fs65.copyFile(backup, file);
29273
+ await fs65.chmod(file, mode);
28697
29274
  return {
28698
29275
  status: "restored",
28699
29276
  file,
@@ -28808,8 +29385,8 @@ function normalizeRel(rel) {
28808
29385
  return path102.normalize(rel).replace(/\/+$/, "");
28809
29386
  }
28810
29387
  async function cowCopy(exec, main, dest, skip, rel = "") {
28811
- await fs65.mkdir(path102.join(dest, rel), { recursive: true });
28812
- for (const entry of await fs65.readdir(path102.join(main, rel), { withFileTypes: true })) {
29388
+ await fs66.mkdir(path102.join(dest, rel), { recursive: true });
29389
+ for (const entry of await fs66.readdir(path102.join(main, rel), { withFileTypes: true })) {
28813
29390
  const childRel = rel ? path102.join(rel, entry.name) : entry.name;
28814
29391
  if (skip.has(childRel)) continue;
28815
29392
  const nested = [...skip].some((s) => s.startsWith(`${childRel}/`));
@@ -28832,12 +29409,12 @@ async function ensureSymlink(link, target) {
28832
29409
  }
28833
29410
  if (st?.isSymbolicLink()) {
28834
29411
  if (readlinkSync(link) === target) return;
28835
- await fs65.unlink(link);
29412
+ await fs66.unlink(link);
28836
29413
  } else if (st) {
28837
29414
  throw new SiteError(`${link} exists and is not a symlink; leaving it alone.`);
28838
29415
  }
28839
- await fs65.mkdir(path102.dirname(link), { recursive: true });
28840
- await fs65.symlink(target, link);
29416
+ await fs66.mkdir(path102.dirname(link), { recursive: true });
29417
+ await fs66.symlink(target, link);
28841
29418
  }
28842
29419
  function memberPath(ctx, ref) {
28843
29420
  const m = ctx.members.find((x) => x.folder === ref || x.id === ref);
@@ -28866,7 +29443,7 @@ function siteLockPath(workspaceRoot, name) {
28866
29443
  }
28867
29444
  async function readLock2(file) {
28868
29445
  try {
28869
- const v = JSON.parse(await fs65.readFile(file, "utf8"));
29446
+ const v = JSON.parse(await fs66.readFile(file, "utf8"));
28870
29447
  return typeof v.owner === "string" && typeof v.expiresAt === "string" ? v : void 0;
28871
29448
  } catch {
28872
29449
  return void 0;
@@ -28895,7 +29472,7 @@ async function releaseSiteLock(ctx, name) {
28895
29472
  const file = siteLockPath(ctx.workspaceRoot, name);
28896
29473
  const held = await readLock2(file);
28897
29474
  if (!held || held.owner !== ctx.slug) return false;
28898
- await fs65.rm(file, { force: true });
29475
+ await fs66.rm(file, { force: true });
28899
29476
  return true;
28900
29477
  }
28901
29478
  function resolvedDefines(site, withSite) {
@@ -28918,19 +29495,19 @@ async function refreshHerdSiteConfig(ctx, name, site, opts = {}) {
28918
29495
  const defines = resolvedDefines(site, siteTemplateValues(ctx, name, site, worktree2));
28919
29496
  let changed = false;
28920
29497
  const instanceFile = path102.join(worktree2, INSTANCE_CONFIG_FILE);
28921
- const current = existsSync7(instanceFile) ? await fs65.readFile(instanceFile, "utf8") : void 0;
29498
+ const current = existsSync7(instanceFile) ? await fs66.readFile(instanceFile, "utf8") : void 0;
28922
29499
  if (current !== renderInstanceConfig(defines)) {
28923
29500
  changed = true;
28924
29501
  if (!opts.dryRun) await writeInstanceConfig(worktree2, defines);
28925
29502
  }
28926
29503
  const wpConfig = path102.join(worktree2, WP_CONFIG_FILE);
28927
29504
  if (existsSync7(wpConfig)) {
28928
- const source = await fs65.readFile(wpConfig, "utf8");
29505
+ const source = await fs66.readFile(wpConfig, "utf8");
28929
29506
  const patch = patchWpConfigSource(source, Object.keys(defines));
28930
29507
  if (patch.text !== source) {
28931
29508
  changed = true;
28932
29509
  if (!opts.dryRun) {
28933
- const mode = (await fs65.stat(wpConfig)).mode & 511;
29510
+ const mode = (await fs66.stat(wpConfig)).mode & 511;
28934
29511
  await writeFileAtomic(wpConfig, patch.text, { mode });
28935
29512
  }
28936
29513
  }
@@ -28977,7 +29554,7 @@ async function provisionHerdSite(ctx, name, site, opts = {}) {
28977
29554
  ctx.log?.(`Cloning ${site.root} (copy-on-write).`);
28978
29555
  const skip = /* @__PURE__ */ new Set([...shares, ...plugins.map((p) => p.rel), INSTANCE_CONFIG_FILE]);
28979
29556
  await cowCopy(ctx.exec, main, partial, skip);
28980
- await fs65.rename(partial, worktree2);
29557
+ await fs66.rename(partial, worktree2);
28981
29558
  status = "created";
28982
29559
  }
28983
29560
  const links = [];
@@ -29091,7 +29668,7 @@ async function teardownHerdSite(ctx, name, site, recorded) {
29091
29668
  trashed = true;
29092
29669
  } else {
29093
29670
  for (const link of siteLinksInWorktree(ctx.worktreePath, worktree2, site, recorded)) {
29094
- await fs65.unlink(link);
29671
+ await fs66.unlink(link);
29095
29672
  }
29096
29673
  }
29097
29674
  return { unlinked, trashed, lockReleased: false, notes };
@@ -29831,7 +30408,7 @@ function formatEnvStatus(r) {
29831
30408
  init_fs();
29832
30409
  import { createHash as createHash5 } from "node:crypto";
29833
30410
  import { existsSync as existsSync10 } from "node:fs";
29834
- import fs66 from "node:fs/promises";
30411
+ import fs67 from "node:fs/promises";
29835
30412
  import path104 from "node:path";
29836
30413
  function upRefusal(ctx) {
29837
30414
  if (!ctx.enabled) return envEnableHint(ctx);
@@ -30154,7 +30731,7 @@ async function copyLocal(run) {
30154
30731
  continue;
30155
30732
  }
30156
30733
  const key = `copyLocal:${label}`;
30157
- const bytes = await fs66.readFile(src);
30734
+ const bytes = await fs67.readFile(src);
30158
30735
  const hash = sha(bytes);
30159
30736
  if (existsSync10(dest) && run.prior?.sourceEnvHashes[key] === hash) continue;
30160
30737
  run.changes.copiedLocal.push(label);
@@ -30677,7 +31254,7 @@ async function handlePatchWpConfig(opts) {
30677
31254
  if (opts.dryRun) {
30678
31255
  const file = path105.join(docroot, WP_CONFIG_FILE);
30679
31256
  if (!existsSync11(file)) return fail2(opts, `${file} does not exist.`);
30680
- const p = patchWpConfigSource(await fs67.readFile(file, "utf8"), keys);
31257
+ const p = patchWpConfigSource(await fs68.readFile(file, "utf8"), keys);
30681
31258
  const doc = {
30682
31259
  ok: true,
30683
31260
  dryRun: true,
@@ -30757,7 +31334,7 @@ init_logger();
30757
31334
  // src/workspace/worktree/add.ts
30758
31335
  init_fs();
30759
31336
  import path111 from "node:path";
30760
- import fs71 from "fs-extra";
31337
+ import fs72 from "fs-extra";
30761
31338
 
30762
31339
  // src/workspace/worktree/git-worktree.ts
30763
31340
  init_exec();
@@ -30898,12 +31475,12 @@ async function hasUnpushedWork(cwd) {
30898
31475
  // src/workspace/worktree/hydrate.ts
30899
31476
  init_exec();
30900
31477
  import path109 from "node:path";
30901
- import fs69 from "fs-extra";
31478
+ import fs70 from "fs-extra";
30902
31479
 
30903
31480
  // src/workspace/worktree/cow-copy.ts
30904
31481
  init_exec();
30905
31482
  import path107 from "node:path";
30906
- import fs68 from "fs-extra";
31483
+ import fs69 from "fs-extra";
30907
31484
  async function getMacFilesystemType(dir) {
30908
31485
  const df = await runCommand("df", ["-P", dir]);
30909
31486
  if (df.exitCode !== 0) return void 0;
@@ -30934,14 +31511,14 @@ async function detectCowStrategy(dir) {
30934
31511
  return "unknown-platform";
30935
31512
  }
30936
31513
  async function cowCopyDir(src, dest) {
30937
- const srcExists = await fs68.pathExists(src);
31514
+ const srcExists = await fs69.pathExists(src);
30938
31515
  if (!srcExists)
30939
31516
  return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
30940
31517
  const strategy = await detectCowStrategy(path107.dirname(dest));
30941
31518
  if (strategy === "unsupported" || strategy === "unknown-platform") {
30942
31519
  return { strategy, attempted: false, ok: false };
30943
31520
  }
30944
- await fs68.ensureDir(path107.dirname(dest));
31521
+ await fs69.ensureDir(path107.dirname(dest));
30945
31522
  const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
30946
31523
  const result = await runCommand("cp", args);
30947
31524
  return {
@@ -31119,7 +31696,7 @@ function summarizeInstall(install) {
31119
31696
  }
31120
31697
  if (install.plan.manager === "none") return "no install step (no recognized lockfile)";
31121
31698
  const envNotes = (install.missingEnvVars ?? []).map(
31122
- (name) => `${name} not set in environment \u2014 yarn install will fail`
31699
+ (name) => `${name} not set in environment, so yarn install will fail`
31123
31700
  );
31124
31701
  if (!install.ran) {
31125
31702
  const skip = `install skipped (${install.skippedReason ?? "unavailable"})`;
@@ -31234,8 +31811,8 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
31234
31811
  for (const target of HYDRATION_TARGETS) {
31235
31812
  const src = path109.join(mainMemberPath, target);
31236
31813
  const dest = path109.join(worktreeMemberPath, target);
31237
- if (!await fs69.pathExists(src)) continue;
31238
- const destExists = await fs69.pathExists(dest);
31814
+ if (!await fs70.pathExists(src)) continue;
31815
+ const destExists = await fs70.pathExists(dest);
31239
31816
  if (!opts.force && destExists) continue;
31240
31817
  if (opts.force && destExists) {
31241
31818
  const refusal2 = await replaceRefusal(worktreeMemberPath, target);
@@ -31243,7 +31820,7 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
31243
31820
  refusedTargets.push({ target, path: dest, reason: refusal2 });
31244
31821
  continue;
31245
31822
  }
31246
- await fs69.remove(dest);
31823
+ await fs70.remove(dest);
31247
31824
  }
31248
31825
  const copy = await cowCopyDir(src, dest);
31249
31826
  targets.push({ target, copy });
@@ -31286,7 +31863,7 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
31286
31863
  init_exec();
31287
31864
  init_fs();
31288
31865
  import path110 from "node:path";
31289
- import fs70 from "fs-extra";
31866
+ import fs71 from "fs-extra";
31290
31867
  var MACHINE_LOCAL_FILES = [
31291
31868
  "docker-compose.local.yml",
31292
31869
  path110.join(".claude", "settings.local.json")
@@ -31305,7 +31882,7 @@ async function copyLocalFiles(mainMemberPath, worktreeMemberPath, relPaths, opts
31305
31882
  result.skipped.push({ path: rel, reason: "outside-repo" });
31306
31883
  continue;
31307
31884
  }
31308
- if (!await fs70.pathExists(src)) {
31885
+ if (!await fs71.pathExists(src)) {
31309
31886
  result.skipped.push({ path: rel, reason: "missing" });
31310
31887
  continue;
31311
31888
  }
@@ -31313,9 +31890,9 @@ async function copyLocalFiles(mainMemberPath, worktreeMemberPath, relPaths, opts
31313
31890
  result.skipped.push({ path: rel, reason: "tracked" });
31314
31891
  continue;
31315
31892
  }
31316
- await fs70.ensureDir(path110.dirname(dest));
31317
- await fs70.copy(src, dest, { overwrite: true });
31318
- if (opts.mode !== void 0) await fs70.chmod(dest, opts.mode);
31893
+ await fs71.ensureDir(path110.dirname(dest));
31894
+ await fs71.copy(src, dest, { overwrite: true });
31895
+ if (opts.mode !== void 0) await fs71.chmod(dest, opts.mode);
31319
31896
  result.copied.push(rel);
31320
31897
  }
31321
31898
  return result;
@@ -31350,10 +31927,10 @@ async function runAdd(opts) {
31350
31927
  }
31351
31928
  const branch = opts.branch ?? opts.slug;
31352
31929
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
31353
- if (await fs71.pathExists(wsWorktreePath)) {
31930
+ if (await fs72.pathExists(wsWorktreePath)) {
31354
31931
  return {
31355
31932
  ok: false,
31356
- error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate --materialize\` from inside it to check out any missing members (e.g. a worktree created by plain \`git worktree add\`), or \`remove\` first to start over.`
31933
+ error: `Workspace worktree already exists at ${wsWorktreePath}. Run \`haus workspace worktree hydrate --materialize\` from inside it to check out any missing members (e.g. a worktree created by plain \`git worktree add\`), or \`remove\` first to start over.`
31357
31934
  };
31358
31935
  }
31359
31936
  if (opts.dryRun) {
@@ -31363,7 +31940,7 @@ async function runAdd(opts) {
31363
31940
  if (!wsOutcome.ok) {
31364
31941
  return {
31365
31942
  ok: false,
31366
- error: wsOutcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in the workspace repo \u2014 pick a different --branch.` : `Failed to create the workspace worktree: ${wsOutcome.stderr ?? "unknown error"}`
31943
+ error: wsOutcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in the workspace repo. Pick a different --branch.` : `Failed to create the workspace worktree: ${wsOutcome.stderr ?? "unknown error"}`
31367
31944
  };
31368
31945
  }
31369
31946
  const memberResults = [];
@@ -31380,7 +31957,7 @@ async function runAdd(opts) {
31380
31957
  status: "failed",
31381
31958
  branch,
31382
31959
  branchAction: outcome.branchAction,
31383
- error: outcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in ${member.id} \u2014 pick a different --branch.` : outcome.stderr ?? "unknown error",
31960
+ error: outcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in ${member.id}. Pick a different --branch.` : outcome.stderr ?? "unknown error",
31384
31961
  durationMs: Date.now() - start
31385
31962
  });
31386
31963
  continue;
@@ -31468,7 +32045,7 @@ async function runDryRun(args) {
31468
32045
  import { spawn as spawn2 } from "node:child_process";
31469
32046
  import fsSync from "node:fs";
31470
32047
  import path117 from "node:path";
31471
- import fs73 from "fs-extra";
32048
+ import fs74 from "fs-extra";
31472
32049
 
31473
32050
  // src/workspace/worktree/doctor.ts
31474
32051
  import { existsSync as existsSync14 } from "node:fs";
@@ -31477,7 +32054,7 @@ import path114 from "node:path";
31477
32054
  // src/workspace/worktree/hydrate-lock.ts
31478
32055
  init_paths();
31479
32056
  import path112 from "node:path";
31480
- import fs72 from "fs-extra";
32057
+ import fs73 from "fs-extra";
31481
32058
  var STALE_LOCK_MS = 30 * 60 * 1e3;
31482
32059
  function locksDir(mainRoot) {
31483
32060
  return hausPath(mainRoot, "locks");
@@ -31488,7 +32065,7 @@ function logsDir(mainRoot) {
31488
32065
  function assertSlug(slug) {
31489
32066
  if (!isValidWorktreeSlug(slug)) {
31490
32067
  throw new Error(
31491
- `Invalid worktree slug ${JSON.stringify(slug)} \u2014 slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
32068
+ `Invalid worktree slug ${JSON.stringify(slug)}: slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
31492
32069
  );
31493
32070
  }
31494
32071
  return slug;
@@ -31512,7 +32089,7 @@ function defaultIsAlive2(pid) {
31512
32089
  }
31513
32090
  async function readOwnerPid(lockPath) {
31514
32091
  try {
31515
- const raw = (await fs72.readFile(path112.join(lockPath, "pid"), "utf8")).trim();
32092
+ const raw = (await fs73.readFile(path112.join(lockPath, "pid"), "utf8")).trim();
31516
32093
  if (raw === "") return void 0;
31517
32094
  const pid = Number.parseInt(raw, 10);
31518
32095
  return Number.isInteger(pid) && pid > 0 ? pid : void 0;
@@ -31521,13 +32098,13 @@ async function readOwnerPid(lockPath) {
31521
32098
  }
31522
32099
  }
31523
32100
  async function claim(lockPath, pid) {
31524
- await fs72.ensureDir(path112.dirname(lockPath));
32101
+ await fs73.ensureDir(path112.dirname(lockPath));
31525
32102
  try {
31526
- await fs72.mkdir(lockPath);
32103
+ await fs73.mkdir(lockPath);
31527
32104
  } catch {
31528
32105
  return false;
31529
32106
  }
31530
- await fs72.writeFile(path112.join(lockPath, "pid"), String(pid), "utf8");
32107
+ await fs73.writeFile(path112.join(lockPath, "pid"), String(pid), "utf8");
31531
32108
  return true;
31532
32109
  }
31533
32110
  async function acquireHydrateLock(mainRoot, slug, deps = {}) {
@@ -31546,7 +32123,7 @@ async function acquireHydrateLock(mainRoot, slug, deps = {}) {
31546
32123
  }
31547
32124
  if (alive) return { acquired: false, reason: "live", ownerPid, lockPath };
31548
32125
  } else {
31549
- const stat2 = await fs72.stat(lockPath).catch(() => void 0);
32126
+ const stat2 = await fs73.stat(lockPath).catch(() => void 0);
31550
32127
  if (stat2 === void 0) {
31551
32128
  return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", lockPath };
31552
32129
  }
@@ -31554,18 +32131,18 @@ async function acquireHydrateLock(mainRoot, slug, deps = {}) {
31554
32131
  return { acquired: false, reason: "starting", lockPath };
31555
32132
  }
31556
32133
  }
31557
- await fs72.remove(lockPath);
32134
+ await fs73.remove(lockPath);
31558
32135
  return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", ownerPid, lockPath };
31559
32136
  }
31560
32137
  async function recordHydrateLockOwner(mainRoot, slug, pid) {
31561
- await fs72.writeFile(path112.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
32138
+ await fs73.writeFile(path112.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
31562
32139
  }
31563
32140
  async function releaseHydrateLock(mainRoot, slug) {
31564
- await fs72.remove(hydrateLockPath(mainRoot, slug));
32141
+ await fs73.remove(hydrateLockPath(mainRoot, slug));
31565
32142
  }
31566
32143
  async function readHydrateLock(mainRoot, slug) {
31567
32144
  const lockPath = hydrateLockPath(mainRoot, slug);
31568
- const stat2 = await fs72.stat(lockPath).catch(() => void 0);
32145
+ const stat2 = await fs73.stat(lockPath).catch(() => void 0);
31569
32146
  if (stat2 === void 0) return { held: false };
31570
32147
  const ownerPid = await readOwnerPid(lockPath);
31571
32148
  return {
@@ -31659,7 +32236,7 @@ async function runDoctor2() {
31659
32236
  members: [],
31660
32237
  orphans: [],
31661
32238
  problems: [
31662
- `Not a haus workspace \u2014 no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
32239
+ `Not a haus workspace: no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
31663
32240
  ],
31664
32241
  remedies: [],
31665
32242
  autoHydrate: false,
@@ -31719,7 +32296,7 @@ async function runDoctor2() {
31719
32296
  }
31720
32297
  if (memberChecks.some((m) => !m.materialized)) {
31721
32298
  remedies.push(
31722
- 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."
32299
+ hydrationInProgress ? `A hydration is already running for "${slug}"${hydrationOwnerPid !== void 0 ? ` (pid ${hydrationOwnerPid})` : ""}. 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."
31723
32300
  );
31724
32301
  }
31725
32302
  }
@@ -31793,7 +32370,7 @@ async function runHydrateWorktree(opts) {
31793
32370
  member: member.id,
31794
32371
  folder: member.folder,
31795
32372
  status: "skipped",
31796
- error: "not materialized in this worktree \u2014 re-run with --materialize to create it"
32373
+ error: "not materialized in this worktree. Re-run with --materialize to create it"
31797
32374
  };
31798
32375
  }
31799
32376
  const branchAction = await branchExists(member.absPath, targetBranch) ? "checkout" : "create";
@@ -31813,7 +32390,7 @@ async function runHydrateWorktree(opts) {
31813
32390
  member: member.id,
31814
32391
  folder: member.folder,
31815
32392
  status: "skipped",
31816
- error: "not materialized in this worktree \u2014 re-run with --materialize to create it"
32393
+ error: "not materialized in this worktree. Re-run with --materialize to create it"
31817
32394
  };
31818
32395
  }
31819
32396
  const outcome = await addWorktree(member.absPath, memberPath2, targetBranch, {
@@ -31824,7 +32401,7 @@ async function runHydrateWorktree(opts) {
31824
32401
  member: member.id,
31825
32402
  folder: member.folder,
31826
32403
  status: "failed",
31827
- error: outcome.checkedOutElsewhere ? `Branch "${targetBranch}" is already checked out elsewhere in ${member.id} \u2014 remove that worktree or resolve the branch conflict first.` : outcome.stderr ?? "unknown error creating the member worktree"
32404
+ error: outcome.checkedOutElsewhere ? `Branch "${targetBranch}" is already checked out elsewhere in ${member.id}. Remove that worktree or resolve the branch conflict first.` : outcome.stderr ?? "unknown error creating the member worktree"
31828
32405
  };
31829
32406
  }
31830
32407
  branchAction = outcome.branchAction;
@@ -31977,11 +32554,11 @@ async function consumeLastRunFailure(mainRoot, slug) {
31977
32554
  const file = hydrateLastRunPath(mainRoot, slug);
31978
32555
  let parsed;
31979
32556
  try {
31980
- parsed = JSON.parse(await fs73.readFile(file, "utf8"));
32557
+ parsed = JSON.parse(await fs74.readFile(file, "utf8"));
31981
32558
  } catch {
31982
32559
  return void 0;
31983
32560
  }
31984
- await fs73.remove(file).catch(() => void 0);
32561
+ await fs74.remove(file).catch(() => void 0);
31985
32562
  if (parsed && parsed.ok === false) return parsed;
31986
32563
  return void 0;
31987
32564
  }
@@ -32053,7 +32630,7 @@ async function runAutoHydrate(opts = {}, deps = {}) {
32053
32630
  reason: "could not determine the haus CLI entry point to re-invoke"
32054
32631
  };
32055
32632
  }
32056
- await fs73.ensureDir(logsDir(mainRoot));
32633
+ await fs74.ensureDir(logsDir(mainRoot));
32057
32634
  appendLog(
32058
32635
  logPath,
32059
32636
  `autohydrate: forking hydrate --materialize for "${slug}" (${missingMembers.join(", ")})`
@@ -32105,7 +32682,7 @@ async function runDetachedHydration() {
32105
32682
  }
32106
32683
  const detail = "results" in result ? result.results : [];
32107
32684
  const failures = detail.filter((r) => r.status === "failed" || anyInstallFailed(r.hydration)).map(
32108
- (r) => r.status === "failed" ? `${r.member}: failed to materialize \u2014 ${r.error ?? "unknown error"}` : `${r.member}: materialized, but its install FAILED via ${failedManagers(r.hydration)}`
32685
+ (r) => r.status === "failed" ? `${r.member}: failed to materialize: ${r.error ?? "unknown error"}` : `${r.member}: materialized, but its install FAILED via ${failedManagers(r.hydration)}`
32109
32686
  );
32110
32687
  await writeLastRun(mainRoot, slug, result.ok, failures);
32111
32688
  appendLog(logPath, `hydrate finished: ${result.ok ? "ok" : `FAILED (${failures.join("; ")})`}`);
@@ -32122,22 +32699,22 @@ async function runDetachedHydration() {
32122
32699
  async function writeLastRun(mainRoot, slug, ok2, failures) {
32123
32700
  const file = hydrateLastRunPath(mainRoot, slug);
32124
32701
  if (ok2) {
32125
- await fs73.remove(file).catch(() => void 0);
32702
+ await fs74.remove(file).catch(() => void 0);
32126
32703
  return;
32127
32704
  }
32128
32705
  const record = { slug, finishedAt: (/* @__PURE__ */ new Date()).toISOString(), ok: ok2, failures };
32129
- await fs73.ensureDir(path117.dirname(file));
32130
- await fs73.writeJson(file, record, { spaces: 2 }).catch(() => void 0);
32706
+ await fs74.ensureDir(path117.dirname(file));
32707
+ await fs74.writeJson(file, record, { spaces: 2 }).catch(() => void 0);
32131
32708
  }
32132
32709
 
32133
32710
  // src/workspace/worktree/dematerialize.ts
32134
32711
  import { existsSync as existsSync17 } from "node:fs";
32135
32712
  import path121 from "node:path";
32136
- import fs77 from "fs-extra";
32713
+ import fs78 from "fs-extra";
32137
32714
 
32138
32715
  // src/workspace/worktree/member-targets.ts
32139
32716
  import path118 from "node:path";
32140
- import fs74 from "fs-extra";
32717
+ import fs75 from "fs-extra";
32141
32718
  function normalizeStateMembers(state, allMembers) {
32142
32719
  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 }));
32143
32720
  }
@@ -32162,15 +32739,15 @@ function resolveMemberTargets(wsWorktreePath, stateMembers, allMembers) {
32162
32739
  async function filterExistingTargets(targets) {
32163
32740
  const existing = [];
32164
32741
  for (const target of targets) {
32165
- if (await fs74.pathExists(target.path)) existing.push(target);
32742
+ if (await fs75.pathExists(target.path)) existing.push(target);
32166
32743
  }
32167
32744
  return existing;
32168
32745
  }
32169
32746
  function unresolvedDroppedBlockerReason() {
32170
- 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)";
32747
+ return "no longer in the workspace config and this worktree predates absPath tracking: 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)";
32171
32748
  }
32172
32749
  function unresolvedDroppedFailureReason() {
32173
- 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";
32750
+ return "directory removed, but its git worktree registration could not be unregistered (no recorded absPath); run `git worktree prune` in its original owning repo";
32174
32751
  }
32175
32752
  async function findWorkBlockers(targets, ignorePathsFor = () => [], envLinksFor = () => []) {
32176
32753
  const blockers = [];
@@ -32194,12 +32771,12 @@ async function findWorkBlockers(targets, ignorePathsFor = () => [], envLinksFor
32194
32771
  // src/workspace/worktree/remove.ts
32195
32772
  import { existsSync as existsSync16 } from "node:fs";
32196
32773
  import path120 from "node:path";
32197
- import fs76 from "fs-extra";
32774
+ import fs77 from "fs-extra";
32198
32775
 
32199
32776
  // src/workspace/worktree/snapshot.ts
32200
32777
  import { realpathSync as realpathSync3 } from "node:fs";
32201
32778
  import path119 from "node:path";
32202
- import fs75 from "fs-extra";
32779
+ import fs76 from "fs-extra";
32203
32780
  init_exec();
32204
32781
  init_paths();
32205
32782
  function pathspec(ignore) {
@@ -32213,7 +32790,7 @@ function samePath(a, b) {
32213
32790
  }
32214
32791
  }
32215
32792
  async function probeDirty(target) {
32216
- if (!await fs75.pathExists(target.path)) return { state: "not-a-worktree" };
32793
+ if (!await fs76.pathExists(target.path)) return { state: "not-a-worktree" };
32217
32794
  const notOwnWorktree = (cause) => target.skipIfNotWorktree ? { state: "not-a-worktree" } : { state: "unknown", cause };
32218
32795
  const top = await runGit(["rev-parse", "--show-toplevel"], { cwd: target.path });
32219
32796
  if (top.exitCode !== 0) {
@@ -32270,9 +32847,9 @@ async function writeOne(target, dest) {
32270
32847
  const branch = (await gitOrThrow(["rev-parse", "--abbrev-ref", "HEAD"], target.path)).trim();
32271
32848
  const commit = (await gitOrThrow(["rev-parse", "HEAD"], target.path)).trim();
32272
32849
  const untracked = (await gitOrThrow(["ls-files", "--others", "--exclude-standard", "-z", ...spec], target.path)).split("\0").filter((entry) => entry.length > 0);
32273
- await fs75.ensureDir(dest);
32274
- await fs75.writeFile(path119.join(dest, "changes.patch"), patch);
32275
- await fs75.writeFile(
32850
+ await fs76.ensureDir(dest);
32851
+ await fs76.writeFile(path119.join(dest, "changes.patch"), patch);
32852
+ await fs76.writeFile(
32276
32853
  path119.join(dest, "HEAD.txt"),
32277
32854
  `branch: ${branch}
32278
32855
  commit: ${commit}
@@ -32281,14 +32858,14 @@ worktree: ${target.path}
32281
32858
  );
32282
32859
  for (const rel of untracked) {
32283
32860
  const to = path119.join(dest, "untracked", rel);
32284
- await fs75.ensureDir(path119.dirname(to));
32285
- await fs75.copy(path119.join(target.path, rel), to, { dereference: false });
32861
+ await fs76.ensureDir(path119.dirname(to));
32862
+ await fs76.copy(path119.join(target.path, rel), to, { dereference: false });
32286
32863
  }
32287
- if (!(await fs75.readFile(path119.join(dest, "changes.patch"))).equals(patch)) {
32864
+ if (!(await fs76.readFile(path119.join(dest, "changes.patch"))).equals(patch)) {
32288
32865
  throw new Error("changes.patch does not read back as written");
32289
32866
  }
32290
32867
  for (const rel of untracked) {
32291
- const copied = await fs75.lstat(path119.join(dest, "untracked", rel)).catch(() => void 0);
32868
+ const copied = await fs76.lstat(path119.join(dest, "untracked", rel)).catch(() => void 0);
32292
32869
  if (!copied) throw new Error(`untracked/${rel} is missing from the snapshot`);
32293
32870
  }
32294
32871
  }
@@ -32303,7 +32880,7 @@ async function snapshotDirtyWorktrees(workspaceRoot, targets) {
32303
32880
  try {
32304
32881
  await writeOne(target, path119.join(abs, target.label));
32305
32882
  } catch (err) {
32306
- await fs75.remove(abs).catch(() => {
32883
+ await fs76.remove(abs).catch(() => {
32307
32884
  });
32308
32885
  const message2 = err instanceof Error ? err.message : String(err);
32309
32886
  const fix = err instanceof GitStepError ? "fix that git error in the worktree and run again" : `fix the backup location (${dir}) and run again`;
@@ -32363,7 +32940,7 @@ async function runRemove2(opts, deps = {}) {
32363
32940
  if (!resolved.ok) return { ok: false, error: resolved.reason };
32364
32941
  const { rootInfo, workspaceRoot } = resolved;
32365
32942
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
32366
- if (!await fs76.pathExists(wsWorktreePath)) {
32943
+ if (!await fs77.pathExists(wsWorktreePath)) {
32367
32944
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
32368
32945
  }
32369
32946
  const state = await readWorktreeState(wsWorktreePath);
@@ -32443,8 +33020,8 @@ async function runRemove2(opts, deps = {}) {
32443
33020
  }
32444
33021
  await pruneWorktrees(workspaceRoot);
32445
33022
  }
32446
- if (wsRemoveOk && await fs76.pathExists(wsWorktreePath)) {
32447
- await fs76.remove(wsWorktreePath);
33023
+ if (wsRemoveOk && await fs77.pathExists(wsWorktreePath)) {
33024
+ await fs77.remove(wsWorktreePath);
32448
33025
  }
32449
33026
  return {
32450
33027
  ok: failed.length === 0,
@@ -32479,14 +33056,14 @@ async function runDematerialize(opts, deps = {}) {
32479
33056
  } catch (err) {
32480
33057
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
32481
33058
  }
32482
- if (!await fs77.pathExists(wsWorktreePath)) {
33059
+ if (!await fs78.pathExists(wsWorktreePath)) {
32483
33060
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
32484
33061
  }
32485
33062
  const lock = await readHydrateLock(workspaceRoot, slug);
32486
33063
  if (lock.held) {
32487
33064
  return {
32488
33065
  ok: false,
32489
- error: `A hydration is running for "${slug}"${lock.ownerPid !== void 0 ? ` (pid ${lock.ownerPid})` : ""} \u2014 wait for ${lock.lockPath} to disappear before dematerializing it.`
33066
+ error: `A hydration is running for "${slug}"${lock.ownerPid !== void 0 ? ` (pid ${lock.ownerPid})` : ""}. Wait for ${lock.lockPath} to disappear before dematerializing it.`
32490
33067
  };
32491
33068
  }
32492
33069
  const state = await readWorktreeState(wsWorktreePath);
@@ -32539,8 +33116,8 @@ async function runDematerialize(opts, deps = {}) {
32539
33116
  }
32540
33117
  for (const folder of unresolvedDroppedMembers) {
32541
33118
  const droppedPath = path121.join(wsWorktreePath, folder);
32542
- if (await fs77.pathExists(droppedPath)) {
32543
- await fs77.remove(droppedPath);
33119
+ if (await fs78.pathExists(droppedPath)) {
33120
+ await fs78.remove(droppedPath);
32544
33121
  failed.push({ repo: folder, error: unresolvedDroppedFailureReason() });
32545
33122
  }
32546
33123
  }
@@ -32563,14 +33140,14 @@ async function runDematerialize(opts, deps = {}) {
32563
33140
 
32564
33141
  // src/workspace/worktree/list.ts
32565
33142
  import path122 from "node:path";
32566
- import fs78 from "fs-extra";
33143
+ import fs79 from "fs-extra";
32567
33144
  async function runList2() {
32568
33145
  const resolved = await resolveWorkspaceForWorktree();
32569
33146
  if (!resolved.ok) return { ok: false, error: resolved.reason };
32570
33147
  const { rootInfo, workspaceRoot } = resolved;
32571
33148
  const dir = worktreesDir(workspaceRoot);
32572
- if (!await fs78.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
32573
- const entries = await fs78.readdir(dir, { withFileTypes: true });
33149
+ if (!await fs79.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
33150
+ const entries = await fs79.readdir(dir, { withFileTypes: true });
32574
33151
  const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
32575
33152
  const configuredMembers = await readMembers(rootInfo);
32576
33153
  const worktrees = [];
@@ -32581,7 +33158,7 @@ async function runList2() {
32581
33158
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
32582
33159
  for (const m of source) {
32583
33160
  const memberPath2 = path122.join(wtPath, m.folder);
32584
- const materialized = await fs78.pathExists(memberPath2);
33161
+ const materialized = await fs79.pathExists(memberPath2);
32585
33162
  const actualBranch = materialized ? await currentBranch(memberPath2) : void 0;
32586
33163
  members.push({
32587
33164
  id: m.id,
@@ -32604,12 +33181,12 @@ async function runList2() {
32604
33181
  // src/workspace/worktree/reclaim.ts
32605
33182
  import { existsSync as existsSync18 } from "node:fs";
32606
33183
  import path123 from "node:path";
32607
- import fs79 from "fs-extra";
33184
+ import fs80 from "fs-extra";
32608
33185
  var MS_PER_DAY = 24 * 60 * 60 * 1e3;
32609
33186
  async function lastTouchedMs(worktreeRoot) {
32610
33187
  const candidates = [worktreeRoot, path123.join(worktreeRoot, ".haus-worktree.json")];
32611
33188
  try {
32612
- const dotGit = await fs79.readFile(path123.join(worktreeRoot, ".git"), "utf8");
33189
+ const dotGit = await fs80.readFile(path123.join(worktreeRoot, ".git"), "utf8");
32613
33190
  const match = /^gitdir:\s*(.+)$/m.exec(dotGit);
32614
33191
  if (match?.[1]) {
32615
33192
  const gitdir = match[1].trim();
@@ -32619,7 +33196,7 @@ async function lastTouchedMs(worktreeRoot) {
32619
33196
  }
32620
33197
  let newest = 0;
32621
33198
  for (const candidate of candidates) {
32622
- const stat2 = await fs79.stat(candidate).catch(() => void 0);
33199
+ const stat2 = await fs80.stat(candidate).catch(() => void 0);
32623
33200
  if (stat2 && stat2.mtimeMs > newest) newest = stat2.mtimeMs;
32624
33201
  }
32625
33202
  return newest;
@@ -32646,7 +33223,7 @@ async function runReclaim(opts = {}, deps = {}) {
32646
33223
  if (!existsSync18(dir)) {
32647
33224
  return { ok: true, dryRun: !opts.write, olderThanDays, candidates: [], reclaimed: 0 };
32648
33225
  }
32649
- const allEntries = (await fs79.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
33226
+ const allEntries = (await fs80.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
32650
33227
  const entries = allEntries.filter((name) => isValidWorktreeSlug(name));
32651
33228
  const unaddressable = allEntries.filter((name) => !isValidWorktreeSlug(name));
32652
33229
  const members = await readMembers(rootInfo);
@@ -32659,7 +33236,7 @@ async function runReclaim(opts = {}, deps = {}) {
32659
33236
  ageDays: (Date.now() - await lastTouchedMs(path123.join(dir, name))) / MS_PER_DAY,
32660
33237
  materializedMembers: 0,
32661
33238
  action: "skip",
32662
- 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"
33239
+ reason: "directory name is not a valid worktree slug: no haus worktree command can address it; remove it by hand if it is debris"
32663
33240
  });
32664
33241
  }
32665
33242
  for (const slug of entries) {
@@ -32812,10 +33389,10 @@ async function handleAdd(opts) {
32812
33389
  const hydrationNote = m.hydration ? describeInstall(m.hydration) : "not hydrated (--no-hydrate)";
32813
33390
  emitMemberLine(
32814
33391
  m.hydration,
32815
- ` ${m.member}: ${m.branchAction} "${m.branch}" \u2014 ${hydrationNote} (${m.durationMs}ms)`
33392
+ ` ${m.member}: ${m.branchAction} "${m.branch}": ${hydrationNote} (${m.durationMs}ms)`
32816
33393
  );
32817
33394
  } else {
32818
- warn(` ${m.member}: FAILED \u2014 ${m.error}`);
33395
+ warn(` ${m.member}: FAILED: ${m.error}`);
32819
33396
  }
32820
33397
  }
32821
33398
  if (!result.ok) {
@@ -32862,7 +33439,7 @@ async function handleHydrate(opts) {
32862
33439
  warn(` ${refusedTargetLine(r.member, refused)}`);
32863
33440
  }
32864
33441
  } else {
32865
- warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
33442
+ warn(` ${r.member}: ${r.status}${r.error ? `: ${r.error}` : ""}`);
32866
33443
  }
32867
33444
  }
32868
33445
  if (!result.ok) {
@@ -32902,7 +33479,7 @@ async function handleRemove(opts) {
32902
33479
  return;
32903
33480
  }
32904
33481
  if (!result.ok && "blocked" in result && result.blocked) {
32905
- error(`Refusing to remove "${opts.slug}" \u2014 uncommitted or unpushed work found:`);
33482
+ error(`Refusing to remove "${opts.slug}": uncommitted or unpushed work found:`);
32906
33483
  for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
32907
33484
  error("Re-run with --force to remove anyway.");
32908
33485
  process.exitCode = 1;
@@ -32961,7 +33538,7 @@ function pruneLines(r) {
32961
33538
  const left = r.orphans.filter((o) => !r.removed.includes(o));
32962
33539
  if (left.length > 0) {
32963
33540
  const label = r.error === STILL_REGISTERED_ERROR ? "Not pruned" : "FAILED to prune";
32964
- const detail = r.error ? ` \u2014 ${r.error}` : "";
33541
+ const detail = r.error ? `: ${r.error}` : "";
32965
33542
  lines.push(`${label} ${r.repo}: ${left.join(", ")}${detail}`);
32966
33543
  }
32967
33544
  return lines;
@@ -33053,7 +33630,7 @@ function describeAutoHydrate(result) {
33053
33630
  switch (result.status) {
33054
33631
  case "started":
33055
33632
  lines.push(
33056
- `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}`
33633
+ `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: \`haus workspace worktree doctor\` turns green before the work is finished. Log: ${result.logPath}`
33057
33634
  );
33058
33635
  break;
33059
33636
  case "in-progress":
@@ -33129,7 +33706,7 @@ async function handleDematerialize(opts) {
33129
33706
  return;
33130
33707
  }
33131
33708
  if (!result.ok && "blocked" in result) {
33132
- error(`Refusing to dematerialize "${result.slug}" \u2014 uncommitted or unpushed work found:`);
33709
+ error(`Refusing to dematerialize "${result.slug}": uncommitted or unpushed work found:`);
33133
33710
  for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
33134
33711
  error("Re-run with --force to dematerialize anyway.");
33135
33712
  process.exitCode = 1;
@@ -33150,7 +33727,7 @@ async function handleDematerialize(opts) {
33150
33727
  if (result.backup) log(snapshotLine(result.backup));
33151
33728
  logEnvironment(result.environment);
33152
33729
  log(
33153
- `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.`
33730
+ `Dematerialized "${result.slug}": ${result.removed.join(", ") || "(nothing)"}. The workspace worktree and its branch are untouched. Run \`haus workspace worktree hydrate --materialize\` to bring the members back.`
33154
33731
  );
33155
33732
  if (result.failed.length > 0) {
33156
33733
  for (const f of result.failed) error(` Failed to dematerialize ${f.repo}: ${f.error}`);
@@ -33190,14 +33767,14 @@ async function handleReclaim(opts) {
33190
33767
  );
33191
33768
  break;
33192
33769
  case "blocked":
33193
- warn(`Blocked ${c.slug} (${age}) \u2014 uncommitted or unpushed work:`);
33770
+ warn(`Blocked ${c.slug} (${age}): uncommitted or unpushed work:`);
33194
33771
  for (const b of c.blockers ?? []) warn(` ${b.repo}: ${b.reason}`);
33195
33772
  break;
33196
33773
  case "failed":
33197
33774
  error(`FAILED ${c.slug} (${age}): ${c.reason ?? "unknown error"}`);
33198
33775
  break;
33199
33776
  case "skip":
33200
- log(`Keeping ${c.slug} (${age}) \u2014 ${c.reason ?? "not a candidate"}`);
33777
+ log(`Keeping ${c.slug} (${age}): ${c.reason ?? "not a candidate"}`);
33201
33778
  break;
33202
33779
  }
33203
33780
  }
@@ -33476,17 +34053,17 @@ init_paths();
33476
34053
  // src/workspace/link-context/copy-record.ts
33477
34054
  import os13 from "node:os";
33478
34055
  import path127 from "node:path";
33479
- import fs80 from "fs-extra";
34056
+ import fs81 from "fs-extra";
33480
34057
  init_fs();
33481
34058
  init_paths();
33482
34059
  async function copyWithoutLinks(source, dest) {
33483
- await fs80.copy(source, dest, {
34060
+ await fs81.copy(source, dest, {
33484
34061
  overwrite: true,
33485
34062
  errorOnExist: false,
33486
34063
  dereference: false,
33487
34064
  filter: (s) => {
33488
34065
  try {
33489
- return !fs80.lstatSync(s).isSymbolicLink();
34066
+ return !fs81.lstatSync(s).isSymbolicLink();
33490
34067
  } catch {
33491
34068
  return false;
33492
34069
  }
@@ -33494,11 +34071,11 @@ async function copyWithoutLinks(source, dest) {
33494
34071
  });
33495
34072
  }
33496
34073
  async function withStage(fn) {
33497
- const stage = await fs80.mkdtemp(path127.join(os13.tmpdir(), "haus-staged-link-"));
34074
+ const stage = await fs81.mkdtemp(path127.join(os13.tmpdir(), "haus-staged-link-"));
33498
34075
  try {
33499
34076
  return await fn(stage);
33500
34077
  } finally {
33501
- await fs80.remove(stage);
34078
+ await fs81.remove(stage);
33502
34079
  }
33503
34080
  }
33504
34081
  async function stagedCopyHash(sourceAbs, destRel) {
@@ -33516,7 +34093,7 @@ async function recordedCopyHash(workspaceRoot, entry) {
33516
34093
  const rel = recordedRel(entry.path);
33517
34094
  if (await unsafeTrackedPath(workspaceRoot, rel) !== null) return void 0;
33518
34095
  const copyAbs = path127.resolve(workspaceRoot, rel);
33519
- if (!await fs80.pathExists(copyAbs)) return void 0;
34096
+ if (!await fs81.pathExists(copyAbs)) return void 0;
33520
34097
  if (!await holdsOnlyPlainFiles(copyAbs)) return void 0;
33521
34098
  const sourceRel = entry.sourceRelPath.replace(/\\/g, "/");
33522
34099
  const staged = await withStage(async (stage) => {
@@ -33542,7 +34119,10 @@ var EDITED_COPY_REASON = {
33542
34119
  };
33543
34120
  var UNSAFE_PATH_LINE = {
33544
34121
  "escapes-root": (rel) => `Refusing to remove linked context path outside the workspace root: ${rel}`,
33545
- symlink: (rel) => `Refusing to remove linked context path through a symlink: ${rel}`
34122
+ symlink: (rel) => `Refusing to remove linked context path through a symlink: ${rel}`,
34123
+ // A copy lands below the workspace root's `.claude/` only, so an entry naming anything else
34124
+ // was edited by hand (#758): its path is left, and so is the entry.
34125
+ "outside-install-root": (rel) => `Refusing to remove linked context path ${rel}: haus never copies there (only into a copy's own path below .claude/), so it leaves it. Delete the entry from .haus-workflow/workspace.manifest.json by hand.`
33546
34126
  };
33547
34127
  async function removeLinkedCopies(workspaceRoot, entries, opts, say) {
33548
34128
  const result = {
@@ -33804,7 +34384,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
33804
34384
  flag({
33805
34385
  repo: "(workspace)",
33806
34386
  kind: "no-config",
33807
- detail: "Missing or malformed haus.workspace.yaml \u2014 run `haus workspace discover --write` or `init`."
34387
+ detail: "Missing or malformed haus.workspace.yaml. Run `haus workspace discover --write` or `init`."
33808
34388
  });
33809
34389
  return report2();
33810
34390
  }
@@ -33831,7 +34411,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
33831
34411
  flag({
33832
34412
  repo: "(workspace)",
33833
34413
  kind: "no-manifest",
33834
- detail: "No workspace.manifest.json \u2014 run `haus workspace setup --write` first."
34414
+ detail: "No workspace.manifest.json. Run `haus workspace setup --write` first."
33835
34415
  });
33836
34416
  return report2();
33837
34417
  }
@@ -33839,7 +34419,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
33839
34419
  flag({
33840
34420
  repo: "(workspace)",
33841
34421
  kind: "no-manifest",
33842
- detail: 'workspace.manifest.json\'s "repos" field is not an array \u2014 run `haus workspace setup --write` to regenerate it.'
34422
+ detail: 'workspace.manifest.json\'s "repos" field is not an array. Run `haus workspace setup --write` to regenerate it.'
33843
34423
  });
33844
34424
  return report2();
33845
34425
  }
@@ -33852,7 +34432,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
33852
34432
  flag({
33853
34433
  repo: repo.name,
33854
34434
  kind: "missing-from-manifest",
33855
- detail: "Configured in yaml but absent from the manifest \u2014 run `haus workspace setup --write`."
34435
+ detail: "Configured in yaml but absent from the manifest. Run `haus workspace setup --write`."
33856
34436
  });
33857
34437
  continue;
33858
34438
  }
@@ -33868,14 +34448,14 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
33868
34448
  flag({
33869
34449
  repo: repo.name,
33870
34450
  kind: "version-mismatch",
33871
- detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
34451
+ detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion}. Re-run setup.`
33872
34452
  });
33873
34453
  }
33874
34454
  if (!existsSync19(claudePath(repoRoot))) {
33875
34455
  flag({
33876
34456
  repo: repo.name,
33877
34457
  kind: "missing-claude",
33878
- detail: "Missing .claude/ \u2014 run `haus workspace setup --write`."
34458
+ detail: "Missing .claude/. Run `haus workspace setup --write`."
33879
34459
  });
33880
34460
  }
33881
34461
  const lock = await checkLock(repoRoot);
@@ -33888,7 +34468,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
33888
34468
  flag({
33889
34469
  repo: repo.name,
33890
34470
  kind: "missing-lock",
33891
- detail: "Missing .haus-workflow/haus.lock.json \u2014 run `haus workspace setup --write`."
34471
+ detail: "Missing .haus-workflow/haus.lock.json. Run `haus workspace setup --write`."
33892
34472
  });
33893
34473
  } else if (lock.count > 0 && !lock.ok) {
33894
34474
  flag({ repo: repo.name, kind: "invalid-lock", detail: describeUnhealthyLock(lock) });
@@ -33902,7 +34482,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
33902
34482
  flag({
33903
34483
  repo: "(workspace)",
33904
34484
  kind: "catalog-ref-mismatch",
33905
- detail: `Repos are on different catalog refs \u2014 ${catalogRefByRepo.map((r) => `${r.repo}: ${r.ref}`).join(", ")}. Run \`haus workspace setup --write\` to bring them onto the same ref.`
34485
+ detail: `Repos are on different catalog refs: ${catalogRefByRepo.map((r) => `${r.repo}: ${r.ref}`).join(", ")}. Run \`haus workspace setup --write\` to bring them onto the same ref.`
33906
34486
  });
33907
34487
  }
33908
34488
  if (manifest.linkedContext && manifest.linkedContext.length > 0) {
@@ -34070,7 +34650,7 @@ function describeUnhealthyLock(lock) {
34070
34650
  );
34071
34651
  }
34072
34652
  if (parts.length === 0) {
34073
- return "haus.lock.json present but invalid (e.g. a malformed version) \u2014 re-run `haus workspace setup --write`.";
34653
+ return "haus.lock.json present but invalid (e.g. a malformed version). Re-run `haus workspace setup --write`.";
34074
34654
  }
34075
34655
  return `haus.lock.json: ${parts.join("; ")}.`;
34076
34656
  }
@@ -34081,7 +34661,7 @@ init_logger();
34081
34661
  // src/workspace/link-context/link.ts
34082
34662
  import { rm } from "node:fs/promises";
34083
34663
  import path132 from "node:path";
34084
- import fs82 from "fs-extra";
34664
+ import fs83 from "fs-extra";
34085
34665
  init_fs();
34086
34666
  init_logger();
34087
34667
  init_paths();
@@ -34091,7 +34671,7 @@ import path131 from "node:path";
34091
34671
 
34092
34672
  // src/workspace/link-context/scan-source.ts
34093
34673
  import path130 from "node:path";
34094
- import fs81 from "fs-extra";
34674
+ import fs82 from "fs-extra";
34095
34675
  var ASSET_SUBDIR = {
34096
34676
  skill: "skills",
34097
34677
  agent: "agents",
@@ -34099,7 +34679,7 @@ var ASSET_SUBDIR = {
34099
34679
  };
34100
34680
  async function sourceExists(abs) {
34101
34681
  try {
34102
- await fs81.stat(abs);
34682
+ await fs82.stat(abs);
34103
34683
  return true;
34104
34684
  } catch (err) {
34105
34685
  const code2 = err.code;
@@ -34116,11 +34696,11 @@ async function hasLinkableContext(member) {
34116
34696
  async function collectSkills(member) {
34117
34697
  const dir = path130.join(member.absPath, ".claude", "skills");
34118
34698
  if (!await sourceExists(dir)) return [];
34119
- const names4 = await fs81.readdir(dir);
34699
+ const names4 = await fs82.readdir(dir);
34120
34700
  const out = [];
34121
34701
  for (const name of [...names4].sort()) {
34122
34702
  const abs = path130.join(dir, name);
34123
- const stat2 = await fs81.lstat(abs);
34703
+ const stat2 = await fs82.lstat(abs);
34124
34704
  if (!stat2.isDirectory()) continue;
34125
34705
  if (!await sourceExists(path130.join(abs, "SKILL.md"))) continue;
34126
34706
  out.push({ type: "skill", name, sourceRelPath: path130.posix.join(".claude", "skills", name) });
@@ -34131,12 +34711,12 @@ async function collectFlatMarkdown(member, type) {
34131
34711
  const subdir = ASSET_SUBDIR[type];
34132
34712
  const dir = path130.join(member.absPath, ".claude", subdir);
34133
34713
  if (!await sourceExists(dir)) return [];
34134
- const names4 = await fs81.readdir(dir);
34714
+ const names4 = await fs82.readdir(dir);
34135
34715
  const out = [];
34136
34716
  for (const fileName of [...names4].sort()) {
34137
34717
  if (!fileName.endsWith(".md")) continue;
34138
34718
  const abs = path130.join(dir, fileName);
34139
- if (!(await fs81.lstat(abs)).isFile()) continue;
34719
+ if (!(await fs82.lstat(abs)).isFile()) continue;
34140
34720
  const name = fileName.slice(0, -".md".length);
34141
34721
  out.push({ type, name, sourceRelPath: path130.posix.join(".claude", subdir, fileName) });
34142
34722
  }
@@ -34265,7 +34845,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
34265
34845
  const detail = plan.collisions.map((c) => `${c.type} "${c.destKey}" claimed by: ${c.repos.join(", ")}`).join("; ");
34266
34846
  return {
34267
34847
  ok: false,
34268
- error: `Name collision(s) after the repo-folder prefix \u2014 refusing to overwrite either copy. Rename one of the conflicting repos' folders (in haus.workspace.yaml/repos.manifest.json) or the conflicting skill/agent/command itself to resolve: ${detail}`,
34848
+ error: `Name collision(s) after the repo-folder prefix, refusing to overwrite either copy. Rename one of the conflicting repos' folders (in haus.workspace.yaml/repos.manifest.json) or the conflicting skill/agent/command itself to resolve: ${detail}`,
34269
34849
  collisions: plan.collisions,
34270
34850
  refusedFiles: []
34271
34851
  };
@@ -34499,8 +35079,8 @@ async function swapInCopy(workspaceRoot, sourceAbs, destRel) {
34499
35079
  const destAbs = path132.join(workspaceRoot, destRel);
34500
35080
  let folder;
34501
35081
  try {
34502
- await fs82.ensureDir(path132.dirname(destAbs));
34503
- folder = await fs82.mkdtemp(path132.join(path132.dirname(destAbs), STAGING_PREFIX));
35082
+ await fs83.ensureDir(path132.dirname(destAbs));
35083
+ folder = await fs83.mkdtemp(path132.join(path132.dirname(destAbs), STAGING_PREFIX));
34504
35084
  } catch (err) {
34505
35085
  return { failure: messageOf(err) };
34506
35086
  }
@@ -34519,13 +35099,13 @@ async function swapInCopy(workspaceRoot, sourceAbs, destRel) {
34519
35099
  }
34520
35100
  if (recordFailure === void 0) {
34521
35101
  const hadOld = await isOnDisk2(destAbs);
34522
- if (hadOld) await fs82.rename(destAbs, aside);
35102
+ if (hadOld) await fs83.rename(destAbs, aside);
34523
35103
  try {
34524
- await fs82.rename(staged, destAbs);
35104
+ await fs83.rename(staged, destAbs);
34525
35105
  } catch (err) {
34526
35106
  if (hadOld) {
34527
35107
  try {
34528
- await fs82.rename(aside, destAbs);
35108
+ await fs83.rename(aside, destAbs);
34529
35109
  } catch (back) {
34530
35110
  return {
34531
35111
  failure: `${messageOf(err)}; putting the old copy back failed too (${messageOf(back)})`,
@@ -34549,7 +35129,7 @@ async function swapInCopy(workspaceRoot, sourceAbs, destRel) {
34549
35129
  }
34550
35130
  async function isOnDisk2(abs) {
34551
35131
  try {
34552
- await fs82.lstat(abs);
35132
+ await fs83.lstat(abs);
34553
35133
  return true;
34554
35134
  } catch (err) {
34555
35135
  const code2 = err.code;
@@ -34688,7 +35268,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
34688
35268
  continue;
34689
35269
  }
34690
35270
  if (!res.setupOk) {
34691
- const catalogReason = !res.catalogInstallOk ? "No catalog items were installed for this repo (zero installable content) \u2014 see the per-repo output above, or re-run with --allow-empty-cache." : void 0;
35271
+ const catalogReason = !res.catalogInstallOk ? "No catalog items were installed for this repo (zero installable content). See the per-repo output above, or re-run with --allow-empty-cache." : void 0;
34692
35272
  const reasons = [
34693
35273
  ...catalogReason === void 0 ? [] : [catalogReason],
34694
35274
  ...res.refusals ?? []
@@ -34844,7 +35424,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
34844
35424
  const manifest = buildManifest2({
34845
35425
  client: config.client,
34846
35426
  repos: manifestRepos,
34847
- // buildManifest rebuilds the manifest from scratch each run — carry the prior
35427
+ // buildManifest rebuilds the manifest from scratch each run, carry the prior
34848
35428
  // `linkedContext` section forward so a plain `setup --write` never silently
34849
35429
  // drops entries `link-context` wrote on an earlier run.
34850
35430
  linkedContext: prior?.linkedContext
@@ -34935,7 +35515,7 @@ async function linkContextStep(workspaceRoot, json, written, say) {
34935
35515
  // src/commands/workspace/undo.ts
34936
35516
  import { existsSync as existsSync21, statSync as statSync2 } from "node:fs";
34937
35517
  import path134 from "node:path";
34938
- import fs83 from "fs-extra";
35518
+ import fs84 from "fs-extra";
34939
35519
  init_fs();
34940
35520
  init_logger();
34941
35521
  init_paths();
@@ -35015,7 +35595,7 @@ async function planWorkspaceRoot(workspaceRoot, config) {
35015
35595
  const aggregates = [];
35016
35596
  for (const name of WORKSPACE_AGGREGATE_FILES) {
35017
35597
  const abs = hausPath(workspaceRoot, name);
35018
- if (await fs83.pathExists(abs)) aggregates.push({ abs, rel: `.haus-workflow/${name}` });
35598
+ if (await fs84.pathExists(abs)) aggregates.push({ abs, rel: `.haus-workflow/${name}` });
35019
35599
  }
35020
35600
  if (manifestRead.status === "refused") {
35021
35601
  return {
@@ -35088,7 +35668,7 @@ async function settleManifest(workspaceRoot, kept, dryRun) {
35088
35668
  log(`[dry-run] would remove ${WORKSPACE_MANIFEST_FILE}`);
35089
35669
  return false;
35090
35670
  }
35091
- await fs83.remove(manifestPath(workspaceRoot));
35671
+ await fs84.remove(manifestPath(workspaceRoot));
35092
35672
  log(`Removed ${WORKSPACE_MANIFEST_FILE}`);
35093
35673
  return false;
35094
35674
  }
@@ -35174,11 +35754,11 @@ async function undoWorkspaceRoot(plan, workspaceRoot, force) {
35174
35754
  );
35175
35755
  if (removal.exitCode === 1) owesExit = true;
35176
35756
  for (const { abs, rel } of plan.aggregates) {
35177
- await fs83.remove(abs);
35757
+ await fs84.remove(abs);
35178
35758
  log(`Removed ${rel}`);
35179
35759
  }
35180
35760
  if (docGoes) {
35181
- await fs83.remove(docFile.abs);
35761
+ await fs84.remove(docFile.abs);
35182
35762
  log(
35183
35763
  doc.kind === "kept" ? `Removed ${WORKSPACE_DOC_FILE} (${doc.reason}; removed with --force)` : `Removed ${WORKSPACE_DOC_FILE}`
35184
35764
  );
@@ -35186,10 +35766,10 @@ async function undoWorkspaceRoot(plan, workspaceRoot, force) {
35186
35766
  if (strip !== null) {
35187
35767
  const what = strip.legacy ? "pre-upgrade workspace haus import block" : "workspace haus import block";
35188
35768
  if (strip.remaining.length === 0) {
35189
- await fs83.remove(claudeMd.abs);
35769
+ await fs84.remove(claudeMd.abs);
35190
35770
  log(`Removed CLAUDE.md (only contained the ${what}).`);
35191
35771
  } else {
35192
- await fs83.writeFile(claudeMd.abs, strip.remaining, "utf8");
35772
+ await fs84.writeFile(claudeMd.abs, strip.remaining, "utf8");
35193
35773
  log(
35194
35774
  strip.legacy ? "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)." : "Removed haus import block from CLAUDE.md (user content preserved)."
35195
35775
  );
@@ -35552,7 +36132,7 @@ catalogBundle.command("import <file>").description("Unpack a catalog bundle into
35552
36132
  );
35553
36133
  program.command("update").description("Sync the remote catalog cache, refresh the lockfile, and check for CLI updates").option("--check").option("--fast", "With --check, skip per-item content hashing (cheap count+ref only)").option("--from-hook", "Emit JSON for the Claude Code SessionStart hook (silent if up to date)").action(runUpdate);
35554
36134
  program.command("ci-gate").description(
35555
- "Aggregate `doctor` + `decisions check` + `update --check --fast` into one pass/fail gate for CI. Uses the fast/cheap update-check tier (no per-item content hashing) to keep CI runs quick \u2014 run `haus update --check` directly for the fully-hashed tier. Each constituent command remains independently runnable with its own unchanged exit-code contract."
36135
+ "Aggregate `doctor` + `decisions check` + `update --check --fast` into one pass/fail gate for CI. Uses the fast/cheap update-check tier (no per-item content hashing) to keep CI runs quick. Run `haus update --check` directly for the fully-hashed tier. Each constituent command remains independently runnable with its own unchanged exit-code contract."
35556
36136
  ).option("--json", "Emit machine-readable {doctor, decisions, update, ok}").option(
35557
36137
  "--no-update",
35558
36138
  "Skip the catalog leg (update --check), which needs the network; the Lefthook gate uses it"
@@ -35683,7 +36263,7 @@ worktree.command("prune").description(
35683
36263
  );
35684
36264
  worktree.command("autohydrate").description(
35685
36265
  "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)"
35686
- ).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(
36266
+ ).option("--from-hook", "Emit for the SessionStart hook (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(
35687
36267
  (opts) => runWorktree("autohydrate", {
35688
36268
  fromHook: opts.fromHook,
35689
36269
  dryRun: opts.dryRun,
@@ -35702,7 +36282,7 @@ worktree.command("dematerialize [slug]").description(
35702
36282
  })
35703
36283
  );
35704
36284
  worktree.command("reclaim").description(
35705
- "Dematerialize workspace worktrees untouched for longer than `worktree.reclaimAfterDays` \u2014 previews unless --write (ADR-0075)"
36285
+ "Dematerialize workspace worktrees untouched for longer than `worktree.reclaimAfterDays`; previews unless --write (ADR-0075)"
35706
36286
  ).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(
35707
36287
  (opts) => runWorktree("reclaim", {
35708
36288
  olderThanDays: opts.olderThanDays,
@@ -35710,7 +36290,7 @@ worktree.command("reclaim").description(
35710
36290
  json: opts.json
35711
36291
  })
35712
36292
  );
35713
- 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 }));
36293
+ worktree.command("doctor").description("Fast, side-effect-free health check for the current workspace worktree").option("--from-hook", "Emit for the SessionStart hook (always exits 0)").action((opts) => runWorktree("doctor", { fromHook: opts.fromHook }));
35714
36294
  var worktreeEnv = worktree.command("env").description(
35715
36295
  "Per-worktree local environment from localdev.yml's `instance:` section: own ports, databases, Redis index, index prefix and site (ADR-0163; enabled by `worktree.env: true`)"
35716
36296
  );