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