@haus-tech/haus-workflow 4.6.0 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/cli.js +1509 -307
- package/library/catalog/manifest.json +415 -338
- package/library/catalog/validation-rules.json +6 -2
- package/library/global/agents/request-assessor.md +44 -0
- package/library/global/agents/request-classifier.md +55 -0
- package/library/global/agents/request-scorer.md +54 -0
- package/library/global/agents/request-specifier-eskalerad.md +43 -0
- package/library/global/agents/request-specifier.md +44 -0
- package/library/global/settings-fragments/hooks.json +1 -1
- package/library/global/skills/request/SKILL.md +446 -0
- package/library/global/skills/request/scripts/compare-classifications.mjs +94 -0
- package/library/global/skills/request/scripts/compare-scores.mjs +50 -0
- package/library/global/skills/request/scripts/contract-check.mjs +334 -0
- package/library/global/skills/request/scripts/lib.mjs +113 -0
- package/library/global/skills/request/scripts/precheck.mjs +217 -0
- package/library/global/skills/request/scripts/ref-hash.mjs +22 -0
- 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();
|
|
@@ -8898,6 +9045,17 @@ var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
|
8898
9045
|
var MD_SUFFIX = " -->";
|
|
8899
9046
|
var FM_FENCE = "---";
|
|
8900
9047
|
var FM_KEY = "haus_managed";
|
|
9048
|
+
var JS_PREFIX = "// HAUS-MANAGED";
|
|
9049
|
+
var JS_EXT_RE = /\.[mc]?js$/i;
|
|
9050
|
+
var MD_EXT_RE = /\.(md|markdown)$/i;
|
|
9051
|
+
function stampableMarkerForm(destPath) {
|
|
9052
|
+
if (JS_EXT_RE.test(destPath)) return "js";
|
|
9053
|
+
if (MD_EXT_RE.test(destPath)) return "markdown";
|
|
9054
|
+
return void 0;
|
|
9055
|
+
}
|
|
9056
|
+
function isShebang(line2) {
|
|
9057
|
+
return (line2 ?? "").startsWith("#!");
|
|
9058
|
+
}
|
|
8901
9059
|
function parseAttrs(raw) {
|
|
8902
9060
|
const attrs = parseHausManagedAttrs(raw) ?? parseHausManagedAttrs(`<!-- HAUS-MANAGED ${raw.trim()} -->`);
|
|
8903
9061
|
if (!attrs?.id || !attrs.v || !attrs.source) return void 0;
|
|
@@ -8920,8 +9078,11 @@ function frontmatterMarkerValue(content2) {
|
|
|
8920
9078
|
return void 0;
|
|
8921
9079
|
}
|
|
8922
9080
|
function parseMarkdownHeader(content2) {
|
|
8923
|
-
const
|
|
9081
|
+
const lines = content2.split("\n");
|
|
9082
|
+
const firstLine = lines[0] ?? "";
|
|
8924
9083
|
if (firstLine.startsWith(MD_PREFIX)) return parseAttrs(firstLine);
|
|
9084
|
+
const jsLine = isShebang(firstLine) ? lines[1] ?? "" : firstLine;
|
|
9085
|
+
if (jsLine.startsWith(JS_PREFIX)) return parseAttrs(jsLine.slice(JS_PREFIX.length));
|
|
8925
9086
|
const fmValue = frontmatterMarkerValue(content2);
|
|
8926
9087
|
if (fmValue !== void 0) return parseAttrs(fmValue);
|
|
8927
9088
|
return void 0;
|
|
@@ -8956,7 +9117,18 @@ function stampFrontmatter(content2, h) {
|
|
|
8956
9117
|
];
|
|
8957
9118
|
return rebuilt.join("\n");
|
|
8958
9119
|
}
|
|
8959
|
-
function
|
|
9120
|
+
function buildJsHeader(h) {
|
|
9121
|
+
return `${JS_PREFIX} ${buildMarkerValue(h)}`;
|
|
9122
|
+
}
|
|
9123
|
+
function stampJs(content2, h) {
|
|
9124
|
+
const lines = content2.split("\n");
|
|
9125
|
+
const at = isShebang(lines[0]) ? 1 : 0;
|
|
9126
|
+
if ((lines[at] ?? "").startsWith(JS_PREFIX)) lines[at] = buildJsHeader(h);
|
|
9127
|
+
else lines.splice(at, 0, buildJsHeader(h));
|
|
9128
|
+
return lines.join("\n");
|
|
9129
|
+
}
|
|
9130
|
+
function stampMarkdown(content2, h, destPath) {
|
|
9131
|
+
if (destPath !== void 0 && stampableMarkerForm(destPath) === "js") return stampJs(content2, h);
|
|
8960
9132
|
if (hasFrontmatter(content2)) return stampFrontmatter(content2, h);
|
|
8961
9133
|
const header = buildMarkdownHeader(h);
|
|
8962
9134
|
const firstLine = content2.split("\n")[0] ?? "";
|
|
@@ -8973,7 +9145,7 @@ ${content2}`;
|
|
|
8973
9145
|
// src/install/apply.ts
|
|
8974
9146
|
var SCHEMA_VERSION2 = "1";
|
|
8975
9147
|
function hashContent(content2) {
|
|
8976
|
-
return `sha256-${
|
|
9148
|
+
return `sha256-${crypto3.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
|
|
8977
9149
|
}
|
|
8978
9150
|
function destFileMissing(destPath) {
|
|
8979
9151
|
try {
|
|
@@ -9010,6 +9182,21 @@ function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
|
|
|
9010
9182
|
}
|
|
9011
9183
|
return entries;
|
|
9012
9184
|
}
|
|
9185
|
+
function collectFlatChannel(srcDir, claudeDir, dirName, idPrefix) {
|
|
9186
|
+
const entries = [];
|
|
9187
|
+
const channelDir = path47.join(srcDir, dirName);
|
|
9188
|
+
if (!fs40.pathExistsSync(channelDir)) return entries;
|
|
9189
|
+
for (const fileName of fs40.readdirSync(channelDir)) {
|
|
9190
|
+
if (!fileName.endsWith(".md")) continue;
|
|
9191
|
+
const name = fileName.slice(0, -".md".length);
|
|
9192
|
+
entries.push({
|
|
9193
|
+
stableId: `${idPrefix}.${name}`,
|
|
9194
|
+
srcRelPath: path47.join("library", "global", dirName, fileName),
|
|
9195
|
+
destPath: path47.join(claudeDir, dirName, fileName)
|
|
9196
|
+
});
|
|
9197
|
+
}
|
|
9198
|
+
return entries;
|
|
9199
|
+
}
|
|
9013
9200
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
9014
9201
|
const entries = [];
|
|
9015
9202
|
const skillsDir = path47.join(srcDir, "skills");
|
|
@@ -9026,18 +9213,8 @@ function collectSourceFiles(srcDir, claudeDir) {
|
|
|
9026
9213
|
}
|
|
9027
9214
|
}
|
|
9028
9215
|
}
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
for (const fileName of fs40.readdirSync(commandsDir)) {
|
|
9032
|
-
if (!fileName.endsWith(".md")) continue;
|
|
9033
|
-
const commandName = fileName.slice(0, -".md".length);
|
|
9034
|
-
entries.push({
|
|
9035
|
-
stableId: `command.${commandName}`,
|
|
9036
|
-
srcRelPath: path47.join("library", "global", "commands", fileName),
|
|
9037
|
-
destPath: path47.join(claudeDir, "commands", fileName)
|
|
9038
|
-
});
|
|
9039
|
-
}
|
|
9040
|
-
}
|
|
9216
|
+
entries.push(...collectFlatChannel(srcDir, claudeDir, "commands", "command"));
|
|
9217
|
+
entries.push(...collectFlatChannel(srcDir, claudeDir, "agents", "agent"));
|
|
9041
9218
|
return entries;
|
|
9042
9219
|
}
|
|
9043
9220
|
async function applyInstall(options = {}) {
|
|
@@ -9072,11 +9249,19 @@ async function applyInstall(options = {}) {
|
|
|
9072
9249
|
warn(`Source file not found: ${entry.srcRelPath}`);
|
|
9073
9250
|
continue;
|
|
9074
9251
|
}
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9252
|
+
if (stampableMarkerForm(entry.destPath) === void 0)
|
|
9253
|
+
throw new Error(
|
|
9254
|
+
`No safe ownership-marker form for ${entry.srcRelPath} \u2014 only .md and .js/.mjs/.cjs aux files can ship in library/global (see ADR-0072)`
|
|
9255
|
+
);
|
|
9256
|
+
const stamped = stampMarkdown(
|
|
9257
|
+
rawContent,
|
|
9258
|
+
{
|
|
9259
|
+
stableId: entry.stableId,
|
|
9260
|
+
schemaVersion: SCHEMA_VERSION2,
|
|
9261
|
+
source
|
|
9262
|
+
},
|
|
9263
|
+
entry.destPath
|
|
9264
|
+
);
|
|
9080
9265
|
const newHash = hashContent(stamped);
|
|
9081
9266
|
const existing = manifestByDest.get(entry.destPath);
|
|
9082
9267
|
if (check) {
|
|
@@ -10307,20 +10492,246 @@ async function runFetchRefs(options) {
|
|
|
10307
10492
|
}
|
|
10308
10493
|
}
|
|
10309
10494
|
|
|
10310
|
-
// src/security/
|
|
10311
|
-
|
|
10312
|
-
|
|
10313
|
-
|
|
10495
|
+
// src/security/shell-tokens.ts
|
|
10496
|
+
var WRAPPER_COMMANDS = /* @__PURE__ */ new Set([
|
|
10497
|
+
"builtin",
|
|
10498
|
+
"command",
|
|
10499
|
+
"env",
|
|
10500
|
+
"exec",
|
|
10501
|
+
"ionice",
|
|
10502
|
+
"nice",
|
|
10503
|
+
"nohup",
|
|
10504
|
+
"setsid",
|
|
10505
|
+
"stdbuf",
|
|
10506
|
+
"time",
|
|
10507
|
+
"timeout",
|
|
10508
|
+
"xargs"
|
|
10509
|
+
]);
|
|
10510
|
+
var ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
10511
|
+
function commandBaseName(word) {
|
|
10512
|
+
const lowered = word.toLowerCase();
|
|
10513
|
+
const lastSlash = lowered.lastIndexOf("/");
|
|
10514
|
+
return lastSlash === -1 ? lowered : lowered.slice(lastSlash + 1);
|
|
10515
|
+
}
|
|
10516
|
+
function parseShellSegments(command) {
|
|
10517
|
+
const segments = [];
|
|
10518
|
+
let segment = [];
|
|
10519
|
+
let word = "";
|
|
10520
|
+
let hasUnquoted = false;
|
|
10521
|
+
let started = false;
|
|
10522
|
+
let quote = null;
|
|
10523
|
+
const endWord = () => {
|
|
10524
|
+
if (started) segment.push({ value: word, quoted: !hasUnquoted });
|
|
10525
|
+
word = "";
|
|
10526
|
+
hasUnquoted = false;
|
|
10527
|
+
started = false;
|
|
10528
|
+
};
|
|
10529
|
+
const endSegment = () => {
|
|
10530
|
+
endWord();
|
|
10531
|
+
if (segment.length > 0) segments.push(segment);
|
|
10532
|
+
segment = [];
|
|
10533
|
+
};
|
|
10534
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
10535
|
+
const char = command[i];
|
|
10536
|
+
if (quote) {
|
|
10537
|
+
if (char === quote) quote = null;
|
|
10538
|
+
else {
|
|
10539
|
+
word += char;
|
|
10540
|
+
started = true;
|
|
10541
|
+
}
|
|
10542
|
+
continue;
|
|
10543
|
+
}
|
|
10544
|
+
if (char === '"' || char === "'") {
|
|
10545
|
+
quote = char;
|
|
10546
|
+
started = true;
|
|
10547
|
+
continue;
|
|
10548
|
+
}
|
|
10549
|
+
if (char === "\\") {
|
|
10550
|
+
const next = command[i + 1];
|
|
10551
|
+
if (next === "\n") {
|
|
10552
|
+
i += 1;
|
|
10553
|
+
continue;
|
|
10554
|
+
}
|
|
10555
|
+
if (next !== void 0) {
|
|
10556
|
+
word += next;
|
|
10557
|
+
hasUnquoted = true;
|
|
10558
|
+
started = true;
|
|
10559
|
+
i += 1;
|
|
10560
|
+
}
|
|
10561
|
+
continue;
|
|
10562
|
+
}
|
|
10563
|
+
if (";&|\n`(){}".includes(char)) {
|
|
10564
|
+
endSegment();
|
|
10565
|
+
continue;
|
|
10566
|
+
}
|
|
10567
|
+
if (/\s/.test(char)) {
|
|
10568
|
+
endWord();
|
|
10569
|
+
continue;
|
|
10570
|
+
}
|
|
10571
|
+
word += char;
|
|
10572
|
+
hasUnquoted = true;
|
|
10573
|
+
started = true;
|
|
10314
10574
|
}
|
|
10315
|
-
|
|
10316
|
-
return
|
|
10575
|
+
endSegment();
|
|
10576
|
+
return segments;
|
|
10317
10577
|
}
|
|
10318
|
-
function
|
|
10319
|
-
const
|
|
10320
|
-
|
|
10578
|
+
function commandWords(segment) {
|
|
10579
|
+
const words = segment.filter((token) => !(token.quoted && /\s/.test(token.value))).map((token) => token.value);
|
|
10580
|
+
let start = 0;
|
|
10581
|
+
while (start < words.length) {
|
|
10582
|
+
const word = words[start];
|
|
10583
|
+
if (ENV_ASSIGNMENT.test(word)) {
|
|
10584
|
+
start += 1;
|
|
10585
|
+
continue;
|
|
10586
|
+
}
|
|
10587
|
+
if (WRAPPER_COMMANDS.has(commandBaseName(word))) {
|
|
10588
|
+
start += 1;
|
|
10589
|
+
while (start < words.length && words[start].startsWith("-")) start += 1;
|
|
10590
|
+
continue;
|
|
10591
|
+
}
|
|
10592
|
+
break;
|
|
10593
|
+
}
|
|
10594
|
+
return words.slice(start);
|
|
10595
|
+
}
|
|
10596
|
+
|
|
10597
|
+
// src/security/dangerous-command-shapes.ts
|
|
10598
|
+
var FORCE_PUSH_FLAGS = /* @__PURE__ */ new Set(["--force", "-f", "--force-with-lease", "--force-if-includes"]);
|
|
10599
|
+
var SQL_CLIENTS = /* @__PURE__ */ new Set([
|
|
10600
|
+
"clickhouse-client",
|
|
10601
|
+
"cockroach",
|
|
10602
|
+
"mariadb",
|
|
10603
|
+
"mongo",
|
|
10604
|
+
"mongosh",
|
|
10605
|
+
"mycli",
|
|
10606
|
+
"mysql",
|
|
10607
|
+
"pgcli",
|
|
10608
|
+
"psql",
|
|
10609
|
+
"sqlite",
|
|
10610
|
+
"sqlite3"
|
|
10611
|
+
]);
|
|
10612
|
+
var PUBLISHERS = /* @__PURE__ */ new Set(["bun", "npm", "pnpm", "yarn"]);
|
|
10613
|
+
var RUN_SCRIPT_VERBS = ["run", "run-script", "rum", "urn"];
|
|
10614
|
+
function shortFlagGroupHas(word, flag) {
|
|
10615
|
+
return /^-[A-Za-z]+$/.test(word) && !word.startsWith("--") && word.slice(1).includes(flag);
|
|
10616
|
+
}
|
|
10617
|
+
function commandName(words) {
|
|
10618
|
+
return commandBaseName(words[0] ?? "");
|
|
10619
|
+
}
|
|
10620
|
+
function hasWord(words, name) {
|
|
10621
|
+
return words.some((word) => word.toLowerCase() === name);
|
|
10622
|
+
}
|
|
10623
|
+
var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
10624
|
+
"-c",
|
|
10625
|
+
"-C",
|
|
10626
|
+
"--config-env",
|
|
10627
|
+
"--exec-path",
|
|
10628
|
+
"--git-dir",
|
|
10629
|
+
"--namespace",
|
|
10630
|
+
"--work-tree"
|
|
10631
|
+
]);
|
|
10632
|
+
var GIT_TEXT_FLAGS = /* @__PURE__ */ new Set(["-m", "--message", "-F", "--file"]);
|
|
10633
|
+
function gitSubcommand(words) {
|
|
10634
|
+
if (commandName(words) !== "git") return void 0;
|
|
10635
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
10636
|
+
const word = words[i];
|
|
10637
|
+
if (GIT_VALUE_FLAGS.has(word)) {
|
|
10638
|
+
i += 1;
|
|
10639
|
+
continue;
|
|
10640
|
+
}
|
|
10641
|
+
if (word.startsWith("-")) continue;
|
|
10642
|
+
return word.toLowerCase();
|
|
10643
|
+
}
|
|
10644
|
+
return void 0;
|
|
10645
|
+
}
|
|
10646
|
+
function gitArguments(words) {
|
|
10647
|
+
const args = [];
|
|
10648
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
10649
|
+
const word = words[i];
|
|
10650
|
+
if (GIT_VALUE_FLAGS.has(word) || GIT_TEXT_FLAGS.has(word)) {
|
|
10651
|
+
i += 1;
|
|
10652
|
+
continue;
|
|
10653
|
+
}
|
|
10654
|
+
args.push(word);
|
|
10655
|
+
}
|
|
10656
|
+
return args;
|
|
10657
|
+
}
|
|
10658
|
+
function isForcePush(words) {
|
|
10659
|
+
if (gitSubcommand(words) !== "push") return false;
|
|
10660
|
+
return gitArguments(words).some(
|
|
10661
|
+
(word) => FORCE_PUSH_FLAGS.has(word) || word.startsWith("--force-with-lease=") || shortFlagGroupHas(word, "f") || /^\+\S+/.test(word)
|
|
10662
|
+
);
|
|
10663
|
+
}
|
|
10664
|
+
function isHookBypass(words) {
|
|
10665
|
+
const subcommand = gitSubcommand(words);
|
|
10666
|
+
if (subcommand !== "commit" && subcommand !== "push") return false;
|
|
10667
|
+
const args = gitArguments(words);
|
|
10668
|
+
if (args.some((word) => word === "--no-verify" || word.startsWith("--no-verify="))) return true;
|
|
10669
|
+
return subcommand === "commit" && args.some((word) => shortFlagGroupHas(word, "n"));
|
|
10670
|
+
}
|
|
10671
|
+
function isWorldWritableMode(word) {
|
|
10672
|
+
if (/^[0-7]+$/.test(word)) {
|
|
10673
|
+
return (Number(word.slice(-1)) & 2) !== 0;
|
|
10674
|
+
}
|
|
10675
|
+
return word.split(",").some(isWorldWritableClause);
|
|
10676
|
+
}
|
|
10677
|
+
function isWorldWritableClause(clause) {
|
|
10678
|
+
const symbolic = /^([ugoa]*)([+=])([rwxstugo]+)$/i.exec(clause);
|
|
10679
|
+
if (!symbolic) return false;
|
|
10680
|
+
const [, who, , perms] = symbolic;
|
|
10681
|
+
if (!perms.toLowerCase().includes("w")) return false;
|
|
10682
|
+
const scope = who.toLowerCase();
|
|
10683
|
+
return scope === "" || scope.includes("a") || scope.includes("o");
|
|
10684
|
+
}
|
|
10685
|
+
function isRecursiveChmod(words) {
|
|
10686
|
+
if (commandName(words) !== "chmod") return false;
|
|
10687
|
+
const recursive = words.some((word) => word === "--recursive" || shortFlagGroupHas(word, "R"));
|
|
10688
|
+
if (!recursive) return false;
|
|
10689
|
+
return words.slice(1).some((word) => !word.startsWith("-") && isWorldWritableMode(word));
|
|
10690
|
+
}
|
|
10691
|
+
function isPackagePublish(words) {
|
|
10692
|
+
if (!PUBLISHERS.has(commandName(words))) return false;
|
|
10693
|
+
if (!hasWord(words, "publish")) return false;
|
|
10694
|
+
if (RUN_SCRIPT_VERBS.some((verb) => hasWord(words, verb))) return false;
|
|
10695
|
+
return !words.some((word) => word === "--dry-run" || word === "--dry-run=true");
|
|
10696
|
+
}
|
|
10697
|
+
function isPrivilegeEscalation(words) {
|
|
10698
|
+
return commandName(words) === "sudo";
|
|
10699
|
+
}
|
|
10700
|
+
function isDestructiveSql(segment) {
|
|
10701
|
+
const words = commandWords(segment);
|
|
10702
|
+
if (words.length === 0) return false;
|
|
10703
|
+
if (words.some((word) => SQL_CLIENTS.has(commandBaseName(word)))) {
|
|
10704
|
+
const text = segment.map((token) => token.value).join(" ").toLowerCase().replace(/\s+/g, " ");
|
|
10705
|
+
return RUNTIME_ONLY_DENY_COMMANDS.some((phrase) => text.includes(phrase));
|
|
10706
|
+
}
|
|
10707
|
+
const spoken = words.join(" ").toLowerCase();
|
|
10708
|
+
return RUNTIME_ONLY_DENY_COMMANDS.some((phrase) => spoken.startsWith(phrase));
|
|
10709
|
+
}
|
|
10710
|
+
var SEGMENT_MATCHERS = [
|
|
10711
|
+
{ name: "a privilege escalation", matches: isPrivilegeEscalation },
|
|
10712
|
+
{ name: "a force push", matches: isForcePush },
|
|
10713
|
+
{ name: "a commit or push that skips the hooks", matches: isHookBypass },
|
|
10714
|
+
{ name: "a recursive permission change", matches: isRecursiveChmod },
|
|
10715
|
+
{ name: "a package publish", matches: isPackagePublish }
|
|
10716
|
+
];
|
|
10717
|
+
function matchDangerousCommandShape(command) {
|
|
10718
|
+
for (const segment of parseShellSegments(command)) {
|
|
10719
|
+
const words = commandWords(segment);
|
|
10720
|
+
if (words.length === 0) continue;
|
|
10721
|
+
const matched = SEGMENT_MATCHERS.find((matcher) => matcher.matches(words));
|
|
10722
|
+
if (matched) return matched.name;
|
|
10723
|
+
if (isDestructiveSql(segment)) return "a destructive database statement";
|
|
10724
|
+
}
|
|
10321
10725
|
return void 0;
|
|
10322
10726
|
}
|
|
10323
10727
|
|
|
10728
|
+
// src/security/guard-bash.ts
|
|
10729
|
+
function guardBash(command) {
|
|
10730
|
+
const shape = matchDangerousCommandShape(command);
|
|
10731
|
+
if (!shape) return void 0;
|
|
10732
|
+
return `I didn't run that \u2014 it looks like ${shape}, which can permanently change or delete things: ${command}`;
|
|
10733
|
+
}
|
|
10734
|
+
|
|
10324
10735
|
// src/security/guard-file-access.ts
|
|
10325
10736
|
function guardFileAccess(candidate) {
|
|
10326
10737
|
const normalized = normalizePathForMatch(candidate);
|
|
@@ -10339,7 +10750,7 @@ async function runGuard(kind, _options) {
|
|
|
10339
10750
|
}
|
|
10340
10751
|
const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
|
|
10341
10752
|
if (kind === "file-access") {
|
|
10342
|
-
const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
|
|
10753
|
+
const candidate = String(toolInput.notebook_path ?? toolInput.path ?? toolInput.file_path ?? "");
|
|
10343
10754
|
const reason2 = guardFileAccess(candidate);
|
|
10344
10755
|
if (reason2) {
|
|
10345
10756
|
emitPreToolUseDeny(reason2);
|
|
@@ -10589,7 +11000,7 @@ async function recommend(root, context, opts = {}) {
|
|
|
10589
11000
|
const tagMatch = item.tags.find((t) => stackSet.has(t.toLowerCase()));
|
|
10590
11001
|
if (tagMatch) push("stack-match", "stack/dependency match", stackSignal(tagMatch));
|
|
10591
11002
|
const pm = context.packageManager;
|
|
10592
|
-
const pmVersionedMatch = pm === "yarn" || pm === "pnpm" ?
|
|
11003
|
+
const pmVersionedMatch = pm === "yarn" || pm === "pnpm" ? itemTags.includes(pm) || itemTags.includes(`${pm}4`) || pm === "pnpm" && itemTags.includes("pnpm89") : itemTags.includes(pm);
|
|
10593
11004
|
if (pmVersionedMatch) {
|
|
10594
11005
|
push(
|
|
10595
11006
|
"package-manager-match",
|
|
@@ -11355,7 +11766,7 @@ async function runInstall(options, deps = {}) {
|
|
|
11355
11766
|
if (result.created.length) parts.push(`${result.created.length} file(s) added`);
|
|
11356
11767
|
if (result.updated.length) parts.push(`${result.updated.length} file(s) updated`);
|
|
11357
11768
|
log(
|
|
11358
|
-
parts.length ? ` \u2022 ${parts.join(", ")} in ${claudeConfigDirLabel()} (skills, slash commands)` : " \u2022 already up to date \u2014 no files changed"
|
|
11769
|
+
parts.length ? ` \u2022 ${parts.join(", ")} in ${claudeConfigDirLabel()} (skills, agents, slash commands)` : " \u2022 already up to date \u2014 no files changed"
|
|
11359
11770
|
);
|
|
11360
11771
|
log(
|
|
11361
11772
|
` \u2022 ensured hooks + security rules are present in ${claudeConfigDirLabel()}/settings.json`
|
|
@@ -11949,17 +12360,19 @@ async function runRemove(ids, options = {}) {
|
|
|
11949
12360
|
for (const id of found) {
|
|
11950
12361
|
const entry = byId.get(id);
|
|
11951
12362
|
const relPaths = entry.paths ?? [];
|
|
11952
|
-
const
|
|
11953
|
-
if (
|
|
11954
|
-
for (const rel of
|
|
11955
|
-
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") {
|
|
11956
12367
|
warn(`Refusing to touch ${id}'s recorded path outside the project root: ${rel}`);
|
|
12368
|
+
} else if (reason === "symlink") {
|
|
12369
|
+
warn(`Refusing to touch ${id}'s recorded path through a symlink: ${rel}`);
|
|
11957
12370
|
}
|
|
11958
12371
|
}
|
|
11959
12372
|
report.skipped.push({ id, reason: "unsafe-path" });
|
|
11960
12373
|
continue;
|
|
11961
12374
|
}
|
|
11962
|
-
const existing = await resolveExistingTrackedPaths(root, id, relPaths, warn);
|
|
12375
|
+
const existing = await resolveExistingTrackedPaths(root, id, relPaths, warn, unsafeByRel);
|
|
11963
12376
|
if (existing.length === 0) {
|
|
11964
12377
|
if (relPaths.length === 0) {
|
|
11965
12378
|
warn(`${id} has no recorded paths \u2014 leaving its lock entry in place.`);
|
|
@@ -12135,7 +12548,12 @@ async function collectLockTrackedPaths(root) {
|
|
|
12135
12548
|
const preserved = [];
|
|
12136
12549
|
for (const row of lock ?? []) {
|
|
12137
12550
|
const id = row.id ?? "(unknown item)";
|
|
12138
|
-
const
|
|
12551
|
+
const relPaths = row.paths ?? [];
|
|
12552
|
+
const unsafeByRel = await unsafeReasonsFor(root, relPaths);
|
|
12553
|
+
const existing = await resolveExistingTrackedPaths(root, id, relPaths, log, unsafeByRel);
|
|
12554
|
+
for (const [rel, reason] of unsafeByRel) {
|
|
12555
|
+
if (reason !== null) preserved.push(path56.resolve(root, rel));
|
|
12556
|
+
}
|
|
12139
12557
|
if (existing.length === 0) continue;
|
|
12140
12558
|
const gate = await hashGateEntry(root, { hash: row.hash }, existing);
|
|
12141
12559
|
if (!gate.ok) {
|
|
@@ -12267,7 +12685,7 @@ User-owned .claude/ files will be preserved.`
|
|
|
12267
12685
|
init_fs();
|
|
12268
12686
|
init_logger();
|
|
12269
12687
|
init_paths();
|
|
12270
|
-
import
|
|
12688
|
+
import crypto4 from "node:crypto";
|
|
12271
12689
|
import path57 from "node:path";
|
|
12272
12690
|
import fs44 from "fs-extra";
|
|
12273
12691
|
async function runUninstall(options = {}) {
|
|
@@ -12316,7 +12734,7 @@ async function runUninstall(options = {}) {
|
|
|
12316
12734
|
result.skipped.push(entry.destPath);
|
|
12317
12735
|
continue;
|
|
12318
12736
|
}
|
|
12319
|
-
const currentHash = `sha256-${
|
|
12737
|
+
const currentHash = `sha256-${crypto4.createHash("sha256").update(content2).digest("hex")}`;
|
|
12320
12738
|
if (currentHash !== entry.hash && !force) {
|
|
12321
12739
|
warn(
|
|
12322
12740
|
`Skipping user-edited haus file (hash mismatch): ${entry.destPath} \u2014 use --force to delete`
|
|
@@ -12523,6 +12941,22 @@ function auditManifestStructure(manifestVersion2, items) {
|
|
|
12523
12941
|
if (item.version && !ITEM_SEMVER_RE.test(item.version)) {
|
|
12524
12942
|
failures.push(`${item.id}: version "${item.version}" is not valid semver (expected X.Y.Z)`);
|
|
12525
12943
|
}
|
|
12944
|
+
if (typeof item.tokenEstimate === "number" && item.tokenEstimate > 0) {
|
|
12945
|
+
if (typeof item.tokenBudget !== "number") {
|
|
12946
|
+
failures.push(
|
|
12947
|
+
`${item.id}: tokenEstimate ${item.tokenEstimate} but no tokenBudget \u2014 an item that loads into context needs a growth ceiling (estimate + ~30% headroom, so ${Math.ceil(item.tokenEstimate * 1.3)})`
|
|
12948
|
+
);
|
|
12949
|
+
} else if (item.tokenEstimate > item.tokenBudget) {
|
|
12950
|
+
failures.push(
|
|
12951
|
+
`${item.id}: tokenEstimate (${item.tokenEstimate}) exceeds tokenBudget (${item.tokenBudget})`
|
|
12952
|
+
);
|
|
12953
|
+
}
|
|
12954
|
+
}
|
|
12955
|
+
if (item.default !== true && !String(item.ecosystem ?? "").trim()) {
|
|
12956
|
+
failures.push(
|
|
12957
|
+
`${item.id}: non-default item missing ecosystem (it groups alternatives for conflict detection; baseline items are exempt)`
|
|
12958
|
+
);
|
|
12959
|
+
}
|
|
12526
12960
|
const prev = seenIds.get(item.id);
|
|
12527
12961
|
if (prev !== void 0) {
|
|
12528
12962
|
failures.push(`${item.id}: duplicate id (first at index ${prev})`);
|
|
@@ -13107,6 +13541,9 @@ function summarizeInstall(install) {
|
|
|
13107
13541
|
const details = [...envNotes, ...tail.length > 0 ? tail.split("\n") : []];
|
|
13108
13542
|
return details.length > 0 ? [head, ...details].join("\n ") : head;
|
|
13109
13543
|
}
|
|
13544
|
+
function installFailed(install) {
|
|
13545
|
+
return install?.ran === true && install.ok === false;
|
|
13546
|
+
}
|
|
13110
13547
|
function withMissingEnv(result, missingEnvVars) {
|
|
13111
13548
|
return missingEnvVars.length > 0 ? { ...result, missingEnvVars } : result;
|
|
13112
13549
|
}
|
|
@@ -13164,6 +13601,9 @@ async function runInstallPlan(dir, plan, deps = {}) {
|
|
|
13164
13601
|
|
|
13165
13602
|
// src/workspace/worktree/hydrate.ts
|
|
13166
13603
|
var HYDRATION_TARGETS = ["node_modules", "vendor", "obj", "bin"];
|
|
13604
|
+
function memberOutcomeOk(member) {
|
|
13605
|
+
return member.status !== "failed" && !installFailed(member.hydration?.install);
|
|
13606
|
+
}
|
|
13167
13607
|
async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
13168
13608
|
const start = Date.now();
|
|
13169
13609
|
const targets = [];
|
|
@@ -13266,6 +13706,13 @@ function worktreePath(mainRoot, slug) {
|
|
|
13266
13706
|
}
|
|
13267
13707
|
return path65.join(worktreesDir(mainRoot), slug);
|
|
13268
13708
|
}
|
|
13709
|
+
function slugFromWorktreePath(mainRoot, somePath) {
|
|
13710
|
+
const dir = worktreesDir(mainRoot);
|
|
13711
|
+
const prefix = `${dir}${path65.sep}`;
|
|
13712
|
+
if (!somePath.startsWith(prefix)) return void 0;
|
|
13713
|
+
const segment = somePath.slice(prefix.length).split(path65.sep)[0];
|
|
13714
|
+
return segment !== void 0 && segment !== "" ? segment : void 0;
|
|
13715
|
+
}
|
|
13269
13716
|
async function readWorktreeState(worktreeRoot) {
|
|
13270
13717
|
const state = await readJson(path65.join(worktreeRoot, WORKTREE_STATE_FILE));
|
|
13271
13718
|
if (state == null || typeof state !== "object" || !Array.isArray(state.members)) return void 0;
|
|
@@ -13356,7 +13803,7 @@ async function runAdd(opts) {
|
|
|
13356
13803
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
13357
13804
|
members: stateMembers
|
|
13358
13805
|
});
|
|
13359
|
-
const anyFailed = memberResults.some((m) => m
|
|
13806
|
+
const anyFailed = memberResults.some((m) => !memberOutcomeOk(m));
|
|
13360
13807
|
return {
|
|
13361
13808
|
ok: !anyFailed,
|
|
13362
13809
|
dryRun: false,
|
|
@@ -13401,27 +13848,135 @@ async function runDryRun(args) {
|
|
|
13401
13848
|
};
|
|
13402
13849
|
}
|
|
13403
13850
|
|
|
13851
|
+
// src/workspace/worktree/autohydrate.ts
|
|
13852
|
+
import { spawn } from "node:child_process";
|
|
13853
|
+
import fsSync from "node:fs";
|
|
13854
|
+
import path71 from "node:path";
|
|
13855
|
+
import fs51 from "fs-extra";
|
|
13856
|
+
|
|
13404
13857
|
// src/workspace/worktree/doctor.ts
|
|
13405
13858
|
import { existsSync as existsSync6 } from "node:fs";
|
|
13406
|
-
import
|
|
13859
|
+
import path69 from "node:path";
|
|
13860
|
+
|
|
13861
|
+
// src/workspace/worktree/hydrate-lock.ts
|
|
13862
|
+
init_paths();
|
|
13863
|
+
import path67 from "node:path";
|
|
13864
|
+
import fs50 from "fs-extra";
|
|
13865
|
+
var STALE_LOCK_MS = 30 * 60 * 1e3;
|
|
13866
|
+
function locksDir(mainRoot) {
|
|
13867
|
+
return hausPath(mainRoot, "locks");
|
|
13868
|
+
}
|
|
13869
|
+
function logsDir(mainRoot) {
|
|
13870
|
+
return hausPath(mainRoot, "logs");
|
|
13871
|
+
}
|
|
13872
|
+
function assertSlug(slug) {
|
|
13873
|
+
if (!isValidWorktreeSlug(slug)) {
|
|
13874
|
+
throw new Error(
|
|
13875
|
+
`Invalid worktree slug ${JSON.stringify(slug)} \u2014 slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
|
|
13876
|
+
);
|
|
13877
|
+
}
|
|
13878
|
+
return slug;
|
|
13879
|
+
}
|
|
13880
|
+
function hydrateLockPath(mainRoot, slug) {
|
|
13881
|
+
return path67.join(locksDir(mainRoot), `hydrate-${assertSlug(slug)}.lock`);
|
|
13882
|
+
}
|
|
13883
|
+
function hydrateLogPath(mainRoot, slug) {
|
|
13884
|
+
return path67.join(logsDir(mainRoot), `hydrate-${assertSlug(slug)}.log`);
|
|
13885
|
+
}
|
|
13886
|
+
function hydrateLastRunPath(mainRoot, slug) {
|
|
13887
|
+
return path67.join(logsDir(mainRoot), `hydrate-${assertSlug(slug)}.last.json`);
|
|
13888
|
+
}
|
|
13889
|
+
function defaultIsAlive(pid) {
|
|
13890
|
+
try {
|
|
13891
|
+
process.kill(pid, 0);
|
|
13892
|
+
return true;
|
|
13893
|
+
} catch (err) {
|
|
13894
|
+
return err.code === "EPERM";
|
|
13895
|
+
}
|
|
13896
|
+
}
|
|
13897
|
+
async function readOwnerPid(lockPath) {
|
|
13898
|
+
try {
|
|
13899
|
+
const raw = (await fs50.readFile(path67.join(lockPath, "pid"), "utf8")).trim();
|
|
13900
|
+
if (raw === "") return void 0;
|
|
13901
|
+
const pid = Number.parseInt(raw, 10);
|
|
13902
|
+
return Number.isInteger(pid) && pid > 0 ? pid : void 0;
|
|
13903
|
+
} catch {
|
|
13904
|
+
return void 0;
|
|
13905
|
+
}
|
|
13906
|
+
}
|
|
13907
|
+
async function claim(lockPath, pid) {
|
|
13908
|
+
await fs50.ensureDir(path67.dirname(lockPath));
|
|
13909
|
+
try {
|
|
13910
|
+
await fs50.mkdir(lockPath);
|
|
13911
|
+
} catch {
|
|
13912
|
+
return false;
|
|
13913
|
+
}
|
|
13914
|
+
await fs50.writeFile(path67.join(lockPath, "pid"), String(pid), "utf8");
|
|
13915
|
+
return true;
|
|
13916
|
+
}
|
|
13917
|
+
async function acquireHydrateLock(mainRoot, slug, deps = {}) {
|
|
13918
|
+
const lockPath = hydrateLockPath(mainRoot, slug);
|
|
13919
|
+
const pid = deps.pid ?? process.pid;
|
|
13920
|
+
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
13921
|
+
const now = deps.now ?? Date.now;
|
|
13922
|
+
if (await claim(lockPath, pid)) return { acquired: true, lockPath };
|
|
13923
|
+
const ownerPid = await readOwnerPid(lockPath);
|
|
13924
|
+
if (ownerPid !== void 0) {
|
|
13925
|
+
let alive;
|
|
13926
|
+
try {
|
|
13927
|
+
alive = isAlive(ownerPid);
|
|
13928
|
+
} catch {
|
|
13929
|
+
alive = true;
|
|
13930
|
+
}
|
|
13931
|
+
if (alive) return { acquired: false, reason: "live", ownerPid, lockPath };
|
|
13932
|
+
} else {
|
|
13933
|
+
const stat = await fs50.stat(lockPath).catch(() => void 0);
|
|
13934
|
+
if (stat === void 0) {
|
|
13935
|
+
return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", lockPath };
|
|
13936
|
+
}
|
|
13937
|
+
if (now() - stat.mtimeMs <= STALE_LOCK_MS) {
|
|
13938
|
+
return { acquired: false, reason: "starting", lockPath };
|
|
13939
|
+
}
|
|
13940
|
+
}
|
|
13941
|
+
await fs50.remove(lockPath);
|
|
13942
|
+
return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", ownerPid, lockPath };
|
|
13943
|
+
}
|
|
13944
|
+
async function recordHydrateLockOwner(mainRoot, slug, pid) {
|
|
13945
|
+
await fs50.writeFile(path67.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
|
|
13946
|
+
}
|
|
13947
|
+
async function releaseHydrateLock(mainRoot, slug) {
|
|
13948
|
+
await fs50.remove(hydrateLockPath(mainRoot, slug));
|
|
13949
|
+
}
|
|
13950
|
+
async function readHydrateLock(mainRoot, slug) {
|
|
13951
|
+
const lockPath = hydrateLockPath(mainRoot, slug);
|
|
13952
|
+
const stat = await fs50.stat(lockPath).catch(() => void 0);
|
|
13953
|
+
if (stat === void 0) return { held: false };
|
|
13954
|
+
const ownerPid = await readOwnerPid(lockPath);
|
|
13955
|
+
return {
|
|
13956
|
+
held: true,
|
|
13957
|
+
...ownerPid !== void 0 ? { ownerPid } : {},
|
|
13958
|
+
ageMs: Date.now() - stat.mtimeMs,
|
|
13959
|
+
lockPath
|
|
13960
|
+
};
|
|
13961
|
+
}
|
|
13407
13962
|
|
|
13408
13963
|
// src/workspace/worktree/orphans.ts
|
|
13409
13964
|
import { existsSync as existsSync5 } from "node:fs";
|
|
13410
|
-
import
|
|
13965
|
+
import path68 from "node:path";
|
|
13411
13966
|
var WORKSPACE_REPO_ID = "(workspace)";
|
|
13412
13967
|
async function findOrphanedRegistrations(repos, wsWorktreesDir) {
|
|
13413
13968
|
const orphans = [];
|
|
13414
13969
|
for (const repo of repos) {
|
|
13415
13970
|
const entries = await listWorktrees(repo.absPath);
|
|
13416
13971
|
for (const entry of entries) {
|
|
13417
|
-
const entryPath =
|
|
13418
|
-
const marker = `${
|
|
13972
|
+
const entryPath = path68.normalize(entry.path);
|
|
13973
|
+
const marker = `${path68.sep}.claude${path68.sep}worktrees${path68.sep}`;
|
|
13419
13974
|
const idx = entryPath.indexOf(marker);
|
|
13420
13975
|
if (idx === -1) continue;
|
|
13421
13976
|
const rest = entryPath.slice(idx + marker.length);
|
|
13422
|
-
const slug = rest.split(
|
|
13977
|
+
const slug = rest.split(path68.sep)[0];
|
|
13423
13978
|
if (!slug) continue;
|
|
13424
|
-
if (existsSync5(
|
|
13979
|
+
if (existsSync5(path68.join(wsWorktreesDir, slug))) continue;
|
|
13425
13980
|
orphans.push({
|
|
13426
13981
|
repoId: repo.id,
|
|
13427
13982
|
ownerRepo: repo.absPath,
|
|
@@ -13436,7 +13991,7 @@ async function findOrphanedRegistrations(repos, wsWorktreesDir) {
|
|
|
13436
13991
|
// src/workspace/worktree/doctor.ts
|
|
13437
13992
|
var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
|
|
13438
13993
|
function looksHydrated(memberPath) {
|
|
13439
|
-
return HYDRATION_MARKERS.some((marker) => existsSync6(
|
|
13994
|
+
return HYDRATION_MARKERS.some((marker) => existsSync6(path69.join(memberPath, marker)));
|
|
13440
13995
|
}
|
|
13441
13996
|
async function runDoctor2() {
|
|
13442
13997
|
const rootInfo = await resolveRoots();
|
|
@@ -13448,11 +14003,12 @@ async function runDoctor2() {
|
|
|
13448
14003
|
orphans: [],
|
|
13449
14004
|
problems: ["Not inside a git repository."],
|
|
13450
14005
|
remedies: [],
|
|
13451
|
-
autoHydrate: false
|
|
14006
|
+
autoHydrate: false,
|
|
14007
|
+
hydrationInProgress: false
|
|
13452
14008
|
};
|
|
13453
14009
|
}
|
|
13454
14010
|
const workspaceRoot = rootInfo.mainRoot;
|
|
13455
|
-
const isWorkspace = existsSync6(
|
|
14011
|
+
const isWorkspace = existsSync6(path69.join(workspaceRoot, WORKSPACE_FILE)) || existsSync6(path69.join(workspaceRoot, REPOS_MANIFEST_FILE));
|
|
13456
14012
|
if (!isWorkspace) {
|
|
13457
14013
|
return {
|
|
13458
14014
|
isWorkspace: false,
|
|
@@ -13463,11 +14019,12 @@ async function runDoctor2() {
|
|
|
13463
14019
|
`Not a haus workspace \u2014 no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
|
|
13464
14020
|
],
|
|
13465
14021
|
remedies: [],
|
|
13466
|
-
autoHydrate: false
|
|
14022
|
+
autoHydrate: false,
|
|
14023
|
+
hydrationInProgress: false
|
|
13467
14024
|
};
|
|
13468
14025
|
}
|
|
13469
14026
|
const wsWorktreesDir = worktreesDir(workspaceRoot);
|
|
13470
|
-
const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${
|
|
14027
|
+
const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path69.sep}`);
|
|
13471
14028
|
const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
|
|
13472
14029
|
const workspaceConfig = await readWorkspaceConfig(workspaceRoot);
|
|
13473
14030
|
const autoHydrate = workspaceConfig?.worktree?.autoHydrate === true;
|
|
@@ -13476,12 +14033,14 @@ async function runDoctor2() {
|
|
|
13476
14033
|
const remedies = [];
|
|
13477
14034
|
const memberChecks = [];
|
|
13478
14035
|
let slug;
|
|
14036
|
+
let hydrationInProgress = false;
|
|
14037
|
+
let hydrationOwnerPid;
|
|
13479
14038
|
if (inWorkspaceWorktree) {
|
|
13480
14039
|
const wtPath = rootInfo.repoRoot;
|
|
13481
|
-
slug = rootInfo.worktreeName ??
|
|
14040
|
+
slug = rootInfo.worktreeName ?? path69.basename(wtPath);
|
|
13482
14041
|
const state = await readWorktreeState(wtPath);
|
|
13483
14042
|
for (const member of members) {
|
|
13484
|
-
const memberPath =
|
|
14043
|
+
const memberPath = path69.join(wtPath, member.folder);
|
|
13485
14044
|
const materialized = existsSync6(memberPath);
|
|
13486
14045
|
const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
|
|
13487
14046
|
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
@@ -13507,9 +14066,14 @@ async function runDoctor2() {
|
|
|
13507
14066
|
);
|
|
13508
14067
|
}
|
|
13509
14068
|
}
|
|
14069
|
+
const lock = await readHydrateLock(workspaceRoot, slug).catch(() => ({ held: false }));
|
|
14070
|
+
if (lock.held) {
|
|
14071
|
+
hydrationInProgress = true;
|
|
14072
|
+
hydrationOwnerPid = lock.ownerPid;
|
|
14073
|
+
}
|
|
13510
14074
|
if (memberChecks.some((m) => !m.materialized)) {
|
|
13511
14075
|
remedies.push(
|
|
13512
|
-
"Run `haus workspace worktree hydrate --materialize` to materialize the missing members."
|
|
14076
|
+
hydrationInProgress ? `A hydration is already running for "${slug}"${hydrationOwnerPid !== void 0 ? ` (pid ${hydrationOwnerPid})` : ""} \u2014 wait for ${lock.held ? lock.lockPath : "the lock"} to disappear rather than starting another.` : "Run `haus workspace worktree hydrate --materialize` to materialize the missing members."
|
|
13513
14077
|
);
|
|
13514
14078
|
}
|
|
13515
14079
|
}
|
|
@@ -13521,7 +14085,7 @@ async function runDoctor2() {
|
|
|
13521
14085
|
wsWorktreesDir
|
|
13522
14086
|
);
|
|
13523
14087
|
const orphans = orphanScan.map(
|
|
13524
|
-
(o) => `${o.repoId}: worktree registered at ${o.registeredPath} but ${
|
|
14088
|
+
(o) => `${o.repoId}: worktree registered at ${o.registeredPath} but ${path69.join(wsWorktreesDir, o.slug)} no longer exists.`
|
|
13525
14089
|
);
|
|
13526
14090
|
if (orphans.length > 0) {
|
|
13527
14091
|
problems.push(...orphans);
|
|
@@ -13535,14 +14099,16 @@ async function runDoctor2() {
|
|
|
13535
14099
|
orphans,
|
|
13536
14100
|
problems,
|
|
13537
14101
|
remedies,
|
|
13538
|
-
autoHydrate
|
|
14102
|
+
autoHydrate,
|
|
14103
|
+
hydrationInProgress,
|
|
14104
|
+
...hydrationOwnerPid !== void 0 ? { hydrationOwnerPid } : {}
|
|
13539
14105
|
};
|
|
13540
14106
|
}
|
|
13541
14107
|
|
|
13542
14108
|
// src/workspace/worktree/hydrate-command.ts
|
|
13543
14109
|
init_fs();
|
|
13544
14110
|
import { existsSync as existsSync7 } from "node:fs";
|
|
13545
|
-
import
|
|
14111
|
+
import path70 from "node:path";
|
|
13546
14112
|
async function runHydrateWorktree(opts) {
|
|
13547
14113
|
const rootInfo = await resolveRoots();
|
|
13548
14114
|
if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
|
|
@@ -13553,7 +14119,7 @@ async function runHydrateWorktree(opts) {
|
|
|
13553
14119
|
}
|
|
13554
14120
|
const workspaceRoot = rootInfo.mainRoot;
|
|
13555
14121
|
const wtPath = rootInfo.repoRoot;
|
|
13556
|
-
if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${
|
|
14122
|
+
if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path70.sep}`)) {
|
|
13557
14123
|
return {
|
|
13558
14124
|
ok: false,
|
|
13559
14125
|
error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
|
|
@@ -13565,13 +14131,13 @@ async function runHydrateWorktree(opts) {
|
|
|
13565
14131
|
return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
|
|
13566
14132
|
}
|
|
13567
14133
|
const state = await readWorktreeState(wtPath);
|
|
13568
|
-
const slug = state?.slug ??
|
|
14134
|
+
const slug = state?.slug ?? path70.basename(wtPath);
|
|
13569
14135
|
const detectedBranch = await currentBranch(wtPath);
|
|
13570
14136
|
const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
|
|
13571
14137
|
if (opts.dryRun) {
|
|
13572
14138
|
const results2 = await Promise.all(
|
|
13573
14139
|
selected.map(async (member) => {
|
|
13574
|
-
const memberPath =
|
|
14140
|
+
const memberPath = path70.join(wtPath, member.folder);
|
|
13575
14141
|
if (existsSync7(memberPath)) {
|
|
13576
14142
|
return { member: member.id, folder: member.folder, status: "planned" };
|
|
13577
14143
|
}
|
|
@@ -13592,7 +14158,7 @@ async function runHydrateWorktree(opts) {
|
|
|
13592
14158
|
const results = await mapWithConcurrency(
|
|
13593
14159
|
selected,
|
|
13594
14160
|
async (member) => {
|
|
13595
|
-
const memberPath =
|
|
14161
|
+
const memberPath = path70.join(wtPath, member.folder);
|
|
13596
14162
|
let branchAction;
|
|
13597
14163
|
if (!existsSync7(memberPath)) {
|
|
13598
14164
|
if (!opts.materialize) {
|
|
@@ -13651,47 +14217,7 @@ async function runHydrateWorktree(opts) {
|
|
|
13651
14217
|
members: Array.from(byId.values())
|
|
13652
14218
|
});
|
|
13653
14219
|
}
|
|
13654
|
-
return { ok: results.every(
|
|
13655
|
-
}
|
|
13656
|
-
|
|
13657
|
-
// src/workspace/worktree/list.ts
|
|
13658
|
-
import path70 from "node:path";
|
|
13659
|
-
import fs50 from "fs-extra";
|
|
13660
|
-
async function runList2() {
|
|
13661
|
-
const resolved = await resolveWorkspaceForWorktree();
|
|
13662
|
-
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
13663
|
-
const { rootInfo, workspaceRoot } = resolved;
|
|
13664
|
-
const dir = worktreesDir(workspaceRoot);
|
|
13665
|
-
if (!await fs50.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
|
|
13666
|
-
const entries = await fs50.readdir(dir, { withFileTypes: true });
|
|
13667
|
-
const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
13668
|
-
const configuredMembers = await readMembers(rootInfo);
|
|
13669
|
-
const worktrees = [];
|
|
13670
|
-
for (const slug of slugs) {
|
|
13671
|
-
const wtPath = path70.join(dir, slug);
|
|
13672
|
-
const state = await readWorktreeState(wtPath);
|
|
13673
|
-
const members = [];
|
|
13674
|
-
const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
|
|
13675
|
-
for (const m of source) {
|
|
13676
|
-
const memberPath = path70.join(wtPath, m.folder);
|
|
13677
|
-
const materialized = await fs50.pathExists(memberPath);
|
|
13678
|
-
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
13679
|
-
members.push({
|
|
13680
|
-
id: m.id,
|
|
13681
|
-
folder: m.folder,
|
|
13682
|
-
expectedBranch: m.branch,
|
|
13683
|
-
materialized,
|
|
13684
|
-
actualBranch
|
|
13685
|
-
});
|
|
13686
|
-
}
|
|
13687
|
-
worktrees.push({
|
|
13688
|
-
slug,
|
|
13689
|
-
branch: state?.branch ?? await currentBranch(wtPath) ?? "unknown",
|
|
13690
|
-
createdAt: state?.createdAt,
|
|
13691
|
-
members
|
|
13692
|
-
});
|
|
13693
|
-
}
|
|
13694
|
-
return { ok: true, workspaceRoot, worktrees };
|
|
14220
|
+
return { ok: results.every(memberOutcomeOk), dryRun: false, slug, results };
|
|
13695
14221
|
}
|
|
13696
14222
|
|
|
13697
14223
|
// src/workspace/worktree/prune.ts
|
|
@@ -13734,58 +14260,523 @@ async function runPrune2(opts) {
|
|
|
13734
14260
|
return { ok: results.every((r) => r.pruned), dryRun: false, results };
|
|
13735
14261
|
}
|
|
13736
14262
|
|
|
13737
|
-
// src/workspace/worktree/
|
|
13738
|
-
|
|
13739
|
-
|
|
13740
|
-
|
|
13741
|
-
const
|
|
13742
|
-
|
|
13743
|
-
|
|
13744
|
-
|
|
13745
|
-
|
|
13746
|
-
|
|
14263
|
+
// src/workspace/worktree/autohydrate.ts
|
|
14264
|
+
var NO_AUTO_HYDRATE_ENV = "HAUS_NO_AUTO_HYDRATE";
|
|
14265
|
+
var LOG_SIZE_CAP_BYTES = 512 * 1024;
|
|
14266
|
+
function defaultSpawnDetached(spec) {
|
|
14267
|
+
const fd = fsSync.openSync(spec.logPath, "a");
|
|
14268
|
+
try {
|
|
14269
|
+
const child = spawn(spec.command, spec.args, {
|
|
14270
|
+
cwd: spec.cwd,
|
|
14271
|
+
detached: true,
|
|
14272
|
+
stdio: ["ignore", fd, fd]
|
|
14273
|
+
});
|
|
14274
|
+
child.unref();
|
|
14275
|
+
return child.pid;
|
|
14276
|
+
} finally {
|
|
14277
|
+
fsSync.closeSync(fd);
|
|
13747
14278
|
}
|
|
13748
|
-
|
|
13749
|
-
|
|
13750
|
-
|
|
13751
|
-
|
|
13752
|
-
|
|
13753
|
-
|
|
13754
|
-
|
|
13755
|
-
|
|
13756
|
-
|
|
13757
|
-
|
|
13758
|
-
|
|
13759
|
-
|
|
13760
|
-
|
|
14279
|
+
}
|
|
14280
|
+
function appendLog(logPath, line2) {
|
|
14281
|
+
try {
|
|
14282
|
+
fsSync.mkdirSync(path71.dirname(logPath), { recursive: true });
|
|
14283
|
+
const size = fsSync.statSync(logPath, { throwIfNoEntry: false })?.size ?? 0;
|
|
14284
|
+
if (size > LOG_SIZE_CAP_BYTES) {
|
|
14285
|
+
fsSync.writeFileSync(logPath, `--- log rotated (exceeded ${LOG_SIZE_CAP_BYTES} bytes) ---
|
|
14286
|
+
`);
|
|
14287
|
+
}
|
|
14288
|
+
fsSync.appendFileSync(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${line2}
|
|
14289
|
+
`);
|
|
14290
|
+
} catch {
|
|
14291
|
+
}
|
|
14292
|
+
}
|
|
14293
|
+
async function consumeLastRunFailure(mainRoot, slug) {
|
|
14294
|
+
const file = hydrateLastRunPath(mainRoot, slug);
|
|
14295
|
+
let parsed;
|
|
14296
|
+
try {
|
|
14297
|
+
parsed = JSON.parse(await fs51.readFile(file, "utf8"));
|
|
14298
|
+
} catch {
|
|
14299
|
+
return void 0;
|
|
14300
|
+
}
|
|
14301
|
+
await fs51.remove(file).catch(() => void 0);
|
|
14302
|
+
if (parsed && parsed.ok === false) return parsed;
|
|
14303
|
+
return void 0;
|
|
14304
|
+
}
|
|
14305
|
+
async function resolveWorktree() {
|
|
14306
|
+
const rootInfo = await resolveRoots();
|
|
14307
|
+
if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) return void 0;
|
|
14308
|
+
const mainRoot = rootInfo.mainRoot;
|
|
14309
|
+
const wtPath = rootInfo.repoRoot;
|
|
14310
|
+
const slug = slugFromWorktreePath(mainRoot, wtPath);
|
|
14311
|
+
if (slug === void 0) return void 0;
|
|
14312
|
+
return { mainRoot, wtPath, slug };
|
|
14313
|
+
}
|
|
14314
|
+
async function runAutoHydrate(opts = {}, deps = {}) {
|
|
14315
|
+
const env = opts.env ?? process.env;
|
|
14316
|
+
const resolved = await resolveWorktree();
|
|
14317
|
+
if (!resolved) return { status: "not-a-workspace-worktree" };
|
|
14318
|
+
const { mainRoot, wtPath, slug } = resolved;
|
|
14319
|
+
if (env[NO_AUTO_HYDRATE_ENV] === "1") {
|
|
14320
|
+
return { status: "disabled", slug, reason: `${NO_AUTO_HYDRATE_ENV}=1 is set` };
|
|
14321
|
+
}
|
|
14322
|
+
const config = await readWorkspaceConfig(mainRoot);
|
|
14323
|
+
if (config?.worktree?.autoHydrate !== true) {
|
|
14324
|
+
return {
|
|
14325
|
+
status: "disabled",
|
|
14326
|
+
slug,
|
|
14327
|
+
reason: "worktree.autoHydrate is not enabled in haus.workspace.yaml"
|
|
14328
|
+
};
|
|
14329
|
+
}
|
|
14330
|
+
const lastRunFailure = await consumeLastRunFailure(mainRoot, slug);
|
|
14331
|
+
const withLastRun = (result) => lastRunFailure ? { ...result, lastRunFailure } : result;
|
|
14332
|
+
let prunedRepos;
|
|
14333
|
+
if (!opts.dryRun) {
|
|
14334
|
+
const prune = await runPrune2({}).catch(() => void 0);
|
|
14335
|
+
if (prune && !("error" in prune)) {
|
|
14336
|
+
prunedRepos = prune.results.filter((r) => r.pruned).map((r) => r.repo);
|
|
14337
|
+
}
|
|
14338
|
+
}
|
|
14339
|
+
const report = await runDoctor2();
|
|
14340
|
+
const missingMembers = report.members.filter((m) => !m.materialized).map((m) => m.id);
|
|
14341
|
+
if (missingMembers.length === 0) {
|
|
14342
|
+
return withLastRun({
|
|
14343
|
+
status: "up-to-date",
|
|
14344
|
+
slug,
|
|
14345
|
+
missingMembers,
|
|
14346
|
+
...prunedRepos ? { prunedRepos } : {}
|
|
14347
|
+
});
|
|
14348
|
+
}
|
|
14349
|
+
const logPath = hydrateLogPath(mainRoot, slug);
|
|
14350
|
+
if (opts.dryRun) {
|
|
14351
|
+
return withLastRun({ status: "planned", slug, missingMembers, logPath });
|
|
14352
|
+
}
|
|
14353
|
+
const acquired = await acquireHydrateLock(mainRoot, slug);
|
|
14354
|
+
if (!acquired.acquired) {
|
|
14355
|
+
return withLastRun({
|
|
14356
|
+
status: "in-progress",
|
|
14357
|
+
slug,
|
|
14358
|
+
missingMembers,
|
|
14359
|
+
...acquired.ownerPid !== void 0 ? { ownerPid: acquired.ownerPid } : {},
|
|
14360
|
+
lockPath: acquired.lockPath,
|
|
14361
|
+
logPath
|
|
14362
|
+
});
|
|
14363
|
+
}
|
|
14364
|
+
const entry = process.argv[1];
|
|
14365
|
+
if (entry === void 0) {
|
|
14366
|
+
await releaseHydrateLock(mainRoot, slug);
|
|
14367
|
+
return {
|
|
14368
|
+
status: "error",
|
|
14369
|
+
slug,
|
|
14370
|
+
reason: "could not determine the haus CLI entry point to re-invoke"
|
|
14371
|
+
};
|
|
14372
|
+
}
|
|
14373
|
+
await fs51.ensureDir(logsDir(mainRoot));
|
|
14374
|
+
appendLog(
|
|
14375
|
+
logPath,
|
|
14376
|
+
`autohydrate: forking hydrate --materialize for "${slug}" (${missingMembers.join(", ")})`
|
|
14377
|
+
);
|
|
14378
|
+
const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
|
|
14379
|
+
let pid;
|
|
14380
|
+
try {
|
|
14381
|
+
pid = spawnDetached({
|
|
14382
|
+
command: process.execPath,
|
|
14383
|
+
args: [entry, "workspace", "worktree", "autohydrate", "--detached-child"],
|
|
14384
|
+
cwd: wtPath,
|
|
14385
|
+
logPath
|
|
14386
|
+
});
|
|
14387
|
+
} catch (err) {
|
|
14388
|
+
await releaseHydrateLock(mainRoot, slug);
|
|
14389
|
+
return {
|
|
14390
|
+
status: "error",
|
|
14391
|
+
slug,
|
|
14392
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
14393
|
+
};
|
|
14394
|
+
}
|
|
14395
|
+
if (pid === void 0) {
|
|
14396
|
+
await releaseHydrateLock(mainRoot, slug);
|
|
14397
|
+
return { status: "error", slug, reason: "the detached hydration reported no pid" };
|
|
14398
|
+
}
|
|
14399
|
+
await recordHydrateLockOwner(mainRoot, slug, pid);
|
|
14400
|
+
return withLastRun({
|
|
14401
|
+
status: "started",
|
|
14402
|
+
slug,
|
|
14403
|
+
missingMembers,
|
|
14404
|
+
pid,
|
|
14405
|
+
lockPath: acquired.lockPath,
|
|
14406
|
+
logPath,
|
|
14407
|
+
...prunedRepos ? { prunedRepos } : {}
|
|
14408
|
+
});
|
|
14409
|
+
}
|
|
14410
|
+
async function runDetachedHydration() {
|
|
14411
|
+
const resolved = await resolveWorktree();
|
|
14412
|
+
if (!resolved) return { ok: false, failures: ["not inside a workspace worktree"] };
|
|
14413
|
+
const { mainRoot, slug } = resolved;
|
|
14414
|
+
const logPath = hydrateLogPath(mainRoot, slug);
|
|
14415
|
+
try {
|
|
14416
|
+
const result = await runHydrateWorktree({ materialize: true });
|
|
14417
|
+
if ("error" in result && !result.ok) {
|
|
14418
|
+
const failures2 = [result.error];
|
|
14419
|
+
await writeLastRun(mainRoot, slug, false, failures2);
|
|
14420
|
+
appendLog(logPath, `hydrate failed: ${result.error}`);
|
|
14421
|
+
return { ok: false, slug, failures: failures2 };
|
|
14422
|
+
}
|
|
14423
|
+
const detail = "results" in result ? result.results : [];
|
|
14424
|
+
const failures = detail.filter((r) => r.status === "failed" || installFailed(r.hydration?.install)).map(
|
|
14425
|
+
(r) => r.status === "failed" ? `${r.member}: failed to materialize \u2014 ${r.error ?? "unknown error"}` : `${r.member}: materialized, but its install FAILED via ${r.hydration?.install.plan.manager}`
|
|
14426
|
+
);
|
|
14427
|
+
await writeLastRun(mainRoot, slug, result.ok, failures);
|
|
14428
|
+
appendLog(logPath, `hydrate finished: ${result.ok ? "ok" : `FAILED (${failures.join("; ")})`}`);
|
|
14429
|
+
return { ok: result.ok, slug, failures };
|
|
14430
|
+
} catch (err) {
|
|
14431
|
+
const message2 = err instanceof Error ? err.message : String(err);
|
|
14432
|
+
await writeLastRun(mainRoot, slug, false, [message2]);
|
|
14433
|
+
appendLog(logPath, `hydrate threw: ${message2}`);
|
|
14434
|
+
return { ok: false, slug, failures: [message2] };
|
|
14435
|
+
} finally {
|
|
14436
|
+
await releaseHydrateLock(mainRoot, slug).catch(() => void 0);
|
|
14437
|
+
}
|
|
14438
|
+
}
|
|
14439
|
+
async function writeLastRun(mainRoot, slug, ok, failures) {
|
|
14440
|
+
const file = hydrateLastRunPath(mainRoot, slug);
|
|
14441
|
+
if (ok) {
|
|
14442
|
+
await fs51.remove(file).catch(() => void 0);
|
|
14443
|
+
return;
|
|
14444
|
+
}
|
|
14445
|
+
const record = { slug, finishedAt: (/* @__PURE__ */ new Date()).toISOString(), ok, failures };
|
|
14446
|
+
await fs51.ensureDir(path71.dirname(file));
|
|
14447
|
+
await fs51.writeJson(file, record, { spaces: 2 }).catch(() => void 0);
|
|
14448
|
+
}
|
|
14449
|
+
|
|
14450
|
+
// src/workspace/worktree/dematerialize.ts
|
|
14451
|
+
import path73 from "node:path";
|
|
14452
|
+
import fs53 from "fs-extra";
|
|
14453
|
+
|
|
14454
|
+
// src/workspace/worktree/member-targets.ts
|
|
14455
|
+
import path72 from "node:path";
|
|
14456
|
+
import fs52 from "fs-extra";
|
|
14457
|
+
function normalizeStateMembers(state, allMembers) {
|
|
14458
|
+
return state?.members.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath })) ?? allMembers.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath }));
|
|
14459
|
+
}
|
|
14460
|
+
function resolveMemberTargets(wsWorktreePath, stateMembers, allMembers) {
|
|
14461
|
+
const targets = [];
|
|
14462
|
+
const unresolvedDroppedMembers = [];
|
|
14463
|
+
for (const stateMember of stateMembers) {
|
|
14464
|
+
const configMember = allMembers.find((m) => m.folder === stateMember.folder);
|
|
14465
|
+
const ownerRepo = configMember?.absPath ?? stateMember.absPath;
|
|
14466
|
+
if (!ownerRepo) {
|
|
14467
|
+
if (!configMember) unresolvedDroppedMembers.push(stateMember.folder);
|
|
14468
|
+
continue;
|
|
13761
14469
|
}
|
|
13762
14470
|
targets.push({
|
|
13763
14471
|
repo: configMember?.id ?? stateMember.id,
|
|
13764
14472
|
ownerRepo,
|
|
13765
|
-
path:
|
|
14473
|
+
path: path72.join(wsWorktreePath, stateMember.folder)
|
|
13766
14474
|
});
|
|
13767
14475
|
}
|
|
13768
|
-
|
|
14476
|
+
return { targets, unresolvedDroppedMembers };
|
|
14477
|
+
}
|
|
14478
|
+
async function filterExistingTargets(targets) {
|
|
14479
|
+
const existing = [];
|
|
14480
|
+
for (const target of targets) {
|
|
14481
|
+
if (await fs52.pathExists(target.path)) existing.push(target);
|
|
14482
|
+
}
|
|
14483
|
+
return existing;
|
|
14484
|
+
}
|
|
14485
|
+
function unresolvedDroppedBlockerReason() {
|
|
14486
|
+
return "no longer in the workspace config and this worktree predates absPath tracking \u2014 cannot verify for uncommitted work or unregister its git worktree; removing anyway will delete its directory here but leak the registration in whatever repo it came from (run `git worktree prune` there manually afterward)";
|
|
14487
|
+
}
|
|
14488
|
+
function unresolvedDroppedFailureReason() {
|
|
14489
|
+
return "directory removed, but its git worktree registration could not be unregistered (no recorded absPath) \u2014 run `git worktree prune` in its original owning repo";
|
|
14490
|
+
}
|
|
14491
|
+
async function findWorkBlockers(targets, ignorePathsFor = () => []) {
|
|
14492
|
+
const blockers = [];
|
|
13769
14493
|
for (const target of targets) {
|
|
13770
|
-
if (await
|
|
14494
|
+
if (await hasUncommittedChanges(target.path, ignorePathsFor(target))) {
|
|
14495
|
+
blockers.push({ repo: target.repo, reason: "uncommitted changes" });
|
|
14496
|
+
continue;
|
|
14497
|
+
}
|
|
14498
|
+
const unpushed = await hasUnpushedWork(target.path);
|
|
14499
|
+
if (unpushed.unpushed) blockers.push({ repo: target.repo, reason: unpushed.reason });
|
|
14500
|
+
}
|
|
14501
|
+
return blockers;
|
|
14502
|
+
}
|
|
14503
|
+
|
|
14504
|
+
// src/workspace/worktree/dematerialize.ts
|
|
14505
|
+
function resolveSlug(opts, workspaceRoot, repoRoot, isLinkedWorktree) {
|
|
14506
|
+
if (opts.slug !== void 0) return { slug: opts.slug };
|
|
14507
|
+
const slug = isLinkedWorktree ? slugFromWorktreePath(workspaceRoot, repoRoot) : void 0;
|
|
14508
|
+
if (slug !== void 0) return { slug };
|
|
14509
|
+
return {
|
|
14510
|
+
error: "Name the worktree to dematerialize (`haus workspace worktree dematerialize <slug>`), or run this from inside one of this workspace's .claude/worktrees/<slug> checkouts."
|
|
14511
|
+
};
|
|
14512
|
+
}
|
|
14513
|
+
async function runDematerialize(opts) {
|
|
14514
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
14515
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
14516
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
14517
|
+
const slugResult = resolveSlug(opts, workspaceRoot, rootInfo.repoRoot, rootInfo.isLinkedWorktree);
|
|
14518
|
+
if ("error" in slugResult) return { ok: false, error: slugResult.error };
|
|
14519
|
+
const { slug } = slugResult;
|
|
14520
|
+
let wsWorktreePath;
|
|
14521
|
+
try {
|
|
14522
|
+
wsWorktreePath = worktreePath(workspaceRoot, slug);
|
|
14523
|
+
} catch (err) {
|
|
14524
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
14525
|
+
}
|
|
14526
|
+
if (!await fs53.pathExists(wsWorktreePath)) {
|
|
14527
|
+
return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
|
|
13771
14528
|
}
|
|
14529
|
+
const lock = await readHydrateLock(workspaceRoot, slug);
|
|
14530
|
+
if (lock.held) {
|
|
14531
|
+
return {
|
|
14532
|
+
ok: false,
|
|
14533
|
+
error: `A hydration is running for "${slug}"${lock.ownerPid !== void 0 ? ` (pid ${lock.ownerPid})` : ""} \u2014 wait for ${lock.lockPath} to disappear before dematerializing it.`
|
|
14534
|
+
};
|
|
14535
|
+
}
|
|
14536
|
+
const state = await readWorktreeState(wsWorktreePath);
|
|
14537
|
+
const allMembers = await readMembers(rootInfo);
|
|
14538
|
+
const stateMembers = normalizeStateMembers(state, allMembers);
|
|
14539
|
+
const { targets, unresolvedDroppedMembers } = resolveMemberTargets(
|
|
14540
|
+
wsWorktreePath,
|
|
14541
|
+
stateMembers,
|
|
14542
|
+
allMembers
|
|
14543
|
+
);
|
|
14544
|
+
const existingTargets = await filterExistingTargets(targets);
|
|
13772
14545
|
if (!opts.force) {
|
|
13773
|
-
const blockers =
|
|
13774
|
-
|
|
13775
|
-
|
|
13776
|
-
|
|
13777
|
-
|
|
14546
|
+
const blockers = unresolvedDroppedMembers.map((folder) => ({
|
|
14547
|
+
repo: folder,
|
|
14548
|
+
reason: unresolvedDroppedBlockerReason()
|
|
14549
|
+
}));
|
|
14550
|
+
blockers.push(...await findWorkBlockers(existingTargets));
|
|
14551
|
+
if (blockers.length > 0) return { ok: false, blocked: true, slug, blockers };
|
|
14552
|
+
}
|
|
14553
|
+
if (opts.dryRun) {
|
|
14554
|
+
return { ok: true, dryRun: true, slug, wouldRemove: existingTargets.map((t) => t.repo) };
|
|
14555
|
+
}
|
|
14556
|
+
const removed = [];
|
|
14557
|
+
const failed = [];
|
|
14558
|
+
for (const folder of unresolvedDroppedMembers) {
|
|
14559
|
+
const droppedPath = path73.join(wsWorktreePath, folder);
|
|
14560
|
+
if (await fs53.pathExists(droppedPath)) {
|
|
14561
|
+
await fs53.remove(droppedPath);
|
|
14562
|
+
failed.push({ repo: folder, error: unresolvedDroppedFailureReason() });
|
|
14563
|
+
}
|
|
14564
|
+
}
|
|
14565
|
+
for (const target of existingTargets) {
|
|
14566
|
+
const result = await removeWorktree(target.ownerRepo, target.path);
|
|
14567
|
+
if (result.ok) removed.push(target.repo);
|
|
14568
|
+
else failed.push({ repo: target.repo, error: result.stderr ?? "unknown error" });
|
|
14569
|
+
await pruneWorktrees(target.ownerRepo);
|
|
14570
|
+
}
|
|
14571
|
+
return { ok: failed.length === 0, dryRun: false, slug, removed, failed };
|
|
14572
|
+
}
|
|
14573
|
+
|
|
14574
|
+
// src/workspace/worktree/list.ts
|
|
14575
|
+
import path74 from "node:path";
|
|
14576
|
+
import fs54 from "fs-extra";
|
|
14577
|
+
async function runList2() {
|
|
14578
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
14579
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
14580
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
14581
|
+
const dir = worktreesDir(workspaceRoot);
|
|
14582
|
+
if (!await fs54.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
|
|
14583
|
+
const entries = await fs54.readdir(dir, { withFileTypes: true });
|
|
14584
|
+
const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
14585
|
+
const configuredMembers = await readMembers(rootInfo);
|
|
14586
|
+
const worktrees = [];
|
|
14587
|
+
for (const slug of slugs) {
|
|
14588
|
+
const wtPath = path74.join(dir, slug);
|
|
14589
|
+
const state = await readWorktreeState(wtPath);
|
|
14590
|
+
const members = [];
|
|
14591
|
+
const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
|
|
14592
|
+
for (const m of source) {
|
|
14593
|
+
const memberPath = path74.join(wtPath, m.folder);
|
|
14594
|
+
const materialized = await fs54.pathExists(memberPath);
|
|
14595
|
+
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
14596
|
+
members.push({
|
|
14597
|
+
id: m.id,
|
|
14598
|
+
folder: m.folder,
|
|
14599
|
+
expectedBranch: m.branch,
|
|
14600
|
+
materialized,
|
|
14601
|
+
actualBranch
|
|
14602
|
+
});
|
|
14603
|
+
}
|
|
14604
|
+
worktrees.push({
|
|
14605
|
+
slug,
|
|
14606
|
+
branch: state?.branch ?? await currentBranch(wtPath) ?? "unknown",
|
|
14607
|
+
createdAt: state?.createdAt,
|
|
14608
|
+
members
|
|
14609
|
+
});
|
|
14610
|
+
}
|
|
14611
|
+
return { ok: true, workspaceRoot, worktrees };
|
|
14612
|
+
}
|
|
14613
|
+
|
|
14614
|
+
// src/workspace/worktree/reclaim.ts
|
|
14615
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
14616
|
+
import path75 from "node:path";
|
|
14617
|
+
import fs55 from "fs-extra";
|
|
14618
|
+
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
14619
|
+
async function lastTouchedMs(worktreeRoot) {
|
|
14620
|
+
const candidates = [worktreeRoot, path75.join(worktreeRoot, ".haus-worktree.json")];
|
|
14621
|
+
try {
|
|
14622
|
+
const dotGit = await fs55.readFile(path75.join(worktreeRoot, ".git"), "utf8");
|
|
14623
|
+
const match = /^gitdir:\s*(.+)$/m.exec(dotGit);
|
|
14624
|
+
if (match?.[1]) {
|
|
14625
|
+
const gitdir = match[1].trim();
|
|
14626
|
+
candidates.push(path75.join(gitdir, "HEAD"), path75.join(gitdir, "index"));
|
|
14627
|
+
}
|
|
14628
|
+
} catch {
|
|
14629
|
+
}
|
|
14630
|
+
let newest = 0;
|
|
14631
|
+
for (const candidate of candidates) {
|
|
14632
|
+
const stat = await fs55.stat(candidate).catch(() => void 0);
|
|
14633
|
+
if (stat && stat.mtimeMs > newest) newest = stat.mtimeMs;
|
|
14634
|
+
}
|
|
14635
|
+
return newest;
|
|
14636
|
+
}
|
|
14637
|
+
async function runReclaim(opts = {}) {
|
|
14638
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
14639
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
14640
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
14641
|
+
const config = await readWorkspaceConfig(workspaceRoot);
|
|
14642
|
+
const olderThanDays = opts.olderThanDays ?? config?.worktree?.reclaimAfterDays;
|
|
14643
|
+
if (olderThanDays === void 0) {
|
|
14644
|
+
return {
|
|
14645
|
+
ok: false,
|
|
14646
|
+
error: "No reclamation age configured. Pass `--older-than-days <n>`, or set `worktree.reclaimAfterDays` in haus.workspace.yaml."
|
|
14647
|
+
};
|
|
14648
|
+
}
|
|
14649
|
+
if (!Number.isFinite(olderThanDays) || olderThanDays <= 0) {
|
|
14650
|
+
return {
|
|
14651
|
+
ok: false,
|
|
14652
|
+
error: `--older-than-days must be a number greater than 0 (got ${String(olderThanDays)}).`
|
|
14653
|
+
};
|
|
14654
|
+
}
|
|
14655
|
+
const dir = worktreesDir(workspaceRoot);
|
|
14656
|
+
if (!existsSync8(dir)) {
|
|
14657
|
+
return { ok: true, dryRun: !opts.write, olderThanDays, candidates: [], reclaimed: 0 };
|
|
14658
|
+
}
|
|
14659
|
+
const allEntries = (await fs55.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
14660
|
+
const entries = allEntries.filter((name) => isValidWorktreeSlug(name));
|
|
14661
|
+
const unaddressable = allEntries.filter((name) => !isValidWorktreeSlug(name));
|
|
14662
|
+
const members = await readMembers(rootInfo);
|
|
14663
|
+
const currentSlug = rootInfo.isLinkedWorktree ? slugFromWorktreePath(workspaceRoot, rootInfo.repoRoot) : void 0;
|
|
14664
|
+
const candidates = [];
|
|
14665
|
+
let reclaimed = 0;
|
|
14666
|
+
for (const name of unaddressable) {
|
|
14667
|
+
candidates.push({
|
|
14668
|
+
slug: name,
|
|
14669
|
+
ageDays: (Date.now() - await lastTouchedMs(path75.join(dir, name))) / MS_PER_DAY,
|
|
14670
|
+
materializedMembers: 0,
|
|
14671
|
+
action: "skip",
|
|
14672
|
+
reason: "directory name is not a valid worktree slug \u2014 no haus worktree command can address it; remove it by hand if it is debris"
|
|
14673
|
+
});
|
|
14674
|
+
}
|
|
14675
|
+
for (const slug of entries) {
|
|
14676
|
+
const wtPath = path75.join(dir, slug);
|
|
14677
|
+
const state = await readWorktreeState(wtPath);
|
|
14678
|
+
const stateMembers = normalizeStateMembers(state, members);
|
|
14679
|
+
const materialized = stateMembers.filter((m) => existsSync8(path75.join(wtPath, m.folder)));
|
|
14680
|
+
const ageDays = (Date.now() - await lastTouchedMs(wtPath)) / MS_PER_DAY;
|
|
14681
|
+
const base = { slug, ageDays, materializedMembers: materialized.length };
|
|
14682
|
+
if (slug === currentSlug) {
|
|
14683
|
+
candidates.push({ ...base, action: "skip", reason: "this is the current worktree" });
|
|
14684
|
+
continue;
|
|
14685
|
+
}
|
|
14686
|
+
if (materialized.length === 0) {
|
|
14687
|
+
candidates.push({ ...base, action: "skip", reason: "nothing materialized to reclaim" });
|
|
14688
|
+
continue;
|
|
14689
|
+
}
|
|
14690
|
+
const lock = await readHydrateLock(workspaceRoot, slug).catch(() => ({ held: false }));
|
|
14691
|
+
if (lock.held) {
|
|
14692
|
+
candidates.push({ ...base, action: "skip", reason: "a hydration is running for it" });
|
|
14693
|
+
continue;
|
|
14694
|
+
}
|
|
14695
|
+
if (ageDays < olderThanDays) {
|
|
14696
|
+
candidates.push({
|
|
14697
|
+
...base,
|
|
14698
|
+
action: "skip",
|
|
14699
|
+
reason: `touched ${ageDays.toFixed(1)} days ago (threshold ${olderThanDays})`
|
|
13778
14700
|
});
|
|
14701
|
+
continue;
|
|
14702
|
+
}
|
|
14703
|
+
const outcome = await runDematerialize({ slug, dryRun: !opts.write });
|
|
14704
|
+
if (!outcome.ok && "blocked" in outcome) {
|
|
14705
|
+
candidates.push({ ...base, action: "blocked", blockers: outcome.blockers });
|
|
14706
|
+
continue;
|
|
13779
14707
|
}
|
|
13780
|
-
|
|
13781
|
-
|
|
13782
|
-
|
|
13783
|
-
|
|
14708
|
+
if (!outcome.ok && "error" in outcome) {
|
|
14709
|
+
candidates.push({ ...base, action: "failed", reason: outcome.error });
|
|
14710
|
+
continue;
|
|
14711
|
+
}
|
|
14712
|
+
if ("dryRun" in outcome && outcome.dryRun) {
|
|
14713
|
+
candidates.push({ ...base, action: "reclaim", members: outcome.wouldRemove });
|
|
14714
|
+
continue;
|
|
14715
|
+
}
|
|
14716
|
+
if ("removed" in outcome) {
|
|
14717
|
+
if (outcome.failed.length > 0) {
|
|
14718
|
+
candidates.push({
|
|
14719
|
+
...base,
|
|
14720
|
+
action: "failed",
|
|
14721
|
+
members: outcome.removed,
|
|
14722
|
+
reason: outcome.failed.map((f) => `${f.repo}: ${f.error}`).join("; ")
|
|
14723
|
+
});
|
|
13784
14724
|
continue;
|
|
13785
14725
|
}
|
|
13786
|
-
|
|
13787
|
-
|
|
14726
|
+
reclaimed += 1;
|
|
14727
|
+
candidates.push({ ...base, action: "reclaim", members: outcome.removed });
|
|
13788
14728
|
}
|
|
14729
|
+
}
|
|
14730
|
+
return {
|
|
14731
|
+
ok: candidates.every((c) => c.action !== "failed"),
|
|
14732
|
+
dryRun: !opts.write,
|
|
14733
|
+
olderThanDays,
|
|
14734
|
+
candidates,
|
|
14735
|
+
reclaimed
|
|
14736
|
+
};
|
|
14737
|
+
}
|
|
14738
|
+
|
|
14739
|
+
// src/workspace/worktree/remove.ts
|
|
14740
|
+
import fs56 from "fs-extra";
|
|
14741
|
+
async function runRemove2(opts) {
|
|
14742
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
14743
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
14744
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
14745
|
+
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
14746
|
+
if (!await fs56.pathExists(wsWorktreePath)) {
|
|
14747
|
+
return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
|
|
14748
|
+
}
|
|
14749
|
+
const state = await readWorktreeState(wsWorktreePath);
|
|
14750
|
+
const allMembers = await readMembers(rootInfo);
|
|
14751
|
+
const stateMembers = normalizeStateMembers(state, allMembers);
|
|
14752
|
+
const { targets: memberOnlyTargets, unresolvedDroppedMembers } = resolveMemberTargets(
|
|
14753
|
+
wsWorktreePath,
|
|
14754
|
+
stateMembers,
|
|
14755
|
+
allMembers
|
|
14756
|
+
);
|
|
14757
|
+
const targets = [
|
|
14758
|
+
{ repo: "(workspace)", ownerRepo: workspaceRoot, path: wsWorktreePath },
|
|
14759
|
+
...memberOnlyTargets
|
|
14760
|
+
];
|
|
14761
|
+
const existingTargets = await filterExistingTargets(targets);
|
|
14762
|
+
if (!opts.force) {
|
|
14763
|
+
const blockers = unresolvedDroppedMembers.map((folder) => ({
|
|
14764
|
+
repo: folder,
|
|
14765
|
+
reason: unresolvedDroppedBlockerReason()
|
|
14766
|
+
}));
|
|
14767
|
+
blockers.push(
|
|
14768
|
+
...await findWorkBlockers(
|
|
14769
|
+
existingTargets,
|
|
14770
|
+
(target) => (
|
|
14771
|
+
// The workspace worktree root always contains content that is untracked by
|
|
14772
|
+
// design and must not read as "uncommitted work": our own state file, and
|
|
14773
|
+
// every member folder (each a separate nested repo, so it shows as `?? forms/`
|
|
14774
|
+
// whether or not that member is clean). Members are dirty-checked
|
|
14775
|
+
// independently as their own targets.
|
|
14776
|
+
target.repo === "(workspace)" ? [WORKTREE_STATE_FILE, ...stateMembers.map((m) => m.folder)] : []
|
|
14777
|
+
)
|
|
14778
|
+
)
|
|
14779
|
+
);
|
|
13789
14780
|
if (blockers.length > 0) return { ok: false, blocked: true, blockers };
|
|
13790
14781
|
}
|
|
13791
14782
|
if (opts.dryRun) {
|
|
@@ -13799,10 +14790,7 @@ async function runRemove2(opts) {
|
|
|
13799
14790
|
const removed = [];
|
|
13800
14791
|
const failed = [];
|
|
13801
14792
|
for (const folder of unresolvedDroppedMembers) {
|
|
13802
|
-
failed.push({
|
|
13803
|
-
repo: folder,
|
|
13804
|
-
error: "directory removed, but its git worktree registration could not be unregistered (no recorded absPath) \u2014 run `git worktree prune` in its original owning repo"
|
|
13805
|
-
});
|
|
14793
|
+
failed.push({ repo: folder, error: unresolvedDroppedFailureReason() });
|
|
13806
14794
|
}
|
|
13807
14795
|
const memberTargets = existingTargets.filter((t) => t.repo !== "(workspace)");
|
|
13808
14796
|
for (const target of memberTargets) {
|
|
@@ -13823,8 +14811,8 @@ async function runRemove2(opts) {
|
|
|
13823
14811
|
}
|
|
13824
14812
|
await pruneWorktrees(workspaceRoot);
|
|
13825
14813
|
}
|
|
13826
|
-
if (wsRemoveOk && await
|
|
13827
|
-
await
|
|
14814
|
+
if (wsRemoveOk && await fs56.pathExists(wsWorktreePath)) {
|
|
14815
|
+
await fs56.remove(wsWorktreePath);
|
|
13828
14816
|
}
|
|
13829
14817
|
return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
|
|
13830
14818
|
}
|
|
@@ -13840,6 +14828,23 @@ function emitMemberLine(hydration, line2) {
|
|
|
13840
14828
|
if (hydration?.install.ran && !hydration.install.ok) warn(line2);
|
|
13841
14829
|
else log(line2);
|
|
13842
14830
|
}
|
|
14831
|
+
function countMemberFailures(members) {
|
|
14832
|
+
let materialize = 0;
|
|
14833
|
+
let install = 0;
|
|
14834
|
+
for (const m of members) {
|
|
14835
|
+
if (m.status === "failed") materialize += 1;
|
|
14836
|
+
else if (installFailed(m.hydration?.install)) install += 1;
|
|
14837
|
+
}
|
|
14838
|
+
return { materialize, install };
|
|
14839
|
+
}
|
|
14840
|
+
function describeMemberFailures(members) {
|
|
14841
|
+
const { materialize, install } = countMemberFailures(members);
|
|
14842
|
+
const parts = [];
|
|
14843
|
+
if (materialize > 0) parts.push(`${materialize} member(s) failed to materialize`);
|
|
14844
|
+
if (install > 0) parts.push(`${install} member(s) materialized but their install FAILED`);
|
|
14845
|
+
const summary = parts.length > 0 ? parts.join(" and ") : "the run did not complete cleanly";
|
|
14846
|
+
return `${summary} \u2014 see the per-member detail above for exactly what exists.`;
|
|
14847
|
+
}
|
|
13843
14848
|
async function handleAdd(opts) {
|
|
13844
14849
|
if (!opts.slug) {
|
|
13845
14850
|
if (opts.json) emitJson({ ok: false, error: "Usage: haus workspace worktree add <slug>" });
|
|
@@ -13892,10 +14897,7 @@ async function handleAdd(opts) {
|
|
|
13892
14897
|
}
|
|
13893
14898
|
}
|
|
13894
14899
|
if (!result.ok) {
|
|
13895
|
-
|
|
13896
|
-
error(
|
|
13897
|
-
`${failedCount} member(s) failed to materialize \u2014 the worktree is partially built. See the per-member detail above for exactly what exists.`
|
|
13898
|
-
);
|
|
14900
|
+
error(`The worktree is partially built: ${describeMemberFailures(result.members)}`);
|
|
13899
14901
|
process.exitCode = 1;
|
|
13900
14902
|
}
|
|
13901
14903
|
}
|
|
@@ -13938,7 +14940,10 @@ async function handleHydrate(opts) {
|
|
|
13938
14940
|
warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
|
|
13939
14941
|
}
|
|
13940
14942
|
}
|
|
13941
|
-
if (!result.ok)
|
|
14943
|
+
if (!result.ok) {
|
|
14944
|
+
error(describeMemberFailures(result.results));
|
|
14945
|
+
process.exitCode = 1;
|
|
14946
|
+
}
|
|
13942
14947
|
}
|
|
13943
14948
|
async function handleRemove(opts) {
|
|
13944
14949
|
if (!opts.slug) {
|
|
@@ -14081,6 +15086,168 @@ async function handleDoctor(opts) {
|
|
|
14081
15086
|
}
|
|
14082
15087
|
}
|
|
14083
15088
|
}
|
|
15089
|
+
function describeAutoHydrate(result) {
|
|
15090
|
+
const lines = [];
|
|
15091
|
+
if (result.lastRunFailure) {
|
|
15092
|
+
lines.push(
|
|
15093
|
+
`The previous automatic hydration of "${result.lastRunFailure.slug}" FAILED (${result.lastRunFailure.finishedAt}): ${result.lastRunFailure.failures.join("; ")}. Re-run \`haus workspace worktree hydrate --materialize\` and read the error before trusting the member repos.`
|
|
15094
|
+
);
|
|
15095
|
+
}
|
|
15096
|
+
switch (result.status) {
|
|
15097
|
+
case "started":
|
|
15098
|
+
lines.push(
|
|
15099
|
+
`The member repos are missing in this worktree and are being hydrated in the background now (${result.missingMembers?.join(", ")}). This takes minutes, not seconds. Wait until ${result.lockPath} disappears before reading any file in a member repo \u2014 \`haus workspace worktree doctor\` turns green before the work is finished. Log: ${result.logPath}`
|
|
15100
|
+
);
|
|
15101
|
+
break;
|
|
15102
|
+
case "in-progress":
|
|
15103
|
+
lines.push(
|
|
15104
|
+
`The member repos in this worktree are already being hydrated by another session${result.ownerPid !== void 0 ? ` (pid ${result.ownerPid})` : ""}. Wait until ${result.lockPath} disappears before reading files in the member repos.`
|
|
15105
|
+
);
|
|
15106
|
+
break;
|
|
15107
|
+
case "planned":
|
|
15108
|
+
lines.push(
|
|
15109
|
+
`[dry-run] Would hydrate ${result.missingMembers?.join(", ")} in the background for "${result.slug}". Log would be ${result.logPath}.`
|
|
15110
|
+
);
|
|
15111
|
+
break;
|
|
15112
|
+
case "error":
|
|
15113
|
+
lines.push(`Automatic hydration could not start: ${result.reason ?? "unknown reason"}`);
|
|
15114
|
+
break;
|
|
15115
|
+
case "disabled":
|
|
15116
|
+
case "up-to-date":
|
|
15117
|
+
case "not-a-workspace-worktree":
|
|
15118
|
+
break;
|
|
15119
|
+
}
|
|
15120
|
+
return lines;
|
|
15121
|
+
}
|
|
15122
|
+
async function handleAutoHydrate(opts) {
|
|
15123
|
+
if (opts.detachedChild) {
|
|
15124
|
+
const outcome = await runDetachedHydration();
|
|
15125
|
+
if (opts.json) emitJson(outcome);
|
|
15126
|
+
else if (!outcome.ok) for (const f of outcome.failures) error(f);
|
|
15127
|
+
if (!outcome.ok) process.exitCode = 1;
|
|
15128
|
+
return;
|
|
15129
|
+
}
|
|
15130
|
+
let result;
|
|
15131
|
+
try {
|
|
15132
|
+
result = await runAutoHydrate({
|
|
15133
|
+
dryRun: resolveWriteMode(opts, "mutate-by-default").kind === "preview"
|
|
15134
|
+
});
|
|
15135
|
+
} catch (err) {
|
|
15136
|
+
const message2 = err instanceof Error ? err.message : String(err);
|
|
15137
|
+
if (opts.fromHook) return;
|
|
15138
|
+
if (opts.json) emitJson({ status: "error", reason: message2 });
|
|
15139
|
+
else error(message2);
|
|
15140
|
+
process.exitCode = 1;
|
|
15141
|
+
return;
|
|
15142
|
+
}
|
|
15143
|
+
const lines = describeAutoHydrate(result);
|
|
15144
|
+
if (opts.fromHook) {
|
|
15145
|
+
if (lines.length > 0) emitFromHookContext(lines.join(" "));
|
|
15146
|
+
return;
|
|
15147
|
+
}
|
|
15148
|
+
if (opts.json) {
|
|
15149
|
+
emitJson(result);
|
|
15150
|
+
if (result.status === "error") process.exitCode = 1;
|
|
15151
|
+
return;
|
|
15152
|
+
}
|
|
15153
|
+
if (lines.length === 0) {
|
|
15154
|
+
log(
|
|
15155
|
+
result.status === "disabled" ? `Automatic hydration is off: ${result.reason ?? "not enabled"}.` : "Nothing to hydrate."
|
|
15156
|
+
);
|
|
15157
|
+
return;
|
|
15158
|
+
}
|
|
15159
|
+
for (const line2 of lines) log(line2);
|
|
15160
|
+
if (result.status === "error") process.exitCode = 1;
|
|
15161
|
+
}
|
|
15162
|
+
async function handleDematerialize(opts) {
|
|
15163
|
+
const dematOpts = {
|
|
15164
|
+
...opts.slug !== void 0 ? { slug: opts.slug } : {},
|
|
15165
|
+
force: Boolean(opts.force),
|
|
15166
|
+
dryRun: resolveWriteMode(opts, "mutate-by-default").kind === "preview"
|
|
15167
|
+
};
|
|
15168
|
+
const result = await runDematerialize(dematOpts);
|
|
15169
|
+
if (opts.json) {
|
|
15170
|
+
emitJson(result);
|
|
15171
|
+
if (!result.ok || "removed" in result && result.failed.length > 0) process.exitCode = 1;
|
|
15172
|
+
return;
|
|
15173
|
+
}
|
|
15174
|
+
if (!result.ok && "blocked" in result) {
|
|
15175
|
+
error(`Refusing to dematerialize "${result.slug}" \u2014 uncommitted or unpushed work found:`);
|
|
15176
|
+
for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
|
|
15177
|
+
error("Re-run with --force to dematerialize anyway.");
|
|
15178
|
+
process.exitCode = 1;
|
|
15179
|
+
return;
|
|
15180
|
+
}
|
|
15181
|
+
if (!result.ok && "error" in result) {
|
|
15182
|
+
error(result.error);
|
|
15183
|
+
process.exitCode = 1;
|
|
15184
|
+
return;
|
|
15185
|
+
}
|
|
15186
|
+
if ("dryRun" in result && result.dryRun) {
|
|
15187
|
+
log(`[dry-run] Would dematerialize: ${result.wouldRemove.join(", ") || "(nothing)"}`);
|
|
15188
|
+
return;
|
|
15189
|
+
}
|
|
15190
|
+
if ("removed" in result) {
|
|
15191
|
+
log(
|
|
15192
|
+
`Dematerialized "${result.slug}": ${result.removed.join(", ") || "(nothing)"}. The workspace worktree and its branch are untouched \u2014 run \`haus workspace worktree hydrate --materialize\` to bring the members back.`
|
|
15193
|
+
);
|
|
15194
|
+
if (result.failed.length > 0) {
|
|
15195
|
+
for (const f of result.failed) error(` Failed to dematerialize ${f.repo}: ${f.error}`);
|
|
15196
|
+
process.exitCode = 1;
|
|
15197
|
+
}
|
|
15198
|
+
}
|
|
15199
|
+
}
|
|
15200
|
+
async function handleReclaim(opts) {
|
|
15201
|
+
const result = await runReclaim({
|
|
15202
|
+
...opts.olderThanDays !== void 0 ? { olderThanDays: opts.olderThanDays } : {},
|
|
15203
|
+
write: resolveWriteMode(opts, "preview-by-default").kind === "write"
|
|
15204
|
+
});
|
|
15205
|
+
if (opts.json) {
|
|
15206
|
+
emitJson(result);
|
|
15207
|
+
if (!result.ok) process.exitCode = 1;
|
|
15208
|
+
return;
|
|
15209
|
+
}
|
|
15210
|
+
if (!result.ok && "error" in result) {
|
|
15211
|
+
error(result.error);
|
|
15212
|
+
process.exitCode = 1;
|
|
15213
|
+
return;
|
|
15214
|
+
}
|
|
15215
|
+
if (!("candidates" in result)) return;
|
|
15216
|
+
if (result.candidates.length === 0) {
|
|
15217
|
+
log("No workspace worktrees under .claude/worktrees/.");
|
|
15218
|
+
return;
|
|
15219
|
+
}
|
|
15220
|
+
for (const c of result.candidates) {
|
|
15221
|
+
const age = `${c.ageDays.toFixed(1)}d`;
|
|
15222
|
+
switch (c.action) {
|
|
15223
|
+
case "reclaim":
|
|
15224
|
+
log(
|
|
15225
|
+
dryRunPrefix(
|
|
15226
|
+
result.dryRun,
|
|
15227
|
+
`${result.dryRun ? "Would reclaim" : "Reclaimed"} ${c.slug} (${age}): ${(c.members ?? []).join(", ")}`
|
|
15228
|
+
)
|
|
15229
|
+
);
|
|
15230
|
+
break;
|
|
15231
|
+
case "blocked":
|
|
15232
|
+
warn(`Blocked ${c.slug} (${age}) \u2014 uncommitted or unpushed work:`);
|
|
15233
|
+
for (const b of c.blockers ?? []) warn(` ${b.repo}: ${b.reason}`);
|
|
15234
|
+
break;
|
|
15235
|
+
case "failed":
|
|
15236
|
+
error(`FAILED ${c.slug} (${age}): ${c.reason ?? "unknown error"}`);
|
|
15237
|
+
break;
|
|
15238
|
+
case "skip":
|
|
15239
|
+
log(`Keeping ${c.slug} (${age}) \u2014 ${c.reason ?? "not a candidate"}`);
|
|
15240
|
+
break;
|
|
15241
|
+
}
|
|
15242
|
+
}
|
|
15243
|
+
if (result.dryRun) {
|
|
15244
|
+
const would = result.candidates.filter((c) => c.action === "reclaim").length;
|
|
15245
|
+
log(
|
|
15246
|
+
would > 0 ? `Re-run with --write to reclaim ${would} worktree(s).` : "Nothing to reclaim at this age."
|
|
15247
|
+
);
|
|
15248
|
+
}
|
|
15249
|
+
if (!result.ok) process.exitCode = 1;
|
|
15250
|
+
}
|
|
14084
15251
|
async function runWorktree(action, opts = {}) {
|
|
14085
15252
|
switch (action) {
|
|
14086
15253
|
case "add":
|
|
@@ -14095,12 +15262,18 @@ async function runWorktree(action, opts = {}) {
|
|
|
14095
15262
|
return handleDoctor(opts);
|
|
14096
15263
|
case "prune":
|
|
14097
15264
|
return handlePrune(opts);
|
|
15265
|
+
case "autohydrate":
|
|
15266
|
+
return handleAutoHydrate(opts);
|
|
15267
|
+
case "dematerialize":
|
|
15268
|
+
return handleDematerialize(opts);
|
|
15269
|
+
case "reclaim":
|
|
15270
|
+
return handleReclaim(opts);
|
|
14098
15271
|
}
|
|
14099
15272
|
}
|
|
14100
15273
|
|
|
14101
15274
|
// src/commands/workspace.ts
|
|
14102
|
-
import { existsSync as
|
|
14103
|
-
import
|
|
15275
|
+
import { existsSync as existsSync12, statSync as statSync3 } from "node:fs";
|
|
15276
|
+
import path84 from "node:path";
|
|
14104
15277
|
init_fs();
|
|
14105
15278
|
init_logger();
|
|
14106
15279
|
|
|
@@ -14158,8 +15331,8 @@ ${summaries.map(
|
|
|
14158
15331
|
}
|
|
14159
15332
|
|
|
14160
15333
|
// src/commands/workspace/doctor.ts
|
|
14161
|
-
import { existsSync as
|
|
14162
|
-
import
|
|
15334
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
15335
|
+
import path77 from "node:path";
|
|
14163
15336
|
init_logger();
|
|
14164
15337
|
init_paths();
|
|
14165
15338
|
|
|
@@ -14167,14 +15340,14 @@ init_paths();
|
|
|
14167
15340
|
init_fs();
|
|
14168
15341
|
init_paths();
|
|
14169
15342
|
import { readFileSync as readFileSync4 } from "node:fs";
|
|
14170
|
-
import
|
|
15343
|
+
import path76 from "node:path";
|
|
14171
15344
|
var MANIFEST_FILE2 = "workspace.manifest.json";
|
|
14172
15345
|
function manifestPath(workspaceRoot) {
|
|
14173
15346
|
return hausPath(workspaceRoot, MANIFEST_FILE2);
|
|
14174
15347
|
}
|
|
14175
15348
|
function hausVersion() {
|
|
14176
15349
|
try {
|
|
14177
|
-
const pkg = JSON.parse(readFileSync4(
|
|
15350
|
+
const pkg = JSON.parse(readFileSync4(path76.join(packageRoot(), "package.json"), "utf8"));
|
|
14178
15351
|
return pkg.version ?? "0.0.0";
|
|
14179
15352
|
} catch {
|
|
14180
15353
|
return "0.0.0";
|
|
@@ -14266,7 +15439,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
14266
15439
|
const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
|
|
14267
15440
|
const catalogRefByRepo = [];
|
|
14268
15441
|
for (const repo of config.repos) {
|
|
14269
|
-
const repoRoot =
|
|
15442
|
+
const repoRoot = path77.resolve(workspaceRoot, repo.path);
|
|
14270
15443
|
const entry = manifestByName.get(repo.name);
|
|
14271
15444
|
if (!entry) {
|
|
14272
15445
|
flag({
|
|
@@ -14291,7 +15464,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
14291
15464
|
detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
|
|
14292
15465
|
});
|
|
14293
15466
|
}
|
|
14294
|
-
if (!
|
|
15467
|
+
if (!existsSync9(claudePath(repoRoot))) {
|
|
14295
15468
|
flag({
|
|
14296
15469
|
repo: repo.name,
|
|
14297
15470
|
kind: "missing-claude",
|
|
@@ -14300,7 +15473,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
14300
15473
|
}
|
|
14301
15474
|
const lock = await checkLock(repoRoot);
|
|
14302
15475
|
if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
|
|
14303
|
-
if (!
|
|
15476
|
+
if (!existsSync9(hausPath(repoRoot, "haus.lock.json"))) {
|
|
14304
15477
|
flag({
|
|
14305
15478
|
repo: repo.name,
|
|
14306
15479
|
kind: "missing-lock",
|
|
@@ -14331,7 +15504,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
14331
15504
|
const memberById = new Map(members.map((m) => [m.id, m]));
|
|
14332
15505
|
for (const entry of manifest.linkedContext) {
|
|
14333
15506
|
const member = memberById.get(entry.repo);
|
|
14334
|
-
if (!member || !
|
|
15507
|
+
if (!member || !existsSync9(member.absPath)) {
|
|
14335
15508
|
flag({
|
|
14336
15509
|
repo: entry.repo,
|
|
14337
15510
|
kind: "missing-linked-context-source",
|
|
@@ -14339,7 +15512,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
14339
15512
|
});
|
|
14340
15513
|
continue;
|
|
14341
15514
|
}
|
|
14342
|
-
if (!
|
|
15515
|
+
if (!existsSync9(path77.join(workspaceRoot, entry.path))) {
|
|
14343
15516
|
flag({
|
|
14344
15517
|
repo: entry.repo,
|
|
14345
15518
|
kind: "missing-linked-context-copy",
|
|
@@ -14385,18 +15558,18 @@ function emit(args) {
|
|
|
14385
15558
|
init_logger();
|
|
14386
15559
|
|
|
14387
15560
|
// src/workspace/link-context/link.ts
|
|
14388
|
-
import
|
|
14389
|
-
import
|
|
15561
|
+
import path80 from "node:path";
|
|
15562
|
+
import fs59 from "fs-extra";
|
|
14390
15563
|
init_fs();
|
|
14391
15564
|
init_logger();
|
|
14392
15565
|
|
|
14393
15566
|
// src/workspace/link-context/plan.ts
|
|
14394
|
-
import
|
|
14395
|
-
import
|
|
15567
|
+
import path79 from "node:path";
|
|
15568
|
+
import fs58 from "fs-extra";
|
|
14396
15569
|
|
|
14397
15570
|
// src/workspace/link-context/scan-source.ts
|
|
14398
|
-
import
|
|
14399
|
-
import
|
|
15571
|
+
import path78 from "node:path";
|
|
15572
|
+
import fs57 from "fs-extra";
|
|
14400
15573
|
var ASSET_SUBDIR = {
|
|
14401
15574
|
skill: "skills",
|
|
14402
15575
|
agent: "agents",
|
|
@@ -14404,36 +15577,36 @@ var ASSET_SUBDIR = {
|
|
|
14404
15577
|
};
|
|
14405
15578
|
async function hasLinkableContext(member) {
|
|
14406
15579
|
for (const subdir of Object.values(ASSET_SUBDIR)) {
|
|
14407
|
-
if (await
|
|
15580
|
+
if (await fs57.pathExists(path78.join(member.absPath, ".claude", subdir))) return true;
|
|
14408
15581
|
}
|
|
14409
15582
|
return false;
|
|
14410
15583
|
}
|
|
14411
15584
|
async function collectSkills(member) {
|
|
14412
|
-
const dir =
|
|
14413
|
-
if (!await
|
|
14414
|
-
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);
|
|
14415
15588
|
const out = [];
|
|
14416
15589
|
for (const name of [...names].sort()) {
|
|
14417
|
-
const abs =
|
|
14418
|
-
const stat = await
|
|
15590
|
+
const abs = path78.join(dir, name);
|
|
15591
|
+
const stat = await fs57.lstat(abs);
|
|
14419
15592
|
if (!stat.isDirectory()) continue;
|
|
14420
|
-
if (!await
|
|
14421
|
-
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) });
|
|
14422
15595
|
}
|
|
14423
15596
|
return out;
|
|
14424
15597
|
}
|
|
14425
15598
|
async function collectFlatMarkdown(member, type) {
|
|
14426
15599
|
const subdir = ASSET_SUBDIR[type];
|
|
14427
|
-
const dir =
|
|
14428
|
-
if (!await
|
|
14429
|
-
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);
|
|
14430
15603
|
const out = [];
|
|
14431
15604
|
for (const fileName of [...names].sort()) {
|
|
14432
15605
|
if (!fileName.endsWith(".md")) continue;
|
|
14433
|
-
const abs =
|
|
14434
|
-
if (!(await
|
|
15606
|
+
const abs = path78.join(dir, fileName);
|
|
15607
|
+
if (!(await fs57.lstat(abs)).isFile()) continue;
|
|
14435
15608
|
const name = fileName.slice(0, -".md".length);
|
|
14436
|
-
out.push({ type, name, sourceRelPath:
|
|
15609
|
+
out.push({ type, name, sourceRelPath: path78.posix.join(".claude", subdir, fileName) });
|
|
14437
15610
|
}
|
|
14438
15611
|
return out;
|
|
14439
15612
|
}
|
|
@@ -14448,9 +15621,9 @@ async function collectSourceAssets(member) {
|
|
|
14448
15621
|
|
|
14449
15622
|
// src/workspace/link-context/plan.ts
|
|
14450
15623
|
function repoFolderPrefix(member) {
|
|
14451
|
-
const base =
|
|
15624
|
+
const base = path79.posix.basename(member.folder);
|
|
14452
15625
|
if (base && base !== "." && base !== "/") return base;
|
|
14453
|
-
const fromAbs =
|
|
15626
|
+
const fromAbs = path79.basename(member.absPath);
|
|
14454
15627
|
return fromAbs || member.id;
|
|
14455
15628
|
}
|
|
14456
15629
|
function assetKey(type, destKey) {
|
|
@@ -14465,7 +15638,7 @@ async function buildLinkPlan(members) {
|
|
|
14465
15638
|
const byKey = /* @__PURE__ */ new Map();
|
|
14466
15639
|
const membersById = new Map(members.map((m) => [m.id, m]));
|
|
14467
15640
|
for (const member of members) {
|
|
14468
|
-
if (!await
|
|
15641
|
+
if (!await fs58.pathExists(member.absPath)) {
|
|
14469
15642
|
skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
|
|
14470
15643
|
continue;
|
|
14471
15644
|
}
|
|
@@ -14507,7 +15680,7 @@ async function buildLinkPlan(members) {
|
|
|
14507
15680
|
name: asset.name,
|
|
14508
15681
|
destKey,
|
|
14509
15682
|
sourceRelPath: asset.sourceRelPath,
|
|
14510
|
-
sourceAbsPath:
|
|
15683
|
+
sourceAbsPath: path79.join(member.absPath, asset.sourceRelPath),
|
|
14511
15684
|
sourceHash
|
|
14512
15685
|
});
|
|
14513
15686
|
}
|
|
@@ -14518,7 +15691,7 @@ async function buildLinkPlan(members) {
|
|
|
14518
15691
|
function destRelPathFor(type, destKey) {
|
|
14519
15692
|
const subdir = `${type}s`;
|
|
14520
15693
|
const leaf = type === "skill" ? destKey : `${destKey}.md`;
|
|
14521
|
-
return
|
|
15694
|
+
return path80.posix.join(".claude", subdir, leaf);
|
|
14522
15695
|
}
|
|
14523
15696
|
async function runLinkContext(workspaceRoot, opts = {}) {
|
|
14524
15697
|
const write = resolveWriteMode(opts, "preview-by-default").kind === "write";
|
|
@@ -14544,17 +15717,17 @@ async function runLinkContext(workspaceRoot, opts = {}) {
|
|
|
14544
15717
|
const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
14545
15718
|
for (const entry of plan.entries) {
|
|
14546
15719
|
const destRel = destRelPathFor(entry.type, entry.destKey);
|
|
14547
|
-
const destAbs =
|
|
15720
|
+
const destAbs = path80.join(workspaceRoot, destRel);
|
|
14548
15721
|
if (write) {
|
|
14549
|
-
await
|
|
14550
|
-
if (await
|
|
14551
|
-
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, {
|
|
14552
15725
|
overwrite: true,
|
|
14553
15726
|
errorOnExist: false,
|
|
14554
15727
|
dereference: false,
|
|
14555
15728
|
filter: (s) => {
|
|
14556
15729
|
try {
|
|
14557
|
-
return !
|
|
15730
|
+
return !fs59.lstatSync(s).isSymbolicLink();
|
|
14558
15731
|
} catch {
|
|
14559
15732
|
return false;
|
|
14560
15733
|
}
|
|
@@ -14583,7 +15756,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
|
|
|
14583
15756
|
warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
|
|
14584
15757
|
continue;
|
|
14585
15758
|
}
|
|
14586
|
-
if (await
|
|
15759
|
+
if (await fs59.pathExists(abs)) await fs59.remove(abs);
|
|
14587
15760
|
}
|
|
14588
15761
|
}
|
|
14589
15762
|
if (write) {
|
|
@@ -14634,12 +15807,12 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
|
|
|
14634
15807
|
}
|
|
14635
15808
|
|
|
14636
15809
|
// src/commands/workspace/setup.ts
|
|
14637
|
-
import { existsSync as
|
|
14638
|
-
import
|
|
15810
|
+
import { existsSync as existsSync10, statSync } from "node:fs";
|
|
15811
|
+
import path82 from "node:path";
|
|
14639
15812
|
|
|
14640
15813
|
// src/claude/write-workspace-claude-md.ts
|
|
14641
|
-
import
|
|
14642
|
-
import
|
|
15814
|
+
import path81 from "node:path";
|
|
15815
|
+
import fs60 from "fs-extra";
|
|
14643
15816
|
init_fs();
|
|
14644
15817
|
init_logger();
|
|
14645
15818
|
init_paths();
|
|
@@ -14664,11 +15837,11 @@ ${WORKSPACE_BLOCK_END}`;
|
|
|
14664
15837
|
async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
14665
15838
|
const block = buildWorkspaceImportBlock(opts.client, opts.members);
|
|
14666
15839
|
const dryRun = opts.dryRun ?? false;
|
|
14667
|
-
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") :
|
|
15840
|
+
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path81.join(workspaceRoot, "CLAUDE.md");
|
|
14668
15841
|
const say = opts.quiet ? () => {
|
|
14669
15842
|
} : log;
|
|
14670
|
-
const existed = await
|
|
14671
|
-
const prev = existed ? await
|
|
15843
|
+
const existed = await fs60.pathExists(filePath);
|
|
15844
|
+
const prev = existed ? await fs60.readFile(filePath, "utf8") : "";
|
|
14672
15845
|
const next = opts.collision ? `${block}
|
|
14673
15846
|
` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
|
|
14674
15847
|
const printable = displayPath(workspaceRoot, filePath);
|
|
@@ -14697,20 +15870,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
|
14697
15870
|
init_fs();
|
|
14698
15871
|
init_logger();
|
|
14699
15872
|
function resolveWorkspaceRoot(start = process.cwd()) {
|
|
14700
|
-
let dir =
|
|
15873
|
+
let dir = path82.resolve(start);
|
|
14701
15874
|
for (; ; ) {
|
|
14702
|
-
if (
|
|
14703
|
-
const parent =
|
|
14704
|
-
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);
|
|
14705
15878
|
dir = parent;
|
|
14706
15879
|
}
|
|
14707
15880
|
}
|
|
14708
15881
|
function isRootRepo(workspaceRoot, repoPath) {
|
|
14709
|
-
return
|
|
15882
|
+
return path82.resolve(workspaceRoot, repoPath) === path82.resolve(workspaceRoot);
|
|
14710
15883
|
}
|
|
14711
15884
|
async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
14712
15885
|
const apply = options.write ?? false;
|
|
14713
|
-
const configText = await readText(
|
|
15886
|
+
const configText = await readText(path82.join(workspaceRoot, WORKSPACE_FILE));
|
|
14714
15887
|
if (!configText) {
|
|
14715
15888
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
14716
15889
|
process.exitCode = 1;
|
|
@@ -14737,12 +15910,12 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
14737
15910
|
const statuses = [];
|
|
14738
15911
|
const aggregateInputs = [];
|
|
14739
15912
|
for (const repo of repos) {
|
|
14740
|
-
const repoRoot =
|
|
15913
|
+
const repoRoot = path82.resolve(workspaceRoot, repo.path);
|
|
14741
15914
|
say(`
|
|
14742
15915
|
\u2192 ${repo.name} (${repo.path})`);
|
|
14743
15916
|
let res;
|
|
14744
15917
|
try {
|
|
14745
|
-
if (!
|
|
15918
|
+
if (!existsSync10(repoRoot) || !statSync(repoRoot).isDirectory()) {
|
|
14746
15919
|
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
14747
15920
|
}
|
|
14748
15921
|
res = await runSetupCore(repoRoot, {
|
|
@@ -14842,7 +16015,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
14842
16015
|
if (apply && !options.dryRun) {
|
|
14843
16016
|
const statusByName = new Map(statuses.map((s) => [s.name, s]));
|
|
14844
16017
|
const prior = await readManifest2(workspaceRoot);
|
|
14845
|
-
if (!prior &&
|
|
16018
|
+
if (!prior && existsSync10(manifestPath(workspaceRoot))) {
|
|
14846
16019
|
warn(
|
|
14847
16020
|
"Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
|
|
14848
16021
|
);
|
|
@@ -14853,7 +16026,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
14853
16026
|
const status = statusByName.get(repo.name);
|
|
14854
16027
|
const role = repo.role ?? status?.roles?.[0] ?? "auto";
|
|
14855
16028
|
if (status?.status === "ok") {
|
|
14856
|
-
const lock = await checkLock(
|
|
16029
|
+
const lock = await checkLock(path82.resolve(workspaceRoot, repo.path));
|
|
14857
16030
|
manifestRepos.push({
|
|
14858
16031
|
name: repo.name,
|
|
14859
16032
|
path: repo.path,
|
|
@@ -14909,7 +16082,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
14909
16082
|
try {
|
|
14910
16083
|
const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
|
|
14911
16084
|
if (linkResult.ok) {
|
|
14912
|
-
written.push(...linkResult.added.map((rel) =>
|
|
16085
|
+
written.push(...linkResult.added.map((rel) => path82.join(workspaceRoot, rel)));
|
|
14913
16086
|
if (!options.json) {
|
|
14914
16087
|
say(
|
|
14915
16088
|
`Linked ${linkResult.linked.length} cross-repo skill/agent/command copy(ies) into .claude/ (${linkResult.added.length} new, ${linkResult.removed.length} removed)${linkResult.skipped.length > 0 ? ` \u2014 ${linkResult.skipped.length} member(s) skipped, see above` : ""}.`
|
|
@@ -14941,9 +16114,9 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
|
|
|
14941
16114
|
}
|
|
14942
16115
|
|
|
14943
16116
|
// src/commands/workspace/undo.ts
|
|
14944
|
-
import { existsSync as
|
|
14945
|
-
import
|
|
14946
|
-
import
|
|
16117
|
+
import { existsSync as existsSync11, statSync as statSync2 } from "node:fs";
|
|
16118
|
+
import path83 from "node:path";
|
|
16119
|
+
import fs61 from "fs-extra";
|
|
14947
16120
|
init_fs();
|
|
14948
16121
|
init_logger();
|
|
14949
16122
|
init_paths();
|
|
@@ -14973,11 +16146,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
14973
16146
|
}
|
|
14974
16147
|
if (options.dryRun) {
|
|
14975
16148
|
for (const repo of config.repos) {
|
|
14976
|
-
const repoRoot =
|
|
16149
|
+
const repoRoot = path83.resolve(workspaceRoot, repo.path);
|
|
14977
16150
|
log(`
|
|
14978
16151
|
\u2192 ${repo.name} (${repo.path})`);
|
|
14979
16152
|
try {
|
|
14980
|
-
if (!
|
|
16153
|
+
if (!existsSync11(repoRoot) || !statSync2(repoRoot).isDirectory()) {
|
|
14981
16154
|
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
14982
16155
|
}
|
|
14983
16156
|
await runUndo({ dryRun: true, root: repoRoot });
|
|
@@ -14994,7 +16167,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
14994
16167
|
warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
|
|
14995
16168
|
continue;
|
|
14996
16169
|
}
|
|
14997
|
-
if (await
|
|
16170
|
+
if (await fs61.pathExists(abs)) {
|
|
14998
16171
|
log(`[dry-run] Would remove ${entry.path} (linked ${entry.type} from ${entry.repo})`);
|
|
14999
16172
|
}
|
|
15000
16173
|
}
|
|
@@ -15004,12 +16177,12 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
15004
16177
|
hausPath(workspaceRoot, "WORKSPACE.md")
|
|
15005
16178
|
];
|
|
15006
16179
|
for (const abs of workspaceArtifacts) {
|
|
15007
|
-
if (await
|
|
15008
|
-
log(`[dry-run] Would remove ${
|
|
16180
|
+
if (await fs61.pathExists(abs)) {
|
|
16181
|
+
log(`[dry-run] Would remove ${path83.relative(workspaceRoot, abs)}`);
|
|
15009
16182
|
}
|
|
15010
16183
|
}
|
|
15011
|
-
if (!await
|
|
15012
|
-
const claudeMd = await readText(
|
|
16184
|
+
if (!await fs61.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
|
|
16185
|
+
const claudeMd = await readText(path83.join(workspaceRoot, "CLAUDE.md"));
|
|
15013
16186
|
if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
|
|
15014
16187
|
log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
|
|
15015
16188
|
}
|
|
@@ -15028,11 +16201,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
15028
16201
|
}
|
|
15029
16202
|
let anyRepoFailed = false;
|
|
15030
16203
|
for (const repo of config.repos) {
|
|
15031
|
-
const repoRoot =
|
|
16204
|
+
const repoRoot = path83.resolve(workspaceRoot, repo.path);
|
|
15032
16205
|
log(`
|
|
15033
16206
|
\u2192 ${repo.name} (${repo.path})`);
|
|
15034
16207
|
try {
|
|
15035
|
-
if (!
|
|
16208
|
+
if (!existsSync11(repoRoot) || !statSync2(repoRoot).isDirectory()) {
|
|
15036
16209
|
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
15037
16210
|
}
|
|
15038
16211
|
await runUndo({ yes: true, root: repoRoot });
|
|
@@ -15048,30 +16221,30 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
15048
16221
|
warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
|
|
15049
16222
|
continue;
|
|
15050
16223
|
}
|
|
15051
|
-
if (await
|
|
15052
|
-
await
|
|
16224
|
+
if (await fs61.pathExists(abs)) {
|
|
16225
|
+
await fs61.remove(abs);
|
|
15053
16226
|
log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
|
|
15054
16227
|
}
|
|
15055
16228
|
}
|
|
15056
16229
|
for (const rel of WORKSPACE_AGGREGATE_FILES) {
|
|
15057
16230
|
const abs = hausPath(workspaceRoot, rel);
|
|
15058
|
-
if (await
|
|
15059
|
-
await
|
|
15060
|
-
log(`Removed ${
|
|
16231
|
+
if (await fs61.pathExists(abs)) {
|
|
16232
|
+
await fs61.remove(abs);
|
|
16233
|
+
log(`Removed ${path83.relative(workspaceRoot, abs)}`);
|
|
15061
16234
|
}
|
|
15062
16235
|
}
|
|
15063
16236
|
const manifestFile = manifestPath(workspaceRoot);
|
|
15064
|
-
if (await
|
|
15065
|
-
await
|
|
15066
|
-
log(`Removed ${
|
|
16237
|
+
if (await fs61.pathExists(manifestFile)) {
|
|
16238
|
+
await fs61.remove(manifestFile);
|
|
16239
|
+
log(`Removed ${path83.relative(workspaceRoot, manifestFile)}`);
|
|
15067
16240
|
}
|
|
15068
16241
|
const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
|
|
15069
|
-
if (await
|
|
15070
|
-
await
|
|
15071
|
-
log(`Removed ${
|
|
16242
|
+
if (await fs61.pathExists(workspaceMdPath)) {
|
|
16243
|
+
await fs61.remove(workspaceMdPath);
|
|
16244
|
+
log(`Removed ${path83.relative(workspaceRoot, workspaceMdPath)}`);
|
|
15072
16245
|
} else {
|
|
15073
|
-
const claudeMdPath =
|
|
15074
|
-
if (
|
|
16246
|
+
const claudeMdPath = path83.join(workspaceRoot, "CLAUDE.md");
|
|
16247
|
+
if (existsSync11(claudeMdPath)) {
|
|
15075
16248
|
const prev = await readText(claudeMdPath);
|
|
15076
16249
|
if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
|
|
15077
16250
|
const next = stripHausBlock(prev, {
|
|
@@ -15079,19 +16252,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
15079
16252
|
end: WORKSPACE_BLOCK_END
|
|
15080
16253
|
});
|
|
15081
16254
|
if (next.length === 0) {
|
|
15082
|
-
await
|
|
16255
|
+
await fs61.remove(claudeMdPath);
|
|
15083
16256
|
log("Removed CLAUDE.md (only contained the workspace haus import block).");
|
|
15084
16257
|
} else {
|
|
15085
|
-
await
|
|
16258
|
+
await fs61.writeFile(claudeMdPath, next, "utf8");
|
|
15086
16259
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
15087
16260
|
}
|
|
15088
16261
|
} else if (prev && hasLegacyWorkspaceBlock(prev)) {
|
|
15089
16262
|
const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
|
|
15090
16263
|
if (next.length === 0) {
|
|
15091
|
-
await
|
|
16264
|
+
await fs61.remove(claudeMdPath);
|
|
15092
16265
|
log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
|
|
15093
16266
|
} else {
|
|
15094
|
-
await
|
|
16267
|
+
await fs61.writeFile(claudeMdPath, next, "utf8");
|
|
15095
16268
|
log(
|
|
15096
16269
|
"Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
|
|
15097
16270
|
);
|
|
@@ -15117,7 +16290,7 @@ function normalizeMaxDepth(maxDepth) {
|
|
|
15117
16290
|
return void 0;
|
|
15118
16291
|
}
|
|
15119
16292
|
async function initWorkspace(options = {}) {
|
|
15120
|
-
if (
|
|
16293
|
+
if (existsSync12(WORKSPACE_FILE) && !options.yes) {
|
|
15121
16294
|
const ok = await confirm(
|
|
15122
16295
|
`${WORKSPACE_FILE} already exists \u2014 overwrite with a fresh placeholder? Existing client/repos/relationships will be lost. Run \`haus workspace discover\` instead to merge in newly found repos.`
|
|
15123
16296
|
);
|
|
@@ -15139,7 +16312,7 @@ relationships: []
|
|
|
15139
16312
|
log("Workspace initialized.");
|
|
15140
16313
|
}
|
|
15141
16314
|
async function scanWorkspace(workspaceRoot, opts) {
|
|
15142
|
-
const configText = await readText(
|
|
16315
|
+
const configText = await readText(path84.join(workspaceRoot, WORKSPACE_FILE));
|
|
15143
16316
|
if (!configText) {
|
|
15144
16317
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
15145
16318
|
process.exitCode = 1;
|
|
@@ -15161,9 +16334,9 @@ async function scanWorkspace(workspaceRoot, opts) {
|
|
|
15161
16334
|
const inputs = [];
|
|
15162
16335
|
const failures = [];
|
|
15163
16336
|
for (const repo of config.repos) {
|
|
15164
|
-
const repoRoot =
|
|
16337
|
+
const repoRoot = path84.resolve(workspaceRoot, repo.path);
|
|
15165
16338
|
try {
|
|
15166
|
-
if (!
|
|
16339
|
+
if (!existsSync12(repoRoot) || !statSync3(repoRoot).isDirectory()) {
|
|
15167
16340
|
throw new Error("path is not a directory");
|
|
15168
16341
|
}
|
|
15169
16342
|
const result = await scanProject(repoRoot);
|
|
@@ -15230,7 +16403,7 @@ init_logger();
|
|
|
15230
16403
|
init_paths();
|
|
15231
16404
|
function cliVersion() {
|
|
15232
16405
|
try {
|
|
15233
|
-
const pkgPath =
|
|
16406
|
+
const pkgPath = path85.join(packageRoot(), "package.json");
|
|
15234
16407
|
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
15235
16408
|
return pkg.version ?? "0.0.0";
|
|
15236
16409
|
} catch {
|
|
@@ -15240,7 +16413,7 @@ function cliVersion() {
|
|
|
15240
16413
|
var program = new Command();
|
|
15241
16414
|
function validateRuntimeNodeVersion() {
|
|
15242
16415
|
try {
|
|
15243
|
-
const pkgPath =
|
|
16416
|
+
const pkgPath = path85.join(packageRoot(), "package.json");
|
|
15244
16417
|
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
15245
16418
|
const requiredRange = pkg.engines?.node;
|
|
15246
16419
|
if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
|
|
@@ -15400,6 +16573,35 @@ worktree.command("prune").description(
|
|
|
15400
16573
|
).option("--dry-run", "Preview which registrations would be pruned").option("--json", "Emit the structured prune result as JSON").action(
|
|
15401
16574
|
(opts) => runWorktree("prune", { dryRun: opts.dryRun, json: opts.json })
|
|
15402
16575
|
);
|
|
16576
|
+
worktree.command("autohydrate").description(
|
|
16577
|
+
"Materialize + hydrate this worktree's missing member repos in the background when `worktree.autoHydrate` is enabled (the acting half of the flag; see ADR-0074)"
|
|
16578
|
+
).option("--from-hook", "Emit for the SessionStart hook \u2014 always exits 0").option("--dry-run", "Report what would be hydrated without forking anything").option("--json", "Emit the structured autohydrate result as JSON").addOption(new Option("--detached-child", "internal: run the forked hydration").hideHelp()).action(
|
|
16579
|
+
(opts) => runWorktree("autohydrate", {
|
|
16580
|
+
fromHook: opts.fromHook,
|
|
16581
|
+
dryRun: opts.dryRun,
|
|
16582
|
+
json: opts.json,
|
|
16583
|
+
detachedChild: opts.detachedChild
|
|
16584
|
+
})
|
|
16585
|
+
);
|
|
16586
|
+
worktree.command("dematerialize [slug]").description(
|
|
16587
|
+
"Drop a workspace worktree's member worktrees while keeping the workspace worktree, its branch and its state (the other end of `hydrate --materialize`; see ADR-0075)"
|
|
16588
|
+
).option("--force", "Bypass the uncommitted/unpushed-work refusal").option("--dry-run", "Preview which members would be dropped").option("--json", "Emit the structured dematerialize result as JSON").action(
|
|
16589
|
+
(slug, opts) => runWorktree("dematerialize", {
|
|
16590
|
+
slug,
|
|
16591
|
+
force: opts.force,
|
|
16592
|
+
dryRun: opts.dryRun,
|
|
16593
|
+
json: opts.json
|
|
16594
|
+
})
|
|
16595
|
+
);
|
|
16596
|
+
worktree.command("reclaim").description(
|
|
16597
|
+
"Dematerialize workspace worktrees untouched for longer than `worktree.reclaimAfterDays` \u2014 previews unless --write (ADR-0075)"
|
|
16598
|
+
).option("--older-than-days <n>", "Override worktree.reclaimAfterDays", Number.parseFloat).option("--write", "Perform the reclamation (default previews only)").option("--json", "Emit the structured reclaim result as JSON").action(
|
|
16599
|
+
(opts) => runWorktree("reclaim", {
|
|
16600
|
+
olderThanDays: opts.olderThanDays,
|
|
16601
|
+
write: opts.write,
|
|
16602
|
+
json: opts.json
|
|
16603
|
+
})
|
|
16604
|
+
);
|
|
15403
16605
|
worktree.command("doctor").description("Fast, side-effect-free health check for the current workspace worktree").option("--from-hook", "Emit for the SessionStart hook \u2014 always exits 0").action((opts) => runWorktree("doctor", { fromHook: opts.fromHook }));
|
|
15404
16606
|
program.parseAsync(process.argv).catch((err) => {
|
|
15405
16607
|
const message2 = err instanceof Error ? err.message : String(err);
|