@haus-tech/haus-workflow 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/cli.js +2324 -506
- package/library/catalog/manifest.json +13 -13
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -59,12 +59,12 @@ var init_exec = __esm({
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
// src/cli.ts
|
|
62
|
-
import { readFileSync as
|
|
63
|
-
import
|
|
62
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
63
|
+
import path70 from "node:path";
|
|
64
64
|
import { Command } from "commander";
|
|
65
65
|
|
|
66
66
|
// src/commands/apply.ts
|
|
67
|
-
import
|
|
67
|
+
import path23 from "node:path";
|
|
68
68
|
import checkbox from "@inquirer/checkbox";
|
|
69
69
|
|
|
70
70
|
// src/catalog/load-catalog.ts
|
|
@@ -141,13 +141,44 @@ async function atomicWriteText(file, value) {
|
|
|
141
141
|
await fs.writeFile(tempPath, value, "utf8");
|
|
142
142
|
await fs.move(tempPath, file, { overwrite: true });
|
|
143
143
|
}
|
|
144
|
+
async function findNestedRepoDirs(root) {
|
|
145
|
+
const matches = await fg("**/.git", {
|
|
146
|
+
cwd: root,
|
|
147
|
+
dot: true,
|
|
148
|
+
onlyFiles: false,
|
|
149
|
+
followSymbolicLinks: false,
|
|
150
|
+
suppressErrors: true,
|
|
151
|
+
ignore: ["**/node_modules/**", "**/dist/**"]
|
|
152
|
+
});
|
|
153
|
+
const owners = /* @__PURE__ */ new Set();
|
|
154
|
+
for (const match of matches) {
|
|
155
|
+
const owner = path.posix.dirname(match);
|
|
156
|
+
if (owner === "." || owner === "") continue;
|
|
157
|
+
owners.add(owner);
|
|
158
|
+
}
|
|
159
|
+
const shallowestFirst = [...owners].sort((a, b) => a.split("/").length - b.split("/").length);
|
|
160
|
+
const nested = [];
|
|
161
|
+
for (const dir of shallowestFirst) {
|
|
162
|
+
if (nested.some((kept) => dir === kept || dir.startsWith(`${kept}/`))) continue;
|
|
163
|
+
nested.push(dir);
|
|
164
|
+
}
|
|
165
|
+
return nested;
|
|
166
|
+
}
|
|
144
167
|
async function listFiles(root, patterns) {
|
|
168
|
+
const nestedRepoDirs = await findNestedRepoDirs(root);
|
|
169
|
+
const ignore = [
|
|
170
|
+
"**/node_modules/**",
|
|
171
|
+
"**/.git/**",
|
|
172
|
+
"**/dist/**",
|
|
173
|
+
"**/tests/fixtures/**",
|
|
174
|
+
...nestedRepoDirs.map((d) => `${d}/**`)
|
|
175
|
+
];
|
|
145
176
|
const files = await fg(patterns, {
|
|
146
177
|
cwd: root,
|
|
147
178
|
dot: true,
|
|
148
179
|
onlyFiles: true,
|
|
149
180
|
suppressErrors: true,
|
|
150
|
-
ignore
|
|
181
|
+
ignore
|
|
151
182
|
});
|
|
152
183
|
return files.sort((a, b) => a.localeCompare(b));
|
|
153
184
|
}
|
|
@@ -478,12 +509,12 @@ async function resolveCatalogRef(opts) {
|
|
|
478
509
|
const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
|
|
479
510
|
const tag = await fetchLatest();
|
|
480
511
|
if (tag !== null) return tag;
|
|
481
|
-
const
|
|
482
|
-
if (
|
|
512
|
+
const fallback2 = opts?.fallbackRef;
|
|
513
|
+
if (fallback2) {
|
|
483
514
|
warn(
|
|
484
|
-
`Tag resolution failed \u2014 using cached ref ${
|
|
515
|
+
`Tag resolution failed \u2014 using cached ref ${fallback2}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
|
|
485
516
|
);
|
|
486
|
-
return
|
|
517
|
+
return fallback2;
|
|
487
518
|
}
|
|
488
519
|
const bundled = getBundledCatalogRef();
|
|
489
520
|
if (bundled) {
|
|
@@ -1489,6 +1520,37 @@ function catalogItemContentPath(contentRoot, item) {
|
|
|
1489
1520
|
return path9.join(contentRoot, item.path);
|
|
1490
1521
|
}
|
|
1491
1522
|
|
|
1523
|
+
// src/claude/gitignore-owned-check.ts
|
|
1524
|
+
init_exec();
|
|
1525
|
+
var HAUS_OWNED_TRACKED_PATHS = [
|
|
1526
|
+
".claude",
|
|
1527
|
+
".claude/skills",
|
|
1528
|
+
".claude/agents",
|
|
1529
|
+
".claude/commands",
|
|
1530
|
+
".haus-workflow"
|
|
1531
|
+
];
|
|
1532
|
+
async function findGitignoredHausPaths(root) {
|
|
1533
|
+
try {
|
|
1534
|
+
const result = await runGit(["check-ignore", "--", ...HAUS_OWNED_TRACKED_PATHS], {
|
|
1535
|
+
cwd: root,
|
|
1536
|
+
timeout: 5e3
|
|
1537
|
+
});
|
|
1538
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) return [];
|
|
1539
|
+
return [
|
|
1540
|
+
...new Set(
|
|
1541
|
+
result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean)
|
|
1542
|
+
)
|
|
1543
|
+
];
|
|
1544
|
+
} catch {
|
|
1545
|
+
return [];
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
function summarizeGitignoredHausDirs(paths) {
|
|
1549
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
1550
|
+
for (const p of paths) dirs.add(p === ".haus-workflow" ? ".haus-workflow/" : ".claude/");
|
|
1551
|
+
return [...dirs];
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1492
1554
|
// src/claude/write-claude-files.ts
|
|
1493
1555
|
import path19 from "node:path";
|
|
1494
1556
|
import fs18 from "fs-extra";
|
|
@@ -1596,7 +1658,8 @@ function digestFileContent(buf) {
|
|
|
1596
1658
|
}
|
|
1597
1659
|
return hashText(buf);
|
|
1598
1660
|
}
|
|
1599
|
-
async function hashInstalledPaths(root, relPaths) {
|
|
1661
|
+
async function hashInstalledPaths(root, relPaths, opts = {}) {
|
|
1662
|
+
const followSymlinks = opts.followSymlinks ?? true;
|
|
1600
1663
|
if (relPaths.length === 0) {
|
|
1601
1664
|
return hashText(EMPTY_LOCK_PATHS_TOKEN);
|
|
1602
1665
|
}
|
|
@@ -1605,17 +1668,24 @@ async function hashInstalledPaths(root, relPaths) {
|
|
|
1605
1668
|
for (const rel of normalized) {
|
|
1606
1669
|
const abs = path10.join(root, rel);
|
|
1607
1670
|
if (!await fs7.pathExists(abs)) continue;
|
|
1608
|
-
const stat = await fs7.stat(abs);
|
|
1671
|
+
const stat = followSymlinks ? await fs7.stat(abs) : await fs7.lstat(abs);
|
|
1672
|
+
if (!followSymlinks && stat.isSymbolicLink()) continue;
|
|
1609
1673
|
if (stat.isFile()) {
|
|
1610
1674
|
const body = await fs7.readFile(abs);
|
|
1611
1675
|
fileDigests.push({ rel, digest: digestFileContent(body) });
|
|
1612
1676
|
continue;
|
|
1613
1677
|
}
|
|
1614
1678
|
if (!stat.isDirectory()) continue;
|
|
1615
|
-
const inner = await fg2("**/*", {
|
|
1679
|
+
const inner = await fg2("**/*", {
|
|
1680
|
+
cwd: abs,
|
|
1681
|
+
onlyFiles: true,
|
|
1682
|
+
dot: true,
|
|
1683
|
+
followSymbolicLinks: followSymlinks
|
|
1684
|
+
});
|
|
1616
1685
|
for (const sub of inner.sort()) {
|
|
1617
|
-
const relFile = path10.join(rel, sub).replace(/\\/g, "/");
|
|
1618
1686
|
const absFile = path10.join(abs, sub);
|
|
1687
|
+
if (!followSymlinks && (await fs7.lstat(absFile)).isSymbolicLink()) continue;
|
|
1688
|
+
const relFile = path10.join(rel, sub).replace(/\\/g, "/");
|
|
1619
1689
|
const body = await fs7.readFile(absFile);
|
|
1620
1690
|
fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
|
|
1621
1691
|
}
|
|
@@ -2033,8 +2103,8 @@ function buildDenyRules() {
|
|
|
2033
2103
|
for (const command of DENY_COMMANDS) {
|
|
2034
2104
|
rules.push(`Bash(${command}:*)`);
|
|
2035
2105
|
}
|
|
2036
|
-
for (const
|
|
2037
|
-
const pattern = DENY_DIRS.has(
|
|
2106
|
+
for (const path71 of DENY_PATHS) {
|
|
2107
|
+
const pattern = DENY_DIRS.has(path71) ? `${path71}/**` : path71;
|
|
2038
2108
|
for (const tool of FILE_TOOLS) {
|
|
2039
2109
|
rules.push(`${tool}(${pattern})`);
|
|
2040
2110
|
}
|
|
@@ -2078,6 +2148,13 @@ var PROJECT_HOOK_FRAGMENTS = [
|
|
|
2078
2148
|
event: "SessionStart",
|
|
2079
2149
|
matcher: "*",
|
|
2080
2150
|
command: "haus update --from-hook"
|
|
2151
|
+
},
|
|
2152
|
+
{
|
|
2153
|
+
id: "hook.workspace.worktree-check",
|
|
2154
|
+
gate: "keep",
|
|
2155
|
+
event: "SessionStart",
|
|
2156
|
+
matcher: "*",
|
|
2157
|
+
command: "haus workspace worktree doctor --from-hook"
|
|
2081
2158
|
}
|
|
2082
2159
|
];
|
|
2083
2160
|
async function readProjectSettings(root) {
|
|
@@ -2186,6 +2263,10 @@ var CANONICAL_HOOKS = {
|
|
|
2186
2263
|
{
|
|
2187
2264
|
matcher: "*",
|
|
2188
2265
|
hooks: [{ type: "command", command: "haus update --from-hook" }]
|
|
2266
|
+
},
|
|
2267
|
+
{
|
|
2268
|
+
matcher: "*",
|
|
2269
|
+
hooks: [{ type: "command", command: "haus workspace worktree doctor --from-hook" }]
|
|
2189
2270
|
}
|
|
2190
2271
|
]
|
|
2191
2272
|
}
|
|
@@ -2555,7 +2636,6 @@ function refillContent(existing, v) {
|
|
|
2555
2636
|
}
|
|
2556
2637
|
var FALLBACK_CONTEXT = {
|
|
2557
2638
|
generatedAt: "",
|
|
2558
|
-
root: "",
|
|
2559
2639
|
repoName: "",
|
|
2560
2640
|
packageManager: "unknown",
|
|
2561
2641
|
repoRoles: [],
|
|
@@ -3247,27 +3327,192 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
|
|
|
3247
3327
|
}
|
|
3248
3328
|
}
|
|
3249
3329
|
|
|
3330
|
+
// src/claude/write-gitignore.ts
|
|
3331
|
+
init_exec();
|
|
3332
|
+
import path20 from "node:path";
|
|
3333
|
+
import fs19 from "fs-extra";
|
|
3334
|
+
var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
|
|
3335
|
+
var GITIGNORE_END = "# HAUS:END haus-managed";
|
|
3336
|
+
var GITIGNORED_ARTIFACT_PATHS = [
|
|
3337
|
+
".haus-workflow/context-map.json",
|
|
3338
|
+
".haus-workflow/recommendation.json",
|
|
3339
|
+
".haus-workflow/sources-report.json",
|
|
3340
|
+
".haus-workflow/deep-context.json"
|
|
3341
|
+
];
|
|
3342
|
+
function findLineMarker3(content2, marker, from = 0) {
|
|
3343
|
+
let idx = content2.indexOf(marker, from);
|
|
3344
|
+
while (idx !== -1) {
|
|
3345
|
+
const lineStart = idx === 0 || content2[idx - 1] === "\n";
|
|
3346
|
+
const after = idx + marker.length;
|
|
3347
|
+
const lineEnd = after === content2.length || content2[after] === "\n" || content2[after] === "\r";
|
|
3348
|
+
if (lineStart && lineEnd) return { start: idx, end: after };
|
|
3349
|
+
idx = content2.indexOf(marker, idx + marker.length);
|
|
3350
|
+
}
|
|
3351
|
+
return null;
|
|
3352
|
+
}
|
|
3353
|
+
function findBlockRangeFor(content2, beginMarker, endMarker) {
|
|
3354
|
+
const begin = findLineMarker3(content2, beginMarker);
|
|
3355
|
+
if (!begin) return null;
|
|
3356
|
+
const end = findLineMarker3(content2, endMarker, begin.end);
|
|
3357
|
+
if (!end) return null;
|
|
3358
|
+
return { start: begin.start, end: end.end };
|
|
3359
|
+
}
|
|
3360
|
+
function gitignoreCovers(lines, entry) {
|
|
3361
|
+
const bare = entry.replace(/^\/+/, "");
|
|
3362
|
+
const exactEquivalents = /* @__PURE__ */ new Set([bare, `/${bare}`]);
|
|
3363
|
+
for (const raw of lines) {
|
|
3364
|
+
const line2 = raw.trim();
|
|
3365
|
+
if (!line2 || line2.startsWith("#")) continue;
|
|
3366
|
+
if (exactEquivalents.has(line2)) return true;
|
|
3367
|
+
const dirCandidate = line2.replace(/^\/+/, "").replace(/\/\*\*$/, "").replace(/\/$/, "");
|
|
3368
|
+
if (dirCandidate && !/[*?[\]!]/.test(dirCandidate)) {
|
|
3369
|
+
if (bare === dirCandidate || bare.startsWith(`${dirCandidate}/`)) return true;
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
return false;
|
|
3373
|
+
}
|
|
3374
|
+
function linesOutsideBlockFor(content2, beginMarker, endMarker) {
|
|
3375
|
+
const range = findBlockRangeFor(content2, beginMarker, endMarker);
|
|
3376
|
+
const outside = range ? content2.slice(0, range.start) + content2.slice(range.end) : content2;
|
|
3377
|
+
return outside.split("\n");
|
|
3378
|
+
}
|
|
3379
|
+
function buildBlockFor(existing, beginMarker, endMarker, comment, patterns) {
|
|
3380
|
+
const outside = linesOutsideBlockFor(existing, beginMarker, endMarker);
|
|
3381
|
+
const needed = patterns.filter((p) => !gitignoreCovers(outside, p));
|
|
3382
|
+
if (needed.length === 0) return null;
|
|
3383
|
+
return [beginMarker, comment, ...needed, endMarker].join("\n");
|
|
3384
|
+
}
|
|
3385
|
+
var ARTIFACT_BLOCK_COMMENT = "# haus scan artifacts \u2014 machine-local, must never be committed (see ADR-0025)";
|
|
3386
|
+
function injectBlockFor(existing, beginMarker, endMarker, comment, patterns) {
|
|
3387
|
+
const block = buildBlockFor(existing, beginMarker, endMarker, comment, patterns);
|
|
3388
|
+
const range = findBlockRangeFor(existing, beginMarker, endMarker);
|
|
3389
|
+
if (block === null) {
|
|
3390
|
+
if (!range) return existing;
|
|
3391
|
+
const before = existing.slice(0, range.start);
|
|
3392
|
+
const after = existing.slice(range.end);
|
|
3393
|
+
const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
3394
|
+
return merged.length > 0 ? `${merged}
|
|
3395
|
+
` : "";
|
|
3396
|
+
}
|
|
3397
|
+
if (range) {
|
|
3398
|
+
const before = existing.slice(0, range.start);
|
|
3399
|
+
const after = existing.slice(range.end);
|
|
3400
|
+
return `${before}${block}${after}`.replace(/\n*$/, "\n");
|
|
3401
|
+
}
|
|
3402
|
+
const loneBegin = findLineMarker3(existing, beginMarker);
|
|
3403
|
+
if (loneBegin) {
|
|
3404
|
+
const before = existing.slice(0, loneBegin.start).trimEnd();
|
|
3405
|
+
if (before.length === 0) return `${block}
|
|
3406
|
+
`;
|
|
3407
|
+
return `${before}
|
|
3408
|
+
|
|
3409
|
+
${block}
|
|
3410
|
+
`;
|
|
3411
|
+
}
|
|
3412
|
+
const trimmed = existing.trimEnd();
|
|
3413
|
+
if (trimmed.length === 0) return `${block}
|
|
3414
|
+
`;
|
|
3415
|
+
return `${trimmed}
|
|
3416
|
+
|
|
3417
|
+
${block}
|
|
3418
|
+
`;
|
|
3419
|
+
}
|
|
3420
|
+
function injectGitignoreBlock(existing) {
|
|
3421
|
+
return injectBlockFor(
|
|
3422
|
+
existing,
|
|
3423
|
+
GITIGNORE_BEGIN,
|
|
3424
|
+
GITIGNORE_END,
|
|
3425
|
+
ARTIFACT_BLOCK_COMMENT,
|
|
3426
|
+
GITIGNORED_ARTIFACT_PATHS
|
|
3427
|
+
);
|
|
3428
|
+
}
|
|
3429
|
+
var LINK_CONTEXT_GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed-link-context v=1";
|
|
3430
|
+
var LINK_CONTEXT_GITIGNORE_END = "# HAUS:END haus-managed-link-context";
|
|
3431
|
+
var LINKED_CONTEXT_GITIGNORE_PATTERNS = [
|
|
3432
|
+
".claude/skills/*--*/",
|
|
3433
|
+
".claude/agents/*--*.md",
|
|
3434
|
+
".claude/commands/*--*.md"
|
|
3435
|
+
];
|
|
3436
|
+
var LINK_CONTEXT_BLOCK_COMMENT = "# haus workspace link-context copies \u2014 derived cross-repo skill/agent/command copies, never committed";
|
|
3437
|
+
function injectLinkContextGitignoreBlock(existing) {
|
|
3438
|
+
return injectBlockFor(
|
|
3439
|
+
existing,
|
|
3440
|
+
LINK_CONTEXT_GITIGNORE_BEGIN,
|
|
3441
|
+
LINK_CONTEXT_GITIGNORE_END,
|
|
3442
|
+
LINK_CONTEXT_BLOCK_COMMENT,
|
|
3443
|
+
LINKED_CONTEXT_GITIGNORE_PATTERNS
|
|
3444
|
+
);
|
|
3445
|
+
}
|
|
3446
|
+
async function writeGitignore(root, dryRun) {
|
|
3447
|
+
const filePath = path20.join(root, ".gitignore");
|
|
3448
|
+
const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
|
|
3449
|
+
const next = injectGitignoreBlock(prev);
|
|
3450
|
+
await writeManagedText(root, filePath, next, dryRun);
|
|
3451
|
+
return filePath;
|
|
3452
|
+
}
|
|
3453
|
+
async function writeLinkContextGitignore(root, dryRun) {
|
|
3454
|
+
const filePath = path20.join(root, ".gitignore");
|
|
3455
|
+
const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
|
|
3456
|
+
const next = injectLinkContextGitignoreBlock(prev);
|
|
3457
|
+
await writeManagedText(root, filePath, next, dryRun);
|
|
3458
|
+
return filePath;
|
|
3459
|
+
}
|
|
3460
|
+
async function listTrackedArtifactPaths(root) {
|
|
3461
|
+
try {
|
|
3462
|
+
const result = await runGit(["ls-files", "--", ...GITIGNORED_ARTIFACT_PATHS], {
|
|
3463
|
+
cwd: root,
|
|
3464
|
+
timeout: 5e3
|
|
3465
|
+
});
|
|
3466
|
+
if (result.exitCode !== 0) return [];
|
|
3467
|
+
return result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean);
|
|
3468
|
+
} catch {
|
|
3469
|
+
return [];
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
async function untrackMachineLocalArtifacts(root, dryRun, notify) {
|
|
3473
|
+
const tracked = await listTrackedArtifactPaths(root);
|
|
3474
|
+
const untracked = [];
|
|
3475
|
+
for (const rel of tracked) {
|
|
3476
|
+
if (dryRun) {
|
|
3477
|
+
notify(
|
|
3478
|
+
`Would untrack ${rel} from git \u2014 it contains machine-local scan output and should never have been committed.`
|
|
3479
|
+
);
|
|
3480
|
+
continue;
|
|
3481
|
+
}
|
|
3482
|
+
const result = await runGit(["rm", "--cached", "--quiet", "--", rel], { cwd: root });
|
|
3483
|
+
if (result.exitCode === 0) {
|
|
3484
|
+
untracked.push(rel);
|
|
3485
|
+
notify(
|
|
3486
|
+
`Untracked ${rel} from git \u2014 it contains machine-local scan output and should never have been committed. Run \`git status\` to review before committing this change.`
|
|
3487
|
+
);
|
|
3488
|
+
} else {
|
|
3489
|
+
notify(`Could not untrack ${rel} from git: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
return untracked;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3250
3495
|
// src/refs/fetch-refs.ts
|
|
3251
|
-
import
|
|
3252
|
-
import
|
|
3496
|
+
import path22 from "node:path";
|
|
3497
|
+
import fs21 from "fs-extra";
|
|
3253
3498
|
|
|
3254
3499
|
// src/refs/cache-meta.ts
|
|
3255
|
-
import
|
|
3256
|
-
import
|
|
3500
|
+
import path21 from "node:path";
|
|
3501
|
+
import fs20 from "fs-extra";
|
|
3257
3502
|
var META_FILENAME = "cache-meta.json";
|
|
3258
3503
|
async function readCacheMeta(cacheDir) {
|
|
3259
|
-
const metaPath =
|
|
3504
|
+
const metaPath = path21.join(cacheDir, META_FILENAME);
|
|
3260
3505
|
try {
|
|
3261
|
-
const raw = await
|
|
3506
|
+
const raw = await fs20.readFile(metaPath, "utf8");
|
|
3262
3507
|
return JSON.parse(raw);
|
|
3263
3508
|
} catch {
|
|
3264
3509
|
return {};
|
|
3265
3510
|
}
|
|
3266
3511
|
}
|
|
3267
3512
|
async function writeCacheMeta(cacheDir, meta) {
|
|
3268
|
-
await
|
|
3269
|
-
const metaPath =
|
|
3270
|
-
await
|
|
3513
|
+
await fs20.ensureDir(cacheDir);
|
|
3514
|
+
const metaPath = path21.join(cacheDir, META_FILENAME);
|
|
3515
|
+
await fs20.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
|
|
3271
3516
|
}
|
|
3272
3517
|
|
|
3273
3518
|
// src/refs/fetch-refs.ts
|
|
@@ -3287,8 +3532,8 @@ function isLlmsTxtUrl(ref) {
|
|
|
3287
3532
|
async function fetchSingleRef(url, cacheDir, meta) {
|
|
3288
3533
|
const existing = meta[url];
|
|
3289
3534
|
const headers = {};
|
|
3290
|
-
const safeFile = existing?.file && existing.file ===
|
|
3291
|
-
const cachedFileExists = safeFile && await
|
|
3535
|
+
const safeFile = existing?.file && existing.file === path22.basename(existing.file);
|
|
3536
|
+
const cachedFileExists = safeFile && await fs21.pathExists(path22.join(cacheDir, existing.file));
|
|
3292
3537
|
if (cachedFileExists) {
|
|
3293
3538
|
if (existing?.etag) headers["If-None-Match"] = existing.etag;
|
|
3294
3539
|
if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
|
|
@@ -3299,8 +3544,8 @@ async function fetchSingleRef(url, cacheDir, meta) {
|
|
|
3299
3544
|
if (!res.ok) return { result: "failed" };
|
|
3300
3545
|
const text = await res.text();
|
|
3301
3546
|
const file = `${urlToSlug(url)}.md`;
|
|
3302
|
-
await
|
|
3303
|
-
await
|
|
3547
|
+
await fs21.ensureDir(cacheDir);
|
|
3548
|
+
await fs21.writeFile(path22.join(cacheDir, file), text, "utf8");
|
|
3304
3549
|
meta[url] = {
|
|
3305
3550
|
url,
|
|
3306
3551
|
etag: res.headers.get("etag") ?? void 0,
|
|
@@ -3323,8 +3568,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
|
|
|
3323
3568
|
if (keepUrls.has(url)) continue;
|
|
3324
3569
|
delete meta[url];
|
|
3325
3570
|
removed++;
|
|
3326
|
-
const safeFile = typeof entry.file === "string" && entry.file ===
|
|
3327
|
-
if (safeFile) await
|
|
3571
|
+
const safeFile = typeof entry.file === "string" && entry.file === path22.basename(entry.file);
|
|
3572
|
+
if (safeFile) await fs21.remove(path22.join(cacheDir, entry.file)).catch(() => {
|
|
3328
3573
|
});
|
|
3329
3574
|
}
|
|
3330
3575
|
if (removed > 0) await writeCacheMeta(cacheDir, meta);
|
|
@@ -3346,10 +3591,10 @@ async function fetchRefsForItems(items, cacheDir) {
|
|
|
3346
3591
|
const outcome = await fetchSingleRef(url, cacheDir, meta);
|
|
3347
3592
|
if (outcome.result === "fetched") {
|
|
3348
3593
|
summary.fetched++;
|
|
3349
|
-
summary.cachedFiles[url] =
|
|
3594
|
+
summary.cachedFiles[url] = path22.join(cacheDir, outcome.file);
|
|
3350
3595
|
} else if (outcome.result === "unchanged") {
|
|
3351
3596
|
summary.unchanged++;
|
|
3352
|
-
if (meta[url]?.file) summary.cachedFiles[url] =
|
|
3597
|
+
if (meta[url]?.file) summary.cachedFiles[url] = path22.join(cacheDir, meta[url].file);
|
|
3353
3598
|
} else {
|
|
3354
3599
|
summary.failed++;
|
|
3355
3600
|
summary.failedUrls.push(url);
|
|
@@ -3372,7 +3617,7 @@ function parseIdList(value) {
|
|
|
3372
3617
|
// src/commands/apply.ts
|
|
3373
3618
|
var STALE_CACHE_THRESHOLD_DAYS = 7;
|
|
3374
3619
|
async function cacheHasItems() {
|
|
3375
|
-
const data = await readJson(
|
|
3620
|
+
const data = await readJson(path23.join(getCacheDir(), "manifest.json"));
|
|
3376
3621
|
return Array.isArray(data?.items) && data.items.length > 0;
|
|
3377
3622
|
}
|
|
3378
3623
|
function installableRecommendedItems(recommended) {
|
|
@@ -3478,6 +3723,8 @@ async function runApply(options) {
|
|
|
3478
3723
|
force: options.force,
|
|
3479
3724
|
prune: options.prune
|
|
3480
3725
|
});
|
|
3726
|
+
await writeGitignore(root, isDryRun);
|
|
3727
|
+
await untrackMachineLocalArtifacts(root, isDryRun, warn);
|
|
3481
3728
|
if (!isDryRun) {
|
|
3482
3729
|
try {
|
|
3483
3730
|
const cacheDir = hausPath(root, "llms-cache");
|
|
@@ -3503,6 +3750,15 @@ async function runApply(options) {
|
|
|
3503
3750
|
);
|
|
3504
3751
|
}
|
|
3505
3752
|
}
|
|
3753
|
+
const gitignoredHausPaths = await findGitignoredHausPaths(root);
|
|
3754
|
+
if (gitignoredHausPaths.length > 0) {
|
|
3755
|
+
const dirs = summarizeGitignoredHausDirs(gitignoredHausPaths);
|
|
3756
|
+
const dirList = dirs.join(" and ");
|
|
3757
|
+
const verb = dirs.length === 1 ? "is" : "are";
|
|
3758
|
+
warn(
|
|
3759
|
+
`${dirList} ${verb} gitignored \u2014 installed skills/agents/commands will not be visible to anything relying on git-tracked state. Remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored).`
|
|
3760
|
+
);
|
|
3761
|
+
}
|
|
3506
3762
|
if (isDryRun) {
|
|
3507
3763
|
log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
|
|
3508
3764
|
} else {
|
|
@@ -3512,8 +3768,8 @@ async function runApply(options) {
|
|
|
3512
3768
|
}
|
|
3513
3769
|
|
|
3514
3770
|
// src/commands/backups.ts
|
|
3515
|
-
import
|
|
3516
|
-
import
|
|
3771
|
+
import path24 from "node:path";
|
|
3772
|
+
import fs22 from "fs-extra";
|
|
3517
3773
|
|
|
3518
3774
|
// src/utils/prompts.ts
|
|
3519
3775
|
import { stdin as input, stdout as output } from "node:process";
|
|
@@ -3543,14 +3799,14 @@ function classify(name) {
|
|
|
3543
3799
|
}
|
|
3544
3800
|
async function listBackups(root) {
|
|
3545
3801
|
const dir = hausPath(root, "backups");
|
|
3546
|
-
if (!await
|
|
3547
|
-
const names = await
|
|
3802
|
+
if (!await fs22.pathExists(dir)) return [];
|
|
3803
|
+
const names = await fs22.readdir(dir);
|
|
3548
3804
|
const entries = [];
|
|
3549
3805
|
for (const name of names) {
|
|
3550
3806
|
const kind = classify(name);
|
|
3551
3807
|
if (!kind) continue;
|
|
3552
|
-
const absPath =
|
|
3553
|
-
const stat = await
|
|
3808
|
+
const absPath = path24.join(dir, name);
|
|
3809
|
+
const stat = await fs22.lstat(absPath);
|
|
3554
3810
|
if (stat.isSymbolicLink()) continue;
|
|
3555
3811
|
const expectDirectory = kind !== "lock";
|
|
3556
3812
|
if (stat.isDirectory() !== expectDirectory) continue;
|
|
@@ -3579,13 +3835,13 @@ async function runList(root) {
|
|
|
3579
3835
|
}
|
|
3580
3836
|
}
|
|
3581
3837
|
async function isUnsafeWriteTarget(root, target) {
|
|
3582
|
-
const segments =
|
|
3838
|
+
const segments = path24.relative(root, target).split(path24.sep).filter(Boolean);
|
|
3583
3839
|
let current = root;
|
|
3584
3840
|
for (let i = 0; i < segments.length; i++) {
|
|
3585
|
-
current =
|
|
3841
|
+
current = path24.join(current, segments[i]);
|
|
3586
3842
|
const isLastSegment = i === segments.length - 1;
|
|
3587
3843
|
try {
|
|
3588
|
-
const stat = await
|
|
3844
|
+
const stat = await fs22.lstat(current);
|
|
3589
3845
|
if (isLastSegment) {
|
|
3590
3846
|
if (!stat.isFile()) return true;
|
|
3591
3847
|
} else if (!stat.isDirectory()) {
|
|
@@ -3600,7 +3856,7 @@ async function isUnsafeWriteTarget(root, target) {
|
|
|
3600
3856
|
}
|
|
3601
3857
|
async function lstatIfRegularFile(abs) {
|
|
3602
3858
|
try {
|
|
3603
|
-
const stat = await
|
|
3859
|
+
const stat = await fs22.lstat(abs);
|
|
3604
3860
|
return stat.isFile() ? stat : null;
|
|
3605
3861
|
} catch (err) {
|
|
3606
3862
|
if (err.code === "ENOENT") return null;
|
|
@@ -3615,8 +3871,8 @@ async function restoreLockBackup(root, entry, yes) {
|
|
|
3615
3871
|
);
|
|
3616
3872
|
return false;
|
|
3617
3873
|
}
|
|
3618
|
-
if (await
|
|
3619
|
-
const targetStat = await
|
|
3874
|
+
if (await fs22.pathExists(target)) {
|
|
3875
|
+
const targetStat = await fs22.stat(target);
|
|
3620
3876
|
if (targetStat.mtimeMs > entry.mtimeMs) {
|
|
3621
3877
|
warn(
|
|
3622
3878
|
`Current haus.lock.json (${new Date(targetStat.mtimeMs).toISOString()}) is newer than backup ${entry.id} (${new Date(entry.mtimeMs).toISOString()}) \u2014 it will be overwritten.`
|
|
@@ -3636,7 +3892,7 @@ async function restoreLockBackup(root, entry, yes) {
|
|
|
3636
3892
|
error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
|
|
3637
3893
|
return false;
|
|
3638
3894
|
}
|
|
3639
|
-
await
|
|
3895
|
+
await fs22.copy(entry.absPath, target, { overwrite: true });
|
|
3640
3896
|
log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
|
|
3641
3897
|
return true;
|
|
3642
3898
|
}
|
|
@@ -3644,10 +3900,10 @@ async function collectFilesRecursive(dir) {
|
|
|
3644
3900
|
const files = [];
|
|
3645
3901
|
const skippedLinks = [];
|
|
3646
3902
|
const skippedOther = [];
|
|
3647
|
-
const names = await
|
|
3903
|
+
const names = await fs22.readdir(dir);
|
|
3648
3904
|
for (const name of names) {
|
|
3649
|
-
const abs =
|
|
3650
|
-
const stat = await
|
|
3905
|
+
const abs = path24.join(dir, name);
|
|
3906
|
+
const stat = await fs22.lstat(abs);
|
|
3651
3907
|
if (stat.isSymbolicLink()) {
|
|
3652
3908
|
skippedLinks.push(abs);
|
|
3653
3909
|
continue;
|
|
@@ -3669,12 +3925,12 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3669
3925
|
const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
|
|
3670
3926
|
if (skippedLinks.length > 0) {
|
|
3671
3927
|
warn(
|
|
3672
|
-
`Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) =>
|
|
3928
|
+
`Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
|
|
3673
3929
|
);
|
|
3674
3930
|
}
|
|
3675
3931
|
if (skippedOther.length > 0) {
|
|
3676
3932
|
warn(
|
|
3677
|
-
`Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) =>
|
|
3933
|
+
`Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
|
|
3678
3934
|
);
|
|
3679
3935
|
}
|
|
3680
3936
|
if (files.length === 0) {
|
|
@@ -3684,8 +3940,8 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3684
3940
|
const safeFiles = [];
|
|
3685
3941
|
const unsafeTargets = [];
|
|
3686
3942
|
for (const abs of files) {
|
|
3687
|
-
const rel =
|
|
3688
|
-
const target =
|
|
3943
|
+
const rel = path24.relative(entry.absPath, abs);
|
|
3944
|
+
const target = path24.join(root, rel);
|
|
3689
3945
|
if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
|
|
3690
3946
|
else safeFiles.push(abs);
|
|
3691
3947
|
}
|
|
@@ -3702,16 +3958,16 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3702
3958
|
const vanished = [];
|
|
3703
3959
|
const toRestore = [];
|
|
3704
3960
|
for (const abs of safeFiles) {
|
|
3705
|
-
const rel =
|
|
3961
|
+
const rel = path24.relative(entry.absPath, abs);
|
|
3706
3962
|
const backupFileStat = await lstatIfRegularFile(abs);
|
|
3707
3963
|
if (!backupFileStat) {
|
|
3708
3964
|
vanished.push(rel);
|
|
3709
3965
|
continue;
|
|
3710
3966
|
}
|
|
3711
3967
|
toRestore.push(abs);
|
|
3712
|
-
const target =
|
|
3713
|
-
if (await
|
|
3714
|
-
const targetStat = await
|
|
3968
|
+
const target = path24.join(root, rel);
|
|
3969
|
+
if (await fs22.pathExists(target)) {
|
|
3970
|
+
const targetStat = await fs22.stat(target);
|
|
3715
3971
|
if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
|
|
3716
3972
|
stale.push({
|
|
3717
3973
|
rel,
|
|
@@ -3750,14 +4006,14 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3750
4006
|
let restoredCount = 0;
|
|
3751
4007
|
const skippedAtWrite = [];
|
|
3752
4008
|
for (const abs of toRestore) {
|
|
3753
|
-
const rel =
|
|
3754
|
-
const target =
|
|
4009
|
+
const rel = path24.relative(entry.absPath, abs);
|
|
4010
|
+
const target = path24.join(root, rel);
|
|
3755
4011
|
if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
|
|
3756
4012
|
skippedAtWrite.push(rel);
|
|
3757
4013
|
continue;
|
|
3758
4014
|
}
|
|
3759
|
-
await
|
|
3760
|
-
await
|
|
4015
|
+
await fs22.ensureDir(path24.dirname(target));
|
|
4016
|
+
await fs22.copy(abs, target, { overwrite: true });
|
|
3761
4017
|
restoredCount++;
|
|
3762
4018
|
}
|
|
3763
4019
|
if (skippedAtWrite.length > 0) {
|
|
@@ -3814,7 +4070,7 @@ Keeping ${keepCount}.`
|
|
|
3814
4070
|
const failed = [];
|
|
3815
4071
|
for (const entry of removeList) {
|
|
3816
4072
|
try {
|
|
3817
|
-
await
|
|
4073
|
+
await fs22.remove(entry.absPath);
|
|
3818
4074
|
removed.push(entry.id);
|
|
3819
4075
|
} catch (err) {
|
|
3820
4076
|
failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
|
|
@@ -3895,16 +4151,16 @@ async function runCatalogAudit() {
|
|
|
3895
4151
|
}
|
|
3896
4152
|
|
|
3897
4153
|
// src/commands/doctor.ts
|
|
3898
|
-
import
|
|
3899
|
-
import
|
|
4154
|
+
import path37 from "node:path";
|
|
4155
|
+
import fs29 from "fs-extra";
|
|
3900
4156
|
|
|
3901
4157
|
// src/decisions/doctor.ts
|
|
3902
|
-
import
|
|
3903
|
-
import
|
|
4158
|
+
import path28 from "node:path";
|
|
4159
|
+
import fs26 from "fs-extra";
|
|
3904
4160
|
|
|
3905
4161
|
// src/decisions/check.ts
|
|
3906
|
-
import
|
|
3907
|
-
import
|
|
4162
|
+
import path27 from "node:path";
|
|
4163
|
+
import fs25 from "fs-extra";
|
|
3908
4164
|
|
|
3909
4165
|
// src/decisions/diff.ts
|
|
3910
4166
|
init_exec();
|
|
@@ -3959,22 +4215,22 @@ function matchesAnyRegex(filePath, patterns) {
|
|
|
3959
4215
|
}
|
|
3960
4216
|
|
|
3961
4217
|
// src/decisions/paths.ts
|
|
3962
|
-
import
|
|
3963
|
-
import
|
|
4218
|
+
import path25 from "node:path";
|
|
4219
|
+
import fs23 from "fs-extra";
|
|
3964
4220
|
async function resolveDecisionsDir(root) {
|
|
3965
|
-
const primary =
|
|
3966
|
-
if (await
|
|
3967
|
-
const legacy =
|
|
3968
|
-
if (await
|
|
4221
|
+
const primary = path25.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
4222
|
+
if (await fs23.pathExists(primary)) return primary;
|
|
4223
|
+
const legacy = path25.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
4224
|
+
if (await fs23.pathExists(legacy)) return legacy;
|
|
3969
4225
|
return primary;
|
|
3970
4226
|
}
|
|
3971
4227
|
function relativeDecisionPath(decisionsDir, root) {
|
|
3972
|
-
return
|
|
4228
|
+
return path25.relative(root, decisionsDir).replace(/\\/g, "/");
|
|
3973
4229
|
}
|
|
3974
4230
|
|
|
3975
4231
|
// src/decisions/validate.ts
|
|
3976
|
-
import
|
|
3977
|
-
import
|
|
4232
|
+
import path26 from "node:path";
|
|
4233
|
+
import fs24 from "fs-extra";
|
|
3978
4234
|
function validateDecisionContent(content2) {
|
|
3979
4235
|
const errors = [];
|
|
3980
4236
|
for (const section of DECISIONS_TRIGGERS.requiredSections) {
|
|
@@ -4003,14 +4259,14 @@ function decisionNumberFromFile(name) {
|
|
|
4003
4259
|
return match?.[1] ?? null;
|
|
4004
4260
|
}
|
|
4005
4261
|
async function validateIndexSync(decisionsDir, decisionFiles) {
|
|
4006
|
-
const readmePath =
|
|
4007
|
-
if (!await
|
|
4262
|
+
const readmePath = path26.join(decisionsDir, "README.md");
|
|
4263
|
+
if (!await fs24.pathExists(readmePath)) {
|
|
4008
4264
|
return ["docs/decisions/README.md is missing"];
|
|
4009
4265
|
}
|
|
4010
4266
|
const readme = await readText(readmePath) ?? "";
|
|
4011
4267
|
const errors = [];
|
|
4012
4268
|
for (const file of decisionFiles) {
|
|
4013
|
-
const num = decisionNumberFromFile(
|
|
4269
|
+
const num = decisionNumberFromFile(path26.basename(file));
|
|
4014
4270
|
if (num && !indexReferencesNumber(readme, num)) {
|
|
4015
4271
|
errors.push(`README.md missing index row for ADR-${num}`);
|
|
4016
4272
|
}
|
|
@@ -4064,22 +4320,22 @@ async function checkSatisfaction(input2) {
|
|
|
4064
4320
|
const errors = [];
|
|
4065
4321
|
const decisionsPrefix = `${input2.decisionsRel}/`;
|
|
4066
4322
|
const newDecisionFiles = input2.changedFiles.filter(
|
|
4067
|
-
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(
|
|
4323
|
+
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path27.basename(f))
|
|
4068
4324
|
);
|
|
4069
4325
|
if (newDecisionFiles.length === 0) {
|
|
4070
4326
|
errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
|
|
4071
4327
|
return errors;
|
|
4072
4328
|
}
|
|
4073
|
-
const decisionsDir =
|
|
4329
|
+
const decisionsDir = path27.join(input2.root, input2.decisionsRel);
|
|
4074
4330
|
for (const rel of newDecisionFiles) {
|
|
4075
|
-
const abs =
|
|
4331
|
+
const abs = path27.join(input2.root, rel);
|
|
4076
4332
|
const content2 = await readText(abs) ?? "";
|
|
4077
4333
|
const valid = validateDecisionContent(content2);
|
|
4078
4334
|
if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
|
|
4079
4335
|
}
|
|
4080
4336
|
const indexErrors = await validateIndexSync(
|
|
4081
4337
|
decisionsDir,
|
|
4082
|
-
newDecisionFiles.map((f) =>
|
|
4338
|
+
newDecisionFiles.map((f) => path27.basename(f))
|
|
4083
4339
|
);
|
|
4084
4340
|
errors.push(...indexErrors);
|
|
4085
4341
|
return errors;
|
|
@@ -4123,8 +4379,8 @@ ${opts.commitMessages ?? ""}`;
|
|
|
4123
4379
|
changedFiles: stats.files
|
|
4124
4380
|
});
|
|
4125
4381
|
if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
|
|
4126
|
-
const legacyExists = await
|
|
4127
|
-
if (legacyExists && !await
|
|
4382
|
+
const legacyExists = await fs25.pathExists(path27.join(root, legacyRel));
|
|
4383
|
+
if (legacyExists && !await fs25.pathExists(decisionsDir)) {
|
|
4128
4384
|
satisfactionErrors = await checkSatisfaction({
|
|
4129
4385
|
root,
|
|
4130
4386
|
decisionsRel: legacyRel,
|
|
@@ -4144,18 +4400,18 @@ ${opts.commitMessages ?? ""}`;
|
|
|
4144
4400
|
return { triggered: true, satisfied: false, reasons, securityTriggered };
|
|
4145
4401
|
}
|
|
4146
4402
|
async function listDecisionNumbers(decisionsDir) {
|
|
4147
|
-
if (!await
|
|
4148
|
-
const entries = await
|
|
4403
|
+
if (!await fs25.pathExists(decisionsDir)) return [];
|
|
4404
|
+
const entries = await fs25.readdir(decisionsDir);
|
|
4149
4405
|
return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
|
|
4150
4406
|
}
|
|
4151
4407
|
|
|
4152
4408
|
// src/decisions/doctor.ts
|
|
4153
4409
|
async function auditDecisionsLayout(root) {
|
|
4154
4410
|
const findings = [];
|
|
4155
|
-
const primary =
|
|
4156
|
-
const legacy =
|
|
4157
|
-
const hasPrimary = await
|
|
4158
|
-
const hasLegacy = await
|
|
4411
|
+
const primary = path28.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
4412
|
+
const legacy = path28.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
4413
|
+
const hasPrimary = await fs26.pathExists(primary);
|
|
4414
|
+
const hasLegacy = await fs26.pathExists(legacy);
|
|
4159
4415
|
if (!hasPrimary && hasLegacy) {
|
|
4160
4416
|
findings.push({
|
|
4161
4417
|
level: "advisory",
|
|
@@ -4168,8 +4424,8 @@ async function auditDecisionsLayout(root) {
|
|
|
4168
4424
|
if (!hasPrimary) return findings;
|
|
4169
4425
|
const decisionsDir = await resolveDecisionsDir(root);
|
|
4170
4426
|
const rel = relativeDecisionPath(decisionsDir, root);
|
|
4171
|
-
const readmePath =
|
|
4172
|
-
if (!await
|
|
4427
|
+
const readmePath = path28.join(decisionsDir, "README.md");
|
|
4428
|
+
if (!await fs26.pathExists(readmePath)) {
|
|
4173
4429
|
findings.push({
|
|
4174
4430
|
level: "advisory",
|
|
4175
4431
|
line: `- DECISIONS: missing ${rel}/README.md index`,
|
|
@@ -4179,7 +4435,7 @@ async function auditDecisionsLayout(root) {
|
|
|
4179
4435
|
return findings;
|
|
4180
4436
|
}
|
|
4181
4437
|
const readme = await readText(readmePath);
|
|
4182
|
-
const entries = (await
|
|
4438
|
+
const entries = (await fs26.readdir(decisionsDir)).filter(
|
|
4183
4439
|
(f) => f.endsWith(".md") && f !== "README.md"
|
|
4184
4440
|
);
|
|
4185
4441
|
for (const file of entries) {
|
|
@@ -4193,7 +4449,7 @@ async function auditDecisionsLayout(root) {
|
|
|
4193
4449
|
});
|
|
4194
4450
|
}
|
|
4195
4451
|
}
|
|
4196
|
-
const claude = await readText(
|
|
4452
|
+
const claude = await readText(path28.join(root, "CLAUDE.md"));
|
|
4197
4453
|
if (claude && !claude.includes(`@${rel}/README.md`)) {
|
|
4198
4454
|
findings.push({
|
|
4199
4455
|
level: "advisory",
|
|
@@ -4224,17 +4480,187 @@ async function auditDecisionsLayout(root) {
|
|
|
4224
4480
|
}
|
|
4225
4481
|
|
|
4226
4482
|
// src/scanner/read-context.ts
|
|
4227
|
-
import
|
|
4228
|
-
import
|
|
4483
|
+
import path36 from "node:path";
|
|
4484
|
+
import fs28 from "fs-extra";
|
|
4485
|
+
|
|
4486
|
+
// src/commands/workspace/config.ts
|
|
4487
|
+
import path29 from "node:path";
|
|
4488
|
+
import YAML from "yaml";
|
|
4489
|
+
var WORKSPACE_FILE = "haus.workspace.yaml";
|
|
4490
|
+
function parseWorkspaceConfig(text) {
|
|
4491
|
+
if (!text) return void 0;
|
|
4492
|
+
let parsed;
|
|
4493
|
+
try {
|
|
4494
|
+
parsed = YAML.parse(text);
|
|
4495
|
+
} catch {
|
|
4496
|
+
return void 0;
|
|
4497
|
+
}
|
|
4498
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
4499
|
+
const obj = parsed;
|
|
4500
|
+
const repos = Array.isArray(obj.repos) ? obj.repos.filter(
|
|
4501
|
+
(r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
|
|
4502
|
+
) : [];
|
|
4503
|
+
return {
|
|
4504
|
+
client: typeof obj.client === "string" ? obj.client : "unknown",
|
|
4505
|
+
repos,
|
|
4506
|
+
relationships: Array.isArray(obj.relationships) ? obj.relationships : []
|
|
4507
|
+
};
|
|
4508
|
+
}
|
|
4509
|
+
async function readWorkspaceConfig(workspaceRoot) {
|
|
4510
|
+
return parseWorkspaceConfig(await readText(path29.join(workspaceRoot, WORKSPACE_FILE)));
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
// src/workspace/members.ts
|
|
4514
|
+
import path30 from "node:path";
|
|
4515
|
+
var REPOS_MANIFEST_FILE = "repos.manifest.json";
|
|
4516
|
+
var REPOS_LOCAL_FILE = "repos.local.json";
|
|
4517
|
+
var MemberConfigError = class extends Error {
|
|
4518
|
+
constructor(filePath, reason) {
|
|
4519
|
+
super(`Cannot read workspace member config at ${filePath}: ${reason}`);
|
|
4520
|
+
this.filePath = filePath;
|
|
4521
|
+
}
|
|
4522
|
+
filePath;
|
|
4523
|
+
name = "MemberConfigError";
|
|
4524
|
+
};
|
|
4525
|
+
function resolveAbsPath(workspaceRoot, folder, overrides) {
|
|
4526
|
+
const override = overrides[folder];
|
|
4527
|
+
if (override) return path30.resolve(workspaceRoot, override);
|
|
4528
|
+
return path30.resolve(workspaceRoot, folder);
|
|
4529
|
+
}
|
|
4530
|
+
async function readPathOverrides(workspaceRoot) {
|
|
4531
|
+
const filePath = path30.join(workspaceRoot, REPOS_LOCAL_FILE);
|
|
4532
|
+
const result = await readJsonDetailed(filePath);
|
|
4533
|
+
if (result.status === "missing") return {};
|
|
4534
|
+
if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
|
|
4535
|
+
const parsed = result.value;
|
|
4536
|
+
const rawOverrides = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.pathOverrides : void 0;
|
|
4537
|
+
if (rawOverrides === null || typeof rawOverrides !== "object" || Array.isArray(rawOverrides)) {
|
|
4538
|
+
return {};
|
|
4539
|
+
}
|
|
4540
|
+
const overrides = {};
|
|
4541
|
+
for (const [folder, value] of Object.entries(rawOverrides)) {
|
|
4542
|
+
if (typeof value === "string") overrides[folder] = value;
|
|
4543
|
+
}
|
|
4544
|
+
return overrides;
|
|
4545
|
+
}
|
|
4546
|
+
async function readFromWorkspaceYaml(workspaceRoot, overrides) {
|
|
4547
|
+
const filePath = path30.join(workspaceRoot, WORKSPACE_FILE);
|
|
4548
|
+
const text = await readText(filePath);
|
|
4549
|
+
if (text === void 0) return void 0;
|
|
4550
|
+
const config = parseWorkspaceConfig(text);
|
|
4551
|
+
if (!config) throw new MemberConfigError(filePath, "malformed YAML or invalid shape");
|
|
4552
|
+
return config.repos.map((repo) => ({
|
|
4553
|
+
id: repo.name,
|
|
4554
|
+
folder: repo.path,
|
|
4555
|
+
absPath: resolveAbsPath(workspaceRoot, repo.path, overrides),
|
|
4556
|
+
source: "haus.workspace.yaml"
|
|
4557
|
+
}));
|
|
4558
|
+
}
|
|
4559
|
+
async function readFromManifest(workspaceRoot, overrides) {
|
|
4560
|
+
const filePath = path30.join(workspaceRoot, REPOS_MANIFEST_FILE);
|
|
4561
|
+
const result = await readJsonDetailed(filePath);
|
|
4562
|
+
if (result.status === "missing") return void 0;
|
|
4563
|
+
if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
|
|
4564
|
+
const parsed = result.value;
|
|
4565
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4566
|
+
throw new MemberConfigError(filePath, 'expected a top-level object with a "repos" array');
|
|
4567
|
+
}
|
|
4568
|
+
const raw = parsed.repos;
|
|
4569
|
+
if (!Array.isArray(raw)) {
|
|
4570
|
+
throw new MemberConfigError(filePath, '"repos" must be an array');
|
|
4571
|
+
}
|
|
4572
|
+
const members = [];
|
|
4573
|
+
raw.forEach((entry, index) => {
|
|
4574
|
+
if (typeof entry !== "object" || entry === null || typeof entry.id !== "string" || typeof entry.folder !== "string") {
|
|
4575
|
+
throw new MemberConfigError(
|
|
4576
|
+
filePath,
|
|
4577
|
+
`repos[${index}] must be an object with string "id" and "folder" fields`
|
|
4578
|
+
);
|
|
4579
|
+
}
|
|
4580
|
+
const e = entry;
|
|
4581
|
+
members.push({
|
|
4582
|
+
id: e.id,
|
|
4583
|
+
folder: e.folder,
|
|
4584
|
+
url: typeof e.repo === "string" ? e.repo : void 0,
|
|
4585
|
+
absPath: resolveAbsPath(workspaceRoot, e.folder, overrides),
|
|
4586
|
+
source: "repos.manifest.json"
|
|
4587
|
+
});
|
|
4588
|
+
});
|
|
4589
|
+
return members;
|
|
4590
|
+
}
|
|
4591
|
+
async function readMembers(rootInfo) {
|
|
4592
|
+
const workspaceRoot = rootInfo.mainRoot;
|
|
4593
|
+
const overrides = await readPathOverrides(workspaceRoot);
|
|
4594
|
+
const fromYaml = await readFromWorkspaceYaml(workspaceRoot, overrides);
|
|
4595
|
+
if (fromYaml) return fromYaml;
|
|
4596
|
+
const fromManifest = await readFromManifest(workspaceRoot, overrides);
|
|
4597
|
+
if (fromManifest) return fromManifest;
|
|
4598
|
+
return [];
|
|
4599
|
+
}
|
|
4229
4600
|
|
|
4230
4601
|
// src/scanner/scan-project.ts
|
|
4231
|
-
import
|
|
4602
|
+
import path35 from "node:path";
|
|
4232
4603
|
|
|
4233
4604
|
// src/utils/audit-checks.ts
|
|
4234
4605
|
function isRecord(v) {
|
|
4235
4606
|
return typeof v === "object" && v !== null;
|
|
4236
4607
|
}
|
|
4237
4608
|
|
|
4609
|
+
// src/utils/git-root.ts
|
|
4610
|
+
init_exec();
|
|
4611
|
+
import path31 from "node:path";
|
|
4612
|
+
function fallback(start) {
|
|
4613
|
+
return {
|
|
4614
|
+
cwd: start,
|
|
4615
|
+
repoRoot: start,
|
|
4616
|
+
gitDir: "",
|
|
4617
|
+
gitCommonDir: "",
|
|
4618
|
+
isLinkedWorktree: false,
|
|
4619
|
+
mainRoot: start,
|
|
4620
|
+
worktreeName: null,
|
|
4621
|
+
isGitRepo: false
|
|
4622
|
+
};
|
|
4623
|
+
}
|
|
4624
|
+
async function resolveAbsoluteGitPath(flag, cwd, repoRoot) {
|
|
4625
|
+
const withFormat = await runGit(["rev-parse", "--path-format=absolute", flag], { cwd });
|
|
4626
|
+
const raw = withFormat.exitCode === 0 ? withFormat.stdout.trim() : "";
|
|
4627
|
+
if (raw && path31.isAbsolute(raw)) return path31.resolve(raw);
|
|
4628
|
+
const plain = await runGit(["rev-parse", flag], { cwd });
|
|
4629
|
+
const plainRaw = plain.exitCode === 0 ? plain.stdout.trim() : "";
|
|
4630
|
+
if (!plainRaw) return "";
|
|
4631
|
+
return path31.isAbsolute(plainRaw) ? path31.resolve(plainRaw) : path31.resolve(repoRoot, plainRaw);
|
|
4632
|
+
}
|
|
4633
|
+
async function resolveRoots(start) {
|
|
4634
|
+
const cwd = start ?? process.cwd();
|
|
4635
|
+
try {
|
|
4636
|
+
const toplevel = await runGit(["rev-parse", "--show-toplevel"], { cwd });
|
|
4637
|
+
if (toplevel.exitCode !== 0) return fallback(cwd);
|
|
4638
|
+
const toplevelRaw = toplevel.stdout.trim();
|
|
4639
|
+
if (!toplevelRaw) return fallback(cwd);
|
|
4640
|
+
const repoRoot = path31.resolve(toplevelRaw);
|
|
4641
|
+
const [gitDir, gitCommonDir] = await Promise.all([
|
|
4642
|
+
resolveAbsoluteGitPath("--git-dir", cwd, repoRoot),
|
|
4643
|
+
resolveAbsoluteGitPath("--git-common-dir", cwd, repoRoot)
|
|
4644
|
+
]);
|
|
4645
|
+
if (!gitDir || !gitCommonDir) return fallback(cwd);
|
|
4646
|
+
const isLinkedWorktree = gitDir !== gitCommonDir;
|
|
4647
|
+
const mainRoot = isLinkedWorktree && path31.basename(gitCommonDir) === ".git" ? path31.dirname(gitCommonDir) : repoRoot;
|
|
4648
|
+
const worktreeName = isLinkedWorktree ? path31.basename(gitDir) : null;
|
|
4649
|
+
return {
|
|
4650
|
+
cwd,
|
|
4651
|
+
repoRoot,
|
|
4652
|
+
gitDir,
|
|
4653
|
+
gitCommonDir,
|
|
4654
|
+
isLinkedWorktree,
|
|
4655
|
+
mainRoot,
|
|
4656
|
+
worktreeName,
|
|
4657
|
+
isGitRepo: true
|
|
4658
|
+
};
|
|
4659
|
+
} catch {
|
|
4660
|
+
return fallback(cwd);
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
|
|
4238
4664
|
// src/utils/versions.ts
|
|
4239
4665
|
import semver from "semver";
|
|
4240
4666
|
function normalizeVersion(version) {
|
|
@@ -4255,8 +4681,8 @@ function compareVersions(a, b) {
|
|
|
4255
4681
|
}
|
|
4256
4682
|
|
|
4257
4683
|
// src/scanner/detect-package-manager.ts
|
|
4258
|
-
import
|
|
4259
|
-
import
|
|
4684
|
+
import path32 from "node:path";
|
|
4685
|
+
import fs27 from "fs-extra";
|
|
4260
4686
|
function detectPackageManager(root, packageManagerField) {
|
|
4261
4687
|
const field = String(packageManagerField ?? "").trim();
|
|
4262
4688
|
if (field.startsWith("yarn@")) {
|
|
@@ -4274,9 +4700,9 @@ function detectPackageManager(root, packageManagerField) {
|
|
|
4274
4700
|
if (satisfiesVersion(version, ">=9")) return "npm";
|
|
4275
4701
|
return "unknown";
|
|
4276
4702
|
}
|
|
4277
|
-
if (
|
|
4278
|
-
if (
|
|
4279
|
-
if (
|
|
4703
|
+
if (fs27.existsSync(path32.join(root, "yarn.lock"))) return "yarn";
|
|
4704
|
+
if (fs27.existsSync(path32.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
4705
|
+
if (fs27.existsSync(path32.join(root, "package-lock.json"))) return "npm";
|
|
4280
4706
|
return "unknown";
|
|
4281
4707
|
}
|
|
4282
4708
|
|
|
@@ -4346,6 +4772,14 @@ function matchRule(rule, ctx) {
|
|
|
4346
4772
|
return false;
|
|
4347
4773
|
}
|
|
4348
4774
|
var ROLE_RULES = [
|
|
4775
|
+
// Workspace/meta-repo root — same bridge signal `readMembers()`/
|
|
4776
|
+
// `resolveWorkspaceForWorktree()` already use (src/workspace/members.ts,
|
|
4777
|
+
// src/workspace/worktree/root.ts): presence of either config file marks this
|
|
4778
|
+
// root as a workspace aggregating sibling repos, not a runnable app of its own.
|
|
4779
|
+
// `fileEquals` only matches the file at the scan root (not nested under a
|
|
4780
|
+
// sub-repo, which the nested-`.git` boundary excludes from `files` anyway) —
|
|
4781
|
+
// see Task 3.5, docs/plans/workspace-detection-and-permissions-fixes.md.
|
|
4782
|
+
{ role: "workspace", any: [fileEquals(WORKSPACE_FILE), fileEquals(REPOS_MANIFEST_FILE)] },
|
|
4349
4783
|
{ role: "next-app", any: [dep("next"), fileIncludes("next.config.")] },
|
|
4350
4784
|
{ role: "react-app", any: [dep("react")] },
|
|
4351
4785
|
{ role: "vite-app", any: [dep("vite"), fileIncludes("vite.config.")] },
|
|
@@ -4478,7 +4912,7 @@ function runDetection(ctx, rules = STACK_RULES) {
|
|
|
4478
4912
|
}
|
|
4479
4913
|
|
|
4480
4914
|
// src/scanner/detection.ts
|
|
4481
|
-
import
|
|
4915
|
+
import path33 from "node:path";
|
|
4482
4916
|
var UNSUPPORTED_MARKERS = {
|
|
4483
4917
|
"requirements.txt": "python",
|
|
4484
4918
|
"pyproject.toml": "python",
|
|
@@ -4498,6 +4932,12 @@ function computeDetectionStatus(roles, stacks, unsupportedSignals) {
|
|
|
4498
4932
|
if (!hasRealSignal) return "unknown";
|
|
4499
4933
|
return unsupportedSignals.length > 0 ? "partial" : "supported";
|
|
4500
4934
|
}
|
|
4935
|
+
function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
|
|
4936
|
+
const markers = unsupportedSignals.join(", ");
|
|
4937
|
+
const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
|
|
4938
|
+
const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree \u2014 sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
|
|
4939
|
+
return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
|
|
4940
|
+
}
|
|
4501
4941
|
function blocked(rel) {
|
|
4502
4942
|
return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
|
|
4503
4943
|
}
|
|
@@ -4532,14 +4972,14 @@ function finalizeRoles(registryRoles, deps, files) {
|
|
|
4532
4972
|
function collectUnsupportedSignals(files) {
|
|
4533
4973
|
return [
|
|
4534
4974
|
...new Set(
|
|
4535
|
-
files.map((f) => UNSUPPORTED_MARKERS[
|
|
4975
|
+
files.map((f) => UNSUPPORTED_MARKERS[path33.basename(f)]).filter((s) => Boolean(s))
|
|
4536
4976
|
)
|
|
4537
4977
|
].sort();
|
|
4538
4978
|
}
|
|
4539
4979
|
|
|
4540
4980
|
// src/scanner/render.ts
|
|
4541
4981
|
import { readFile } from "node:fs/promises";
|
|
4542
|
-
import
|
|
4982
|
+
import path34 from "node:path";
|
|
4543
4983
|
async function buildContentBlob(root, files) {
|
|
4544
4984
|
const candidates = files.filter(
|
|
4545
4985
|
(f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
|
|
@@ -4547,7 +4987,7 @@ async function buildContentBlob(root, files) {
|
|
|
4547
4987
|
const slice = candidates.slice(0, 300);
|
|
4548
4988
|
const parts = await mapWithConcurrency(slice, async (rel) => {
|
|
4549
4989
|
try {
|
|
4550
|
-
return await readFile(
|
|
4990
|
+
return await readFile(path34.join(root, rel), "utf8");
|
|
4551
4991
|
} catch {
|
|
4552
4992
|
return "";
|
|
4553
4993
|
}
|
|
@@ -4594,6 +5034,11 @@ var SAFE_FILES = [
|
|
|
4594
5034
|
"pnpm-lock.yaml",
|
|
4595
5035
|
"composer.json",
|
|
4596
5036
|
"composer.lock",
|
|
5037
|
+
// Workspace/meta-repo root markers — presence only, drives the 'workspace' role
|
|
5038
|
+
// (src/scanner/detection-registry.ts). Same files resolveWorkspaceForWorktree()
|
|
5039
|
+
// and readMembers() already treat as the workspace-root signal.
|
|
5040
|
+
WORKSPACE_FILE,
|
|
5041
|
+
REPOS_MANIFEST_FILE,
|
|
4597
5042
|
"nx.json",
|
|
4598
5043
|
"turbo.json",
|
|
4599
5044
|
"tsconfig.json",
|
|
@@ -4634,8 +5079,8 @@ var SAFE_FILES = [
|
|
|
4634
5079
|
"Gemfile"
|
|
4635
5080
|
];
|
|
4636
5081
|
async function scanProject(root) {
|
|
4637
|
-
const pkg = await readJson(
|
|
4638
|
-
const composer = await readJson(
|
|
5082
|
+
const pkg = await readJson(path35.join(root, "package.json"));
|
|
5083
|
+
const composer = await readJson(path35.join(root, "composer.json"));
|
|
4639
5084
|
const files = await listFiles(root, SAFE_FILES);
|
|
4640
5085
|
const safeFiles = files.filter((f) => !blocked(f));
|
|
4641
5086
|
const deps = dependencySet(pkg, composer);
|
|
@@ -4659,10 +5104,10 @@ async function scanProject(root) {
|
|
|
4659
5104
|
crossRepoHints.push("Monorepo orchestration detected");
|
|
4660
5105
|
const unsupportedSignals = collectUnsupportedSignals(safeFiles);
|
|
4661
5106
|
const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
|
|
5107
|
+
const repoNameFallback = pkg?.name ? String(pkg.name) : path35.basename((await resolveRoots(root)).mainRoot);
|
|
4662
5108
|
const context = {
|
|
4663
5109
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4664
|
-
|
|
4665
|
-
repoName: String(pkg?.name ?? path31.basename(root)),
|
|
5110
|
+
repoName: repoNameFallback,
|
|
4666
5111
|
packageManager,
|
|
4667
5112
|
repoRoles: roles,
|
|
4668
5113
|
detectedStacks: stacks,
|
|
@@ -4680,10 +5125,18 @@ async function scanProject(root) {
|
|
|
4680
5125
|
|
|
4681
5126
|
// src/scanner/read-context.ts
|
|
4682
5127
|
async function isCacheFresh(root, cachePath) {
|
|
4683
|
-
const
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
5128
|
+
const markerPaths = [
|
|
5129
|
+
path36.join(root, "package.json"),
|
|
5130
|
+
path36.join(root, WORKSPACE_FILE),
|
|
5131
|
+
path36.join(root, REPOS_MANIFEST_FILE)
|
|
5132
|
+
];
|
|
5133
|
+
const cacheStat = await fs28.stat(cachePath);
|
|
5134
|
+
for (const markerPath of markerPaths) {
|
|
5135
|
+
if (!await fs28.pathExists(markerPath)) continue;
|
|
5136
|
+
const markerStat = await fs28.stat(markerPath);
|
|
5137
|
+
if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
|
|
5138
|
+
}
|
|
5139
|
+
return true;
|
|
4687
5140
|
}
|
|
4688
5141
|
async function readContextOrScan(root) {
|
|
4689
5142
|
const cachePath = hausPath(root, "context-map.json");
|
|
@@ -4781,7 +5234,7 @@ async function runDoctor(options) {
|
|
|
4781
5234
|
} else {
|
|
4782
5235
|
ok(`- HOOKS OK: ${hooks.message}`);
|
|
4783
5236
|
}
|
|
4784
|
-
const rootClaudeMdPath =
|
|
5237
|
+
const rootClaudeMdPath = path37.join(root, "CLAUDE.md");
|
|
4785
5238
|
const rootClaudeMdContent = await readText(rootClaudeMdPath);
|
|
4786
5239
|
if (!rootClaudeMdContent) {
|
|
4787
5240
|
flag(
|
|
@@ -4809,7 +5262,7 @@ async function runDoctor(options) {
|
|
|
4809
5262
|
const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
|
|
4810
5263
|
const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
|
|
4811
5264
|
for (const target of importTargets) {
|
|
4812
|
-
if (!await
|
|
5265
|
+
if (!await fs29.pathExists(hausPath(root, target))) {
|
|
4813
5266
|
flag(
|
|
4814
5267
|
`- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
|
|
4815
5268
|
`A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
|
|
@@ -4820,7 +5273,7 @@ async function runDoctor(options) {
|
|
|
4820
5273
|
}
|
|
4821
5274
|
}
|
|
4822
5275
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
4823
|
-
const workflowExists = await
|
|
5276
|
+
const workflowExists = await fs29.pathExists(workflowPath);
|
|
4824
5277
|
if (!workflowExists) {
|
|
4825
5278
|
flag(
|
|
4826
5279
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
@@ -4835,15 +5288,15 @@ async function runDoctor(options) {
|
|
|
4835
5288
|
} else {
|
|
4836
5289
|
const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
|
|
4837
5290
|
const bodyContent = workflowContent.slice(firstLine.length + 1);
|
|
4838
|
-
const cachePath =
|
|
4839
|
-
const bundledPath =
|
|
5291
|
+
const cachePath = path37.join(getCacheDir(), "templates/agentic-workflow-standard.md");
|
|
5292
|
+
const bundledPath = path37.join(
|
|
4840
5293
|
packageRoot(),
|
|
4841
5294
|
"library",
|
|
4842
5295
|
"global",
|
|
4843
5296
|
"templates",
|
|
4844
5297
|
"agentic-workflow-standard.md"
|
|
4845
5298
|
);
|
|
4846
|
-
const templatePath = await
|
|
5299
|
+
const templatePath = await fs29.pathExists(cachePath) ? cachePath : bundledPath;
|
|
4847
5300
|
const templateContent = await readText(templatePath);
|
|
4848
5301
|
const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
|
|
4849
5302
|
if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
|
|
@@ -4864,7 +5317,7 @@ async function runDoctor(options) {
|
|
|
4864
5317
|
}
|
|
4865
5318
|
}
|
|
4866
5319
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
4867
|
-
const workflowConfigExists = await
|
|
5320
|
+
const workflowConfigExists = await fs29.pathExists(workflowConfigPath);
|
|
4868
5321
|
if (!workflowConfigExists) {
|
|
4869
5322
|
flag(
|
|
4870
5323
|
"- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
|
|
@@ -4872,7 +5325,7 @@ async function runDoctor(options) {
|
|
|
4872
5325
|
"haus apply --write"
|
|
4873
5326
|
);
|
|
4874
5327
|
} else {
|
|
4875
|
-
const cfg = await
|
|
5328
|
+
const cfg = await fs29.readFile(workflowConfigPath, "utf8");
|
|
4876
5329
|
const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
|
|
4877
5330
|
if (unfilled > 0) {
|
|
4878
5331
|
suggest(
|
|
@@ -4885,7 +5338,7 @@ async function runDoctor(options) {
|
|
|
4885
5338
|
}
|
|
4886
5339
|
}
|
|
4887
5340
|
if (workflowExists) {
|
|
4888
|
-
const prettierIgnore = await readText(
|
|
5341
|
+
const prettierIgnore = await readText(path37.join(root, ".prettierignore")) ?? "";
|
|
4889
5342
|
const lines = prettierIgnore.split("\n");
|
|
4890
5343
|
const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
|
|
4891
5344
|
if (missing.length > 0) {
|
|
@@ -4898,6 +5351,29 @@ async function runDoctor(options) {
|
|
|
4898
5351
|
ok(`- .prettierignore: protects ${IGNORED_PATHS.join(" and ")}`);
|
|
4899
5352
|
}
|
|
4900
5353
|
}
|
|
5354
|
+
const trackedArtifacts = await listTrackedArtifactPaths(root);
|
|
5355
|
+
if (trackedArtifacts.length > 0) {
|
|
5356
|
+
flag(
|
|
5357
|
+
`- GITIGNORE: ${trackedArtifacts.length} machine-local scan artifact(s) still tracked in git (${trackedArtifacts.join(", ")})`,
|
|
5358
|
+
`${trackedArtifacts.length} machine-local scan artifact(s) are still tracked in git`,
|
|
5359
|
+
`git rm --cached ${trackedArtifacts.join(" ")} (or run \`haus apply --write\` to do this and update .gitignore for you)`
|
|
5360
|
+
);
|
|
5361
|
+
} else {
|
|
5362
|
+
ok("- GITIGNORE: OK (no machine-local scan artifacts tracked in git)");
|
|
5363
|
+
}
|
|
5364
|
+
const gitignoredHausPaths = await findGitignoredHausPaths(root);
|
|
5365
|
+
if (gitignoredHausPaths.length > 0) {
|
|
5366
|
+
const dirs = summarizeGitignoredHausDirs(gitignoredHausPaths);
|
|
5367
|
+
const dirList = dirs.join(" and ");
|
|
5368
|
+
const verb = dirs.length === 1 ? "is" : "are";
|
|
5369
|
+
flag(
|
|
5370
|
+
`- GITIGNORE (haus content): ${dirList} ${verb} gitignored \u2014 installed skills/agents/commands will not be visible to anything relying on git-tracked state`,
|
|
5371
|
+
`${dirList} ${verb} gitignored, so haus-installed content isn't visible to git-tracked state`,
|
|
5372
|
+
"remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored)"
|
|
5373
|
+
);
|
|
5374
|
+
} else {
|
|
5375
|
+
ok("- GITIGNORE (haus content): OK (.claude/ and .haus-workflow/ are tracked)");
|
|
5376
|
+
}
|
|
4901
5377
|
for (const finding of await auditDecisionsLayout(root)) {
|
|
4902
5378
|
if (finding.level === "ok") ok(finding.line);
|
|
4903
5379
|
else suggest(finding.line, finding.sentence, finding.fix);
|
|
@@ -4935,7 +5411,7 @@ async function runDoctor(options) {
|
|
|
4935
5411
|
);
|
|
4936
5412
|
}
|
|
4937
5413
|
}
|
|
4938
|
-
const pkgJson = await readJson(
|
|
5414
|
+
const pkgJson = await readJson(path37.join(packageRoot(), "package.json"));
|
|
4939
5415
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
4940
5416
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
4941
5417
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -4968,12 +5444,12 @@ async function runDoctor(options) {
|
|
|
4968
5444
|
}
|
|
4969
5445
|
|
|
4970
5446
|
// src/commands/update.ts
|
|
4971
|
-
import
|
|
5447
|
+
import path40 from "node:path";
|
|
4972
5448
|
|
|
4973
5449
|
// src/claude/refresh-project.ts
|
|
4974
|
-
import
|
|
5450
|
+
import fs30 from "fs-extra";
|
|
4975
5451
|
async function isHausProject(root) {
|
|
4976
|
-
if (await
|
|
5452
|
+
if (await fs30.pathExists(hausPath(root, "recommendation.json"))) return true;
|
|
4977
5453
|
const result = await readJsonDetailed(claudePath(root, "settings.json"));
|
|
4978
5454
|
if (result.status === "ok") return result.value._haus != null;
|
|
4979
5455
|
return false;
|
|
@@ -4985,8 +5461,8 @@ async function refreshProjectApply(root) {
|
|
|
4985
5461
|
|
|
4986
5462
|
// src/install/apply.ts
|
|
4987
5463
|
import crypto2 from "node:crypto";
|
|
4988
|
-
import
|
|
4989
|
-
import
|
|
5464
|
+
import path38 from "node:path";
|
|
5465
|
+
import fs31 from "fs-extra";
|
|
4990
5466
|
|
|
4991
5467
|
// src/install/header.ts
|
|
4992
5468
|
var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
@@ -5072,57 +5548,57 @@ function hashContent(content2) {
|
|
|
5072
5548
|
}
|
|
5073
5549
|
function sourceVersion() {
|
|
5074
5550
|
try {
|
|
5075
|
-
const pkgPath =
|
|
5076
|
-
const pkg = JSON.parse(
|
|
5551
|
+
const pkgPath = path38.join(packageRoot(), "package.json");
|
|
5552
|
+
const pkg = JSON.parse(fs31.readFileSync(pkgPath, "utf8"));
|
|
5077
5553
|
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
5078
5554
|
} catch {
|
|
5079
5555
|
return "haus@0.0.0";
|
|
5080
5556
|
}
|
|
5081
5557
|
}
|
|
5082
5558
|
function globalSrcDir() {
|
|
5083
|
-
return
|
|
5559
|
+
return path38.join(packageRoot(), "library", "global");
|
|
5084
5560
|
}
|
|
5085
5561
|
function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
|
|
5086
|
-
const skillDir =
|
|
5562
|
+
const skillDir = path38.join(skillsDir, skillName);
|
|
5087
5563
|
const entries = [];
|
|
5088
|
-
for (const rel of
|
|
5564
|
+
for (const rel of fs31.readdirSync(skillDir, { recursive: true })) {
|
|
5089
5565
|
if (rel === "SKILL.md") continue;
|
|
5090
|
-
const abs =
|
|
5091
|
-
if (!
|
|
5092
|
-
const posixRel = rel.split(
|
|
5566
|
+
const abs = path38.join(skillDir, rel);
|
|
5567
|
+
if (!fs31.statSync(abs).isFile()) continue;
|
|
5568
|
+
const posixRel = rel.split(path38.sep).join("/");
|
|
5093
5569
|
entries.push({
|
|
5094
5570
|
stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
|
|
5095
|
-
srcRelPath:
|
|
5096
|
-
destPath:
|
|
5571
|
+
srcRelPath: path38.join("library", "global", "skills", skillName, rel),
|
|
5572
|
+
destPath: path38.join(claudeDir, "skills", skillName, rel)
|
|
5097
5573
|
});
|
|
5098
5574
|
}
|
|
5099
5575
|
return entries;
|
|
5100
5576
|
}
|
|
5101
5577
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
5102
5578
|
const entries = [];
|
|
5103
|
-
const skillsDir =
|
|
5104
|
-
if (
|
|
5105
|
-
for (const skillName of
|
|
5106
|
-
const skillFile =
|
|
5107
|
-
if (
|
|
5579
|
+
const skillsDir = path38.join(srcDir, "skills");
|
|
5580
|
+
if (fs31.pathExistsSync(skillsDir)) {
|
|
5581
|
+
for (const skillName of fs31.readdirSync(skillsDir)) {
|
|
5582
|
+
const skillFile = path38.join(skillsDir, skillName, "SKILL.md");
|
|
5583
|
+
if (fs31.pathExistsSync(skillFile)) {
|
|
5108
5584
|
entries.push({
|
|
5109
5585
|
stableId: `skill.${skillName}`,
|
|
5110
|
-
srcRelPath:
|
|
5111
|
-
destPath:
|
|
5586
|
+
srcRelPath: path38.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
5587
|
+
destPath: path38.join(claudeDir, "skills", skillName, "SKILL.md")
|
|
5112
5588
|
});
|
|
5113
5589
|
entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
|
|
5114
5590
|
}
|
|
5115
5591
|
}
|
|
5116
5592
|
}
|
|
5117
|
-
const commandsDir =
|
|
5118
|
-
if (
|
|
5119
|
-
for (const fileName of
|
|
5593
|
+
const commandsDir = path38.join(srcDir, "commands");
|
|
5594
|
+
if (fs31.pathExistsSync(commandsDir)) {
|
|
5595
|
+
for (const fileName of fs31.readdirSync(commandsDir)) {
|
|
5120
5596
|
if (!fileName.endsWith(".md")) continue;
|
|
5121
5597
|
const commandName = fileName.slice(0, -".md".length);
|
|
5122
5598
|
entries.push({
|
|
5123
5599
|
stableId: `command.${commandName}`,
|
|
5124
|
-
srcRelPath:
|
|
5125
|
-
destPath:
|
|
5600
|
+
srcRelPath: path38.join("library", "global", "commands", fileName),
|
|
5601
|
+
destPath: path38.join(claudeDir, "commands", fileName)
|
|
5126
5602
|
});
|
|
5127
5603
|
}
|
|
5128
5604
|
}
|
|
@@ -5148,7 +5624,7 @@ async function applyInstall(options = {}) {
|
|
|
5148
5624
|
};
|
|
5149
5625
|
const manifestFiles = [];
|
|
5150
5626
|
for (const entry of sourceFiles) {
|
|
5151
|
-
const srcPath =
|
|
5627
|
+
const srcPath = path38.join(packageRoot(), entry.srcRelPath);
|
|
5152
5628
|
const rawContent = await readText(srcPath);
|
|
5153
5629
|
if (rawContent === void 0) {
|
|
5154
5630
|
warn(`Source file not found: ${entry.srcRelPath}`);
|
|
@@ -5168,7 +5644,7 @@ async function applyInstall(options = {}) {
|
|
|
5168
5644
|
}
|
|
5169
5645
|
continue;
|
|
5170
5646
|
}
|
|
5171
|
-
const destExists =
|
|
5647
|
+
const destExists = fs31.pathExistsSync(entry.destPath);
|
|
5172
5648
|
if (destExists) {
|
|
5173
5649
|
const currentContent = await readText(entry.destPath);
|
|
5174
5650
|
if (currentContent !== void 0) {
|
|
@@ -5208,7 +5684,7 @@ async function applyInstall(options = {}) {
|
|
|
5208
5684
|
}
|
|
5209
5685
|
let mergedSettings;
|
|
5210
5686
|
if (!check) {
|
|
5211
|
-
const fragmentPath =
|
|
5687
|
+
const fragmentPath = path38.join(srcDir, "settings-fragments", "hooks.json");
|
|
5212
5688
|
const fragments = await loadHooksFragment(fragmentPath);
|
|
5213
5689
|
const settings = await readSettings();
|
|
5214
5690
|
const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
|
|
@@ -5222,13 +5698,13 @@ async function applyInstall(options = {}) {
|
|
|
5222
5698
|
const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
|
|
5223
5699
|
for (const entry of existingManifest.files) {
|
|
5224
5700
|
if (currentDestPaths.has(entry.destPath)) continue;
|
|
5225
|
-
if (!
|
|
5701
|
+
if (!fs31.pathExistsSync(entry.destPath)) continue;
|
|
5226
5702
|
const content2 = await readText(entry.destPath);
|
|
5227
5703
|
if (!content2) continue;
|
|
5228
5704
|
const hasHeader = parseMarkdownHeader(content2) !== void 0;
|
|
5229
5705
|
const currentHash = hashContent(content2);
|
|
5230
5706
|
if (hasHeader && currentHash === entry.hash) {
|
|
5231
|
-
if (!dryRun) await
|
|
5707
|
+
if (!dryRun) await fs31.remove(entry.destPath);
|
|
5232
5708
|
result.deleted.push(entry.destPath);
|
|
5233
5709
|
} else {
|
|
5234
5710
|
warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
|
|
@@ -5297,7 +5773,7 @@ function summarizeLockDiff(before, after) {
|
|
|
5297
5773
|
|
|
5298
5774
|
// src/update/lockfile.ts
|
|
5299
5775
|
import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
|
|
5300
|
-
import
|
|
5776
|
+
import path39 from "node:path";
|
|
5301
5777
|
async function readLockSummary(root) {
|
|
5302
5778
|
const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
5303
5779
|
const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
|
|
@@ -5333,7 +5809,7 @@ async function applyLock(root) {
|
|
|
5333
5809
|
try {
|
|
5334
5810
|
const backupDir = hausPath(root, "backups");
|
|
5335
5811
|
await mkdir(backupDir, { recursive: true });
|
|
5336
|
-
await copyFile(lockPath,
|
|
5812
|
+
await copyFile(lockPath, path39.join(backupDir, `haus.lock.${Date.now()}.json`));
|
|
5337
5813
|
} catch {
|
|
5338
5814
|
}
|
|
5339
5815
|
const enriched = await Promise.all(
|
|
@@ -5355,7 +5831,7 @@ function diffLock(before, after) {
|
|
|
5355
5831
|
}
|
|
5356
5832
|
async function hasLocalOverrides(root) {
|
|
5357
5833
|
try {
|
|
5358
|
-
await readFile2(
|
|
5834
|
+
await readFile2(path39.join(root, ".claude", "settings.json"), "utf8");
|
|
5359
5835
|
return true;
|
|
5360
5836
|
} catch {
|
|
5361
5837
|
return false;
|
|
@@ -5371,7 +5847,7 @@ async function runUpdate(options) {
|
|
|
5371
5847
|
return;
|
|
5372
5848
|
}
|
|
5373
5849
|
if (options.check) {
|
|
5374
|
-
const pkgJson2 = await readJson(
|
|
5850
|
+
const pkgJson2 = await readJson(path40.join(packageRoot(), "package.json"));
|
|
5375
5851
|
const currentVersion2 = pkgJson2?.version ?? "0.0.0";
|
|
5376
5852
|
const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
|
|
5377
5853
|
options.fast ? fastLockStatus(root) : checkLock(root),
|
|
@@ -5406,7 +5882,7 @@ async function runUpdate(options) {
|
|
|
5406
5882
|
if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
|
|
5407
5883
|
return;
|
|
5408
5884
|
}
|
|
5409
|
-
const pkgJson = await readJson(
|
|
5885
|
+
const pkgJson = await readJson(path40.join(packageRoot(), "package.json"));
|
|
5410
5886
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
5411
5887
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
5412
5888
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -5448,7 +5924,7 @@ async function runFromHookCheck(root) {
|
|
|
5448
5924
|
try {
|
|
5449
5925
|
const summary = await readLockSummary(root);
|
|
5450
5926
|
if (summary.count === 0) return;
|
|
5451
|
-
const pkgJson = await readJson(
|
|
5927
|
+
const pkgJson = await readJson(path40.join(packageRoot(), "package.json"));
|
|
5452
5928
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
5453
5929
|
const [npmVersion, latestCatalogTag] = await Promise.all([
|
|
5454
5930
|
fetchNpmVersionStatus(currentVersion),
|
|
@@ -5598,7 +6074,7 @@ async function runCiGate(options = {}) {
|
|
|
5598
6074
|
// src/commands/clone.ts
|
|
5599
6075
|
init_exec();
|
|
5600
6076
|
import { existsSync as existsSync2 } from "node:fs";
|
|
5601
|
-
import
|
|
6077
|
+
import path41 from "node:path";
|
|
5602
6078
|
var GIT_LOCATION_VARS = [
|
|
5603
6079
|
"GIT_DIR",
|
|
5604
6080
|
"GIT_WORK_TREE",
|
|
@@ -5648,11 +6124,11 @@ async function runClone(url, opts = {}) {
|
|
|
5648
6124
|
process.exitCode = 1;
|
|
5649
6125
|
return;
|
|
5650
6126
|
}
|
|
5651
|
-
const target =
|
|
6127
|
+
const target = path41.resolve(opts.dir?.trim() || repoNameFromUrl(url));
|
|
5652
6128
|
if (existsSync2(target)) {
|
|
5653
6129
|
const check = await checkExistingTarget(target, url);
|
|
5654
6130
|
if (check.kind === "matches") {
|
|
5655
|
-
log(`\u2022 ${
|
|
6131
|
+
log(`\u2022 ${path41.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
|
|
5656
6132
|
return;
|
|
5657
6133
|
}
|
|
5658
6134
|
const message = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}) \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
|
|
@@ -5762,7 +6238,7 @@ async function runDecisionsGuard(options = {}) {
|
|
|
5762
6238
|
}
|
|
5763
6239
|
|
|
5764
6240
|
// src/commands/decisions.ts
|
|
5765
|
-
import
|
|
6241
|
+
import path42 from "node:path";
|
|
5766
6242
|
|
|
5767
6243
|
// src/decisions/next-number.ts
|
|
5768
6244
|
async function nextDecisionNumber(root) {
|
|
@@ -5873,7 +6349,7 @@ async function runDecisions(sub, arg, options = {}) {
|
|
|
5873
6349
|
return;
|
|
5874
6350
|
}
|
|
5875
6351
|
if (sub === "validate") {
|
|
5876
|
-
const target = arg ?
|
|
6352
|
+
const target = arg ? path42.resolve(root, arg) : void 0;
|
|
5877
6353
|
if (!target) {
|
|
5878
6354
|
error("usage: haus decisions validate <path-to-decision.md>");
|
|
5879
6355
|
process.exitCode = 1;
|
|
@@ -6142,8 +6618,8 @@ async function runGuard(kind, _options) {
|
|
|
6142
6618
|
}
|
|
6143
6619
|
|
|
6144
6620
|
// src/commands/init.ts
|
|
6145
|
-
import
|
|
6146
|
-
import
|
|
6621
|
+
import path44 from "node:path";
|
|
6622
|
+
import fs32 from "fs-extra";
|
|
6147
6623
|
|
|
6148
6624
|
// src/recommender/git-signal.ts
|
|
6149
6625
|
init_exec();
|
|
@@ -6206,11 +6682,9 @@ function describeRequiresAny(clauses) {
|
|
|
6206
6682
|
return "unknown";
|
|
6207
6683
|
}).join(" | ");
|
|
6208
6684
|
}
|
|
6209
|
-
function mergeRecommendationWarnings(context) {
|
|
6685
|
+
function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
|
|
6210
6686
|
const markers = context.unsupportedSignals?.join(", ");
|
|
6211
|
-
const statusLines = context.detectionStatus === "unknown" ? [
|
|
6212
|
-
markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support. Only stack-agnostic workflow and security guidance is applied.` : "Stack not recognised \u2014 no supported framework detected. Only stack-agnostic workflow and security guidance is applied."
|
|
6213
|
-
] : context.detectionStatus === "partial" && markers ? [
|
|
6687
|
+
const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
|
|
6214
6688
|
`Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
|
|
6215
6689
|
] : [];
|
|
6216
6690
|
const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
|
|
@@ -6429,11 +6903,12 @@ async function recommend(root, context, opts = {}) {
|
|
|
6429
6903
|
const skippedRules = skipped.length;
|
|
6430
6904
|
const estimatedContextTokens = estimateContextTokens(selectedRules);
|
|
6431
6905
|
const estimatedTokenReductionPct = tokenReductionPct(selectedRules, skippedRules);
|
|
6906
|
+
const { isLinkedWorktree } = await resolveRoots(root);
|
|
6432
6907
|
return {
|
|
6433
6908
|
recommended,
|
|
6434
6909
|
skipped,
|
|
6435
6910
|
optInEligible,
|
|
6436
|
-
warnings: [...mergeRecommendationWarnings(context), ...includeWarnings],
|
|
6911
|
+
warnings: [...mergeRecommendationWarnings(context, isLinkedWorktree), ...includeWarnings],
|
|
6437
6912
|
estimatedContextTokens,
|
|
6438
6913
|
selectedRules,
|
|
6439
6914
|
skippedRules,
|
|
@@ -6610,9 +7085,162 @@ function buildOptInEligible(items, skipped) {
|
|
|
6610
7085
|
return eligible;
|
|
6611
7086
|
}
|
|
6612
7087
|
|
|
7088
|
+
// src/commands/workspace/discover.ts
|
|
7089
|
+
import path43 from "node:path";
|
|
7090
|
+
import fg4 from "fast-glob";
|
|
7091
|
+
import YAML2 from "yaml";
|
|
7092
|
+
var DEFAULT_MAX_DEPTH = 3;
|
|
7093
|
+
var GIT_MARKER = ["**/.git"];
|
|
7094
|
+
var MANIFEST_MARKERS = [
|
|
7095
|
+
"**/package.json",
|
|
7096
|
+
"**/composer.json",
|
|
7097
|
+
"**/*.csproj",
|
|
7098
|
+
"**/*.sln",
|
|
7099
|
+
"**/*.fsproj",
|
|
7100
|
+
// .NET
|
|
7101
|
+
"**/pom.xml",
|
|
7102
|
+
"**/build.gradle*",
|
|
7103
|
+
// Java
|
|
7104
|
+
"**/Gemfile"
|
|
7105
|
+
// Ruby
|
|
7106
|
+
];
|
|
7107
|
+
var IGNORE = ["**/node_modules/**", "**/vendor/**", "**/dist/**", "**/.haus-workflow/**"];
|
|
7108
|
+
function isDescendant(child, ancestor) {
|
|
7109
|
+
if (ancestor === ".") return child !== ".";
|
|
7110
|
+
return child === ancestor ? false : child.startsWith(`${ancestor}/`);
|
|
7111
|
+
}
|
|
7112
|
+
async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
7113
|
+
const commonOpts = {
|
|
7114
|
+
cwd: workspaceRoot,
|
|
7115
|
+
dot: true,
|
|
7116
|
+
onlyFiles: false,
|
|
7117
|
+
deep: maxDepth,
|
|
7118
|
+
followSymbolicLinks: false,
|
|
7119
|
+
// Discovery walks arbitrary directories under the workspace root; an
|
|
7120
|
+
// unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
|
|
7121
|
+
suppressErrors: true
|
|
7122
|
+
};
|
|
7123
|
+
const [gitMatches, manifestMatches] = await Promise.all([
|
|
7124
|
+
fg4(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
|
|
7125
|
+
fg4(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
|
|
7126
|
+
]);
|
|
7127
|
+
const gitDirs = /* @__PURE__ */ new Set();
|
|
7128
|
+
const manifestDirs = /* @__PURE__ */ new Set();
|
|
7129
|
+
for (const match of gitMatches) {
|
|
7130
|
+
gitDirs.add(path43.posix.dirname(match));
|
|
7131
|
+
}
|
|
7132
|
+
for (const match of manifestMatches) {
|
|
7133
|
+
manifestDirs.add(path43.posix.dirname(match));
|
|
7134
|
+
}
|
|
7135
|
+
const repoRoots = [...gitDirs];
|
|
7136
|
+
const manifestSorted = [...manifestDirs].sort(
|
|
7137
|
+
(a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
|
|
7138
|
+
);
|
|
7139
|
+
for (const dir of manifestSorted) {
|
|
7140
|
+
if (gitDirs.has(dir)) continue;
|
|
7141
|
+
if (repoRoots.some((root) => isDescendant(dir, root))) continue;
|
|
7142
|
+
repoRoots.push(dir);
|
|
7143
|
+
}
|
|
7144
|
+
repoRoots.sort((a, b) => a.localeCompare(b));
|
|
7145
|
+
return repoRoots;
|
|
7146
|
+
}
|
|
7147
|
+
async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
7148
|
+
const repoRoots = await findRepoRoots(workspaceRoot, maxDepth);
|
|
7149
|
+
return mapWithConcurrency(repoRoots, async (relDir) => {
|
|
7150
|
+
const absDir = path43.resolve(workspaceRoot, relDir);
|
|
7151
|
+
const pkg = await readJson(path43.join(absDir, "package.json"));
|
|
7152
|
+
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path43.basename(relDir === "." ? workspaceRoot : absDir);
|
|
7153
|
+
let role = "auto";
|
|
7154
|
+
try {
|
|
7155
|
+
const scan = await scanProject(absDir);
|
|
7156
|
+
if (scan.repoRoles.length > 0) role = scan.repoRoles.join("+");
|
|
7157
|
+
} catch {
|
|
7158
|
+
}
|
|
7159
|
+
return { name, path: relDir === "." ? "." : relDir, role };
|
|
7160
|
+
});
|
|
7161
|
+
}
|
|
7162
|
+
function mergeWorkspaceConfig(existing, discovered, opts = {}) {
|
|
7163
|
+
const existingRepos = existing?.repos ?? [];
|
|
7164
|
+
const byPath = new Map(existingRepos.map((r) => [r.path, r]));
|
|
7165
|
+
for (const repo of discovered) {
|
|
7166
|
+
if (!byPath.has(repo.path)) {
|
|
7167
|
+
byPath.set(repo.path, { name: repo.name, path: repo.path, role: repo.role });
|
|
7168
|
+
}
|
|
7169
|
+
}
|
|
7170
|
+
const ordered = [];
|
|
7171
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7172
|
+
for (const repo of existingRepos) {
|
|
7173
|
+
ordered.push(byPath.get(repo.path));
|
|
7174
|
+
seen.add(repo.path);
|
|
7175
|
+
}
|
|
7176
|
+
for (const repo of discovered) {
|
|
7177
|
+
if (seen.has(repo.path)) continue;
|
|
7178
|
+
ordered.push(byPath.get(repo.path));
|
|
7179
|
+
seen.add(repo.path);
|
|
7180
|
+
}
|
|
7181
|
+
return {
|
|
7182
|
+
client: opts.client ?? existing?.client ?? "unknown",
|
|
7183
|
+
repos: ordered,
|
|
7184
|
+
relationships: existing?.relationships ?? []
|
|
7185
|
+
};
|
|
7186
|
+
}
|
|
7187
|
+
function renderWorkspaceYaml(config) {
|
|
7188
|
+
return YAML2.stringify({
|
|
7189
|
+
client: config.client,
|
|
7190
|
+
repos: config.repos.map((r) => ({ name: r.name, path: r.path, role: r.role ?? "auto" })),
|
|
7191
|
+
relationships: config.relationships
|
|
7192
|
+
});
|
|
7193
|
+
}
|
|
7194
|
+
async function runDiscover(workspaceRoot, opts = {}) {
|
|
7195
|
+
const yamlPath = path43.join(workspaceRoot, "haus.workspace.yaml");
|
|
7196
|
+
const existingText = await readText(yamlPath);
|
|
7197
|
+
const existing = parseWorkspaceConfig(existingText);
|
|
7198
|
+
if (existingText && !existing) {
|
|
7199
|
+
error(
|
|
7200
|
+
"Existing haus.workspace.yaml is malformed \u2014 fix or remove it before running discover (refusing to overwrite)."
|
|
7201
|
+
);
|
|
7202
|
+
process.exitCode = 1;
|
|
7203
|
+
return;
|
|
7204
|
+
}
|
|
7205
|
+
const discovered = await discoverRepos(workspaceRoot, opts.maxDepth ?? DEFAULT_MAX_DEPTH);
|
|
7206
|
+
if (discovered.length === 0) {
|
|
7207
|
+
error("No repos discovered under the workspace root.");
|
|
7208
|
+
process.exitCode = 1;
|
|
7209
|
+
return;
|
|
7210
|
+
}
|
|
7211
|
+
const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
|
|
7212
|
+
const yamlText = renderWorkspaceYaml(merged);
|
|
7213
|
+
if (opts.write) {
|
|
7214
|
+
await writeText(yamlPath, yamlText);
|
|
7215
|
+
if (opts.json) {
|
|
7216
|
+
log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
|
|
7217
|
+
} else {
|
|
7218
|
+
log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
|
|
7219
|
+
}
|
|
7220
|
+
return;
|
|
7221
|
+
}
|
|
7222
|
+
if (opts.json) {
|
|
7223
|
+
log(JSON.stringify({ discovered, config: merged }, null, 2));
|
|
7224
|
+
return;
|
|
7225
|
+
}
|
|
7226
|
+
log("Proposed haus.workspace.yaml (run with --write to persist):\n");
|
|
7227
|
+
log(yamlText);
|
|
7228
|
+
}
|
|
7229
|
+
|
|
7230
|
+
// src/scanner/sibling-repos.ts
|
|
7231
|
+
var SIBLING_REPO_WARNING = 'Multiple sibling repos detected under this root \u2014 consider "haus workspace discover" instead of running setup-project/scan per repo.';
|
|
7232
|
+
async function hasMultipleSiblingRepos(root) {
|
|
7233
|
+
try {
|
|
7234
|
+
const repoRoots = await findRepoRoots(root);
|
|
7235
|
+
return repoRoots.filter((relDir) => relDir !== ".").length >= 2;
|
|
7236
|
+
} catch {
|
|
7237
|
+
return false;
|
|
7238
|
+
}
|
|
7239
|
+
}
|
|
7240
|
+
|
|
6613
7241
|
// src/claude/setup-core.ts
|
|
6614
7242
|
async function runSetupCore(root, opts) {
|
|
6615
|
-
const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
|
|
7243
|
+
const { json, apply, dryRun, quiet, confirm: confirm2, force } = opts;
|
|
6616
7244
|
const say = quiet ? () => {
|
|
6617
7245
|
} : log;
|
|
6618
7246
|
const scanResult = await scanProject(root);
|
|
@@ -6623,8 +7251,35 @@ async function runSetupCore(root, opts) {
|
|
|
6623
7251
|
say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
|
|
6624
7252
|
say(`Package manager: ${scanResult.packageManager}`);
|
|
6625
7253
|
}
|
|
7254
|
+
const siblingWarning = await hasMultipleSiblingRepos(root) ? [SIBLING_REPO_WARNING] : [];
|
|
6626
7255
|
const context = await readContextOrScan(root);
|
|
6627
7256
|
const recommendation = await recommend(root, context);
|
|
7257
|
+
if (recommendation.recommended.length === 0 && !force) {
|
|
7258
|
+
const warningLines2 = [
|
|
7259
|
+
.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
|
|
7260
|
+
];
|
|
7261
|
+
if (json) {
|
|
7262
|
+
say(JSON.stringify(recommendation, null, 2));
|
|
7263
|
+
} else {
|
|
7264
|
+
say("Haus recommendation ready");
|
|
7265
|
+
say("Recommended: 0");
|
|
7266
|
+
say(`Skipped: ${recommendation.skipped.length}`);
|
|
7267
|
+
say(`Repo: ${context.repoName}`);
|
|
7268
|
+
for (const warning of warningLines2) say(`- WARN: ${warning}`);
|
|
7269
|
+
}
|
|
7270
|
+
const message = "No catalog items matched this project at all (zero signal) \u2014 refusing to write recommendation.json/haus.lock.json. Re-run with `haus setup-project --force` to write anyway.";
|
|
7271
|
+
warn(message);
|
|
7272
|
+
process.exitCode = 1;
|
|
7273
|
+
return {
|
|
7274
|
+
root,
|
|
7275
|
+
repoName: context.repoName,
|
|
7276
|
+
roles: scanResult.repoRoles,
|
|
7277
|
+
recommendedCount: 0,
|
|
7278
|
+
warnings: [...warningLines2, message],
|
|
7279
|
+
hooksOk: false,
|
|
7280
|
+
written: []
|
|
7281
|
+
};
|
|
7282
|
+
}
|
|
6628
7283
|
await writeJson(hausPath(root, "recommendation.json"), recommendation);
|
|
6629
7284
|
if (json) {
|
|
6630
7285
|
say(JSON.stringify(recommendation, null, 2));
|
|
@@ -6634,7 +7289,9 @@ async function runSetupCore(root, opts) {
|
|
|
6634
7289
|
say(`Skipped: ${recommendation.skipped.length}`);
|
|
6635
7290
|
}
|
|
6636
7291
|
const hooks = await verifyProjectSettingsHooksContract(root);
|
|
6637
|
-
const warningLines = [
|
|
7292
|
+
const warningLines = [
|
|
7293
|
+
.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
|
|
7294
|
+
];
|
|
6638
7295
|
say(`Repo: ${context.repoName}`);
|
|
6639
7296
|
for (const warning of warningLines) say(`- WARN: ${warning}`);
|
|
6640
7297
|
const hooksOk = hooks.ok;
|
|
@@ -6699,6 +7356,7 @@ async function runSetupProject(options) {
|
|
|
6699
7356
|
json: options.json,
|
|
6700
7357
|
apply: !options.json,
|
|
6701
7358
|
dryRun: false,
|
|
7359
|
+
force: options.force,
|
|
6702
7360
|
confirm: () => confirm("Approve and write Claude files now?")
|
|
6703
7361
|
});
|
|
6704
7362
|
}
|
|
@@ -6706,8 +7364,8 @@ async function runSetupProject(options) {
|
|
|
6706
7364
|
// src/commands/init.ts
|
|
6707
7365
|
async function runInit(options) {
|
|
6708
7366
|
const root = process.cwd();
|
|
6709
|
-
const hausDir =
|
|
6710
|
-
const alreadyInit = await
|
|
7367
|
+
const hausDir = path44.join(root, ".haus-workflow");
|
|
7368
|
+
const alreadyInit = await fs32.pathExists(hausDir);
|
|
6711
7369
|
if (alreadyInit) {
|
|
6712
7370
|
log("Haus AI already initialized in this project.");
|
|
6713
7371
|
log("Run `haus setup-project` to reconfigure.");
|
|
@@ -6842,8 +7500,8 @@ async function runRefresh() {
|
|
|
6842
7500
|
}
|
|
6843
7501
|
|
|
6844
7502
|
// src/install/scaffold.ts
|
|
6845
|
-
import
|
|
6846
|
-
import
|
|
7503
|
+
import path45 from "node:path";
|
|
7504
|
+
import fs33 from "fs-extra";
|
|
6847
7505
|
async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
6848
7506
|
const scaffolded = [];
|
|
6849
7507
|
const skipped = [];
|
|
@@ -6856,27 +7514,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
6856
7514
|
skipped.push(name);
|
|
6857
7515
|
if (outcome === "skipped-exists") skippedExisting.push(name);
|
|
6858
7516
|
};
|
|
6859
|
-
const realRoot = await
|
|
7517
|
+
const realRoot = await fs33.realpath(catalogRoot).catch(() => null);
|
|
6860
7518
|
for (const item of items) {
|
|
6861
7519
|
if (item.type !== "config") continue;
|
|
6862
|
-
const sourcePath =
|
|
6863
|
-
const rel =
|
|
6864
|
-
const escapes = rel === "" ||
|
|
7520
|
+
const sourcePath = path45.resolve(catalogRoot, item.path);
|
|
7521
|
+
const rel = path45.relative(catalogRoot, sourcePath);
|
|
7522
|
+
const escapes = rel === "" || path45.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
|
|
6865
7523
|
if (escapes) {
|
|
6866
7524
|
warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
|
|
6867
7525
|
continue;
|
|
6868
7526
|
}
|
|
6869
|
-
const realSource = await
|
|
7527
|
+
const realSource = await fs33.realpath(sourcePath).catch(() => null);
|
|
6870
7528
|
if (!realRoot || !realSource) {
|
|
6871
7529
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
6872
7530
|
continue;
|
|
6873
7531
|
}
|
|
6874
|
-
const realRel =
|
|
6875
|
-
if (realRel !== "" && (
|
|
7532
|
+
const realRel = path45.relative(realRoot, realSource);
|
|
7533
|
+
if (realRel !== "" && (path45.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
|
|
6876
7534
|
warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
|
|
6877
7535
|
continue;
|
|
6878
7536
|
}
|
|
6879
|
-
const stat = await
|
|
7537
|
+
const stat = await fs33.lstat(sourcePath).catch(() => null);
|
|
6880
7538
|
if (!stat) {
|
|
6881
7539
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
6882
7540
|
continue;
|
|
@@ -6886,14 +7544,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
6886
7544
|
continue;
|
|
6887
7545
|
}
|
|
6888
7546
|
if (stat.isFile()) {
|
|
6889
|
-
const filename =
|
|
6890
|
-
const dest =
|
|
7547
|
+
const filename = path45.basename(sourcePath);
|
|
7548
|
+
const dest = path45.join(projectRoot, filename);
|
|
6891
7549
|
record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
|
|
6892
7550
|
} else if (stat.isDirectory()) {
|
|
6893
|
-
const entries = await
|
|
7551
|
+
const entries = await fs33.readdir(sourcePath);
|
|
6894
7552
|
for (const entry of entries) {
|
|
6895
|
-
const src =
|
|
6896
|
-
const dest =
|
|
7553
|
+
const src = path45.join(sourcePath, entry);
|
|
7554
|
+
const dest = path45.join(projectRoot, entry);
|
|
6897
7555
|
record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
|
|
6898
7556
|
}
|
|
6899
7557
|
}
|
|
@@ -6901,42 +7559,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
6901
7559
|
return { scaffolded, skipped, skippedExisting };
|
|
6902
7560
|
}
|
|
6903
7561
|
async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
|
|
6904
|
-
const destDisplay =
|
|
6905
|
-
const srcStat = await
|
|
7562
|
+
const destDisplay = path45.relative(projectRoot, dest) || path45.basename(dest);
|
|
7563
|
+
const srcStat = await fs33.lstat(src).catch(() => null);
|
|
6906
7564
|
if (!srcStat) {
|
|
6907
|
-
warn(`Skipping ${
|
|
7565
|
+
warn(`Skipping ${path45.basename(src)}: source not found`);
|
|
6908
7566
|
return "skipped-refused";
|
|
6909
7567
|
}
|
|
6910
7568
|
if (srcStat.isSymbolicLink()) {
|
|
6911
|
-
warn(`Skipping ${
|
|
7569
|
+
warn(`Skipping ${path45.basename(src)}: source is a symlink`);
|
|
6912
7570
|
return "skipped-refused";
|
|
6913
7571
|
}
|
|
6914
|
-
const exists = await
|
|
7572
|
+
const exists = await fs33.pathExists(dest);
|
|
6915
7573
|
if (exists && !opts.force) {
|
|
6916
7574
|
warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
|
|
6917
7575
|
return "skipped-exists";
|
|
6918
7576
|
}
|
|
6919
7577
|
if (opts.dryRun) {
|
|
6920
|
-
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${
|
|
7578
|
+
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path45.basename(dest)} (${itemId})`);
|
|
6921
7579
|
return "scaffolded";
|
|
6922
7580
|
}
|
|
6923
7581
|
if (exists && opts.force) {
|
|
6924
|
-
await
|
|
7582
|
+
await fs33.remove(dest);
|
|
6925
7583
|
}
|
|
6926
|
-
await
|
|
6927
|
-
await
|
|
7584
|
+
await fs33.ensureDir(path45.dirname(dest));
|
|
7585
|
+
await fs33.copy(src, dest, {
|
|
6928
7586
|
overwrite: true,
|
|
6929
7587
|
dereference: false,
|
|
6930
7588
|
// A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
|
|
6931
7589
|
filter: (s) => {
|
|
6932
7590
|
try {
|
|
6933
|
-
return !
|
|
7591
|
+
return !fs33.lstatSync(s).isSymbolicLink();
|
|
6934
7592
|
} catch {
|
|
6935
7593
|
return false;
|
|
6936
7594
|
}
|
|
6937
7595
|
}
|
|
6938
7596
|
});
|
|
6939
|
-
log(`${exists ? "Overwrote" : "Created"} ${
|
|
7597
|
+
log(`${exists ? "Overwrote" : "Created"} ${path45.basename(dest)} (${itemId})`);
|
|
6940
7598
|
return "scaffolded";
|
|
6941
7599
|
}
|
|
6942
7600
|
|
|
@@ -6981,7 +7639,8 @@ async function runScaffold(ids, options) {
|
|
|
6981
7639
|
|
|
6982
7640
|
// src/commands/scan.ts
|
|
6983
7641
|
async function runScan(options) {
|
|
6984
|
-
const
|
|
7642
|
+
const root = process.cwd();
|
|
7643
|
+
const result = await scanProject(root);
|
|
6985
7644
|
if (options.json) {
|
|
6986
7645
|
log(JSON.stringify(result, null, 2));
|
|
6987
7646
|
return;
|
|
@@ -6989,12 +7648,19 @@ async function runScan(options) {
|
|
|
6989
7648
|
log("Haus scan complete");
|
|
6990
7649
|
log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
|
|
6991
7650
|
log(`Package manager: ${result.packageManager}`);
|
|
7651
|
+
if (result.detectionStatus === "unknown") {
|
|
7652
|
+
const { isLinkedWorktree } = await resolveRoots(root);
|
|
7653
|
+
log(`- WARN: ${describeUnknownDetection(result.unsupportedSignals, isLinkedWorktree)}`);
|
|
7654
|
+
}
|
|
6992
7655
|
for (const w of result.warnings) log(`- WARN: ${w}`);
|
|
7656
|
+
if (await hasMultipleSiblingRepos(root)) {
|
|
7657
|
+
log(`- WARN: ${SIBLING_REPO_WARNING}`);
|
|
7658
|
+
}
|
|
6993
7659
|
}
|
|
6994
7660
|
|
|
6995
7661
|
// src/commands/undo.ts
|
|
6996
|
-
import
|
|
6997
|
-
import
|
|
7662
|
+
import path46 from "node:path";
|
|
7663
|
+
import fs34 from "fs-extra";
|
|
6998
7664
|
|
|
6999
7665
|
// src/claude/managed-paths.ts
|
|
7000
7666
|
var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
|
|
@@ -7009,7 +7675,7 @@ function coreManagedAbsolutePaths(root) {
|
|
|
7009
7675
|
async function collectCoreManagedPaths(root) {
|
|
7010
7676
|
const existing = [];
|
|
7011
7677
|
for (const abs of coreManagedAbsolutePaths(root)) {
|
|
7012
|
-
if (await
|
|
7678
|
+
if (await fs34.pathExists(abs)) existing.push(abs);
|
|
7013
7679
|
}
|
|
7014
7680
|
return existing;
|
|
7015
7681
|
}
|
|
@@ -7021,18 +7687,18 @@ async function collectLockTrackedPaths(root) {
|
|
|
7021
7687
|
const relPaths = row.paths ?? [];
|
|
7022
7688
|
const existingRel = [];
|
|
7023
7689
|
for (const rel of relPaths) {
|
|
7024
|
-
if (await
|
|
7690
|
+
if (await fs34.pathExists(path46.resolve(root, rel))) existingRel.push(rel);
|
|
7025
7691
|
}
|
|
7026
7692
|
if (existingRel.length === 0) continue;
|
|
7027
7693
|
if (row.hash === void 0) {
|
|
7028
|
-
removable.push(...existingRel.map((rel) =>
|
|
7694
|
+
removable.push(...existingRel.map((rel) => path46.resolve(root, rel)));
|
|
7029
7695
|
continue;
|
|
7030
7696
|
}
|
|
7031
7697
|
const currentHash = await hashInstalledPaths(root, existingRel);
|
|
7032
7698
|
if (currentHash === row.hash) {
|
|
7033
|
-
removable.push(...existingRel.map((rel) =>
|
|
7699
|
+
removable.push(...existingRel.map((rel) => path46.resolve(root, rel)));
|
|
7034
7700
|
} else {
|
|
7035
|
-
preserved.push(...existingRel.map((rel) =>
|
|
7701
|
+
preserved.push(...existingRel.map((rel) => path46.resolve(root, rel)));
|
|
7036
7702
|
log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
|
|
7037
7703
|
}
|
|
7038
7704
|
}
|
|
@@ -7040,51 +7706,51 @@ async function collectLockTrackedPaths(root) {
|
|
|
7040
7706
|
}
|
|
7041
7707
|
async function settingsHasHausContent(root) {
|
|
7042
7708
|
const settingsPath = claudePath(root, "settings.json");
|
|
7043
|
-
if (!await
|
|
7709
|
+
if (!await fs34.pathExists(settingsPath)) return false;
|
|
7044
7710
|
const settings = await readProjectSettings(root);
|
|
7045
7711
|
return settings._haus != null;
|
|
7046
7712
|
}
|
|
7047
7713
|
async function claudeMdHasHausBlock(root) {
|
|
7048
|
-
const filePath =
|
|
7049
|
-
if (!await
|
|
7050
|
-
const text = await
|
|
7714
|
+
const filePath = path46.join(root, "CLAUDE.md");
|
|
7715
|
+
if (!await fs34.pathExists(filePath)) return false;
|
|
7716
|
+
const text = await fs34.readFile(filePath, "utf8");
|
|
7051
7717
|
return text.includes(BLOCK_BEGIN);
|
|
7052
7718
|
}
|
|
7053
7719
|
async function stripProjectSettings(root) {
|
|
7054
7720
|
const settingsPath = claudePath(root, "settings.json");
|
|
7055
|
-
if (!await
|
|
7721
|
+
if (!await fs34.pathExists(settingsPath)) return false;
|
|
7056
7722
|
let settings = await readProjectSettings(root);
|
|
7057
7723
|
settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
7058
7724
|
const hasContent = Object.keys(settings).length > 0;
|
|
7059
7725
|
if (hasContent) {
|
|
7060
7726
|
await writeProjectSettings(root, settings);
|
|
7061
|
-
log(`Stripped haus rules from ${
|
|
7727
|
+
log(`Stripped haus rules from ${path46.relative(root, settingsPath)} (user settings preserved).`);
|
|
7062
7728
|
return true;
|
|
7063
7729
|
}
|
|
7064
|
-
await
|
|
7065
|
-
log(`Removed ${
|
|
7730
|
+
await fs34.remove(settingsPath);
|
|
7731
|
+
log(`Removed ${path46.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
7066
7732
|
return true;
|
|
7067
7733
|
}
|
|
7068
7734
|
async function stripRootClaudeMd(root) {
|
|
7069
|
-
const filePath =
|
|
7070
|
-
if (!await
|
|
7071
|
-
const prev = await
|
|
7735
|
+
const filePath = path46.join(root, "CLAUDE.md");
|
|
7736
|
+
if (!await fs34.pathExists(filePath)) return false;
|
|
7737
|
+
const prev = await fs34.readFile(filePath, "utf8");
|
|
7072
7738
|
if (!prev.includes(BLOCK_BEGIN)) return false;
|
|
7073
7739
|
const next = stripHausBlock(prev);
|
|
7074
7740
|
if (next.length === 0) {
|
|
7075
|
-
await
|
|
7741
|
+
await fs34.remove(filePath);
|
|
7076
7742
|
log("Removed CLAUDE.md (only contained haus import block).");
|
|
7077
7743
|
} else {
|
|
7078
|
-
await
|
|
7744
|
+
await fs34.writeFile(filePath, next, "utf8");
|
|
7079
7745
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
7080
7746
|
}
|
|
7081
7747
|
return true;
|
|
7082
7748
|
}
|
|
7083
7749
|
async function pruneDirIfEmpty(dir) {
|
|
7084
|
-
if (!await
|
|
7750
|
+
if (!await fs34.pathExists(dir)) return;
|
|
7085
7751
|
try {
|
|
7086
|
-
const entries = await
|
|
7087
|
-
if (entries.length === 0) await
|
|
7752
|
+
const entries = await fs34.readdir(dir);
|
|
7753
|
+
if (entries.length === 0) await fs34.remove(dir);
|
|
7088
7754
|
} catch {
|
|
7089
7755
|
}
|
|
7090
7756
|
}
|
|
@@ -7093,13 +7759,13 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
|
|
|
7093
7759
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
7094
7760
|
const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
|
|
7095
7761
|
for (const abs of managedAbsPaths) {
|
|
7096
|
-
if (!await
|
|
7097
|
-
const rel =
|
|
7098
|
-
const backupPath =
|
|
7099
|
-
await
|
|
7100
|
-
await
|
|
7762
|
+
if (!await fs34.pathExists(abs)) continue;
|
|
7763
|
+
const rel = path46.relative(root, abs);
|
|
7764
|
+
const backupPath = path46.join(backupRoot, rel);
|
|
7765
|
+
await fs34.ensureDir(path46.dirname(backupPath));
|
|
7766
|
+
await fs34.copy(abs, backupPath);
|
|
7101
7767
|
}
|
|
7102
|
-
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${
|
|
7768
|
+
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path46.relative(root, backupRoot)}.`);
|
|
7103
7769
|
}
|
|
7104
7770
|
async function runUndo(options) {
|
|
7105
7771
|
const root = options.root ?? process.cwd();
|
|
@@ -7112,7 +7778,7 @@ async function runUndo(options) {
|
|
|
7112
7778
|
log("Nothing to remove: no haus-managed files found in this directory.");
|
|
7113
7779
|
return;
|
|
7114
7780
|
}
|
|
7115
|
-
const relTargets = managed.map((p) =>
|
|
7781
|
+
const relTargets = managed.map((p) => path46.relative(root, p));
|
|
7116
7782
|
const summaryParts = [...relTargets];
|
|
7117
7783
|
if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
|
|
7118
7784
|
if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
|
|
@@ -7134,9 +7800,9 @@ User-owned .claude/ files will be preserved.`
|
|
|
7134
7800
|
}
|
|
7135
7801
|
await backupManagedFilesBeforeUndo(root, managed);
|
|
7136
7802
|
for (const abs of managed) {
|
|
7137
|
-
if (!await
|
|
7138
|
-
await
|
|
7139
|
-
log(`Removed ${
|
|
7803
|
+
if (!await fs34.pathExists(abs)) continue;
|
|
7804
|
+
await fs34.remove(abs);
|
|
7805
|
+
log(`Removed ${path46.relative(root, abs)}`);
|
|
7140
7806
|
}
|
|
7141
7807
|
if (stripSettings) await stripProjectSettings(root);
|
|
7142
7808
|
if (stripClaudeMd) await stripRootClaudeMd(root);
|
|
@@ -7147,8 +7813,8 @@ User-owned .claude/ files will be preserved.`
|
|
|
7147
7813
|
|
|
7148
7814
|
// src/install/uninstall.ts
|
|
7149
7815
|
import crypto3 from "node:crypto";
|
|
7150
|
-
import
|
|
7151
|
-
import
|
|
7816
|
+
import path47 from "node:path";
|
|
7817
|
+
import fs35 from "fs-extra";
|
|
7152
7818
|
async function runUninstall(options = {}) {
|
|
7153
7819
|
const { force = false } = options;
|
|
7154
7820
|
const manifest = await readManifest();
|
|
@@ -7158,7 +7824,7 @@ async function runUninstall(options = {}) {
|
|
|
7158
7824
|
return result;
|
|
7159
7825
|
}
|
|
7160
7826
|
for (const entry of manifest.files) {
|
|
7161
|
-
const exists =
|
|
7827
|
+
const exists = fs35.pathExistsSync(entry.destPath);
|
|
7162
7828
|
if (!exists) continue;
|
|
7163
7829
|
const content2 = await readText(entry.destPath);
|
|
7164
7830
|
if (content2 === void 0) continue;
|
|
@@ -7176,23 +7842,23 @@ async function runUninstall(options = {}) {
|
|
|
7176
7842
|
result.skipped.push(entry.destPath);
|
|
7177
7843
|
continue;
|
|
7178
7844
|
}
|
|
7179
|
-
await
|
|
7180
|
-
await pruneEmptyDir(
|
|
7845
|
+
await fs35.remove(entry.destPath);
|
|
7846
|
+
await pruneEmptyDir(path47.dirname(entry.destPath));
|
|
7181
7847
|
result.deleted.push(entry.destPath);
|
|
7182
7848
|
}
|
|
7183
7849
|
const settings = await readSettings();
|
|
7184
7850
|
const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
7185
7851
|
await writeSettings(stripped);
|
|
7186
7852
|
result.hooksStripped = true;
|
|
7187
|
-
const hausDir =
|
|
7853
|
+
const hausDir = path47.join(globalClaudeDir(), "haus");
|
|
7188
7854
|
const manifestPath2 = hausManifestPath();
|
|
7189
|
-
if (
|
|
7190
|
-
await
|
|
7855
|
+
if (fs35.pathExistsSync(manifestPath2)) {
|
|
7856
|
+
await fs35.remove(manifestPath2);
|
|
7191
7857
|
}
|
|
7192
|
-
if (
|
|
7858
|
+
if (fs35.pathExistsSync(hausDir)) {
|
|
7193
7859
|
try {
|
|
7194
|
-
const remaining = await
|
|
7195
|
-
if (remaining.length === 0) await
|
|
7860
|
+
const remaining = await fs35.readdir(hausDir);
|
|
7861
|
+
if (remaining.length === 0) await fs35.remove(hausDir);
|
|
7196
7862
|
} catch {
|
|
7197
7863
|
}
|
|
7198
7864
|
}
|
|
@@ -7225,11 +7891,11 @@ async function runUninstallCommand(options) {
|
|
|
7225
7891
|
}
|
|
7226
7892
|
|
|
7227
7893
|
// src/commands/validate-catalog.ts
|
|
7228
|
-
import
|
|
7894
|
+
import path49 from "node:path";
|
|
7229
7895
|
|
|
7230
7896
|
// src/catalog/validate-core.ts
|
|
7231
|
-
import
|
|
7232
|
-
import
|
|
7897
|
+
import fs36 from "node:fs";
|
|
7898
|
+
import path48 from "node:path";
|
|
7233
7899
|
var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
7234
7900
|
function auditForbiddenStacks(items) {
|
|
7235
7901
|
const failures = [];
|
|
@@ -7367,14 +8033,14 @@ function checkRequiredFrontmatter(text, label) {
|
|
|
7367
8033
|
}
|
|
7368
8034
|
function readForAudit(absPath, label, failures) {
|
|
7369
8035
|
try {
|
|
7370
|
-
return
|
|
8036
|
+
return fs36.readFileSync(absPath, "utf8");
|
|
7371
8037
|
} catch (err) {
|
|
7372
8038
|
failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
7373
8039
|
return null;
|
|
7374
8040
|
}
|
|
7375
8041
|
}
|
|
7376
8042
|
function auditTemplateContent(manifestDir, absPath, itemId) {
|
|
7377
|
-
const rel =
|
|
8043
|
+
const rel = path48.relative(manifestDir, absPath);
|
|
7378
8044
|
const failures = [];
|
|
7379
8045
|
const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
|
|
7380
8046
|
if (text === null) return failures;
|
|
@@ -7396,47 +8062,47 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
7396
8062
|
const failures = [];
|
|
7397
8063
|
for (const item of items) {
|
|
7398
8064
|
if (!item.path || !isSafeCatalogPath(item.path)) continue;
|
|
7399
|
-
const absPath =
|
|
8065
|
+
const absPath = path48.join(manifestDir, item.path);
|
|
7400
8066
|
if (item.type === "skill") {
|
|
7401
|
-
const skillMd =
|
|
7402
|
-
if (!
|
|
7403
|
-
failures.push(`${item.id}: missing ${
|
|
8067
|
+
const skillMd = path48.join(absPath, "SKILL.md");
|
|
8068
|
+
if (!fs36.existsSync(skillMd)) {
|
|
8069
|
+
failures.push(`${item.id}: missing ${path48.relative(manifestDir, skillMd)}`);
|
|
7404
8070
|
continue;
|
|
7405
8071
|
}
|
|
7406
8072
|
const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
|
|
7407
8073
|
if (text === null) continue;
|
|
7408
8074
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
|
|
7409
8075
|
failures.push(
|
|
7410
|
-
...auditForbiddenTagsInText(text, `${item.id}: ${
|
|
8076
|
+
...auditForbiddenTagsInText(text, `${item.id}: ${path48.relative(manifestDir, skillMd)}`)
|
|
7411
8077
|
);
|
|
7412
8078
|
} else if (item.type === "agent") {
|
|
7413
|
-
if (!
|
|
8079
|
+
if (!fs36.existsSync(absPath)) {
|
|
7414
8080
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
7415
8081
|
continue;
|
|
7416
8082
|
}
|
|
7417
|
-
const rel =
|
|
8083
|
+
const rel = path48.relative(manifestDir, absPath);
|
|
7418
8084
|
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
7419
8085
|
if (text === null) continue;
|
|
7420
8086
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
7421
8087
|
failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
|
|
7422
8088
|
} else if (item.type === "template") {
|
|
7423
|
-
if (!
|
|
8089
|
+
if (!fs36.existsSync(absPath)) {
|
|
7424
8090
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
7425
8091
|
continue;
|
|
7426
8092
|
}
|
|
7427
8093
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
7428
8094
|
} else if (item.type === "command") {
|
|
7429
|
-
if (!
|
|
8095
|
+
if (!fs36.existsSync(absPath)) {
|
|
7430
8096
|
failures.push(`${item.id}: missing command file ${item.path}`);
|
|
7431
8097
|
continue;
|
|
7432
8098
|
}
|
|
7433
|
-
const rel =
|
|
8099
|
+
const rel = path48.relative(manifestDir, absPath);
|
|
7434
8100
|
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
7435
8101
|
if (text === null) continue;
|
|
7436
8102
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
7437
8103
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
7438
8104
|
} else if (item.type === "config") {
|
|
7439
|
-
if (!
|
|
8105
|
+
if (!fs36.existsSync(absPath)) {
|
|
7440
8106
|
failures.push(`${item.id}: missing config path ${item.path}`);
|
|
7441
8107
|
}
|
|
7442
8108
|
}
|
|
@@ -7453,7 +8119,7 @@ var CONTENT_ROOTS = ["skills", "agents", "templates", "commands", "configs"];
|
|
|
7453
8119
|
function walkAllFiles(dir, fn, failures) {
|
|
7454
8120
|
let entries;
|
|
7455
8121
|
try {
|
|
7456
|
-
entries =
|
|
8122
|
+
entries = fs36.readdirSync(dir, { withFileTypes: true });
|
|
7457
8123
|
} catch (err) {
|
|
7458
8124
|
failures.push(
|
|
7459
8125
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -7462,7 +8128,7 @@ function walkAllFiles(dir, fn, failures) {
|
|
|
7462
8128
|
}
|
|
7463
8129
|
for (const entry of entries) {
|
|
7464
8130
|
if (entry.name === ".DS_Store") continue;
|
|
7465
|
-
const full =
|
|
8131
|
+
const full = path48.join(dir, entry.name);
|
|
7466
8132
|
if (entry.isDirectory()) walkAllFiles(full, fn, failures);
|
|
7467
8133
|
else fn(full);
|
|
7468
8134
|
}
|
|
@@ -7480,12 +8146,12 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
7480
8146
|
const failures = [];
|
|
7481
8147
|
const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
|
|
7482
8148
|
for (const dir of CONTENT_ROOTS) {
|
|
7483
|
-
const abs =
|
|
7484
|
-
if (!
|
|
8149
|
+
const abs = path48.join(manifestDir, dir);
|
|
8150
|
+
if (!fs36.existsSync(abs)) continue;
|
|
7485
8151
|
walkAllFiles(
|
|
7486
8152
|
abs,
|
|
7487
8153
|
(file) => {
|
|
7488
|
-
const relPath =
|
|
8154
|
+
const relPath = path48.relative(manifestDir, file).replace(/\\/g, "/");
|
|
7489
8155
|
if (!isPathClaimed(relPath, claimedPaths)) {
|
|
7490
8156
|
failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
|
|
7491
8157
|
}
|
|
@@ -7498,7 +8164,7 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
7498
8164
|
function walkMd(dir, fn, failures) {
|
|
7499
8165
|
let entries;
|
|
7500
8166
|
try {
|
|
7501
|
-
entries =
|
|
8167
|
+
entries = fs36.readdirSync(dir, { withFileTypes: true });
|
|
7502
8168
|
} catch (err) {
|
|
7503
8169
|
failures.push(
|
|
7504
8170
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -7506,7 +8172,7 @@ function walkMd(dir, fn, failures) {
|
|
|
7506
8172
|
return;
|
|
7507
8173
|
}
|
|
7508
8174
|
for (const entry of entries) {
|
|
7509
|
-
const full =
|
|
8175
|
+
const full = path48.join(dir, entry.name);
|
|
7510
8176
|
if (entry.isDirectory()) walkMd(full, fn, failures);
|
|
7511
8177
|
else if (entry.name.endsWith(".md")) fn(full);
|
|
7512
8178
|
}
|
|
@@ -7515,13 +8181,13 @@ function auditMarkdownContent(manifestDir, items) {
|
|
|
7515
8181
|
const failures = [];
|
|
7516
8182
|
const pathSourceMap = buildItemPathSourceMap(items);
|
|
7517
8183
|
for (const dir of ["skills", "agents", "templates", "commands"]) {
|
|
7518
|
-
const abs =
|
|
7519
|
-
if (!
|
|
8184
|
+
const abs = path48.join(manifestDir, dir);
|
|
8185
|
+
if (!fs36.existsSync(abs)) continue;
|
|
7520
8186
|
const dirType = DIR_ITEM_TYPE[dir] ?? "";
|
|
7521
8187
|
walkMd(
|
|
7522
8188
|
abs,
|
|
7523
8189
|
(file) => {
|
|
7524
|
-
const rel =
|
|
8190
|
+
const rel = path48.relative(manifestDir, file);
|
|
7525
8191
|
const text = readForAudit(file, rel, failures);
|
|
7526
8192
|
if (text === null) return;
|
|
7527
8193
|
const norm = rel.replace(/\\/g, "/");
|
|
@@ -7566,8 +8232,8 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
7566
8232
|
process.exitCode = 1;
|
|
7567
8233
|
return;
|
|
7568
8234
|
}
|
|
7569
|
-
const abs =
|
|
7570
|
-
const manifestDir =
|
|
8235
|
+
const abs = path49.resolve(process.cwd(), manifestPath2);
|
|
8236
|
+
const manifestDir = path49.dirname(abs);
|
|
7571
8237
|
const data = await readJson(abs);
|
|
7572
8238
|
if (!data?.items) {
|
|
7573
8239
|
error(`Could not read catalog manifest at ${abs}`);
|
|
@@ -7583,231 +8249,1026 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
7583
8249
|
log(`Catalog valid. ${result.items.length} items checked.`);
|
|
7584
8250
|
}
|
|
7585
8251
|
|
|
7586
|
-
// src/
|
|
7587
|
-
import
|
|
7588
|
-
import
|
|
8252
|
+
// src/workspace/worktree/add.ts
|
|
8253
|
+
import path56 from "node:path";
|
|
8254
|
+
import fs40 from "fs-extra";
|
|
7589
8255
|
|
|
7590
|
-
// src/
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
for (const repo of summaries) {
|
|
7601
|
-
for (const dep2 of repo.deps) {
|
|
7602
|
-
ownership[dep2] ??= [];
|
|
7603
|
-
ownership[dep2].push(repo.name);
|
|
7604
|
-
}
|
|
8256
|
+
// src/workspace/worktree/git-worktree.ts
|
|
8257
|
+
init_exec();
|
|
8258
|
+
async function branchExists(cwd, branch) {
|
|
8259
|
+
const result = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd });
|
|
8260
|
+
return result.exitCode === 0;
|
|
8261
|
+
}
|
|
8262
|
+
async function resolveDefaultBranch(cwd) {
|
|
8263
|
+
const symbolic = await runGit(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd });
|
|
8264
|
+
if (symbolic.exitCode === 0 && symbolic.stdout.trim()) {
|
|
8265
|
+
return { ref: symbolic.stdout.trim(), source: "origin-head" };
|
|
7605
8266
|
}
|
|
7606
|
-
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
const ownershipPath = hausPath(workspaceRoot, "dependency-ownership-map.json");
|
|
7610
|
-
const crossRepoPath = hausPath(workspaceRoot, "cross-repo-summary.md");
|
|
7611
|
-
const contextMapPath = hausPath(workspaceRoot, "workspace-context-map.json");
|
|
7612
|
-
await writeJson(summaryPath, {
|
|
7613
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7614
|
-
repos: summaries
|
|
7615
|
-
});
|
|
7616
|
-
await writeJson(ownershipPath, ownership);
|
|
7617
|
-
await writeText(
|
|
7618
|
-
crossRepoPath,
|
|
7619
|
-
`# Cross Repo Summary
|
|
7620
|
-
|
|
7621
|
-
${summaries.map(
|
|
7622
|
-
(repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
|
|
7623
|
-
).join("\n")}
|
|
7624
|
-
`
|
|
7625
|
-
);
|
|
7626
|
-
await writeJson(contextMapPath, {
|
|
7627
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7628
|
-
roles,
|
|
7629
|
-
crossRepoHints,
|
|
7630
|
-
repos: summaries.map((r) => ({
|
|
7631
|
-
name: r.name,
|
|
7632
|
-
path: r.path,
|
|
7633
|
-
roles: r.roles,
|
|
7634
|
-
packageManager: r.packageManager
|
|
7635
|
-
})),
|
|
7636
|
-
relationships
|
|
7637
|
-
});
|
|
7638
|
-
return [summaryPath, ownershipPath, crossRepoPath, contextMapPath];
|
|
8267
|
+
if (await branchExists(cwd, "main")) return { ref: "main", source: "local-main" };
|
|
8268
|
+
if (await branchExists(cwd, "master")) return { ref: "master", source: "local-master" };
|
|
8269
|
+
return void 0;
|
|
7639
8270
|
}
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
if (
|
|
7647
|
-
|
|
7648
|
-
|
|
7649
|
-
|
|
7650
|
-
|
|
7651
|
-
|
|
8271
|
+
var CHECKED_OUT_ELSEWHERE_PATTERNS = [/already checked out at/i, /already used by worktree/i];
|
|
8272
|
+
function isBranchCheckedOutElsewhereError(stderr) {
|
|
8273
|
+
return CHECKED_OUT_ELSEWHERE_PATTERNS.some((re) => re.test(stderr));
|
|
8274
|
+
}
|
|
8275
|
+
async function addWorktree(cwd, worktreePath2, branch, opts = {}) {
|
|
8276
|
+
const exists = await branchExists(cwd, branch);
|
|
8277
|
+
if (exists) {
|
|
8278
|
+
const result2 = await runGit(["worktree", "add", worktreePath2, branch], { cwd });
|
|
8279
|
+
return {
|
|
8280
|
+
ok: result2.exitCode === 0,
|
|
8281
|
+
branchAction: "checkout",
|
|
8282
|
+
stderr: result2.exitCode === 0 ? void 0 : result2.stderr,
|
|
8283
|
+
checkedOutElsewhere: result2.exitCode !== 0 && isBranchCheckedOutElsewhereError(result2.stderr)
|
|
8284
|
+
};
|
|
7652
8285
|
}
|
|
7653
|
-
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
|
|
7657
|
-
|
|
8286
|
+
let startPoint;
|
|
8287
|
+
if (opts.preferDefaultBranchFrom) {
|
|
8288
|
+
const defaultBranch = await resolveDefaultBranch(cwd);
|
|
8289
|
+
startPoint = defaultBranch?.ref;
|
|
8290
|
+
}
|
|
8291
|
+
const args = startPoint ? ["worktree", "add", worktreePath2, "-b", branch, startPoint] : ["worktree", "add", worktreePath2, "-b", branch];
|
|
8292
|
+
const result = await runGit(args, { cwd });
|
|
7658
8293
|
return {
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
8294
|
+
ok: result.exitCode === 0,
|
|
8295
|
+
branchAction: "create",
|
|
8296
|
+
startPoint,
|
|
8297
|
+
stderr: result.exitCode === 0 ? void 0 : result.stderr,
|
|
8298
|
+
checkedOutElsewhere: result.exitCode !== 0 && isBranchCheckedOutElsewhereError(result.stderr)
|
|
7662
8299
|
};
|
|
7663
8300
|
}
|
|
7664
|
-
async function
|
|
7665
|
-
|
|
8301
|
+
async function removeWorktree(ownerRepo, worktreePath2, force = true) {
|
|
8302
|
+
const args = ["worktree", "remove", worktreePath2];
|
|
8303
|
+
if (force) args.push("--force");
|
|
8304
|
+
const result = await runGit(args, { cwd: ownerRepo });
|
|
8305
|
+
return { ok: result.exitCode === 0, stderr: result.exitCode === 0 ? void 0 : result.stderr };
|
|
7666
8306
|
}
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
import path46 from "node:path";
|
|
7670
|
-
import fg4 from "fast-glob";
|
|
7671
|
-
import YAML2 from "yaml";
|
|
7672
|
-
var DEFAULT_MAX_DEPTH = 3;
|
|
7673
|
-
var REPO_MARKERS = ["**/.git", "**/package.json", "**/composer.json"];
|
|
7674
|
-
var IGNORE = [
|
|
7675
|
-
"**/node_modules/**",
|
|
7676
|
-
"**/.git/**",
|
|
7677
|
-
"**/vendor/**",
|
|
7678
|
-
"**/dist/**",
|
|
7679
|
-
"**/.haus-workflow/**"
|
|
7680
|
-
];
|
|
7681
|
-
function isDescendant(child, ancestor) {
|
|
7682
|
-
if (ancestor === ".") return child !== ".";
|
|
7683
|
-
return child === ancestor ? false : child.startsWith(`${ancestor}/`);
|
|
8307
|
+
async function pruneWorktrees(ownerRepo) {
|
|
8308
|
+
await runGit(["worktree", "prune"], { cwd: ownerRepo });
|
|
7684
8309
|
}
|
|
7685
|
-
async function
|
|
7686
|
-
const
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
8310
|
+
async function listWorktrees(ownerRepo) {
|
|
8311
|
+
const result = await runGit(["worktree", "list", "--porcelain"], { cwd: ownerRepo });
|
|
8312
|
+
if (result.exitCode !== 0) return [];
|
|
8313
|
+
const entries = [];
|
|
8314
|
+
let current = {};
|
|
8315
|
+
for (const line2 of result.stdout.split("\n")) {
|
|
8316
|
+
if (line2.startsWith("worktree ")) {
|
|
8317
|
+
if (current.path) entries.push(current);
|
|
8318
|
+
current = { path: line2.slice("worktree ".length).trim() };
|
|
8319
|
+
} else if (line2.startsWith("branch ")) {
|
|
8320
|
+
current.branch = line2.slice("branch ".length).replace("refs/heads/", "").trim();
|
|
8321
|
+
} else if (line2.startsWith("HEAD ")) {
|
|
8322
|
+
current.head = line2.slice("HEAD ".length).trim();
|
|
8323
|
+
} else if (line2 === "bare") {
|
|
8324
|
+
current.bare = true;
|
|
8325
|
+
} else if (line2 === "detached") {
|
|
8326
|
+
current.detached = true;
|
|
8327
|
+
}
|
|
8328
|
+
}
|
|
8329
|
+
if (current.path) entries.push(current);
|
|
8330
|
+
return entries;
|
|
8331
|
+
}
|
|
8332
|
+
async function currentBranch(cwd) {
|
|
8333
|
+
const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
|
|
8334
|
+
return result.exitCode === 0 ? result.stdout.trim() : void 0;
|
|
8335
|
+
}
|
|
8336
|
+
async function hasUncommittedChanges(cwd, ignorePaths = []) {
|
|
8337
|
+
const args = ["status", "--porcelain"];
|
|
8338
|
+
if (ignorePaths.length > 0) {
|
|
8339
|
+
args.push("--", ".", ...ignorePaths.map((p) => `:!${p}`));
|
|
8340
|
+
}
|
|
8341
|
+
const result = await runGit(args, { cwd });
|
|
8342
|
+
return result.exitCode === 0 && result.stdout.trim().length > 0;
|
|
8343
|
+
}
|
|
8344
|
+
async function hasUnpushedWork(cwd) {
|
|
8345
|
+
const upstream = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
|
|
8346
|
+
cwd
|
|
7696
8347
|
});
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
const
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
8348
|
+
if (upstream.exitCode === 0 && upstream.stdout.trim()) {
|
|
8349
|
+
const upstreamRef = upstream.stdout.trim();
|
|
8350
|
+
const ahead = await runGit(["rev-list", "--count", `${upstreamRef}..HEAD`], { cwd });
|
|
8351
|
+
const count = Number.parseInt(ahead.stdout.trim(), 10);
|
|
8352
|
+
if (Number.isFinite(count) && count > 0) {
|
|
8353
|
+
return { unpushed: true, reason: `${count} commit(s) ahead of ${upstreamRef}` };
|
|
8354
|
+
}
|
|
8355
|
+
return { unpushed: false };
|
|
8356
|
+
}
|
|
8357
|
+
const defaultBranch = await resolveDefaultBranch(cwd);
|
|
8358
|
+
if (!defaultBranch) return { unpushed: false };
|
|
8359
|
+
const [head, defaultSha] = await Promise.all([
|
|
8360
|
+
runGit(["rev-parse", "HEAD"], { cwd }),
|
|
8361
|
+
runGit(["rev-parse", defaultBranch.ref], { cwd })
|
|
8362
|
+
]);
|
|
8363
|
+
if (head.exitCode === 0 && defaultSha.exitCode === 0 && head.stdout.trim() === defaultSha.stdout.trim()) {
|
|
8364
|
+
return { unpushed: false };
|
|
7705
8365
|
}
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
8366
|
+
return {
|
|
8367
|
+
unpushed: true,
|
|
8368
|
+
reason: `no upstream tracking branch configured, and HEAD differs from ${defaultBranch.ref}`
|
|
8369
|
+
};
|
|
8370
|
+
}
|
|
8371
|
+
|
|
8372
|
+
// src/workspace/worktree/hydrate.ts
|
|
8373
|
+
import path52 from "node:path";
|
|
8374
|
+
import fs38 from "fs-extra";
|
|
8375
|
+
|
|
8376
|
+
// src/workspace/worktree/cow-copy.ts
|
|
8377
|
+
init_exec();
|
|
8378
|
+
import path50 from "node:path";
|
|
8379
|
+
import fs37 from "fs-extra";
|
|
8380
|
+
async function getMacFilesystemType(dir) {
|
|
8381
|
+
const df = await runCommand("df", ["-P", dir]);
|
|
8382
|
+
if (df.exitCode !== 0) return void 0;
|
|
8383
|
+
const lines = df.stdout.trim().split("\n");
|
|
8384
|
+
const dataLine = lines[lines.length - 1];
|
|
8385
|
+
const device = dataLine?.split(/\s+/)[0];
|
|
8386
|
+
if (!device) return void 0;
|
|
8387
|
+
const mount = await runCommand("mount");
|
|
8388
|
+
if (mount.exitCode !== 0) return void 0;
|
|
8389
|
+
const line2 = mount.stdout.split("\n").find((l) => l.startsWith(`${device} `));
|
|
8390
|
+
const match = line2?.match(/\(([^,)]+)/);
|
|
8391
|
+
return match?.[1]?.trim();
|
|
8392
|
+
}
|
|
8393
|
+
async function getLinuxFilesystemType(dir) {
|
|
8394
|
+
const result = await runCommand("stat", ["-f", "-c", "%T", dir]);
|
|
8395
|
+
if (result.exitCode !== 0) return void 0;
|
|
8396
|
+
return result.stdout.trim();
|
|
8397
|
+
}
|
|
8398
|
+
async function detectCowStrategy(dir) {
|
|
8399
|
+
if (process.platform === "darwin") {
|
|
8400
|
+
const fsType = await getMacFilesystemType(dir);
|
|
8401
|
+
return fsType === "apfs" ? "darwin-clonefile" : "unsupported";
|
|
8402
|
+
}
|
|
8403
|
+
if (process.platform === "linux") {
|
|
8404
|
+
const fsType = await getLinuxFilesystemType(dir);
|
|
8405
|
+
return fsType === "btrfs" || fsType === "xfs" ? "linux-reflink" : "unsupported";
|
|
8406
|
+
}
|
|
8407
|
+
return "unknown-platform";
|
|
8408
|
+
}
|
|
8409
|
+
async function cowCopyDir(src, dest) {
|
|
8410
|
+
const srcExists = await fs37.pathExists(src);
|
|
8411
|
+
if (!srcExists)
|
|
8412
|
+
return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
|
|
8413
|
+
const strategy = await detectCowStrategy(path50.dirname(dest));
|
|
8414
|
+
if (strategy === "unsupported" || strategy === "unknown-platform") {
|
|
8415
|
+
return { strategy, attempted: false, ok: false };
|
|
8416
|
+
}
|
|
8417
|
+
await fs37.ensureDir(path50.dirname(dest));
|
|
8418
|
+
const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
|
|
8419
|
+
const result = await runCommand("cp", args);
|
|
8420
|
+
return {
|
|
8421
|
+
strategy,
|
|
8422
|
+
attempted: true,
|
|
8423
|
+
ok: result.exitCode === 0,
|
|
8424
|
+
error: result.exitCode === 0 ? void 0 : result.stderr
|
|
8425
|
+
};
|
|
8426
|
+
}
|
|
8427
|
+
|
|
8428
|
+
// src/workspace/worktree/install.ts
|
|
8429
|
+
init_exec();
|
|
8430
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
8431
|
+
import path51 from "node:path";
|
|
8432
|
+
import fg5 from "fast-glob";
|
|
8433
|
+
function detectInstallPlan(signals) {
|
|
8434
|
+
if (signals.hasYarnLock) {
|
|
8435
|
+
return signals.hasPackageManagerField ? { manager: "yarn", command: "corepack", args: ["yarn", "install"] } : { manager: "yarn", command: "yarn", args: ["install"] };
|
|
7714
8436
|
}
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
8437
|
+
if (signals.hasPnpmLock) {
|
|
8438
|
+
return { manager: "pnpm", command: "pnpm", args: ["install", "--frozen-lockfile"] };
|
|
8439
|
+
}
|
|
8440
|
+
if (signals.hasPackageLock) {
|
|
8441
|
+
return { manager: "npm", command: "npm", args: ["install"] };
|
|
8442
|
+
}
|
|
8443
|
+
if (signals.hasComposerLock) {
|
|
8444
|
+
return { manager: "composer", command: "composer", args: ["install"] };
|
|
8445
|
+
}
|
|
8446
|
+
if (signals.hasDotnetProject) {
|
|
8447
|
+
return { manager: "dotnet", command: "dotnet", args: ["restore"] };
|
|
8448
|
+
}
|
|
8449
|
+
return { manager: "none" };
|
|
8450
|
+
}
|
|
8451
|
+
async function computeLockfileSignals(dir) {
|
|
8452
|
+
const hasYarnLock = existsSync3(path51.join(dir, "yarn.lock"));
|
|
8453
|
+
const hasPnpmLock = existsSync3(path51.join(dir, "pnpm-lock.yaml"));
|
|
8454
|
+
const hasPackageLock = existsSync3(path51.join(dir, "package-lock.json"));
|
|
8455
|
+
const hasComposerLock = existsSync3(path51.join(dir, "composer.lock"));
|
|
8456
|
+
const dotnetFiles = await fg5(["**/*.csproj", "**/*.sln"], {
|
|
8457
|
+
cwd: dir,
|
|
8458
|
+
onlyFiles: true,
|
|
8459
|
+
suppressErrors: true,
|
|
8460
|
+
// Consistent with this codebase's no-symlink-follow posture elsewhere
|
|
8461
|
+
// (fs.ts's findNestedRepoDirs, discover.ts's discoverRepos): a symlink
|
|
8462
|
+
// shouldn't be able to route this search outside the intended tree or
|
|
8463
|
+
// reintroduce a symlink-cycle/perf concern.
|
|
8464
|
+
followSymbolicLinks: false,
|
|
8465
|
+
ignore: ["**/node_modules/**", "**/bin/**", "**/obj/**"]
|
|
8466
|
+
});
|
|
8467
|
+
const hasDotnetProject = dotnetFiles.length > 0;
|
|
8468
|
+
let hasPackageManagerField = false;
|
|
8469
|
+
const pkgPath = path51.join(dir, "package.json");
|
|
8470
|
+
if (existsSync3(pkgPath)) {
|
|
7721
8471
|
try {
|
|
7722
|
-
const
|
|
7723
|
-
|
|
8472
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
8473
|
+
hasPackageManagerField = typeof pkg.packageManager === "string" && pkg.packageManager.length > 0;
|
|
7724
8474
|
} catch {
|
|
7725
8475
|
}
|
|
7726
|
-
|
|
7727
|
-
|
|
8476
|
+
}
|
|
8477
|
+
return {
|
|
8478
|
+
hasYarnLock,
|
|
8479
|
+
hasPackageManagerField,
|
|
8480
|
+
hasPnpmLock,
|
|
8481
|
+
hasPackageLock,
|
|
8482
|
+
hasComposerLock,
|
|
8483
|
+
hasDotnetProject
|
|
8484
|
+
};
|
|
7728
8485
|
}
|
|
7729
|
-
function
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
8486
|
+
async function runInstallPlan(dir, plan) {
|
|
8487
|
+
if (plan.manager === "none") {
|
|
8488
|
+
return { plan, ran: false, ok: true, skippedReason: "no recognized lockfile" };
|
|
8489
|
+
}
|
|
8490
|
+
const available = await commandExists(plan.command);
|
|
8491
|
+
if (!available) {
|
|
8492
|
+
return { plan, ran: false, ok: true, skippedReason: `${plan.command} not found on PATH` };
|
|
8493
|
+
}
|
|
8494
|
+
const result = await runCommand(plan.command, plan.args, { cwd: dir });
|
|
8495
|
+
return {
|
|
8496
|
+
plan,
|
|
8497
|
+
ran: true,
|
|
8498
|
+
ok: result.exitCode === 0,
|
|
8499
|
+
exitCode: result.exitCode,
|
|
8500
|
+
stderr: result.exitCode === 0 ? void 0 : result.stderr
|
|
8501
|
+
};
|
|
8502
|
+
}
|
|
8503
|
+
|
|
8504
|
+
// src/workspace/worktree/hydrate.ts
|
|
8505
|
+
var HYDRATION_TARGETS = ["node_modules", "vendor", "obj", "bin"];
|
|
8506
|
+
async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
8507
|
+
const start = Date.now();
|
|
8508
|
+
const targets = [];
|
|
8509
|
+
for (const target of HYDRATION_TARGETS) {
|
|
8510
|
+
const src = path52.join(mainMemberPath, target);
|
|
8511
|
+
const dest = path52.join(worktreeMemberPath, target);
|
|
8512
|
+
if (!await fs38.pathExists(src)) continue;
|
|
8513
|
+
const destExists = await fs38.pathExists(dest);
|
|
8514
|
+
if (!opts.force && destExists) continue;
|
|
8515
|
+
if (opts.force && destExists) {
|
|
8516
|
+
await fs38.remove(dest);
|
|
7735
8517
|
}
|
|
8518
|
+
const copy = await cowCopyDir(src, dest);
|
|
8519
|
+
targets.push({ target, copy });
|
|
7736
8520
|
}
|
|
7737
|
-
const
|
|
7738
|
-
const
|
|
7739
|
-
|
|
7740
|
-
|
|
7741
|
-
|
|
8521
|
+
const signals = await computeLockfileSignals(worktreeMemberPath);
|
|
8522
|
+
const plan = detectInstallPlan(signals);
|
|
8523
|
+
const install = await runInstallPlan(worktreeMemberPath, plan);
|
|
8524
|
+
return { targets, install, durationMs: Date.now() - start };
|
|
8525
|
+
}
|
|
8526
|
+
|
|
8527
|
+
// src/workspace/worktree/local-files.ts
|
|
8528
|
+
init_exec();
|
|
8529
|
+
import path53 from "node:path";
|
|
8530
|
+
import fs39 from "fs-extra";
|
|
8531
|
+
var MACHINE_LOCAL_FILES = [
|
|
8532
|
+
"docker-compose.local.yml",
|
|
8533
|
+
path53.join(".claude", "settings.local.json")
|
|
8534
|
+
];
|
|
8535
|
+
async function isTracked(repoRoot, relFile) {
|
|
8536
|
+
const gitPath = relFile.replace(/\\/g, "/");
|
|
8537
|
+
const result = await runGit(["ls-files", "--error-unmatch", gitPath], { cwd: repoRoot });
|
|
8538
|
+
return result.exitCode === 0;
|
|
8539
|
+
}
|
|
8540
|
+
async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
|
|
8541
|
+
const copied = [];
|
|
8542
|
+
for (const rel of MACHINE_LOCAL_FILES) {
|
|
8543
|
+
const src = path53.join(mainMemberPath, rel);
|
|
8544
|
+
if (!await fs39.pathExists(src)) continue;
|
|
8545
|
+
if (await isTracked(mainMemberPath, rel)) continue;
|
|
8546
|
+
const dest = path53.join(worktreeMemberPath, rel);
|
|
8547
|
+
await fs39.ensureDir(path53.dirname(dest));
|
|
8548
|
+
await fs39.copy(src, dest, { overwrite: true });
|
|
8549
|
+
copied.push(rel);
|
|
8550
|
+
}
|
|
8551
|
+
return copied;
|
|
8552
|
+
}
|
|
8553
|
+
|
|
8554
|
+
// src/workspace/worktree/root.ts
|
|
8555
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
8556
|
+
import path54 from "node:path";
|
|
8557
|
+
async function resolveWorkspaceForWorktree(start) {
|
|
8558
|
+
const rootInfo = await resolveRoots(start);
|
|
8559
|
+
if (!rootInfo.isGitRepo) {
|
|
8560
|
+
return { ok: false, reason: "Not inside a git repository." };
|
|
8561
|
+
}
|
|
8562
|
+
const workspaceRoot = rootInfo.mainRoot;
|
|
8563
|
+
const hasYaml = existsSync4(path54.join(workspaceRoot, WORKSPACE_FILE));
|
|
8564
|
+
const hasManifest = existsSync4(path54.join(workspaceRoot, REPOS_MANIFEST_FILE));
|
|
8565
|
+
if (!hasYaml && !hasManifest) {
|
|
8566
|
+
return {
|
|
8567
|
+
ok: false,
|
|
8568
|
+
reason: `No workspace found at ${workspaceRoot} \u2014 expected ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE}. Run \`haus workspace discover --write\` or \`haus workspace init\` first.`
|
|
8569
|
+
};
|
|
7742
8570
|
}
|
|
7743
|
-
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
8571
|
+
return { ok: true, rootInfo, workspaceRoot };
|
|
8572
|
+
}
|
|
8573
|
+
|
|
8574
|
+
// src/workspace/worktree/select-members.ts
|
|
8575
|
+
function selectMembers(members, only) {
|
|
8576
|
+
if (!only || only.length === 0) return { selected: members, unknown: [] };
|
|
8577
|
+
const wanted = new Set(only);
|
|
8578
|
+
const selected = members.filter((m) => wanted.has(m.id) || wanted.has(m.folder));
|
|
8579
|
+
const matchedKeys = /* @__PURE__ */ new Set();
|
|
8580
|
+
for (const m of selected) {
|
|
8581
|
+
if (wanted.has(m.id)) matchedKeys.add(m.id);
|
|
8582
|
+
if (wanted.has(m.folder)) matchedKeys.add(m.folder);
|
|
8583
|
+
}
|
|
8584
|
+
const unknown = only.filter((name) => !matchedKeys.has(name));
|
|
8585
|
+
return { selected, unknown };
|
|
8586
|
+
}
|
|
8587
|
+
|
|
8588
|
+
// src/workspace/worktree/state.ts
|
|
8589
|
+
import path55 from "node:path";
|
|
8590
|
+
var WORKTREE_STATE_FILE = ".haus-worktree.json";
|
|
8591
|
+
function worktreesDir(mainRoot) {
|
|
8592
|
+
return path55.join(mainRoot, ".claude", "worktrees");
|
|
8593
|
+
}
|
|
8594
|
+
function worktreePath(mainRoot, slug) {
|
|
8595
|
+
return path55.join(worktreesDir(mainRoot), slug);
|
|
8596
|
+
}
|
|
8597
|
+
async function readWorktreeState(worktreeRoot) {
|
|
8598
|
+
return readJson(path55.join(worktreeRoot, WORKTREE_STATE_FILE));
|
|
8599
|
+
}
|
|
8600
|
+
async function writeWorktreeState(worktreeRoot, state) {
|
|
8601
|
+
await writeJson(path55.join(worktreeRoot, WORKTREE_STATE_FILE), state);
|
|
8602
|
+
}
|
|
8603
|
+
|
|
8604
|
+
// src/workspace/worktree/add.ts
|
|
8605
|
+
async function runAdd(opts) {
|
|
8606
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
8607
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
8608
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
8609
|
+
const allMembers = await readMembers(rootInfo);
|
|
8610
|
+
const { selected, unknown } = selectMembers(allMembers, opts.only);
|
|
8611
|
+
if (unknown.length > 0) {
|
|
8612
|
+
return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
|
|
8613
|
+
}
|
|
8614
|
+
const branch = opts.branch ?? opts.slug;
|
|
8615
|
+
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
8616
|
+
if (await fs40.pathExists(wsWorktreePath)) {
|
|
8617
|
+
return {
|
|
8618
|
+
ok: false,
|
|
8619
|
+
error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate\` or \`remove\` first.`
|
|
8620
|
+
};
|
|
8621
|
+
}
|
|
8622
|
+
if (opts.dryRun) {
|
|
8623
|
+
return runDryRun({ workspaceRoot, wsWorktreePath, slug: opts.slug, branch, selected });
|
|
8624
|
+
}
|
|
8625
|
+
const wsOutcome = await addWorktree(workspaceRoot, wsWorktreePath, branch);
|
|
8626
|
+
if (!wsOutcome.ok) {
|
|
8627
|
+
return {
|
|
8628
|
+
ok: false,
|
|
8629
|
+
error: wsOutcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in the workspace repo \u2014 pick a different --branch.` : `Failed to create the workspace worktree: ${wsOutcome.stderr ?? "unknown error"}`
|
|
8630
|
+
};
|
|
8631
|
+
}
|
|
8632
|
+
const memberResults = [];
|
|
8633
|
+
for (const member of selected) {
|
|
8634
|
+
const start = Date.now();
|
|
8635
|
+
const memberWorktreePath = path56.join(wsWorktreePath, member.folder);
|
|
8636
|
+
const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
|
|
8637
|
+
preferDefaultBranchFrom: true
|
|
8638
|
+
});
|
|
8639
|
+
if (!outcome.ok) {
|
|
8640
|
+
memberResults.push({
|
|
8641
|
+
member: member.id,
|
|
8642
|
+
folder: member.folder,
|
|
8643
|
+
status: "failed",
|
|
8644
|
+
branch,
|
|
8645
|
+
branchAction: outcome.branchAction,
|
|
8646
|
+
error: outcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in ${member.id} \u2014 pick a different --branch.` : outcome.stderr ?? "unknown error",
|
|
8647
|
+
durationMs: Date.now() - start
|
|
8648
|
+
});
|
|
8649
|
+
continue;
|
|
8650
|
+
}
|
|
8651
|
+
memberResults.push({
|
|
8652
|
+
member: member.id,
|
|
8653
|
+
folder: member.folder,
|
|
8654
|
+
status: "ok",
|
|
8655
|
+
branch,
|
|
8656
|
+
branchAction: outcome.branchAction,
|
|
8657
|
+
startPoint: outcome.startPoint,
|
|
8658
|
+
durationMs: Date.now() - start
|
|
8659
|
+
});
|
|
7747
8660
|
}
|
|
8661
|
+
const okResults = memberResults.filter((m) => m.status === "ok");
|
|
8662
|
+
await mapWithConcurrency(okResults, async (result) => {
|
|
8663
|
+
const member = selected.find((m) => m.id === result.member);
|
|
8664
|
+
if (!member) return;
|
|
8665
|
+
const memberWorktreePath = path56.join(wsWorktreePath, member.folder);
|
|
8666
|
+
const hydrationStart = Date.now();
|
|
8667
|
+
if (opts.hydrate !== false) {
|
|
8668
|
+
result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
|
|
8669
|
+
}
|
|
8670
|
+
result.localFilesCopied = await copyMachineLocalFiles(member.absPath, memberWorktreePath);
|
|
8671
|
+
result.durationMs += Date.now() - hydrationStart;
|
|
8672
|
+
});
|
|
8673
|
+
const stateMembers = okResults.map((m) => ({
|
|
8674
|
+
id: m.member,
|
|
8675
|
+
folder: m.folder,
|
|
8676
|
+
branch: m.branch,
|
|
8677
|
+
absPath: selected.find((s) => s.id === m.member)?.absPath
|
|
8678
|
+
}));
|
|
8679
|
+
await writeWorktreeState(wsWorktreePath, {
|
|
8680
|
+
slug: opts.slug,
|
|
8681
|
+
branch,
|
|
8682
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8683
|
+
members: stateMembers
|
|
8684
|
+
});
|
|
8685
|
+
const anyFailed = memberResults.some((m) => m.status === "failed");
|
|
7748
8686
|
return {
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
8687
|
+
ok: !anyFailed,
|
|
8688
|
+
dryRun: false,
|
|
8689
|
+
workspaceRoot,
|
|
8690
|
+
slug: opts.slug,
|
|
8691
|
+
branch,
|
|
8692
|
+
worktreePath: wsWorktreePath,
|
|
8693
|
+
workspaceWorktree: { status: "ok", branchAction: wsOutcome.branchAction },
|
|
8694
|
+
members: memberResults
|
|
7752
8695
|
};
|
|
7753
8696
|
}
|
|
7754
|
-
function
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
8697
|
+
async function runDryRun(args) {
|
|
8698
|
+
const { workspaceRoot, wsWorktreePath, slug, branch, selected } = args;
|
|
8699
|
+
const wsBranchExists = await branchExists(workspaceRoot, branch);
|
|
8700
|
+
const members = [];
|
|
8701
|
+
for (const member of selected) {
|
|
8702
|
+
const exists = await branchExists(member.absPath, branch);
|
|
8703
|
+
let startPoint;
|
|
8704
|
+
if (!exists) {
|
|
8705
|
+
const def = await resolveDefaultBranch(member.absPath);
|
|
8706
|
+
startPoint = def?.ref;
|
|
8707
|
+
}
|
|
8708
|
+
members.push({
|
|
8709
|
+
member: member.id,
|
|
8710
|
+
folder: member.folder,
|
|
8711
|
+
status: "planned",
|
|
8712
|
+
branch,
|
|
8713
|
+
branchAction: exists ? "checkout" : "create",
|
|
8714
|
+
startPoint,
|
|
8715
|
+
durationMs: 0
|
|
8716
|
+
});
|
|
8717
|
+
}
|
|
8718
|
+
return {
|
|
8719
|
+
ok: true,
|
|
8720
|
+
dryRun: true,
|
|
8721
|
+
workspaceRoot,
|
|
8722
|
+
slug,
|
|
8723
|
+
branch,
|
|
8724
|
+
worktreePath: wsWorktreePath,
|
|
8725
|
+
workspaceWorktree: { status: "planned", branchAction: wsBranchExists ? "checkout" : "create" },
|
|
8726
|
+
members
|
|
8727
|
+
};
|
|
7760
8728
|
}
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
|
|
7765
|
-
|
|
8729
|
+
|
|
8730
|
+
// src/workspace/worktree/doctor.ts
|
|
8731
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
8732
|
+
import path57 from "node:path";
|
|
8733
|
+
var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
|
|
8734
|
+
function looksHydrated(memberPath) {
|
|
8735
|
+
return HYDRATION_MARKERS.some((marker) => existsSync5(path57.join(memberPath, marker)));
|
|
8736
|
+
}
|
|
8737
|
+
async function runDoctor2() {
|
|
8738
|
+
const rootInfo = await resolveRoots();
|
|
8739
|
+
if (!rootInfo.isGitRepo) {
|
|
8740
|
+
return {
|
|
8741
|
+
isWorkspace: false,
|
|
8742
|
+
inWorkspaceWorktree: false,
|
|
8743
|
+
members: [],
|
|
8744
|
+
orphans: [],
|
|
8745
|
+
problems: ["Not inside a git repository."]
|
|
8746
|
+
};
|
|
8747
|
+
}
|
|
8748
|
+
const workspaceRoot = rootInfo.mainRoot;
|
|
8749
|
+
const isWorkspace = existsSync5(path57.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path57.join(workspaceRoot, REPOS_MANIFEST_FILE));
|
|
8750
|
+
if (!isWorkspace) {
|
|
8751
|
+
return {
|
|
8752
|
+
isWorkspace: false,
|
|
8753
|
+
inWorkspaceWorktree: false,
|
|
8754
|
+
members: [],
|
|
8755
|
+
orphans: [],
|
|
8756
|
+
problems: [
|
|
8757
|
+
`Not a haus workspace \u2014 no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
|
|
8758
|
+
]
|
|
8759
|
+
};
|
|
8760
|
+
}
|
|
8761
|
+
const wsWorktreesDir = worktreesDir(workspaceRoot);
|
|
8762
|
+
const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path57.sep}`);
|
|
8763
|
+
const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
|
|
8764
|
+
const members = await readMembers(rootInfo);
|
|
8765
|
+
const problems = [];
|
|
8766
|
+
const memberChecks = [];
|
|
8767
|
+
let slug;
|
|
8768
|
+
if (inWorkspaceWorktree) {
|
|
8769
|
+
const wtPath = rootInfo.repoRoot;
|
|
8770
|
+
slug = rootInfo.worktreeName ?? path57.basename(wtPath);
|
|
8771
|
+
const state = await readWorktreeState(wtPath);
|
|
8772
|
+
for (const member of members) {
|
|
8773
|
+
const memberPath = path57.join(wtPath, member.folder);
|
|
8774
|
+
const materialized = existsSync5(memberPath);
|
|
8775
|
+
const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
|
|
8776
|
+
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
8777
|
+
const branchMismatch = Boolean(
|
|
8778
|
+
expectedBranch && actualBranch && expectedBranch !== actualBranch
|
|
8779
|
+
);
|
|
8780
|
+
const hydrated = materialized && looksHydrated(memberPath);
|
|
8781
|
+
memberChecks.push({
|
|
8782
|
+
id: member.id,
|
|
8783
|
+
folder: member.folder,
|
|
8784
|
+
materialized,
|
|
8785
|
+
expectedBranch,
|
|
8786
|
+
actualBranch,
|
|
8787
|
+
branchMismatch,
|
|
8788
|
+
hydrated
|
|
8789
|
+
});
|
|
8790
|
+
if (!materialized) problems.push(`${member.id}: not materialized in this worktree.`);
|
|
8791
|
+
else if (branchMismatch) {
|
|
8792
|
+
problems.push(`${member.id}: on branch "${actualBranch}", expected "${expectedBranch}".`);
|
|
8793
|
+
} else if (!hydrated) {
|
|
8794
|
+
problems.push(
|
|
8795
|
+
`${member.id}: materialized but not hydrated (no node_modules/vendor/obj/bin). Run \`haus workspace worktree hydrate\` to fix.`
|
|
8796
|
+
);
|
|
8797
|
+
}
|
|
8798
|
+
}
|
|
8799
|
+
}
|
|
8800
|
+
const orphans = [];
|
|
8801
|
+
for (const member of members) {
|
|
8802
|
+
const entries = await listWorktrees(member.absPath);
|
|
8803
|
+
for (const entry of entries) {
|
|
8804
|
+
const entryPath = path57.normalize(entry.path);
|
|
8805
|
+
const marker = `${path57.sep}.claude${path57.sep}worktrees${path57.sep}`;
|
|
8806
|
+
const idx = entryPath.indexOf(marker);
|
|
8807
|
+
if (idx === -1) continue;
|
|
8808
|
+
const rest = entryPath.slice(idx + marker.length);
|
|
8809
|
+
const slugName = rest.split(path57.sep)[0];
|
|
8810
|
+
if (!slugName) continue;
|
|
8811
|
+
if (!existsSync5(path57.join(wsWorktreesDir, slugName))) {
|
|
8812
|
+
orphans.push(
|
|
8813
|
+
`${member.id}: worktree registered at ${entry.path} but ${path57.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
|
|
8814
|
+
);
|
|
8815
|
+
}
|
|
8816
|
+
}
|
|
8817
|
+
}
|
|
8818
|
+
if (orphans.length > 0) problems.push(...orphans);
|
|
8819
|
+
return { isWorkspace: true, inWorkspaceWorktree, slug, members: memberChecks, orphans, problems };
|
|
8820
|
+
}
|
|
8821
|
+
|
|
8822
|
+
// src/workspace/worktree/hydrate-command.ts
|
|
8823
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
8824
|
+
import path58 from "node:path";
|
|
8825
|
+
async function runHydrateWorktree(opts) {
|
|
8826
|
+
const rootInfo = await resolveRoots();
|
|
8827
|
+
if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
|
|
8828
|
+
return {
|
|
8829
|
+
ok: false,
|
|
8830
|
+
error: "Run this from inside a workspace worktree created by `haus workspace worktree add` (.claude/worktrees/<slug>)."
|
|
8831
|
+
};
|
|
8832
|
+
}
|
|
8833
|
+
const workspaceRoot = rootInfo.mainRoot;
|
|
8834
|
+
const wtPath = rootInfo.repoRoot;
|
|
8835
|
+
if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path58.sep}`)) {
|
|
8836
|
+
return {
|
|
8837
|
+
ok: false,
|
|
8838
|
+
error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
|
|
8839
|
+
};
|
|
8840
|
+
}
|
|
8841
|
+
const members = await readMembers(rootInfo);
|
|
8842
|
+
const { selected, unknown } = selectMembers(members, opts.only);
|
|
8843
|
+
if (unknown.length > 0) {
|
|
8844
|
+
return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
|
|
8845
|
+
}
|
|
8846
|
+
const state = await readWorktreeState(wtPath);
|
|
8847
|
+
const slug = state?.slug ?? path58.basename(wtPath);
|
|
8848
|
+
if (opts.dryRun) {
|
|
8849
|
+
const results2 = selected.map((member) => {
|
|
8850
|
+
const memberPath = path58.join(wtPath, member.folder);
|
|
8851
|
+
return {
|
|
8852
|
+
member: member.id,
|
|
8853
|
+
folder: member.folder,
|
|
8854
|
+
status: existsSync6(memberPath) ? "planned" : "skipped",
|
|
8855
|
+
error: existsSync6(memberPath) ? void 0 : "not materialized in this worktree"
|
|
8856
|
+
};
|
|
8857
|
+
});
|
|
8858
|
+
return { ok: true, dryRun: true, slug, results: results2 };
|
|
8859
|
+
}
|
|
8860
|
+
const results = await mapWithConcurrency(
|
|
8861
|
+
selected,
|
|
8862
|
+
async (member) => {
|
|
8863
|
+
const memberPath = path58.join(wtPath, member.folder);
|
|
8864
|
+
if (!existsSync6(memberPath)) {
|
|
8865
|
+
return {
|
|
8866
|
+
member: member.id,
|
|
8867
|
+
folder: member.folder,
|
|
8868
|
+
status: "skipped",
|
|
8869
|
+
error: "not materialized in this worktree"
|
|
8870
|
+
};
|
|
8871
|
+
}
|
|
8872
|
+
try {
|
|
8873
|
+
const hydration = await hydrateMember(member.absPath, memberPath, { force: opts.force });
|
|
8874
|
+
return { member: member.id, folder: member.folder, status: "ok", hydration };
|
|
8875
|
+
} catch (err) {
|
|
8876
|
+
return {
|
|
8877
|
+
member: member.id,
|
|
8878
|
+
folder: member.folder,
|
|
8879
|
+
status: "failed",
|
|
8880
|
+
error: err instanceof Error ? err.message : String(err)
|
|
8881
|
+
};
|
|
8882
|
+
}
|
|
8883
|
+
}
|
|
8884
|
+
);
|
|
8885
|
+
return { ok: results.every((r) => r.status !== "failed"), dryRun: false, slug, results };
|
|
8886
|
+
}
|
|
8887
|
+
|
|
8888
|
+
// src/workspace/worktree/list.ts
|
|
8889
|
+
import path59 from "node:path";
|
|
8890
|
+
import fs41 from "fs-extra";
|
|
8891
|
+
async function runList2() {
|
|
8892
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
8893
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
8894
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
8895
|
+
const dir = worktreesDir(workspaceRoot);
|
|
8896
|
+
if (!await fs41.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
|
|
8897
|
+
const entries = await fs41.readdir(dir, { withFileTypes: true });
|
|
8898
|
+
const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
8899
|
+
const configuredMembers = await readMembers(rootInfo);
|
|
8900
|
+
const worktrees = [];
|
|
8901
|
+
for (const slug of slugs) {
|
|
8902
|
+
const wtPath = path59.join(dir, slug);
|
|
8903
|
+
const state = await readWorktreeState(wtPath);
|
|
8904
|
+
const members = [];
|
|
8905
|
+
const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
|
|
8906
|
+
for (const m of source) {
|
|
8907
|
+
const memberPath = path59.join(wtPath, m.folder);
|
|
8908
|
+
const materialized = await fs41.pathExists(memberPath);
|
|
8909
|
+
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
8910
|
+
members.push({
|
|
8911
|
+
id: m.id,
|
|
8912
|
+
folder: m.folder,
|
|
8913
|
+
expectedBranch: m.branch,
|
|
8914
|
+
materialized,
|
|
8915
|
+
actualBranch
|
|
8916
|
+
});
|
|
8917
|
+
}
|
|
8918
|
+
worktrees.push({
|
|
8919
|
+
slug,
|
|
8920
|
+
branch: state?.branch ?? await currentBranch(wtPath) ?? "unknown",
|
|
8921
|
+
createdAt: state?.createdAt,
|
|
8922
|
+
members
|
|
8923
|
+
});
|
|
8924
|
+
}
|
|
8925
|
+
return { ok: true, workspaceRoot, worktrees };
|
|
8926
|
+
}
|
|
8927
|
+
|
|
8928
|
+
// src/workspace/worktree/remove.ts
|
|
8929
|
+
import path60 from "node:path";
|
|
8930
|
+
import fs42 from "fs-extra";
|
|
8931
|
+
async function runRemove(opts) {
|
|
8932
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
8933
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
8934
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
8935
|
+
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
8936
|
+
if (!await fs42.pathExists(wsWorktreePath)) {
|
|
8937
|
+
return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
|
|
8938
|
+
}
|
|
8939
|
+
const state = await readWorktreeState(wsWorktreePath);
|
|
8940
|
+
const allMembers = await readMembers(rootInfo);
|
|
8941
|
+
const stateMembers = state?.members.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath })) ?? allMembers.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath }));
|
|
8942
|
+
const targets = [
|
|
8943
|
+
{ repo: "(workspace)", ownerRepo: workspaceRoot, path: wsWorktreePath }
|
|
8944
|
+
];
|
|
8945
|
+
const unresolvedDroppedMembers = [];
|
|
8946
|
+
for (const stateMember of stateMembers) {
|
|
8947
|
+
const configMember = allMembers.find((m) => m.folder === stateMember.folder);
|
|
8948
|
+
const ownerRepo = configMember?.absPath ?? stateMember.absPath;
|
|
8949
|
+
if (!ownerRepo) {
|
|
8950
|
+
if (!configMember) unresolvedDroppedMembers.push(stateMember.folder);
|
|
8951
|
+
continue;
|
|
8952
|
+
}
|
|
8953
|
+
targets.push({
|
|
8954
|
+
repo: configMember?.id ?? stateMember.id,
|
|
8955
|
+
ownerRepo,
|
|
8956
|
+
path: path60.join(wsWorktreePath, stateMember.folder)
|
|
8957
|
+
});
|
|
8958
|
+
}
|
|
8959
|
+
const existingTargets = [];
|
|
8960
|
+
for (const target of targets) {
|
|
8961
|
+
if (await fs42.pathExists(target.path)) existingTargets.push(target);
|
|
8962
|
+
}
|
|
8963
|
+
if (!opts.force) {
|
|
8964
|
+
const blockers = [];
|
|
8965
|
+
for (const folder of unresolvedDroppedMembers) {
|
|
8966
|
+
blockers.push({
|
|
8967
|
+
repo: folder,
|
|
8968
|
+
reason: "no longer in the workspace config and this worktree predates absPath tracking \u2014 cannot verify for uncommitted work or unregister its git worktree; removing anyway will delete its directory here but leak the registration in whatever repo it came from (run `git worktree prune` there manually afterward)"
|
|
8969
|
+
});
|
|
8970
|
+
}
|
|
8971
|
+
for (const target of existingTargets) {
|
|
8972
|
+
const ignorePaths = target.repo === "(workspace)" ? [WORKTREE_STATE_FILE, ...stateMembers.map((m) => m.folder)] : [];
|
|
8973
|
+
if (await hasUncommittedChanges(target.path, ignorePaths)) {
|
|
8974
|
+
blockers.push({ repo: target.repo, reason: "uncommitted changes" });
|
|
8975
|
+
continue;
|
|
8976
|
+
}
|
|
8977
|
+
const unpushed = await hasUnpushedWork(target.path);
|
|
8978
|
+
if (unpushed.unpushed) blockers.push({ repo: target.repo, reason: unpushed.reason });
|
|
8979
|
+
}
|
|
8980
|
+
if (blockers.length > 0) return { ok: false, blocked: true, blockers };
|
|
8981
|
+
}
|
|
8982
|
+
if (opts.dryRun) {
|
|
8983
|
+
return {
|
|
8984
|
+
ok: true,
|
|
8985
|
+
dryRun: true,
|
|
8986
|
+
slug: opts.slug,
|
|
8987
|
+
wouldRemove: existingTargets.map((t) => t.repo)
|
|
8988
|
+
};
|
|
8989
|
+
}
|
|
8990
|
+
const removed = [];
|
|
8991
|
+
const failed = [];
|
|
8992
|
+
for (const folder of unresolvedDroppedMembers) {
|
|
8993
|
+
failed.push({
|
|
8994
|
+
repo: folder,
|
|
8995
|
+
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"
|
|
8996
|
+
});
|
|
8997
|
+
}
|
|
8998
|
+
const memberTargets = existingTargets.filter((t) => t.repo !== "(workspace)");
|
|
8999
|
+
for (const target of memberTargets) {
|
|
9000
|
+
const result = await removeWorktree(target.ownerRepo, target.path);
|
|
9001
|
+
if (result.ok) removed.push(target.repo);
|
|
9002
|
+
else failed.push({ repo: target.repo, error: result.stderr ?? "unknown error" });
|
|
9003
|
+
await pruneWorktrees(target.ownerRepo);
|
|
9004
|
+
}
|
|
9005
|
+
const wsTarget = existingTargets.find((t) => t.repo === "(workspace)");
|
|
9006
|
+
if (wsTarget) {
|
|
9007
|
+
const wsResult = await removeWorktree(workspaceRoot, wsWorktreePath);
|
|
9008
|
+
if (wsResult.ok) removed.push("(workspace)");
|
|
9009
|
+
else failed.push({ repo: "(workspace)", error: wsResult.stderr ?? "unknown error" });
|
|
9010
|
+
await pruneWorktrees(workspaceRoot);
|
|
9011
|
+
}
|
|
9012
|
+
if (await fs42.pathExists(wsWorktreePath)) {
|
|
9013
|
+
await fs42.remove(wsWorktreePath);
|
|
9014
|
+
}
|
|
9015
|
+
return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
|
|
9016
|
+
}
|
|
9017
|
+
|
|
9018
|
+
// src/commands/workspace/worktree.ts
|
|
9019
|
+
function normalizeOnly(only) {
|
|
9020
|
+
if (!only) return void 0;
|
|
9021
|
+
const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
|
|
9022
|
+
const cleaned = list.map((s) => s.trim()).filter(Boolean);
|
|
9023
|
+
return cleaned.length > 0 ? cleaned : void 0;
|
|
9024
|
+
}
|
|
9025
|
+
function describeInstall(hydration) {
|
|
9026
|
+
if (hydration.install.plan.manager === "none") return "no install step (no recognized lockfile)";
|
|
9027
|
+
if (!hydration.install.ran)
|
|
9028
|
+
return `install skipped (${hydration.install.skippedReason ?? "unavailable"})`;
|
|
9029
|
+
return hydration.install.ok ? `hydrated via ${hydration.install.plan.manager}` : `install FAILED via ${hydration.install.plan.manager}`;
|
|
9030
|
+
}
|
|
9031
|
+
async function handleAdd(opts) {
|
|
9032
|
+
if (!opts.slug) {
|
|
9033
|
+
error("Usage: haus workspace worktree add <slug>");
|
|
9034
|
+
process.exitCode = 1;
|
|
9035
|
+
return;
|
|
9036
|
+
}
|
|
9037
|
+
const addOpts = {
|
|
9038
|
+
slug: opts.slug,
|
|
9039
|
+
branch: opts.branch,
|
|
9040
|
+
only: normalizeOnly(opts.only),
|
|
9041
|
+
hydrate: opts.hydrate !== false,
|
|
9042
|
+
dryRun: Boolean(opts.dryRun)
|
|
9043
|
+
};
|
|
9044
|
+
const result = await runAdd(addOpts);
|
|
9045
|
+
if ("error" in result) {
|
|
9046
|
+
error(result.error);
|
|
9047
|
+
process.exitCode = 1;
|
|
9048
|
+
return;
|
|
9049
|
+
}
|
|
9050
|
+
if (result.dryRun) {
|
|
9051
|
+
log(
|
|
9052
|
+
`[dry-run] Would create the workspace worktree at ${result.worktreePath} on branch "${result.branch}" (${result.workspaceWorktree.branchAction}).`
|
|
9053
|
+
);
|
|
9054
|
+
for (const m of result.members) {
|
|
9055
|
+
log(
|
|
9056
|
+
`[dry-run] ${m.member} (${m.folder}): ${m.branchAction}${m.startPoint ? ` from ${m.startPoint}` : ""}`
|
|
9057
|
+
);
|
|
9058
|
+
}
|
|
9059
|
+
return;
|
|
9060
|
+
}
|
|
9061
|
+
log(
|
|
9062
|
+
`Workspace worktree "${result.slug}" ready at ${result.worktreePath} (branch "${result.branch}", ${result.workspaceWorktree.branchAction}).`
|
|
9063
|
+
);
|
|
9064
|
+
for (const m of result.members) {
|
|
9065
|
+
if (m.status === "ok") {
|
|
9066
|
+
const hydrationNote = m.hydration ? describeInstall(m.hydration) : "not hydrated (--no-hydrate)";
|
|
9067
|
+
log(` ${m.member}: ${m.branchAction} "${m.branch}" \u2014 ${hydrationNote} (${m.durationMs}ms)`);
|
|
9068
|
+
} else {
|
|
9069
|
+
warn(` ${m.member}: FAILED \u2014 ${m.error}`);
|
|
9070
|
+
}
|
|
9071
|
+
}
|
|
9072
|
+
if (!result.ok) {
|
|
9073
|
+
const failedCount = result.members.filter((m) => m.status === "failed").length;
|
|
7766
9074
|
error(
|
|
7767
|
-
|
|
9075
|
+
`${failedCount} member(s) failed to materialize \u2014 the worktree is partially built. See the per-member detail above for exactly what exists.`
|
|
7768
9076
|
);
|
|
7769
9077
|
process.exitCode = 1;
|
|
9078
|
+
}
|
|
9079
|
+
}
|
|
9080
|
+
async function handleHydrate(opts) {
|
|
9081
|
+
const hydrateOpts = {
|
|
9082
|
+
only: normalizeOnly(opts.only),
|
|
9083
|
+
force: Boolean(opts.force),
|
|
9084
|
+
dryRun: Boolean(opts.dryRun)
|
|
9085
|
+
};
|
|
9086
|
+
const result = await runHydrateWorktree(hydrateOpts);
|
|
9087
|
+
if (!result.ok && "error" in result) {
|
|
9088
|
+
error(result.error);
|
|
9089
|
+
process.exitCode = 1;
|
|
7770
9090
|
return;
|
|
7771
9091
|
}
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
9092
|
+
if (result.dryRun) {
|
|
9093
|
+
log(`[dry-run] Would hydrate "${result.slug}":`);
|
|
9094
|
+
for (const r of result.results)
|
|
9095
|
+
log(`[dry-run] ${r.member}: ${r.status}${r.error ? ` (${r.error})` : ""}`);
|
|
9096
|
+
return;
|
|
9097
|
+
}
|
|
9098
|
+
log(`Hydrated "${result.slug}":`);
|
|
9099
|
+
for (const r of result.results) {
|
|
9100
|
+
if (r.status === "ok" && r.hydration) log(` ${r.member}: ${describeInstall(r.hydration)}`);
|
|
9101
|
+
else warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
|
|
9102
|
+
}
|
|
9103
|
+
if (!result.ok) process.exitCode = 1;
|
|
9104
|
+
}
|
|
9105
|
+
async function handleRemove(opts) {
|
|
9106
|
+
if (!opts.slug) {
|
|
9107
|
+
error("Usage: haus workspace worktree remove <slug>");
|
|
7775
9108
|
process.exitCode = 1;
|
|
7776
9109
|
return;
|
|
7777
9110
|
}
|
|
7778
|
-
const
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
9111
|
+
const removeOpts = {
|
|
9112
|
+
slug: opts.slug,
|
|
9113
|
+
force: Boolean(opts.force),
|
|
9114
|
+
dryRun: Boolean(opts.dryRun)
|
|
9115
|
+
};
|
|
9116
|
+
const result = await runRemove(removeOpts);
|
|
9117
|
+
if (!result.ok && "error" in result) {
|
|
9118
|
+
error(result.error);
|
|
9119
|
+
process.exitCode = 1;
|
|
9120
|
+
return;
|
|
9121
|
+
}
|
|
9122
|
+
if (!result.ok && "blocked" in result && result.blocked) {
|
|
9123
|
+
error(`Refusing to remove "${opts.slug}" \u2014 uncommitted or unpushed work found:`);
|
|
9124
|
+
for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
|
|
9125
|
+
error("Re-run with --force to remove anyway.");
|
|
9126
|
+
process.exitCode = 1;
|
|
9127
|
+
return;
|
|
9128
|
+
}
|
|
9129
|
+
if ("dryRun" in result && result.dryRun) {
|
|
9130
|
+
log(`[dry-run] Would remove: ${result.wouldRemove.join(", ") || "(nothing)"}`);
|
|
9131
|
+
return;
|
|
9132
|
+
}
|
|
9133
|
+
if ("removed" in result) {
|
|
9134
|
+
log(`Removed: ${result.removed.join(", ") || "(nothing)"}`);
|
|
9135
|
+
if (result.failed.length > 0) {
|
|
9136
|
+
for (const f of result.failed) error(` Failed to remove ${f.repo}: ${f.error}`);
|
|
9137
|
+
process.exitCode = 1;
|
|
7786
9138
|
}
|
|
9139
|
+
}
|
|
9140
|
+
}
|
|
9141
|
+
async function handleList() {
|
|
9142
|
+
const result = await runList2();
|
|
9143
|
+
if (!result.ok) {
|
|
9144
|
+
error(result.error);
|
|
9145
|
+
process.exitCode = 1;
|
|
7787
9146
|
return;
|
|
7788
9147
|
}
|
|
7789
|
-
if (
|
|
7790
|
-
log(
|
|
9148
|
+
if (result.worktrees.length === 0) {
|
|
9149
|
+
log("No workspace worktrees under .claude/worktrees/.");
|
|
7791
9150
|
return;
|
|
7792
9151
|
}
|
|
7793
|
-
|
|
7794
|
-
|
|
9152
|
+
for (const wt of result.worktrees) {
|
|
9153
|
+
log(`${wt.slug} (branch: ${wt.branch})`);
|
|
9154
|
+
for (const m of wt.members) {
|
|
9155
|
+
const status = m.materialized ? `ok, branch ${m.actualBranch ?? "unknown"}` : "MISSING";
|
|
9156
|
+
log(` ${m.id}: ${status}`);
|
|
9157
|
+
}
|
|
9158
|
+
}
|
|
9159
|
+
}
|
|
9160
|
+
async function handleDoctor(opts) {
|
|
9161
|
+
try {
|
|
9162
|
+
const report = await runDoctor2();
|
|
9163
|
+
if (!report.isWorkspace) {
|
|
9164
|
+
if (!opts.fromHook) {
|
|
9165
|
+
warn(report.problems[0] ?? "Not a haus workspace.");
|
|
9166
|
+
process.exitCode = 1;
|
|
9167
|
+
}
|
|
9168
|
+
return;
|
|
9169
|
+
}
|
|
9170
|
+
if (report.problems.length === 0) {
|
|
9171
|
+
log(
|
|
9172
|
+
report.inWorkspaceWorktree ? `Workspace worktree "${report.slug}" looks healthy.` : "No issues found."
|
|
9173
|
+
);
|
|
9174
|
+
return;
|
|
9175
|
+
}
|
|
9176
|
+
for (const p of report.problems) warn(p);
|
|
9177
|
+
if (!opts.fromHook) process.exitCode = 1;
|
|
9178
|
+
} catch (err) {
|
|
9179
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
9180
|
+
if (opts.fromHook) {
|
|
9181
|
+
warn(`worktree doctor: ${message}`);
|
|
9182
|
+
} else {
|
|
9183
|
+
error(message);
|
|
9184
|
+
process.exitCode = 1;
|
|
9185
|
+
}
|
|
9186
|
+
}
|
|
9187
|
+
}
|
|
9188
|
+
async function runWorktree(action, opts = {}) {
|
|
9189
|
+
switch (action) {
|
|
9190
|
+
case "add":
|
|
9191
|
+
return handleAdd(opts);
|
|
9192
|
+
case "hydrate":
|
|
9193
|
+
return handleHydrate(opts);
|
|
9194
|
+
case "remove":
|
|
9195
|
+
return handleRemove(opts);
|
|
9196
|
+
case "list":
|
|
9197
|
+
return handleList();
|
|
9198
|
+
case "doctor":
|
|
9199
|
+
return handleDoctor(opts);
|
|
9200
|
+
}
|
|
9201
|
+
}
|
|
9202
|
+
|
|
9203
|
+
// src/commands/workspace.ts
|
|
9204
|
+
import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
|
|
9205
|
+
import path69 from "node:path";
|
|
9206
|
+
|
|
9207
|
+
// src/commands/workspace/aggregate.ts
|
|
9208
|
+
async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
|
|
9209
|
+
const summaries = repos.map((repo) => ({
|
|
9210
|
+
name: repo.name,
|
|
9211
|
+
path: repo.path,
|
|
9212
|
+
roles: repo.context.repoRoles ?? [],
|
|
9213
|
+
packageManager: repo.context.packageManager,
|
|
9214
|
+
deps: repo.context.dependencies ?? []
|
|
9215
|
+
}));
|
|
9216
|
+
const ownership = {};
|
|
9217
|
+
for (const repo of summaries) {
|
|
9218
|
+
for (const dep2 of repo.deps) {
|
|
9219
|
+
ownership[dep2] ??= [];
|
|
9220
|
+
ownership[dep2].push(repo.name);
|
|
9221
|
+
}
|
|
9222
|
+
}
|
|
9223
|
+
const roles = [...new Set(summaries.flatMap((r) => r.roles))].sort();
|
|
9224
|
+
const crossRepoHints = [...new Set(repos.flatMap((r) => r.context.crossRepoHints ?? []))].sort();
|
|
9225
|
+
const summaryPath = hausPath(workspaceRoot, "workspace-summary.json");
|
|
9226
|
+
const ownershipPath = hausPath(workspaceRoot, "dependency-ownership-map.json");
|
|
9227
|
+
const crossRepoPath = hausPath(workspaceRoot, "cross-repo-summary.md");
|
|
9228
|
+
const contextMapPath = hausPath(workspaceRoot, "workspace-context-map.json");
|
|
9229
|
+
await writeJson(summaryPath, {
|
|
9230
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9231
|
+
repos: summaries
|
|
9232
|
+
});
|
|
9233
|
+
await writeJson(ownershipPath, ownership);
|
|
9234
|
+
await writeText(
|
|
9235
|
+
crossRepoPath,
|
|
9236
|
+
`# Cross Repo Summary
|
|
9237
|
+
|
|
9238
|
+
${summaries.map(
|
|
9239
|
+
(repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
|
|
9240
|
+
).join("\n")}
|
|
9241
|
+
`
|
|
9242
|
+
);
|
|
9243
|
+
await writeJson(contextMapPath, {
|
|
9244
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9245
|
+
roles,
|
|
9246
|
+
crossRepoHints,
|
|
9247
|
+
repos: summaries.map((r) => ({
|
|
9248
|
+
name: r.name,
|
|
9249
|
+
path: r.path,
|
|
9250
|
+
roles: r.roles,
|
|
9251
|
+
packageManager: r.packageManager
|
|
9252
|
+
})),
|
|
9253
|
+
relationships
|
|
9254
|
+
});
|
|
9255
|
+
return [summaryPath, ownershipPath, crossRepoPath, contextMapPath];
|
|
7795
9256
|
}
|
|
7796
9257
|
|
|
7797
9258
|
// src/commands/workspace/doctor.ts
|
|
7798
|
-
import { existsSync as
|
|
7799
|
-
import
|
|
9259
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
9260
|
+
import path62 from "node:path";
|
|
7800
9261
|
|
|
7801
9262
|
// src/commands/workspace/manifest.ts
|
|
7802
|
-
import { readFileSync as
|
|
7803
|
-
import
|
|
9263
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
9264
|
+
import path61 from "node:path";
|
|
7804
9265
|
var MANIFEST_FILE = "workspace.manifest.json";
|
|
7805
9266
|
function manifestPath(workspaceRoot) {
|
|
7806
9267
|
return hausPath(workspaceRoot, MANIFEST_FILE);
|
|
7807
9268
|
}
|
|
7808
9269
|
function hausVersion() {
|
|
7809
9270
|
try {
|
|
7810
|
-
const pkg = JSON.parse(
|
|
9271
|
+
const pkg = JSON.parse(readFileSync4(path61.join(packageRoot(), "package.json"), "utf8"));
|
|
7811
9272
|
return pkg.version ?? "0.0.0";
|
|
7812
9273
|
} catch {
|
|
7813
9274
|
return "0.0.0";
|
|
@@ -7831,7 +9292,8 @@ function buildManifest2(opts) {
|
|
|
7831
9292
|
catalogRef: repo.catalogRef,
|
|
7832
9293
|
status: repo.status,
|
|
7833
9294
|
...repo.error ? { error: repo.error } : {}
|
|
7834
|
-
}))
|
|
9295
|
+
})),
|
|
9296
|
+
...opts.linkedContext ? { linkedContext: opts.linkedContext } : {}
|
|
7835
9297
|
};
|
|
7836
9298
|
}
|
|
7837
9299
|
async function readManifest2(workspaceRoot) {
|
|
@@ -7842,6 +9304,22 @@ async function writeWorkspaceManifest(workspaceRoot, manifest) {
|
|
|
7842
9304
|
await writeJson(target, manifest);
|
|
7843
9305
|
return target;
|
|
7844
9306
|
}
|
|
9307
|
+
async function readLinkedContext(workspaceRoot) {
|
|
9308
|
+
const manifest = await readManifest2(workspaceRoot);
|
|
9309
|
+
return manifest?.linkedContext ?? [];
|
|
9310
|
+
}
|
|
9311
|
+
async function writeLinkedContext(workspaceRoot, linkedContext) {
|
|
9312
|
+
const prior = await readManifest2(workspaceRoot);
|
|
9313
|
+
const next = prior ? { ...prior, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), linkedContext } : {
|
|
9314
|
+
version: 1,
|
|
9315
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9316
|
+
hausVersion: hausVersion(),
|
|
9317
|
+
client: "unknown",
|
|
9318
|
+
repos: [],
|
|
9319
|
+
linkedContext
|
|
9320
|
+
};
|
|
9321
|
+
return writeWorkspaceManifest(workspaceRoot, next);
|
|
9322
|
+
}
|
|
7845
9323
|
|
|
7846
9324
|
// src/commands/workspace/doctor.ts
|
|
7847
9325
|
async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
@@ -7874,7 +9352,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
7874
9352
|
const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
|
|
7875
9353
|
const catalogRefByRepo = [];
|
|
7876
9354
|
for (const repo of config.repos) {
|
|
7877
|
-
const repoRoot =
|
|
9355
|
+
const repoRoot = path62.resolve(workspaceRoot, repo.path);
|
|
7878
9356
|
const entry = manifestByName.get(repo.name);
|
|
7879
9357
|
if (!entry) {
|
|
7880
9358
|
flag({
|
|
@@ -7899,7 +9377,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
7899
9377
|
detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
|
|
7900
9378
|
});
|
|
7901
9379
|
}
|
|
7902
|
-
if (!
|
|
9380
|
+
if (!existsSync7(claudePath(repoRoot))) {
|
|
7903
9381
|
flag({
|
|
7904
9382
|
repo: repo.name,
|
|
7905
9383
|
kind: "missing-claude",
|
|
@@ -7908,7 +9386,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
7908
9386
|
}
|
|
7909
9387
|
const lock = await checkLock(repoRoot);
|
|
7910
9388
|
if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
|
|
7911
|
-
if (!
|
|
9389
|
+
if (!existsSync7(hausPath(repoRoot, "haus.lock.json"))) {
|
|
7912
9390
|
flag({
|
|
7913
9391
|
repo: repo.name,
|
|
7914
9392
|
kind: "missing-lock",
|
|
@@ -7933,6 +9411,40 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
7933
9411
|
detail: `Repos are on different catalog refs \u2014 ${catalogRefByRepo.map((r) => `${r.repo}: ${r.ref}`).join(", ")}. Run \`haus workspace setup --write\` to bring them onto the same ref.`
|
|
7934
9412
|
});
|
|
7935
9413
|
}
|
|
9414
|
+
if (manifest.linkedContext && manifest.linkedContext.length > 0) {
|
|
9415
|
+
const rootInfo = await resolveRoots(workspaceRoot);
|
|
9416
|
+
const members = await readMembers(rootInfo);
|
|
9417
|
+
const memberById = new Map(members.map((m) => [m.id, m]));
|
|
9418
|
+
for (const entry of manifest.linkedContext) {
|
|
9419
|
+
const member = memberById.get(entry.repo);
|
|
9420
|
+
if (!member || !existsSync7(member.absPath)) {
|
|
9421
|
+
flag({
|
|
9422
|
+
repo: entry.repo,
|
|
9423
|
+
kind: "missing-linked-context-source",
|
|
9424
|
+
detail: `Linked ${entry.type} "${entry.name}" (${entry.path}) \u2014 source repo no longer configured or cloned. Re-run \`haus workspace link-context\` to clean up.`
|
|
9425
|
+
});
|
|
9426
|
+
continue;
|
|
9427
|
+
}
|
|
9428
|
+
if (!existsSync7(path62.join(workspaceRoot, entry.path))) {
|
|
9429
|
+
flag({
|
|
9430
|
+
repo: entry.repo,
|
|
9431
|
+
kind: "missing-linked-context-copy",
|
|
9432
|
+
detail: `Linked ${entry.type} "${entry.name}" is recorded in the manifest but ${entry.path} no longer exists at the workspace root. Re-run \`haus workspace link-context\` to restore it.`
|
|
9433
|
+
});
|
|
9434
|
+
continue;
|
|
9435
|
+
}
|
|
9436
|
+
const liveHash = await hashInstalledPaths(member.absPath, [entry.sourceRelPath], {
|
|
9437
|
+
followSymlinks: false
|
|
9438
|
+
});
|
|
9439
|
+
if (liveHash !== entry.sourceHash) {
|
|
9440
|
+
flag({
|
|
9441
|
+
repo: entry.repo,
|
|
9442
|
+
kind: "stale-linked-context",
|
|
9443
|
+
detail: `Linked ${entry.type} "${entry.name}" (${entry.path}) is stale \u2014 the source in ${entry.repo} changed since it was linked. Re-run \`haus workspace link-context\`.`
|
|
9444
|
+
});
|
|
9445
|
+
}
|
|
9446
|
+
}
|
|
9447
|
+
}
|
|
7936
9448
|
return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
|
|
7937
9449
|
}
|
|
7938
9450
|
function emit(args) {
|
|
@@ -7955,13 +9467,256 @@ function emit(args) {
|
|
|
7955
9467
|
return { workspaceRoot, manifest, drift };
|
|
7956
9468
|
}
|
|
7957
9469
|
|
|
9470
|
+
// src/workspace/link-context/link.ts
|
|
9471
|
+
import path65 from "node:path";
|
|
9472
|
+
import fs45 from "fs-extra";
|
|
9473
|
+
|
|
9474
|
+
// src/workspace/link-context/plan.ts
|
|
9475
|
+
import path64 from "node:path";
|
|
9476
|
+
import fs44 from "fs-extra";
|
|
9477
|
+
|
|
9478
|
+
// src/workspace/link-context/scan-source.ts
|
|
9479
|
+
import path63 from "node:path";
|
|
9480
|
+
import fs43 from "fs-extra";
|
|
9481
|
+
var ASSET_SUBDIR = {
|
|
9482
|
+
skill: "skills",
|
|
9483
|
+
agent: "agents",
|
|
9484
|
+
command: "commands"
|
|
9485
|
+
};
|
|
9486
|
+
async function hasLinkableContext(member) {
|
|
9487
|
+
for (const subdir of Object.values(ASSET_SUBDIR)) {
|
|
9488
|
+
if (await fs43.pathExists(path63.join(member.absPath, ".claude", subdir))) return true;
|
|
9489
|
+
}
|
|
9490
|
+
return false;
|
|
9491
|
+
}
|
|
9492
|
+
async function collectSkills(member) {
|
|
9493
|
+
const dir = path63.join(member.absPath, ".claude", "skills");
|
|
9494
|
+
if (!await fs43.pathExists(dir)) return [];
|
|
9495
|
+
const names = await fs43.readdir(dir);
|
|
9496
|
+
const out = [];
|
|
9497
|
+
for (const name of [...names].sort()) {
|
|
9498
|
+
const abs = path63.join(dir, name);
|
|
9499
|
+
const stat = await fs43.lstat(abs);
|
|
9500
|
+
if (!stat.isDirectory()) continue;
|
|
9501
|
+
if (!await fs43.pathExists(path63.join(abs, "SKILL.md"))) continue;
|
|
9502
|
+
out.push({ type: "skill", name, sourceRelPath: path63.posix.join(".claude", "skills", name) });
|
|
9503
|
+
}
|
|
9504
|
+
return out;
|
|
9505
|
+
}
|
|
9506
|
+
async function collectFlatMarkdown(member, type) {
|
|
9507
|
+
const subdir = ASSET_SUBDIR[type];
|
|
9508
|
+
const dir = path63.join(member.absPath, ".claude", subdir);
|
|
9509
|
+
if (!await fs43.pathExists(dir)) return [];
|
|
9510
|
+
const names = await fs43.readdir(dir);
|
|
9511
|
+
const out = [];
|
|
9512
|
+
for (const fileName of [...names].sort()) {
|
|
9513
|
+
if (!fileName.endsWith(".md")) continue;
|
|
9514
|
+
const abs = path63.join(dir, fileName);
|
|
9515
|
+
if (!(await fs43.lstat(abs)).isFile()) continue;
|
|
9516
|
+
const name = fileName.slice(0, -".md".length);
|
|
9517
|
+
out.push({ type, name, sourceRelPath: path63.posix.join(".claude", subdir, fileName) });
|
|
9518
|
+
}
|
|
9519
|
+
return out;
|
|
9520
|
+
}
|
|
9521
|
+
async function collectSourceAssets(member) {
|
|
9522
|
+
const [skills, agents, commands] = await Promise.all([
|
|
9523
|
+
collectSkills(member),
|
|
9524
|
+
collectFlatMarkdown(member, "agent"),
|
|
9525
|
+
collectFlatMarkdown(member, "command")
|
|
9526
|
+
]);
|
|
9527
|
+
return [...skills, ...agents, ...commands];
|
|
9528
|
+
}
|
|
9529
|
+
|
|
9530
|
+
// src/workspace/link-context/plan.ts
|
|
9531
|
+
function repoFolderPrefix(member) {
|
|
9532
|
+
const base = path64.posix.basename(member.folder);
|
|
9533
|
+
if (base && base !== "." && base !== "/") return base;
|
|
9534
|
+
const fromAbs = path64.basename(member.absPath);
|
|
9535
|
+
return fromAbs || member.id;
|
|
9536
|
+
}
|
|
9537
|
+
function assetKey(type, destKey) {
|
|
9538
|
+
return `${type}:${destKey}`;
|
|
9539
|
+
}
|
|
9540
|
+
function splitAssetKey(key) {
|
|
9541
|
+
const idx = key.indexOf(":");
|
|
9542
|
+
return { type: key.slice(0, idx), destKey: key.slice(idx + 1) };
|
|
9543
|
+
}
|
|
9544
|
+
async function buildLinkPlan(members) {
|
|
9545
|
+
const skipped = [];
|
|
9546
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
9547
|
+
const membersById = new Map(members.map((m) => [m.id, m]));
|
|
9548
|
+
for (const member of members) {
|
|
9549
|
+
if (!await fs44.pathExists(member.absPath)) {
|
|
9550
|
+
skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
|
|
9551
|
+
continue;
|
|
9552
|
+
}
|
|
9553
|
+
if (!await hasLinkableContext(member)) {
|
|
9554
|
+
skipped.push({
|
|
9555
|
+
repo: member.id,
|
|
9556
|
+
reason: "no .claude/skills, .claude/agents, or .claude/commands \u2014 nothing to link"
|
|
9557
|
+
});
|
|
9558
|
+
continue;
|
|
9559
|
+
}
|
|
9560
|
+
const assets = await collectSourceAssets(member);
|
|
9561
|
+
const prefix = repoFolderPrefix(member);
|
|
9562
|
+
for (const asset of assets) {
|
|
9563
|
+
const destKey = `${prefix}--${asset.name}`;
|
|
9564
|
+
const key = assetKey(asset.type, destKey);
|
|
9565
|
+
const list = byKey.get(key) ?? [];
|
|
9566
|
+
list.push({ repo: member.id, asset });
|
|
9567
|
+
byKey.set(key, list);
|
|
9568
|
+
}
|
|
9569
|
+
}
|
|
9570
|
+
const collisions = [];
|
|
9571
|
+
const entries = [];
|
|
9572
|
+
for (const [key, list] of byKey) {
|
|
9573
|
+
const { type, destKey } = splitAssetKey(key);
|
|
9574
|
+
const distinctRepos = [...new Set(list.map((l) => l.repo))];
|
|
9575
|
+
if (distinctRepos.length > 1) {
|
|
9576
|
+
collisions.push({ type, destKey, repos: distinctRepos });
|
|
9577
|
+
continue;
|
|
9578
|
+
}
|
|
9579
|
+
const { repo, asset } = list[0];
|
|
9580
|
+
const member = membersById.get(repo);
|
|
9581
|
+
if (!member) continue;
|
|
9582
|
+
const sourceHash = await hashInstalledPaths(member.absPath, [asset.sourceRelPath], {
|
|
9583
|
+
followSymlinks: false
|
|
9584
|
+
});
|
|
9585
|
+
entries.push({
|
|
9586
|
+
repo,
|
|
9587
|
+
type,
|
|
9588
|
+
name: asset.name,
|
|
9589
|
+
destKey,
|
|
9590
|
+
sourceRelPath: asset.sourceRelPath,
|
|
9591
|
+
sourceAbsPath: path64.join(member.absPath, asset.sourceRelPath),
|
|
9592
|
+
sourceHash
|
|
9593
|
+
});
|
|
9594
|
+
}
|
|
9595
|
+
return { entries, skipped, collisions };
|
|
9596
|
+
}
|
|
9597
|
+
|
|
9598
|
+
// src/workspace/link-context/link.ts
|
|
9599
|
+
function destRelPathFor(type, destKey) {
|
|
9600
|
+
const subdir = `${type}s`;
|
|
9601
|
+
const leaf = type === "skill" ? destKey : `${destKey}.md`;
|
|
9602
|
+
return path65.posix.join(".claude", subdir, leaf);
|
|
9603
|
+
}
|
|
9604
|
+
async function runLinkContext(workspaceRoot, opts = {}) {
|
|
9605
|
+
const write = opts.write ?? false;
|
|
9606
|
+
const say = opts.json ? () => {
|
|
9607
|
+
} : log;
|
|
9608
|
+
const rootInfo = await resolveRoots(workspaceRoot);
|
|
9609
|
+
const members = await readMembers(rootInfo);
|
|
9610
|
+
const plan = await buildLinkPlan(members);
|
|
9611
|
+
if (plan.collisions.length > 0) {
|
|
9612
|
+
const detail = plan.collisions.map((c) => `${c.type} "${c.destKey}" claimed by: ${c.repos.join(", ")}`).join("; ");
|
|
9613
|
+
return {
|
|
9614
|
+
ok: false,
|
|
9615
|
+
error: `Name collision(s) after the repo-folder prefix \u2014 refusing to overwrite either copy. Rename one of the conflicting repos' folders (in haus.workspace.yaml/repos.manifest.json) or the conflicting skill/agent/command itself to resolve: ${detail}`,
|
|
9616
|
+
collisions: plan.collisions
|
|
9617
|
+
};
|
|
9618
|
+
}
|
|
9619
|
+
for (const s of plan.skipped) say(` skip ${s.repo}: ${s.reason}`);
|
|
9620
|
+
const priorManifest = await readManifest2(workspaceRoot);
|
|
9621
|
+
const prior = priorManifest?.linkedContext ?? [];
|
|
9622
|
+
const priorPaths = new Set(prior.map((e) => e.path));
|
|
9623
|
+
const nextEntries = [];
|
|
9624
|
+
const added = [];
|
|
9625
|
+
const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9626
|
+
for (const entry of plan.entries) {
|
|
9627
|
+
const destRel = destRelPathFor(entry.type, entry.destKey);
|
|
9628
|
+
const destAbs = path65.join(workspaceRoot, destRel);
|
|
9629
|
+
if (write) {
|
|
9630
|
+
await fs45.ensureDir(path65.dirname(destAbs));
|
|
9631
|
+
if (await fs45.pathExists(destAbs)) await fs45.remove(destAbs);
|
|
9632
|
+
await fs45.copy(entry.sourceAbsPath, destAbs, {
|
|
9633
|
+
overwrite: true,
|
|
9634
|
+
errorOnExist: false,
|
|
9635
|
+
dereference: false,
|
|
9636
|
+
filter: (s) => {
|
|
9637
|
+
try {
|
|
9638
|
+
return !fs45.lstatSync(s).isSymbolicLink();
|
|
9639
|
+
} catch {
|
|
9640
|
+
return false;
|
|
9641
|
+
}
|
|
9642
|
+
}
|
|
9643
|
+
});
|
|
9644
|
+
}
|
|
9645
|
+
if (!priorPaths.has(destRel)) added.push(destRel);
|
|
9646
|
+
nextEntries.push({
|
|
9647
|
+
repo: entry.repo,
|
|
9648
|
+
type: entry.type,
|
|
9649
|
+
name: entry.name,
|
|
9650
|
+
path: destRel,
|
|
9651
|
+
sourceRelPath: entry.sourceRelPath,
|
|
9652
|
+
sourceHash: entry.sourceHash,
|
|
9653
|
+
linkedAt
|
|
9654
|
+
});
|
|
9655
|
+
}
|
|
9656
|
+
const nextPaths = new Set(nextEntries.map((e) => e.path));
|
|
9657
|
+
const removed = [];
|
|
9658
|
+
for (const old of prior) {
|
|
9659
|
+
if (nextPaths.has(old.path)) continue;
|
|
9660
|
+
removed.push(old.path);
|
|
9661
|
+
if (write) {
|
|
9662
|
+
const abs = path65.join(workspaceRoot, old.path);
|
|
9663
|
+
if (await fs45.pathExists(abs)) await fs45.remove(abs);
|
|
9664
|
+
}
|
|
9665
|
+
}
|
|
9666
|
+
if (write) {
|
|
9667
|
+
await writeLinkedContext(workspaceRoot, nextEntries);
|
|
9668
|
+
await writeLinkContextGitignore(workspaceRoot, false);
|
|
9669
|
+
}
|
|
9670
|
+
return {
|
|
9671
|
+
ok: true,
|
|
9672
|
+
workspaceRoot,
|
|
9673
|
+
dryRun: !write,
|
|
9674
|
+
linked: nextEntries,
|
|
9675
|
+
added,
|
|
9676
|
+
removed,
|
|
9677
|
+
skipped: plan.skipped
|
|
9678
|
+
};
|
|
9679
|
+
}
|
|
9680
|
+
|
|
9681
|
+
// src/commands/workspace/link-context.ts
|
|
9682
|
+
async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
|
|
9683
|
+
const options = { write: opts.write, json: opts.json };
|
|
9684
|
+
const result = await runLinkContext(workspaceRoot, options);
|
|
9685
|
+
if (!result.ok) {
|
|
9686
|
+
if (opts.json) {
|
|
9687
|
+
log(JSON.stringify(result, null, 2));
|
|
9688
|
+
} else {
|
|
9689
|
+
error(result.error);
|
|
9690
|
+
}
|
|
9691
|
+
process.exitCode = 1;
|
|
9692
|
+
return;
|
|
9693
|
+
}
|
|
9694
|
+
if (opts.json) {
|
|
9695
|
+
log(JSON.stringify(result, null, 2));
|
|
9696
|
+
return;
|
|
9697
|
+
}
|
|
9698
|
+
for (const s of result.skipped) warn(` skip ${s.repo}: ${s.reason}`);
|
|
9699
|
+
if (result.dryRun) {
|
|
9700
|
+
log(
|
|
9701
|
+
`[dry-run] Would link ${result.linked.length} skill/agent/command copy(ies) into .claude/ (${result.added.length} new, ${result.removed.length} to remove). Re-run with --write to apply.`
|
|
9702
|
+
);
|
|
9703
|
+
return;
|
|
9704
|
+
}
|
|
9705
|
+
log(
|
|
9706
|
+
`Linked ${result.linked.length} skill/agent/command copy(ies) into .claude/ (${result.added.length} new, ${result.removed.length} removed).`
|
|
9707
|
+
);
|
|
9708
|
+
log(
|
|
9709
|
+
"Source of truth for each copy stays in its own member repo \u2014 do not edit the copies directly; re-run `haus workspace link-context` after editing a source, or when `haus workspace doctor` flags one stale."
|
|
9710
|
+
);
|
|
9711
|
+
}
|
|
9712
|
+
|
|
7958
9713
|
// src/commands/workspace/setup.ts
|
|
7959
|
-
import { existsSync as
|
|
7960
|
-
import
|
|
9714
|
+
import { existsSync as existsSync8, statSync } from "node:fs";
|
|
9715
|
+
import path67 from "node:path";
|
|
7961
9716
|
|
|
7962
9717
|
// src/claude/write-workspace-claude-md.ts
|
|
7963
|
-
import
|
|
7964
|
-
import
|
|
9718
|
+
import path66 from "node:path";
|
|
9719
|
+
import fs46 from "fs-extra";
|
|
7965
9720
|
function buildWorkspaceImportBlock(client, members) {
|
|
7966
9721
|
const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
|
|
7967
9722
|
const body = [
|
|
@@ -7979,11 +9734,11 @@ ${BLOCK_END}`;
|
|
|
7979
9734
|
async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
7980
9735
|
const block = buildWorkspaceImportBlock(opts.client, opts.members);
|
|
7981
9736
|
const dryRun = opts.dryRun ?? false;
|
|
7982
|
-
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") :
|
|
9737
|
+
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path66.join(workspaceRoot, "CLAUDE.md");
|
|
7983
9738
|
const say = opts.quiet ? () => {
|
|
7984
9739
|
} : log;
|
|
7985
|
-
const existed = await
|
|
7986
|
-
const prev = existed ? await
|
|
9740
|
+
const existed = await fs46.pathExists(filePath);
|
|
9741
|
+
const prev = existed ? await fs46.readFile(filePath, "utf8") : "";
|
|
7987
9742
|
const next = opts.collision ? `${block}
|
|
7988
9743
|
` : injectHausBlock(prev, block);
|
|
7989
9744
|
const printable = displayPath(workspaceRoot, filePath);
|
|
@@ -8010,20 +9765,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
|
8010
9765
|
|
|
8011
9766
|
// src/commands/workspace/setup.ts
|
|
8012
9767
|
function resolveWorkspaceRoot(start = process.cwd()) {
|
|
8013
|
-
let dir =
|
|
9768
|
+
let dir = path67.resolve(start);
|
|
8014
9769
|
for (; ; ) {
|
|
8015
|
-
if (
|
|
8016
|
-
const parent =
|
|
8017
|
-
if (parent === dir) return
|
|
9770
|
+
if (existsSync8(path67.join(dir, WORKSPACE_FILE))) return dir;
|
|
9771
|
+
const parent = path67.dirname(dir);
|
|
9772
|
+
if (parent === dir) return path67.resolve(start);
|
|
8018
9773
|
dir = parent;
|
|
8019
9774
|
}
|
|
8020
9775
|
}
|
|
8021
9776
|
function isRootRepo(workspaceRoot, repoPath) {
|
|
8022
|
-
return
|
|
9777
|
+
return path67.resolve(workspaceRoot, repoPath) === path67.resolve(workspaceRoot);
|
|
8023
9778
|
}
|
|
8024
9779
|
async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
8025
9780
|
const apply = options.write ?? false;
|
|
8026
|
-
const configText = await readText(
|
|
9781
|
+
const configText = await readText(path67.join(workspaceRoot, WORKSPACE_FILE));
|
|
8027
9782
|
if (!configText) {
|
|
8028
9783
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
8029
9784
|
process.exitCode = 1;
|
|
@@ -8050,11 +9805,11 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
8050
9805
|
const statuses = [];
|
|
8051
9806
|
const aggregateInputs = [];
|
|
8052
9807
|
for (const repo of repos) {
|
|
8053
|
-
const repoRoot =
|
|
9808
|
+
const repoRoot = path67.resolve(workspaceRoot, repo.path);
|
|
8054
9809
|
say(`
|
|
8055
9810
|
\u2192 ${repo.name} (${repo.path})`);
|
|
8056
9811
|
try {
|
|
8057
|
-
if (!
|
|
9812
|
+
if (!existsSync8(repoRoot) || !statSync(repoRoot).isDirectory()) {
|
|
8058
9813
|
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
8059
9814
|
}
|
|
8060
9815
|
const res = await runSetupCore(repoRoot, {
|
|
@@ -8110,7 +9865,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
8110
9865
|
if (apply && !options.dryRun) {
|
|
8111
9866
|
const statusByName = new Map(statuses.map((s) => [s.name, s]));
|
|
8112
9867
|
const prior = await readManifest2(workspaceRoot);
|
|
8113
|
-
if (!prior &&
|
|
9868
|
+
if (!prior && existsSync8(manifestPath(workspaceRoot))) {
|
|
8114
9869
|
warn(
|
|
8115
9870
|
"Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
|
|
8116
9871
|
);
|
|
@@ -8121,7 +9876,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
8121
9876
|
const status = statusByName.get(repo.name);
|
|
8122
9877
|
const role = repo.role ?? status?.roles?.[0] ?? "auto";
|
|
8123
9878
|
if (status?.status === "ok") {
|
|
8124
|
-
const lock = await checkLock(
|
|
9879
|
+
const lock = await checkLock(path67.resolve(workspaceRoot, repo.path));
|
|
8125
9880
|
manifestRepos.push({
|
|
8126
9881
|
name: repo.name,
|
|
8127
9882
|
path: repo.path,
|
|
@@ -8164,9 +9919,33 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
8164
9919
|
);
|
|
8165
9920
|
}
|
|
8166
9921
|
}
|
|
8167
|
-
const manifest = buildManifest2({
|
|
9922
|
+
const manifest = buildManifest2({
|
|
9923
|
+
client: config.client,
|
|
9924
|
+
repos: manifestRepos,
|
|
9925
|
+
// buildManifest rebuilds the manifest from scratch each run — carry the prior
|
|
9926
|
+
// `linkedContext` section forward so a plain `setup --write` never silently
|
|
9927
|
+
// drops entries `link-context` wrote on an earlier run.
|
|
9928
|
+
linkedContext: prior?.linkedContext
|
|
9929
|
+
});
|
|
8168
9930
|
const manifestFile = await writeWorkspaceManifest(workspaceRoot, manifest);
|
|
8169
9931
|
written.push(manifestFile);
|
|
9932
|
+
try {
|
|
9933
|
+
const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
|
|
9934
|
+
if (linkResult.ok) {
|
|
9935
|
+
written.push(...linkResult.added.map((rel) => path67.join(workspaceRoot, rel)));
|
|
9936
|
+
if (!options.json) {
|
|
9937
|
+
say(
|
|
9938
|
+
`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` : ""}.`
|
|
9939
|
+
);
|
|
9940
|
+
}
|
|
9941
|
+
} else {
|
|
9942
|
+
warn(`Skipping workspace link-context: ${linkResult.error}`);
|
|
9943
|
+
}
|
|
9944
|
+
} catch (err) {
|
|
9945
|
+
warn(
|
|
9946
|
+
`Workspace link-context failed: ${err instanceof Error ? err.message : String(err)} \u2014 run \`haus workspace link-context\` directly to retry.`
|
|
9947
|
+
);
|
|
9948
|
+
}
|
|
8170
9949
|
}
|
|
8171
9950
|
const ok = statuses.filter((s) => s.status === "ok").length;
|
|
8172
9951
|
const failed = statuses.length - ok;
|
|
@@ -8184,9 +9963,9 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
|
|
|
8184
9963
|
}
|
|
8185
9964
|
|
|
8186
9965
|
// src/commands/workspace/undo.ts
|
|
8187
|
-
import { existsSync as
|
|
8188
|
-
import
|
|
8189
|
-
import
|
|
9966
|
+
import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
|
|
9967
|
+
import path68 from "node:path";
|
|
9968
|
+
import fs47 from "fs-extra";
|
|
8190
9969
|
var WORKSPACE_AGGREGATE_FILES = [
|
|
8191
9970
|
"workspace-summary.json",
|
|
8192
9971
|
"dependency-ownership-map.json",
|
|
@@ -8214,11 +9993,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
8214
9993
|
}
|
|
8215
9994
|
let anyRepoFailed = false;
|
|
8216
9995
|
for (const repo of config.repos) {
|
|
8217
|
-
const repoRoot =
|
|
9996
|
+
const repoRoot = path68.resolve(workspaceRoot, repo.path);
|
|
8218
9997
|
log(`
|
|
8219
9998
|
\u2192 ${repo.name} (${repo.path})`);
|
|
8220
9999
|
try {
|
|
8221
|
-
if (!
|
|
10000
|
+
if (!existsSync9(repoRoot) || !statSync2(repoRoot).isDirectory()) {
|
|
8222
10001
|
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
8223
10002
|
}
|
|
8224
10003
|
await runUndo({ yes: true, root: repoRoot });
|
|
@@ -8227,33 +10006,41 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
8227
10006
|
warn(`Undo failed for ${repo.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
8228
10007
|
}
|
|
8229
10008
|
}
|
|
10009
|
+
const linkedContext = await readLinkedContext(workspaceRoot);
|
|
10010
|
+
for (const entry of linkedContext) {
|
|
10011
|
+
const abs = path68.join(workspaceRoot, entry.path);
|
|
10012
|
+
if (await fs47.pathExists(abs)) {
|
|
10013
|
+
await fs47.remove(abs);
|
|
10014
|
+
log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
|
|
10015
|
+
}
|
|
10016
|
+
}
|
|
8230
10017
|
for (const rel of WORKSPACE_AGGREGATE_FILES) {
|
|
8231
10018
|
const abs = hausPath(workspaceRoot, rel);
|
|
8232
|
-
if (await
|
|
8233
|
-
await
|
|
8234
|
-
log(`Removed ${
|
|
10019
|
+
if (await fs47.pathExists(abs)) {
|
|
10020
|
+
await fs47.remove(abs);
|
|
10021
|
+
log(`Removed ${path68.relative(workspaceRoot, abs)}`);
|
|
8235
10022
|
}
|
|
8236
10023
|
}
|
|
8237
10024
|
const manifestFile = manifestPath(workspaceRoot);
|
|
8238
|
-
if (await
|
|
8239
|
-
await
|
|
8240
|
-
log(`Removed ${
|
|
10025
|
+
if (await fs47.pathExists(manifestFile)) {
|
|
10026
|
+
await fs47.remove(manifestFile);
|
|
10027
|
+
log(`Removed ${path68.relative(workspaceRoot, manifestFile)}`);
|
|
8241
10028
|
}
|
|
8242
10029
|
const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
|
|
8243
|
-
if (await
|
|
8244
|
-
await
|
|
8245
|
-
log(`Removed ${
|
|
10030
|
+
if (await fs47.pathExists(workspaceMdPath)) {
|
|
10031
|
+
await fs47.remove(workspaceMdPath);
|
|
10032
|
+
log(`Removed ${path68.relative(workspaceRoot, workspaceMdPath)}`);
|
|
8246
10033
|
} else {
|
|
8247
|
-
const claudeMdPath =
|
|
8248
|
-
if (
|
|
10034
|
+
const claudeMdPath = path68.join(workspaceRoot, "CLAUDE.md");
|
|
10035
|
+
if (existsSync9(claudeMdPath)) {
|
|
8249
10036
|
const prev = await readText(claudeMdPath);
|
|
8250
10037
|
if (prev?.includes(BLOCK_BEGIN)) {
|
|
8251
10038
|
const next = stripHausBlock(prev);
|
|
8252
10039
|
if (next.length === 0) {
|
|
8253
|
-
await
|
|
10040
|
+
await fs47.remove(claudeMdPath);
|
|
8254
10041
|
log("Removed CLAUDE.md (only contained the workspace haus import block).");
|
|
8255
10042
|
} else {
|
|
8256
|
-
await
|
|
10043
|
+
await fs47.writeFile(claudeMdPath, next, "utf8");
|
|
8257
10044
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
8258
10045
|
}
|
|
8259
10046
|
}
|
|
@@ -8269,7 +10056,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
8269
10056
|
}
|
|
8270
10057
|
|
|
8271
10058
|
// src/commands/workspace.ts
|
|
8272
|
-
function
|
|
10059
|
+
function normalizeOnly2(only) {
|
|
8273
10060
|
if (!only) return void 0;
|
|
8274
10061
|
const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
|
|
8275
10062
|
const cleaned = list.map((s) => s.trim()).filter(Boolean);
|
|
@@ -8296,7 +10083,7 @@ relationships: []
|
|
|
8296
10083
|
log("Workspace initialized.");
|
|
8297
10084
|
}
|
|
8298
10085
|
async function scanWorkspace(workspaceRoot, opts) {
|
|
8299
|
-
const configText = await readText(
|
|
10086
|
+
const configText = await readText(path69.join(workspaceRoot, WORKSPACE_FILE));
|
|
8300
10087
|
if (!configText) {
|
|
8301
10088
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
8302
10089
|
process.exitCode = 1;
|
|
@@ -8318,9 +10105,9 @@ async function scanWorkspace(workspaceRoot, opts) {
|
|
|
8318
10105
|
const inputs = [];
|
|
8319
10106
|
const failures = [];
|
|
8320
10107
|
for (const repo of config.repos) {
|
|
8321
|
-
const repoRoot =
|
|
10108
|
+
const repoRoot = path69.resolve(workspaceRoot, repo.path);
|
|
8322
10109
|
try {
|
|
8323
|
-
if (!
|
|
10110
|
+
if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
|
|
8324
10111
|
throw new Error("path is not a directory");
|
|
8325
10112
|
}
|
|
8326
10113
|
const result = await scanProject(repoRoot);
|
|
@@ -8366,7 +10153,7 @@ async function runWorkspace(action, options = {}) {
|
|
|
8366
10153
|
dryRun: options.dryRun,
|
|
8367
10154
|
json: options.json,
|
|
8368
10155
|
continueOnError: options.continueOnError,
|
|
8369
|
-
only:
|
|
10156
|
+
only: normalizeOnly2(options.only)
|
|
8370
10157
|
});
|
|
8371
10158
|
return;
|
|
8372
10159
|
case "doctor":
|
|
@@ -8375,14 +10162,17 @@ async function runWorkspace(action, options = {}) {
|
|
|
8375
10162
|
case "undo":
|
|
8376
10163
|
await runWorkspaceUndo(workspaceRoot, { yes: options.yes });
|
|
8377
10164
|
return;
|
|
10165
|
+
case "link-context":
|
|
10166
|
+
await runWorkspaceLinkContext(workspaceRoot, { write: options.write, json: options.json });
|
|
10167
|
+
return;
|
|
8378
10168
|
}
|
|
8379
10169
|
}
|
|
8380
10170
|
|
|
8381
10171
|
// src/cli.ts
|
|
8382
10172
|
function cliVersion() {
|
|
8383
10173
|
try {
|
|
8384
|
-
const pkgPath =
|
|
8385
|
-
const pkg = JSON.parse(
|
|
10174
|
+
const pkgPath = path70.join(packageRoot(), "package.json");
|
|
10175
|
+
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
8386
10176
|
return pkg.version ?? "0.0.0";
|
|
8387
10177
|
} catch {
|
|
8388
10178
|
return "0.0.0";
|
|
@@ -8391,8 +10181,8 @@ function cliVersion() {
|
|
|
8391
10181
|
var program = new Command();
|
|
8392
10182
|
function validateRuntimeNodeVersion() {
|
|
8393
10183
|
try {
|
|
8394
|
-
const pkgPath =
|
|
8395
|
-
const pkg = JSON.parse(
|
|
10184
|
+
const pkgPath = path70.join(packageRoot(), "package.json");
|
|
10185
|
+
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
8396
10186
|
const requiredRange = pkg.engines?.node;
|
|
8397
10187
|
if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
|
|
8398
10188
|
throw new Error(`Node ${process.version} does not satisfy required range ${requiredRange}`);
|
|
@@ -8410,7 +10200,10 @@ program.command("recommend").option("--json").option(
|
|
|
8410
10200
|
"--include <ids...>",
|
|
8411
10201
|
"Force opt-in catalog items into the recommendation as manual selections (space- or comma-separated ids)"
|
|
8412
10202
|
).action(runRecommend);
|
|
8413
|
-
program.command("setup-project").option("--json").
|
|
10203
|
+
program.command("setup-project").option("--json").option(
|
|
10204
|
+
"--force",
|
|
10205
|
+
"Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
|
|
10206
|
+
).action(runSetupProject);
|
|
8414
10207
|
program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
|
|
8415
10208
|
program.command("fetch-refs").description("Fetch and cache llms.txt content from catalog item references").option("--all", "Fetch for all catalog items (default)").option("--id <id>", "Fetch for a single catalog item by id").option("--json", "Emit machine-readable JSON summary to stdout").action(runFetchRefs);
|
|
8416
10209
|
program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
|
|
@@ -8472,6 +10265,31 @@ workspace.command("scan").description("Aggregate a cross-repo summary from a fas
|
|
|
8472
10265
|
workspace.command("setup").description("Per-repo setup loop + workspace layer + manifest").option("--write", "Apply changes (default previews only)").option("--dry-run", "Preview changes without writing").option("--json", "Emit machine-readable per-repo output").option("--continue-on-error", "Keep going past a failed repo (default fail-fast)").option("--only <names>", "Restrict to comma-separated repo names").action((opts) => runWorkspace("setup", opts));
|
|
8473
10266
|
workspace.command("doctor").description("Report workspace drift against the manifest").option("--json", "Output the manifest and drift array as JSON").action((opts) => runWorkspace("doctor", opts));
|
|
8474
10267
|
workspace.command("undo").description("Revert haus setup for every configured repo, plus workspace-root artifacts").option("-y, --yes", "Skip confirmation").action((opts) => runWorkspace("undo", opts));
|
|
10268
|
+
workspace.command("link-context").description(
|
|
10269
|
+
"Copy each member repo's skill/agent/command into the workspace root's .claude/, prefixed to avoid collisions"
|
|
10270
|
+
).option("--write", "Persist copies + manifest + gitignore (default previews only)").option("--json", "Output the link result as JSON").action((opts) => runWorkspace("link-context", opts));
|
|
10271
|
+
var worktree = workspace.command("worktree").description(
|
|
10272
|
+
"Materialize workspace member repos as real git worktrees (per docs/plans/workspace-worktree-materialization.md, Task 4)"
|
|
10273
|
+
);
|
|
10274
|
+
worktree.command("add <slug>").description(
|
|
10275
|
+
"Create a workspace worktree plus one member worktree per repo, on a mirrored branch"
|
|
10276
|
+
).option("--branch <name>", "Branch to create/checkout (default: the slug)").option("--only <repos>", "Restrict to comma- or space-separated member ids/folders").option("--no-hydrate", "Skip hydration (CoW clone + install-reconciliation)").option("--dry-run", "Preview without writing or running anything").action(
|
|
10277
|
+
(slug, opts) => runWorktree("add", {
|
|
10278
|
+
slug,
|
|
10279
|
+
branch: opts.branch,
|
|
10280
|
+
only: opts.only,
|
|
10281
|
+
hydrate: opts.hydrate,
|
|
10282
|
+
dryRun: opts.dryRun
|
|
10283
|
+
})
|
|
10284
|
+
);
|
|
10285
|
+
worktree.command("hydrate").description("Re-run CoW clone + install-reconciliation for the current workspace worktree").option("--only <repos>", "Restrict to comma- or space-separated member ids/folders").option("--force", "Re-clone hydration targets even if already present").option("--dry-run", "Preview without writing or running anything").action(
|
|
10286
|
+
(opts) => runWorktree("hydrate", { only: opts.only, force: opts.force, dryRun: opts.dryRun })
|
|
10287
|
+
);
|
|
10288
|
+
worktree.command("list").description("List materialized workspace worktrees and their member status").action(() => runWorktree("list"));
|
|
10289
|
+
worktree.command("remove <slug>").description("Remove a workspace worktree and its member worktrees").option("--force", "Bypass the uncommitted/unpushed-work refusal").option("--dry-run", "Preview without removing anything").action(
|
|
10290
|
+
(slug, opts) => runWorktree("remove", { slug, force: opts.force, dryRun: opts.dryRun })
|
|
10291
|
+
);
|
|
10292
|
+
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 }));
|
|
8475
10293
|
program.parseAsync(process.argv).catch((err) => {
|
|
8476
10294
|
const message = err instanceof Error ? err.message : String(err);
|
|
8477
10295
|
error(message);
|