@haus-tech/haus-workflow 5.10.1 → 5.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js
CHANGED
|
@@ -337,13 +337,6 @@ var init_constants = __esm({
|
|
|
337
337
|
});
|
|
338
338
|
|
|
339
339
|
// src/utils/exec.ts
|
|
340
|
-
var exec_exports = {};
|
|
341
|
-
__export(exec_exports, {
|
|
342
|
-
assertNeverCommandProbe: () => assertNeverCommandProbe,
|
|
343
|
-
probeCommand: () => probeCommand,
|
|
344
|
-
runCommand: () => runCommand,
|
|
345
|
-
runGit: () => runGit
|
|
346
|
-
});
|
|
347
340
|
import { execa } from "execa";
|
|
348
341
|
async function runCommand(command, args = [], options = {}) {
|
|
349
342
|
try {
|
|
@@ -2965,6 +2958,14 @@ async function addProjectIncludedIds(root, ids) {
|
|
|
2965
2958
|
includedIds: [.../* @__PURE__ */ new Set([...normalizeIdList(existing.includedIds), ...toAdd])].sort()
|
|
2966
2959
|
}));
|
|
2967
2960
|
}
|
|
2961
|
+
async function addProjectDeclaredRoles(root, roles) {
|
|
2962
|
+
const toAdd = normalizeSignalList(roles.map((role) => role.replace(/^role=/i, "")));
|
|
2963
|
+
if (toAdd.length === 0) return;
|
|
2964
|
+
await updateProjectConfig(root, (existing) => ({
|
|
2965
|
+
...existing,
|
|
2966
|
+
declaredRoles: [.../* @__PURE__ */ new Set([...normalizeSignalList(existing.declaredRoles), ...toAdd])].sort()
|
|
2967
|
+
}));
|
|
2968
|
+
}
|
|
2968
2969
|
function withdrawIncludedIds(existing, ids) {
|
|
2969
2970
|
const drop = new Set(ids);
|
|
2970
2971
|
return normalizeIdList(existing.includedIds).filter((id) => !drop.has(id));
|
|
@@ -8704,7 +8705,7 @@ async function runBundleImport(file, options = {}) {
|
|
|
8704
8705
|
|
|
8705
8706
|
// src/commands/doctor.ts
|
|
8706
8707
|
import path50 from "node:path";
|
|
8707
|
-
import
|
|
8708
|
+
import fs43 from "fs-extra";
|
|
8708
8709
|
init_manifest_format();
|
|
8709
8710
|
|
|
8710
8711
|
// src/claude/former-install-orphans.ts
|
|
@@ -8909,11 +8910,13 @@ var BOLD_SPAN = /\*\*([^*\n]+)\*\*/g;
|
|
|
8909
8910
|
var BARE_NAME = /^[a-z0-9][a-z0-9-]{2,}$/;
|
|
8910
8911
|
var KEBAB_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
8911
8912
|
var AGENT_WORD = /\bagents?\b/i;
|
|
8913
|
+
var SENTENCE_END = /[.!?](?=\s|$)/g;
|
|
8912
8914
|
var ITEMS_HEADING = /\b(?:skills?|agents?)\b/i;
|
|
8913
8915
|
var LIST_ITEM = /^(\s*(?:[-*+]|\d+[.)])\s+)(.*)$/;
|
|
8914
8916
|
var LIST_LABEL = /^[A-Za-z][A-Za-z /]{0,24}:\s*/;
|
|
8915
8917
|
var KEYWORD_BEFORE = /(?:sub)?(?:skills?|agents?)\s*[:=-]*\s*$/i;
|
|
8916
8918
|
var KEYWORD_AFTER = /^\s+(?:[A-Za-z]+\s+)?(?:sub)?(?:skills?|agents?)\b/i;
|
|
8919
|
+
var ARTICLE_BEFORE = /\b(?:a|an)\s+$/i;
|
|
8917
8920
|
var ROUTER_BEFORE = /\b(?:routes?|routing|delegates?|dispatch(?:es)?|hands? off)\s*(?:to|→|->|:)?\s*$/i;
|
|
8918
8921
|
var RUN_SEPARATOR = /^\s*(?:\([^)\n]*\))?\s*(?:,|;|\/|\band\b|\bor\b|\bplus\b)\s*(?:the\s+)?$/i;
|
|
8919
8922
|
var INLINE_KEYWORD_NAME = /^(?:sub)?(?:skills?|agents?)\s*[:=]\s*(\S+)$/i;
|
|
@@ -8947,7 +8950,15 @@ function groupRuns(line2, spans) {
|
|
|
8947
8950
|
}
|
|
8948
8951
|
return runs;
|
|
8949
8952
|
}
|
|
8950
|
-
function
|
|
8953
|
+
function sentenceAround(line2, token) {
|
|
8954
|
+
let start = 0;
|
|
8955
|
+
for (const end of line2.matchAll(SENTENCE_END)) {
|
|
8956
|
+
if (end.index >= token.end) return [start, end.index + 1];
|
|
8957
|
+
start = end.index + 1;
|
|
8958
|
+
}
|
|
8959
|
+
return [start, line2.length];
|
|
8960
|
+
}
|
|
8961
|
+
function isAgentContext(line2, token, nearestHeading) {
|
|
8951
8962
|
let blanked = "";
|
|
8952
8963
|
let cursor = 0;
|
|
8953
8964
|
for (const span of boldNameSpans(line2)) {
|
|
@@ -8955,7 +8966,8 @@ function isAgentContext(line2, nearestHeading) {
|
|
|
8955
8966
|
cursor = span.end;
|
|
8956
8967
|
}
|
|
8957
8968
|
blanked += line2.slice(cursor);
|
|
8958
|
-
|
|
8969
|
+
const [start, end] = sentenceAround(line2, token);
|
|
8970
|
+
return AGENT_WORD.test(blanked.slice(start, end)) || AGENT_WORD.test(nearestHeading);
|
|
8959
8971
|
}
|
|
8960
8972
|
function extractNameReferences(lines, { knownNames = /* @__PURE__ */ new Set() } = {}) {
|
|
8961
8973
|
const out = [];
|
|
@@ -8969,16 +8981,17 @@ function extractNameReferences(lines, { knownNames = /* @__PURE__ */ new Set() }
|
|
|
8969
8981
|
const itemBody = listMatch?.[2]?.replace(LIST_LABEL, "");
|
|
8970
8982
|
const itemBodyStart = itemBody === void 0 ? -1 : line2.length - itemBody.length;
|
|
8971
8983
|
const underItemsHeading = ITEMS_HEADING.test(nearestHeading);
|
|
8972
|
-
const looseContext = isAgentContext(line2, nearestHeading);
|
|
8984
|
+
const looseContext = (span) => KEBAB_NAME.test(span.text) && isAgentContext(line2, span, nearestHeading);
|
|
8973
8985
|
for (const run of groupRuns(line2, boldNameSpans(line2))) {
|
|
8974
8986
|
const before = line2.slice(0, run[0].start);
|
|
8975
8987
|
const after = line2.slice(run.at(-1).end);
|
|
8976
|
-
const
|
|
8988
|
+
const classifier = ARTICLE_BEFORE.test(before);
|
|
8989
|
+
const positional = KEYWORD_BEFORE.test(before) || ROUTER_BEFORE.test(before) || !classifier && KEYWORD_AFTER.test(after) || // Two co-listed installed items make the run an item list. One is not enough:
|
|
8977
8990
|
// package names share basenames with skills.
|
|
8978
8991
|
run.filter((span) => knownNames.has(span.text)).length >= 2 || underItemsHeading && run[0].start === itemBodyStart;
|
|
8979
8992
|
for (const span of run) {
|
|
8980
8993
|
if (inCodeSpan(span.start)) continue;
|
|
8981
|
-
if (!positional &&
|
|
8994
|
+
if (!positional && (classifier || !looseContext(span))) continue;
|
|
8982
8995
|
out.push({ line: lineNumber, name: span.text, kind: "bold" });
|
|
8983
8996
|
}
|
|
8984
8997
|
}
|
|
@@ -9044,14 +9057,14 @@ async function readScannedMarkdown(root) {
|
|
|
9044
9057
|
|
|
9045
9058
|
// src/decisions/doctor.ts
|
|
9046
9059
|
import path48 from "node:path";
|
|
9047
|
-
import
|
|
9060
|
+
import fs41 from "fs-extra";
|
|
9048
9061
|
init_fs();
|
|
9049
9062
|
|
|
9050
9063
|
// src/decisions/check.ts
|
|
9051
9064
|
init_fs();
|
|
9052
9065
|
init_paths();
|
|
9053
9066
|
import path47 from "node:path";
|
|
9054
|
-
import
|
|
9067
|
+
import fs40 from "fs-extra";
|
|
9055
9068
|
|
|
9056
9069
|
// src/decisions/dep-bump.ts
|
|
9057
9070
|
var DEP_SECTIONS = [
|
|
@@ -9112,6 +9125,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
|
|
|
9112
9125
|
init_exec();
|
|
9113
9126
|
init_fs();
|
|
9114
9127
|
import path44 from "node:path";
|
|
9128
|
+
import fs37 from "fs-extra";
|
|
9115
9129
|
var NUL = String.fromCharCode(0);
|
|
9116
9130
|
function parseNumstat(output) {
|
|
9117
9131
|
const fields = output.split(NUL);
|
|
@@ -9138,6 +9152,27 @@ function parseNumstat(output) {
|
|
|
9138
9152
|
}
|
|
9139
9153
|
return { linesByFile, linesAdded, linesRemoved };
|
|
9140
9154
|
}
|
|
9155
|
+
function parseNameStatus(output) {
|
|
9156
|
+
const fields = output.split(NUL);
|
|
9157
|
+
const files = [];
|
|
9158
|
+
const presentFiles = [];
|
|
9159
|
+
const renames = [];
|
|
9160
|
+
for (let i = 0; i < fields.length; i++) {
|
|
9161
|
+
const status = fields[i];
|
|
9162
|
+
if (!status) continue;
|
|
9163
|
+
const step = /^[RC]/.test(status) ? 2 : 1;
|
|
9164
|
+
const from = fields[i + 1];
|
|
9165
|
+
const file = fields[i + step];
|
|
9166
|
+
i += step;
|
|
9167
|
+
if (!file) continue;
|
|
9168
|
+
files.push(file);
|
|
9169
|
+
if (status !== "D") presentFiles.push(file);
|
|
9170
|
+
if (status.startsWith("R") && from) {
|
|
9171
|
+
renames.push({ from, to: file, similarity: Number(status.slice(1)) });
|
|
9172
|
+
}
|
|
9173
|
+
}
|
|
9174
|
+
return { files, presentFiles, renames };
|
|
9175
|
+
}
|
|
9141
9176
|
function isPackageJsonPath(file) {
|
|
9142
9177
|
return file === "package.json" || file.endsWith("/package.json");
|
|
9143
9178
|
}
|
|
@@ -9169,6 +9204,10 @@ function toAncestryLogRange(range) {
|
|
|
9169
9204
|
if (!parsed) return `${range}..HEAD`;
|
|
9170
9205
|
return `${parsed.left}..${parsed.right}`;
|
|
9171
9206
|
}
|
|
9207
|
+
async function readCommitMessages(root, range) {
|
|
9208
|
+
const log2 = await runGit(["log", "-z", "--format=%B", toAncestryLogRange(range)], { cwd: root });
|
|
9209
|
+
return log2.stdout.split(NUL).filter((message2) => message2.trim().length > 0);
|
|
9210
|
+
}
|
|
9172
9211
|
async function getCurrentBranch(root) {
|
|
9173
9212
|
const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root });
|
|
9174
9213
|
if (result.exitCode !== 0) return void 0;
|
|
@@ -9178,33 +9217,45 @@ async function getCurrentBranch(root) {
|
|
|
9178
9217
|
if (gha) return gha;
|
|
9179
9218
|
return void 0;
|
|
9180
9219
|
}
|
|
9181
|
-
async function
|
|
9182
|
-
|
|
9183
|
-
if (opts.staged) {
|
|
9184
|
-
const before2 = await readBlob(root, `HEAD:${file}`) ?? empty;
|
|
9185
|
-
const after2 = await readBlob(root, `:${file}`) ?? empty;
|
|
9186
|
-
return { before: before2, after: after2 };
|
|
9187
|
-
}
|
|
9220
|
+
async function diffBaseRev(root, opts) {
|
|
9221
|
+
if (opts.staged) return "HEAD";
|
|
9188
9222
|
const range = opts.range ?? "HEAD";
|
|
9189
9223
|
const parsed = parseDiffRange(range);
|
|
9190
|
-
if (parsed)
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
}
|
|
9200
|
-
const before = await readBlob(root, `${range}:${file}`) ?? empty;
|
|
9201
|
-
const after = await readText(path44.join(root, file)) ?? empty;
|
|
9224
|
+
if (!parsed) return range;
|
|
9225
|
+
if (!parsed.triple) return parsed.left;
|
|
9226
|
+
const mb = await runGit(["merge-base", parsed.left, parsed.right], { cwd: root });
|
|
9227
|
+
return mb.exitCode === 0 ? mb.stdout.trim() : parsed.left;
|
|
9228
|
+
}
|
|
9229
|
+
async function resolvePackageJsonPair(root, file, opts) {
|
|
9230
|
+
const empty = "{}";
|
|
9231
|
+
const before = await readBlob(root, `${await diffBaseRev(root, opts)}:${file}`) ?? empty;
|
|
9232
|
+
const after = await readDiffContent(root, file, opts) ?? empty;
|
|
9202
9233
|
return { before, after };
|
|
9203
9234
|
}
|
|
9235
|
+
async function existsAtDiffBase(root, relPath, opts) {
|
|
9236
|
+
const base = await diffBaseRev(root, opts);
|
|
9237
|
+
return (await runGit(["cat-file", "-e", `${base}:${relPath}`], { cwd: root })).exitCode === 0;
|
|
9238
|
+
}
|
|
9239
|
+
async function readDiffContent(root, file, opts) {
|
|
9240
|
+
if (opts.staged) return await readBlob(root, `:${file}`) ?? void 0;
|
|
9241
|
+
const parsed = parseDiffRange(opts.range ?? "HEAD");
|
|
9242
|
+
if (parsed) return await readBlob(root, `${parsed.right}:${file}`) ?? void 0;
|
|
9243
|
+
return readText(path44.join(root, file));
|
|
9244
|
+
}
|
|
9245
|
+
async function listDiffDirectory(root, dirRel, opts) {
|
|
9246
|
+
const prefix = `${dirRel}/`;
|
|
9247
|
+
const listDirect = async (args) => (await runGit(args, { cwd: root })).stdout.split(NUL).filter((rel) => rel.startsWith(prefix) && !rel.slice(prefix.length).includes("/"));
|
|
9248
|
+
if (opts.staged) return listDirect(["ls-files", "-z", "--", prefix]);
|
|
9249
|
+
const parsed = parseDiffRange(opts.range ?? "HEAD");
|
|
9250
|
+
if (parsed) return listDirect(["ls-tree", "-z", "--name-only", parsed.right, "--", prefix]);
|
|
9251
|
+
const dir = path44.join(root, dirRel);
|
|
9252
|
+
if (!await fs37.pathExists(dir)) return [];
|
|
9253
|
+
return (await fs37.readdir(dir)).map((name) => `${prefix}${name}`);
|
|
9254
|
+
}
|
|
9204
9255
|
async function collectDiffStats(root, opts) {
|
|
9205
|
-
const gitArgs = opts.staged ? ["diff", "--cached", "--name-
|
|
9256
|
+
const gitArgs = opts.staged ? ["diff", "--cached", "--name-status", "-z"] : ["diff", "--name-status", "-z", opts.range ?? "HEAD"];
|
|
9206
9257
|
const names = await runGit(gitArgs, { cwd: root });
|
|
9207
|
-
const files = names.stdout
|
|
9258
|
+
const { files, presentFiles, renames } = parseNameStatus(names.stdout);
|
|
9208
9259
|
const numstatArgs = opts.staged ? ["diff", "--cached", "--numstat", "-z"] : ["diff", "--numstat", "-z", opts.range ?? "HEAD"];
|
|
9209
9260
|
const numstat = await runGit(numstatArgs, { cwd: root });
|
|
9210
9261
|
const { linesByFile, linesAdded, linesRemoved } = parseNumstat(numstat.stdout);
|
|
@@ -9215,6 +9266,8 @@ async function collectDiffStats(root, opts) {
|
|
|
9215
9266
|
}
|
|
9216
9267
|
return {
|
|
9217
9268
|
files,
|
|
9269
|
+
presentFiles,
|
|
9270
|
+
renames,
|
|
9218
9271
|
linesAdded,
|
|
9219
9272
|
linesRemoved,
|
|
9220
9273
|
linesByFile,
|
|
@@ -9262,12 +9315,12 @@ function firstMatchingRegex(filePath, patterns) {
|
|
|
9262
9315
|
|
|
9263
9316
|
// src/decisions/paths.ts
|
|
9264
9317
|
import path45 from "node:path";
|
|
9265
|
-
import
|
|
9318
|
+
import fs38 from "fs-extra";
|
|
9266
9319
|
async function resolveDecisionsDir(root) {
|
|
9267
9320
|
const primary = path45.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
9268
|
-
if (await
|
|
9321
|
+
if (await fs38.pathExists(primary)) return primary;
|
|
9269
9322
|
const legacy = path45.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
9270
|
-
if (await
|
|
9323
|
+
if (await fs38.pathExists(legacy)) return legacy;
|
|
9271
9324
|
return primary;
|
|
9272
9325
|
}
|
|
9273
9326
|
function relativeDecisionPath(decisionsDir, root) {
|
|
@@ -9277,7 +9330,7 @@ function relativeDecisionPath(decisionsDir, root) {
|
|
|
9277
9330
|
// src/decisions/validate.ts
|
|
9278
9331
|
init_fs();
|
|
9279
9332
|
import path46 from "node:path";
|
|
9280
|
-
import
|
|
9333
|
+
import fs39 from "fs-extra";
|
|
9281
9334
|
function validateDecisionContent(content2) {
|
|
9282
9335
|
const errors = [];
|
|
9283
9336
|
for (const section of DECISIONS_TRIGGERS.requiredSections) {
|
|
@@ -9305,10 +9358,38 @@ function decisionNumberFromFile(name) {
|
|
|
9305
9358
|
const match = /^(\d{4})-/.exec(name);
|
|
9306
9359
|
return match?.[1] ?? null;
|
|
9307
9360
|
}
|
|
9308
|
-
|
|
9361
|
+
var ADR_HEADING_RE = /^#\s+ADR-\d{4}\b/;
|
|
9362
|
+
function firstHeading(content2) {
|
|
9363
|
+
const lines = content2.split(/\r?\n/);
|
|
9364
|
+
let start = 0;
|
|
9365
|
+
if (lines[0]?.trim() === "---") {
|
|
9366
|
+
const end = lines.findIndex((line2, i) => i > 0 && line2.trim() === "---");
|
|
9367
|
+
if (end > 0) start = end + 1;
|
|
9368
|
+
}
|
|
9369
|
+
let fence;
|
|
9370
|
+
for (const line2 of lines.slice(start)) {
|
|
9371
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line2)?.[1];
|
|
9372
|
+
if (marker != null) {
|
|
9373
|
+
if (fence == null) fence = marker;
|
|
9374
|
+
else if (marker[0] === fence[0] && marker.length >= fence.length) fence = void 0;
|
|
9375
|
+
continue;
|
|
9376
|
+
}
|
|
9377
|
+
if (fence == null && /^#{1,6}(\s|$)/.test(line2)) return line2;
|
|
9378
|
+
}
|
|
9379
|
+
return void 0;
|
|
9380
|
+
}
|
|
9381
|
+
function isAdrShaped(relPath, content2) {
|
|
9382
|
+
const segments = relPath.replace(/\\/g, "/").toLowerCase().split("/");
|
|
9383
|
+
const dirs = segments.slice(0, -1);
|
|
9384
|
+
if (!/\.md$/i.test(relPath) || dirs.includes("templates")) return false;
|
|
9385
|
+
if (dirs.includes("adr")) return true;
|
|
9386
|
+
const heading = firstHeading(content2);
|
|
9387
|
+
return heading != null && ADR_HEADING_RE.test(heading);
|
|
9388
|
+
}
|
|
9389
|
+
async function validateIndexSync(decisionsDir, decisionFiles, decisionsRel) {
|
|
9309
9390
|
const readmePath = path46.join(decisionsDir, "README.md");
|
|
9310
|
-
if (!await
|
|
9311
|
-
return [
|
|
9391
|
+
if (!await fs39.pathExists(readmePath)) {
|
|
9392
|
+
return [`${decisionsRel}/README.md is missing`];
|
|
9312
9393
|
}
|
|
9313
9394
|
const readme = await readText(readmePath) ?? "";
|
|
9314
9395
|
const errors = [];
|
|
@@ -9322,6 +9403,9 @@ async function validateIndexSync(decisionsDir, decisionFiles) {
|
|
|
9322
9403
|
}
|
|
9323
9404
|
|
|
9324
9405
|
// src/decisions/check.ts
|
|
9406
|
+
function noRecordFailure() {
|
|
9407
|
+
return { missingDecision: false, recordErrors: [], recordWayOut: "" };
|
|
9408
|
+
}
|
|
9325
9409
|
var MANIFEST_LOCK_BASENAMES = /* @__PURE__ */ new Set([
|
|
9326
9410
|
"package.json",
|
|
9327
9411
|
"yarn.lock",
|
|
@@ -9406,34 +9490,92 @@ async function readGateMode(root) {
|
|
|
9406
9490
|
const cfg = await readJson(hausPath(root, "adr-gate.json"));
|
|
9407
9491
|
return cfg?.mode === "off" ? "off" : "on";
|
|
9408
9492
|
}
|
|
9493
|
+
var SKIP_DIRECTIVE = "[adr-skip]";
|
|
9494
|
+
function textLines(text) {
|
|
9495
|
+
return text.split(/\r\n|\r|\n/);
|
|
9496
|
+
}
|
|
9409
9497
|
function hasSkipToken(text) {
|
|
9410
|
-
return text.
|
|
9498
|
+
return textLines(text).some((line2) => line2.trimStart().startsWith(SKIP_DIRECTIVE));
|
|
9411
9499
|
}
|
|
9412
|
-
|
|
9413
|
-
|
|
9414
|
-
|
|
9415
|
-
|
|
9416
|
-
|
|
9500
|
+
function commitHasSkipDirective(message2) {
|
|
9501
|
+
return hasSkipToken(textLines(message2).slice(1).join("\n"));
|
|
9502
|
+
}
|
|
9503
|
+
async function migratedRecords(root, decisionsRel, renames, diff) {
|
|
9504
|
+
const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
|
|
9505
|
+
if (decisionsRel === legacyRel) return /* @__PURE__ */ new Set();
|
|
9506
|
+
const moved = renames.filter(
|
|
9507
|
+
(r) => r.similarity === 100 && r.from.startsWith(`${legacyRel}/`) && r.to.startsWith(`${decisionsRel}/`)
|
|
9417
9508
|
);
|
|
9418
|
-
if (
|
|
9419
|
-
|
|
9420
|
-
|
|
9509
|
+
if (moved.length === 0 || await existsAtDiffBase(root, decisionsRel, diff)) return /* @__PURE__ */ new Set();
|
|
9510
|
+
return new Set(moved.map((r) => r.to));
|
|
9511
|
+
}
|
|
9512
|
+
async function duplicateNumbers(input2, records) {
|
|
9513
|
+
if (records.length === 0) return [];
|
|
9514
|
+
const numberOf = (rel) => decisionNumberFromFile(path47.basename(rel));
|
|
9515
|
+
const listed = (await listDiffDirectory(input2.root, input2.decisionsRel, input2.diff)).filter(
|
|
9516
|
+
(rel) => isDecisionFileName(path47.basename(rel))
|
|
9517
|
+
);
|
|
9518
|
+
const messages = [];
|
|
9519
|
+
for (const num of new Set(records.map(numberOf))) {
|
|
9520
|
+
if (num == null) continue;
|
|
9521
|
+
const changed = records.filter((rel) => numberOf(rel) === num).sort();
|
|
9522
|
+
const others = listed.filter((rel) => numberOf(rel) === num && !changed.includes(rel)).sort();
|
|
9523
|
+
if (changed.length + others.length < 2) continue;
|
|
9524
|
+
messages.push(
|
|
9525
|
+
`duplicate ADR number ${num}: ${[...changed, ...others].join(", ")}; take the next free number (haus decisions next-number)`
|
|
9526
|
+
);
|
|
9421
9527
|
}
|
|
9528
|
+
return messages;
|
|
9529
|
+
}
|
|
9530
|
+
async function auditChangedRecords(input2) {
|
|
9531
|
+
const decisionsPrefix = `${input2.decisionsRel}/`;
|
|
9422
9532
|
const decisionsDir = path47.join(input2.root, input2.decisionsRel);
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
|
|
9533
|
+
const audit = {
|
|
9534
|
+
records: [],
|
|
9535
|
+
misplaced: [],
|
|
9536
|
+
failing: [],
|
|
9537
|
+
duplicates: [],
|
|
9538
|
+
errors: []
|
|
9539
|
+
};
|
|
9540
|
+
const indexErrors = /* @__PURE__ */ new Set();
|
|
9541
|
+
for (const rel of input2.changedFiles) {
|
|
9542
|
+
if (!/\.md$/i.test(rel)) continue;
|
|
9543
|
+
const inside = rel.startsWith(decisionsPrefix);
|
|
9544
|
+
if (inside && !isDecisionFileName(path47.basename(rel))) continue;
|
|
9545
|
+
const content2 = await readDiffContent(input2.root, rel, input2.diff);
|
|
9546
|
+
if (content2 === void 0) continue;
|
|
9547
|
+
if (!inside) {
|
|
9548
|
+
if (!isAdrShaped(rel, content2)) continue;
|
|
9549
|
+
audit.misplaced.push(rel);
|
|
9550
|
+
audit.errors.push(`no ADR outside ${decisionsPrefix}: ${rel}`);
|
|
9551
|
+
continue;
|
|
9552
|
+
}
|
|
9553
|
+
audit.records.push(rel);
|
|
9426
9554
|
const valid = validateDecisionContent(content2);
|
|
9427
|
-
|
|
9428
|
-
|
|
9429
|
-
|
|
9430
|
-
|
|
9431
|
-
|
|
9432
|
-
);
|
|
9433
|
-
errors.push(...indexErrors);
|
|
9434
|
-
return
|
|
9555
|
+
const index = await validateIndexSync(decisionsDir, [path47.basename(rel)], input2.decisionsRel);
|
|
9556
|
+
if (!valid.ok) audit.errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
|
|
9557
|
+
for (const e of index) indexErrors.add(e);
|
|
9558
|
+
if (!valid.ok || index.length > 0) audit.failing.push(rel);
|
|
9559
|
+
}
|
|
9560
|
+
audit.duplicates = await duplicateNumbers(input2, audit.records);
|
|
9561
|
+
audit.errors.push(...indexErrors, ...audit.duplicates);
|
|
9562
|
+
return audit;
|
|
9563
|
+
}
|
|
9564
|
+
function waysOut(decisionsRel) {
|
|
9565
|
+
return `Write an ADR under ${decisionsRel}/ (haus decisions suggest), or put [adr-skip] on its own line in the PR body or a commit body for non-security changes.`;
|
|
9566
|
+
}
|
|
9567
|
+
function recordWayOut(decisionsRel, audit, answersTrigger) {
|
|
9568
|
+
if (audit.errors.length === 0) return "";
|
|
9569
|
+
const answers = answersTrigger ? "; a record moved there also answers the trigger" : "";
|
|
9570
|
+
const steps = [
|
|
9571
|
+
...audit.misplaced.length > 0 ? [
|
|
9572
|
+
`Move ${audit.misplaced.join(", ")} into ${decisionsRel}/ under the next free number (haus decisions next-number) with the house heading${answers}.`
|
|
9573
|
+
] : [],
|
|
9574
|
+
...audit.duplicates.map((duplicate) => `${duplicate}.`),
|
|
9575
|
+
...audit.failing.length > 0 ? [`Fix ${audit.failing.join(", ")} in place.`] : []
|
|
9576
|
+
];
|
|
9577
|
+
return `${steps.join(" ")} A record takes the house format: a \`# ADR-NNNN: Title\` heading, the sections ## Context, ## Decision and ## Consequences, and a row in ${decisionsRel}/README.md. Even [adr-skip] on its own line does not waive this.`;
|
|
9435
9578
|
}
|
|
9436
|
-
var WAYS_OUT = "Write an ADR under docs/decisions/ (haus decisions suggest), or use [adr-skip] for non-security changes.";
|
|
9437
9579
|
async function runDecisionsCheck(root, opts = {}) {
|
|
9438
9580
|
const reasons = [];
|
|
9439
9581
|
if (await readGateMode(root) === "off") {
|
|
@@ -9441,7 +9583,8 @@ async function runDecisionsCheck(root, opts = {}) {
|
|
|
9441
9583
|
triggered: false,
|
|
9442
9584
|
satisfied: true,
|
|
9443
9585
|
reasons: ["gate disabled via .haus-workflow/adr-gate.json"],
|
|
9444
|
-
securityTriggered: false
|
|
9586
|
+
securityTriggered: false,
|
|
9587
|
+
...noRecordFailure()
|
|
9445
9588
|
};
|
|
9446
9589
|
}
|
|
9447
9590
|
const stats = await collectDiffStats(root, opts);
|
|
@@ -9452,73 +9595,77 @@ async function runDecisionsCheck(root, opts = {}) {
|
|
|
9452
9595
|
triggered: false,
|
|
9453
9596
|
satisfied: true,
|
|
9454
9597
|
reasons: ["catalog-sync branch \u2014 exempt"],
|
|
9455
|
-
securityTriggered: false
|
|
9598
|
+
securityTriggered: false,
|
|
9599
|
+
...noRecordFailure()
|
|
9456
9600
|
};
|
|
9457
9601
|
}
|
|
9602
|
+
const decisionsDir = await resolveDecisionsDir(root);
|
|
9603
|
+
const decisionsRel = relativeDecisionPath(decisionsDir, root);
|
|
9604
|
+
const diff = { staged: opts.staged, range: opts.range };
|
|
9605
|
+
const migrated = await migratedRecords(root, decisionsRel, stats.renames ?? [], diff);
|
|
9606
|
+
const changed = await auditChangedRecords({
|
|
9607
|
+
root,
|
|
9608
|
+
decisionsRel,
|
|
9609
|
+
changedFiles: (stats.presentFiles ?? stats.files).filter((rel) => !migrated.has(rel)),
|
|
9610
|
+
diff
|
|
9611
|
+
});
|
|
9458
9612
|
const triggerReasons = explainDecisionTrigger(stats);
|
|
9459
|
-
const triggered = triggerReasons.length > 0;
|
|
9613
|
+
const triggered = triggerReasons.length > 0 || changed.errors.length > 0;
|
|
9460
9614
|
if (!triggered) {
|
|
9461
9615
|
return {
|
|
9462
9616
|
triggered: false,
|
|
9463
9617
|
satisfied: true,
|
|
9464
9618
|
reasons: ["no decision-worthy changes"],
|
|
9465
|
-
securityTriggered
|
|
9619
|
+
securityTriggered,
|
|
9620
|
+
...noRecordFailure()
|
|
9466
9621
|
};
|
|
9467
9622
|
}
|
|
9468
9623
|
reasons.push(...triggerReasons);
|
|
9469
|
-
const
|
|
9470
|
-
|
|
9471
|
-
if (
|
|
9472
|
-
reasons.push("[adr-skip]
|
|
9473
|
-
return { triggered: true, satisfied: true, reasons, securityTriggered };
|
|
9624
|
+
const skip = hasSkipToken(opts.prBody ?? "") || (opts.commitMessages ?? []).some(commitHasSkipDirective);
|
|
9625
|
+
const waived = skip && !securityTriggered;
|
|
9626
|
+
if (waived && changed.errors.length === 0) {
|
|
9627
|
+
reasons.push("gate waived by [adr-skip] on its own line (non-security change)");
|
|
9628
|
+
return { triggered: true, satisfied: true, reasons, securityTriggered, ...noRecordFailure() };
|
|
9629
|
+
}
|
|
9630
|
+
if (waived) {
|
|
9631
|
+
reasons.push(
|
|
9632
|
+
"the trigger rules are waived by [adr-skip] on its own line, but a failing record is not"
|
|
9633
|
+
);
|
|
9474
9634
|
}
|
|
9475
|
-
if (
|
|
9476
|
-
reasons.push("[adr-skip]
|
|
9635
|
+
if (skip && securityTriggered) {
|
|
9636
|
+
reasons.push("security/auth path changes ignore [adr-skip], even on its own line");
|
|
9477
9637
|
}
|
|
9478
|
-
const
|
|
9479
|
-
const
|
|
9480
|
-
const
|
|
9481
|
-
|
|
9482
|
-
root,
|
|
9483
|
-
decisionsRel,
|
|
9484
|
-
changedFiles: stats.files
|
|
9485
|
-
});
|
|
9486
|
-
if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
|
|
9487
|
-
const legacyExists = await fs39.pathExists(path47.join(root, legacyRel));
|
|
9488
|
-
if (legacyExists && !await fs39.pathExists(decisionsDir)) {
|
|
9489
|
-
satisfactionErrors = await checkSatisfaction({
|
|
9490
|
-
root,
|
|
9491
|
-
decisionsRel: legacyRel,
|
|
9492
|
-
changedFiles: stats.files
|
|
9493
|
-
});
|
|
9494
|
-
}
|
|
9495
|
-
}
|
|
9496
|
-
if (satisfactionErrors.length === 0) {
|
|
9638
|
+
const missingDecision = !waived && triggerReasons.length > 0 && changed.records.length === 0;
|
|
9639
|
+
const wayOut = recordWayOut(decisionsRel, changed, missingDecision);
|
|
9640
|
+
const recordFields = { missingDecision, recordErrors: changed.errors, recordWayOut: wayOut };
|
|
9641
|
+
if (!missingDecision && changed.errors.length === 0) {
|
|
9497
9642
|
reasons.push("decision record present");
|
|
9498
|
-
return {
|
|
9499
|
-
triggered: true,
|
|
9500
|
-
satisfied: true,
|
|
9501
|
-
reasons,
|
|
9502
|
-
securityTriggered
|
|
9503
|
-
};
|
|
9643
|
+
return { triggered: true, satisfied: true, reasons, securityTriggered, ...recordFields };
|
|
9504
9644
|
}
|
|
9505
|
-
reasons.push(
|
|
9506
|
-
reasons.push(
|
|
9507
|
-
|
|
9645
|
+
if (missingDecision) reasons.push(`no new or updated decision under ${decisionsRel}/`);
|
|
9646
|
+
reasons.push(...changed.errors);
|
|
9647
|
+
if (missingDecision && changed.errors.length === 0) reasons.push(waysOut(decisionsRel));
|
|
9648
|
+
if (wayOut) reasons.push(wayOut);
|
|
9649
|
+
return { triggered: true, satisfied: false, reasons, securityTriggered, ...recordFields };
|
|
9508
9650
|
}
|
|
9509
9651
|
async function listDecisionNumbers(decisionsDir) {
|
|
9510
|
-
if (!await
|
|
9511
|
-
const entries = await
|
|
9652
|
+
if (!await fs40.pathExists(decisionsDir)) return [];
|
|
9653
|
+
const entries = await fs40.readdir(decisionsDir);
|
|
9512
9654
|
return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
|
|
9513
9655
|
}
|
|
9514
9656
|
|
|
9515
9657
|
// src/decisions/doctor.ts
|
|
9658
|
+
async function countMarkdownFiles(dir) {
|
|
9659
|
+
if (!(await fs41.stat(dir)).isDirectory()) return 0;
|
|
9660
|
+
const entries = await fs41.readdir(dir, { recursive: true, withFileTypes: true });
|
|
9661
|
+
return entries.filter((entry) => entry.isFile() && /\.md$/i.test(entry.name)).length;
|
|
9662
|
+
}
|
|
9516
9663
|
async function auditDecisionsLayout(root, profile) {
|
|
9517
9664
|
const findings = [];
|
|
9518
9665
|
const primary = path48.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
9519
9666
|
const legacy = path48.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
9520
|
-
const hasPrimary = await
|
|
9521
|
-
const hasLegacy = await
|
|
9667
|
+
const hasPrimary = await fs41.pathExists(primary);
|
|
9668
|
+
const hasLegacy = await fs41.pathExists(legacy);
|
|
9522
9669
|
if (!hasPrimary && hasLegacy) {
|
|
9523
9670
|
if (PROFILES[profile].writesDecisionsSeed) {
|
|
9524
9671
|
findings.push({
|
|
@@ -9531,10 +9678,20 @@ async function auditDecisionsLayout(root, profile) {
|
|
|
9531
9678
|
return findings;
|
|
9532
9679
|
}
|
|
9533
9680
|
if (!hasPrimary) return findings;
|
|
9681
|
+
if (hasLegacy) {
|
|
9682
|
+
const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
|
|
9683
|
+
const primaryRel = DECISIONS_TRIGGERS.decisionsDir;
|
|
9684
|
+
findings.push({
|
|
9685
|
+
level: "advisory",
|
|
9686
|
+
line: `- DECISIONS: a second ADR home exists at ${legacyRel}/ (${await countMarkdownFiles(legacy)} file(s)); records there are invisible to the gate`,
|
|
9687
|
+
sentence: `Decision records in ${legacyRel}/ never count: the decisions gate accepts records only in ${primaryRel}/, and a record moved there must pass haus decisions validate and take a free number`,
|
|
9688
|
+
fix: `move each file to ${primaryRel}/ under the next free number (haus decisions next-number), with the # ADR-NNNN: Title heading and a README row`
|
|
9689
|
+
});
|
|
9690
|
+
}
|
|
9534
9691
|
const decisionsDir = await resolveDecisionsDir(root);
|
|
9535
9692
|
const rel = relativeDecisionPath(decisionsDir, root);
|
|
9536
9693
|
const readmePath = path48.join(decisionsDir, "README.md");
|
|
9537
|
-
if (!await
|
|
9694
|
+
if (!await fs41.pathExists(readmePath)) {
|
|
9538
9695
|
findings.push({
|
|
9539
9696
|
level: "advisory",
|
|
9540
9697
|
line: `- DECISIONS: missing ${rel}/README.md index`,
|
|
@@ -9544,7 +9701,7 @@ async function auditDecisionsLayout(root, profile) {
|
|
|
9544
9701
|
return findings;
|
|
9545
9702
|
}
|
|
9546
9703
|
const readme = await readText(readmePath);
|
|
9547
|
-
const entries = (await
|
|
9704
|
+
const entries = (await fs41.readdir(decisionsDir)).filter(
|
|
9548
9705
|
(f) => f.endsWith(".md") && f !== "README.md"
|
|
9549
9706
|
);
|
|
9550
9707
|
for (const file of entries) {
|
|
@@ -9593,7 +9750,7 @@ init_fs();
|
|
|
9593
9750
|
init_logger();
|
|
9594
9751
|
init_paths();
|
|
9595
9752
|
import path49 from "node:path";
|
|
9596
|
-
import
|
|
9753
|
+
import fs42 from "fs-extra";
|
|
9597
9754
|
function manifestPathIn(configDir) {
|
|
9598
9755
|
return path49.join(configDir, "haus", "install-manifest.json");
|
|
9599
9756
|
}
|
|
@@ -9623,9 +9780,9 @@ async function pruneVacatedDirs(root, vacatedFiles) {
|
|
|
9623
9780
|
async function destinationWritable(dir) {
|
|
9624
9781
|
let candidate = path49.resolve(dir);
|
|
9625
9782
|
for (; ; ) {
|
|
9626
|
-
if (await
|
|
9783
|
+
if (await fs42.pathExists(candidate)) {
|
|
9627
9784
|
try {
|
|
9628
|
-
await
|
|
9785
|
+
await fs42.access(candidate, fs42.constants.W_OK);
|
|
9629
9786
|
return true;
|
|
9630
9787
|
} catch (err) {
|
|
9631
9788
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -9657,7 +9814,7 @@ async function migrateGlobalConfigDir(options = {}) {
|
|
|
9657
9814
|
if (!legacy || !Array.isArray(legacy.files)) {
|
|
9658
9815
|
return { ...base, reason: "no install manifest under ~/.claude" };
|
|
9659
9816
|
}
|
|
9660
|
-
if (await
|
|
9817
|
+
if (await fs42.pathExists(manifestPathIn(to))) {
|
|
9661
9818
|
return { ...base, reason: "an install manifest already exists in the resolved config dir" };
|
|
9662
9819
|
}
|
|
9663
9820
|
const writable = await destinationWritable(to);
|
|
@@ -9680,14 +9837,14 @@ async function migrateGlobalConfigDir(options = {}) {
|
|
|
9680
9837
|
continue;
|
|
9681
9838
|
}
|
|
9682
9839
|
const newDest = path49.join(to, rel);
|
|
9683
|
-
if (!await
|
|
9840
|
+
if (!await fs42.pathExists(entry.destPath)) {
|
|
9684
9841
|
rewritten.push({ ...entry, destPath: newDest });
|
|
9685
9842
|
continue;
|
|
9686
9843
|
}
|
|
9687
|
-
if (await
|
|
9844
|
+
if (await fs42.pathExists(newDest)) {
|
|
9688
9845
|
if (await sameContent(entry.destPath, newDest)) {
|
|
9689
9846
|
if (!dryRun) {
|
|
9690
|
-
await
|
|
9847
|
+
await fs42.remove(entry.destPath);
|
|
9691
9848
|
vacated.push(entry.destPath);
|
|
9692
9849
|
}
|
|
9693
9850
|
moved.push(newDest);
|
|
@@ -9704,7 +9861,7 @@ async function migrateGlobalConfigDir(options = {}) {
|
|
|
9704
9861
|
continue;
|
|
9705
9862
|
}
|
|
9706
9863
|
try {
|
|
9707
|
-
await
|
|
9864
|
+
await fs42.move(entry.destPath, newDest);
|
|
9708
9865
|
vacated.push(entry.destPath);
|
|
9709
9866
|
moved.push(newDest);
|
|
9710
9867
|
rewritten.push({ ...entry, destPath: newDest });
|
|
@@ -9729,7 +9886,7 @@ async function migrateGlobalConfigDir(options = {}) {
|
|
|
9729
9886
|
}
|
|
9730
9887
|
try {
|
|
9731
9888
|
await writeJson(manifestPathIn(to), { ...legacy, files: rewritten });
|
|
9732
|
-
await
|
|
9889
|
+
await fs42.remove(legacyManifestPath);
|
|
9733
9890
|
vacated.push(legacyManifestPath);
|
|
9734
9891
|
} catch (err) {
|
|
9735
9892
|
return {
|
|
@@ -10027,7 +10184,7 @@ async function runDoctor(options) {
|
|
|
10027
10184
|
const resolved = path50.resolve(root, target);
|
|
10028
10185
|
const relFromRoot = path50.relative(root, resolved);
|
|
10029
10186
|
const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path50.sep}`) || path50.isAbsolute(relFromRoot);
|
|
10030
|
-
if (escapesRoot || !await
|
|
10187
|
+
if (escapesRoot || !await fs43.pathExists(resolved)) {
|
|
10031
10188
|
flag(
|
|
10032
10189
|
`- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
|
|
10033
10190
|
`A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
|
|
@@ -10045,7 +10202,7 @@ async function runDoctor(options) {
|
|
|
10045
10202
|
);
|
|
10046
10203
|
}
|
|
10047
10204
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
10048
|
-
const workflowExists = await
|
|
10205
|
+
const workflowExists = await fs43.pathExists(workflowPath);
|
|
10049
10206
|
if (!workflowExists) {
|
|
10050
10207
|
flag(
|
|
10051
10208
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
@@ -10068,7 +10225,7 @@ async function runDoctor(options) {
|
|
|
10068
10225
|
"templates",
|
|
10069
10226
|
path50.basename(PROFILES[profile].workflowTemplateRel)
|
|
10070
10227
|
);
|
|
10071
|
-
const templatePath = await
|
|
10228
|
+
const templatePath = await fs43.pathExists(cachePath) ? cachePath : bundledPath;
|
|
10072
10229
|
const templateContent = await readText(templatePath);
|
|
10073
10230
|
const verdict = checkManagedTamper(bodyContent, managedHeader.hash, templateContent ?? null);
|
|
10074
10231
|
if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
|
|
@@ -10096,7 +10253,7 @@ async function runDoctor(options) {
|
|
|
10096
10253
|
}
|
|
10097
10254
|
}
|
|
10098
10255
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
10099
|
-
const workflowConfigExists = await
|
|
10256
|
+
const workflowConfigExists = await fs43.pathExists(workflowConfigPath);
|
|
10100
10257
|
if (!workflowConfigExists) {
|
|
10101
10258
|
flag(
|
|
10102
10259
|
"- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
|
|
@@ -10104,7 +10261,7 @@ async function runDoctor(options) {
|
|
|
10104
10261
|
"haus apply --write"
|
|
10105
10262
|
);
|
|
10106
10263
|
} else {
|
|
10107
|
-
const cfg = await
|
|
10264
|
+
const cfg = await fs43.readFile(workflowConfigPath, "utf8");
|
|
10108
10265
|
const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
|
|
10109
10266
|
if (unfilled > 0) {
|
|
10110
10267
|
suggest(
|
|
@@ -10116,11 +10273,11 @@ async function runDoctor(options) {
|
|
|
10116
10273
|
ok("- .haus-workflow/workflow-config.md: OK (project-owned)");
|
|
10117
10274
|
}
|
|
10118
10275
|
}
|
|
10119
|
-
const docsSkillInstalled = await
|
|
10276
|
+
const docsSkillInstalled = await fs43.pathExists(
|
|
10120
10277
|
path50.join(root, ".claude", "skills", "writing-documentation", "SKILL.md")
|
|
10121
10278
|
);
|
|
10122
10279
|
if (docsSkillInstalled) {
|
|
10123
|
-
if (await
|
|
10280
|
+
if (await fs43.pathExists(path50.join(root, "docs", "SUMMARY.md"))) {
|
|
10124
10281
|
ok("- PROJECT DOCS: OK (docs/SUMMARY.md present)");
|
|
10125
10282
|
} else if (PROFILES[profile].runsDocsPass) {
|
|
10126
10283
|
suggest(
|
|
@@ -10449,7 +10606,7 @@ async function runDoctor(options) {
|
|
|
10449
10606
|
suggest(
|
|
10450
10607
|
`- ELIGIBILITY DRIFT: ${drifted.length} installed item(s) are no longer recommended by the current signals (${drifted.slice(0, 3).join(", ")}${drifted.length > 3 ? ", \u2026" : ""})`,
|
|
10451
10608
|
`${drifted.length} installed item(s) no longer match anything haus can detect in this repo \u2014 often a role that only \`deep-context.json\` knew about, which is not tracked in git`,
|
|
10452
|
-
"declare the role or stack in .haus-workflow/haus.config.json (
|
|
10609
|
+
"declare the role with `haus recommend --declare-role <role>` or the stack in .haus-workflow/haus.config.json (declaredStacks), re-run the docs pass to regenerate deep-context.json, or `haus remove <id>` if the item is genuinely no longer wanted"
|
|
10453
10610
|
);
|
|
10454
10611
|
}
|
|
10455
10612
|
}
|
|
@@ -10684,7 +10841,7 @@ async function runDoctor(options) {
|
|
|
10684
10841
|
let onDisk = false;
|
|
10685
10842
|
for (const rel of row.paths ?? []) {
|
|
10686
10843
|
const abs = resolveContainedPath(root, rel);
|
|
10687
|
-
if (abs !== null && await
|
|
10844
|
+
if (abs !== null && await fs43.pathExists(abs)) {
|
|
10688
10845
|
onDisk = true;
|
|
10689
10846
|
break;
|
|
10690
10847
|
}
|
|
@@ -10766,11 +10923,11 @@ init_github_rate_limit();
|
|
|
10766
10923
|
init_private_catalog_access();
|
|
10767
10924
|
|
|
10768
10925
|
// src/claude/refresh-project.ts
|
|
10769
|
-
import
|
|
10926
|
+
import fs44 from "fs-extra";
|
|
10770
10927
|
init_fs();
|
|
10771
10928
|
init_paths();
|
|
10772
10929
|
async function isHausProject(root) {
|
|
10773
|
-
if (await
|
|
10930
|
+
if (await fs44.pathExists(hausPath(root, "recommendation.json"))) return true;
|
|
10774
10931
|
const settings = await readJsonDetailed(claudePath(root, "settings.json"));
|
|
10775
10932
|
if (settings.status === "ok" && settings.value._haus != null) return true;
|
|
10776
10933
|
const lock = await readJsonDetailed(hausPath(root, "haus.lock.json"));
|
|
@@ -10792,7 +10949,7 @@ async function refreshProjectApply(root) {
|
|
|
10792
10949
|
|
|
10793
10950
|
// src/install/apply.ts
|
|
10794
10951
|
import path52 from "node:path";
|
|
10795
|
-
import
|
|
10952
|
+
import fs46 from "fs-extra";
|
|
10796
10953
|
init_fs();
|
|
10797
10954
|
init_logger();
|
|
10798
10955
|
init_paths();
|
|
@@ -10902,7 +11059,7 @@ ${content2}`;
|
|
|
10902
11059
|
// src/install/internal-channel.ts
|
|
10903
11060
|
init_fs();
|
|
10904
11061
|
import path51 from "node:path";
|
|
10905
|
-
import
|
|
11062
|
+
import fs45 from "fs-extra";
|
|
10906
11063
|
var INSTALL_CONFIG_SCHEMA = "haus-install-config/1";
|
|
10907
11064
|
var INTERNAL_NAME_PREFIXES = ["request", "slack-cowork"];
|
|
10908
11065
|
function internalPrefixMatch(name) {
|
|
@@ -10936,7 +11093,7 @@ async function writeInstallConfig(config) {
|
|
|
10936
11093
|
}
|
|
10937
11094
|
async function removeInstallConfig() {
|
|
10938
11095
|
const p = installConfigPath();
|
|
10939
|
-
if (
|
|
11096
|
+
if (fs45.pathExistsSync(p)) await fs45.remove(p);
|
|
10940
11097
|
}
|
|
10941
11098
|
function envOverride() {
|
|
10942
11099
|
const raw = process.env.HAUS_INTERNAL_CHANNEL?.trim().toLowerCase();
|
|
@@ -10966,8 +11123,8 @@ async function pruneEmptyDirsUpward(dirs, stopAt) {
|
|
|
10966
11123
|
let dir = start;
|
|
10967
11124
|
while (dir.startsWith(stopAt) && dir !== stopAt) {
|
|
10968
11125
|
try {
|
|
10969
|
-
if ((await
|
|
10970
|
-
await
|
|
11126
|
+
if ((await fs46.readdir(dir)).length > 0) break;
|
|
11127
|
+
await fs46.remove(dir);
|
|
10971
11128
|
} catch {
|
|
10972
11129
|
break;
|
|
10973
11130
|
}
|
|
@@ -10980,7 +11137,7 @@ function hashContent(content2) {
|
|
|
10980
11137
|
}
|
|
10981
11138
|
function destFileMissing(destPath) {
|
|
10982
11139
|
try {
|
|
10983
|
-
return !
|
|
11140
|
+
return !fs46.statSync(destPath).isFile();
|
|
10984
11141
|
} catch {
|
|
10985
11142
|
return true;
|
|
10986
11143
|
}
|
|
@@ -10988,7 +11145,7 @@ function destFileMissing(destPath) {
|
|
|
10988
11145
|
function sourceVersion() {
|
|
10989
11146
|
try {
|
|
10990
11147
|
const pkgPath = path52.join(packageRoot(), "package.json");
|
|
10991
|
-
const pkg = JSON.parse(
|
|
11148
|
+
const pkg = JSON.parse(fs46.readFileSync(pkgPath, "utf8"));
|
|
10992
11149
|
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
10993
11150
|
} catch {
|
|
10994
11151
|
return "haus@0.0.0";
|
|
@@ -11000,10 +11157,10 @@ function globalSrcDir() {
|
|
|
11000
11157
|
function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
|
|
11001
11158
|
const skillDir = path52.join(skillsDir, skillName);
|
|
11002
11159
|
const entries = [];
|
|
11003
|
-
for (const rel of
|
|
11160
|
+
for (const rel of fs46.readdirSync(skillDir, { recursive: true })) {
|
|
11004
11161
|
if (rel === "SKILL.md") continue;
|
|
11005
11162
|
const abs = path52.join(skillDir, rel);
|
|
11006
|
-
if (!
|
|
11163
|
+
if (!fs46.statSync(abs).isFile()) continue;
|
|
11007
11164
|
const posixRel = rel.split(path52.sep).join("/");
|
|
11008
11165
|
entries.push({
|
|
11009
11166
|
stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
|
|
@@ -11016,8 +11173,8 @@ function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
|
|
|
11016
11173
|
function collectFlatChannel(srcDir, claudeDir, dirName, idPrefix) {
|
|
11017
11174
|
const entries = [];
|
|
11018
11175
|
const channelDir = path52.join(srcDir, dirName);
|
|
11019
|
-
if (!
|
|
11020
|
-
for (const fileName of
|
|
11176
|
+
if (!fs46.pathExistsSync(channelDir)) return entries;
|
|
11177
|
+
for (const fileName of fs46.readdirSync(channelDir)) {
|
|
11021
11178
|
if (!fileName.endsWith(".md")) continue;
|
|
11022
11179
|
const name = fileName.slice(0, -".md".length);
|
|
11023
11180
|
entries.push({
|
|
@@ -11031,10 +11188,10 @@ function collectFlatChannel(srcDir, claudeDir, dirName, idPrefix) {
|
|
|
11031
11188
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
11032
11189
|
const entries = [];
|
|
11033
11190
|
const skillsDir = path52.join(srcDir, "skills");
|
|
11034
|
-
if (
|
|
11035
|
-
for (const skillName of
|
|
11191
|
+
if (fs46.pathExistsSync(skillsDir)) {
|
|
11192
|
+
for (const skillName of fs46.readdirSync(skillsDir)) {
|
|
11036
11193
|
const skillFile = path52.join(skillsDir, skillName, "SKILL.md");
|
|
11037
|
-
if (
|
|
11194
|
+
if (fs46.pathExistsSync(skillFile)) {
|
|
11038
11195
|
entries.push({
|
|
11039
11196
|
stableId: `skill.${skillName}`,
|
|
11040
11197
|
srcRelPath: path52.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
@@ -11107,7 +11264,7 @@ async function applyInstall(options = {}) {
|
|
|
11107
11264
|
}
|
|
11108
11265
|
continue;
|
|
11109
11266
|
}
|
|
11110
|
-
const destExists =
|
|
11267
|
+
const destExists = fs46.pathExistsSync(entry.destPath);
|
|
11111
11268
|
if (destExists) {
|
|
11112
11269
|
const currentContent = await readText(entry.destPath);
|
|
11113
11270
|
if (currentContent !== void 0) {
|
|
@@ -11167,13 +11324,13 @@ async function applyInstall(options = {}) {
|
|
|
11167
11324
|
manifestFiles.push(entry);
|
|
11168
11325
|
continue;
|
|
11169
11326
|
}
|
|
11170
|
-
if (!
|
|
11327
|
+
if (!fs46.pathExistsSync(entry.destPath)) continue;
|
|
11171
11328
|
const content2 = await readText(entry.destPath);
|
|
11172
11329
|
if (!content2) continue;
|
|
11173
11330
|
const hasHeader = parseMarkdownHeader(content2) !== void 0;
|
|
11174
11331
|
const currentHash = hashContent(content2);
|
|
11175
11332
|
if (hasHeader && currentHash === entry.hash) {
|
|
11176
|
-
if (!dryRun) await
|
|
11333
|
+
if (!dryRun) await fs46.remove(entry.destPath);
|
|
11177
11334
|
emptiedDirs.add(path52.dirname(entry.destPath));
|
|
11178
11335
|
result.deleted.push(entry.destPath);
|
|
11179
11336
|
} else {
|
|
@@ -11857,7 +12014,7 @@ function isCatalogItemInstallable(item) {
|
|
|
11857
12014
|
// src/install/scaffold.ts
|
|
11858
12015
|
init_logger();
|
|
11859
12016
|
import path56 from "node:path";
|
|
11860
|
-
import
|
|
12017
|
+
import fs47 from "fs-extra";
|
|
11861
12018
|
async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
11862
12019
|
const scaffolded = [];
|
|
11863
12020
|
const skipped = [];
|
|
@@ -11870,7 +12027,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
11870
12027
|
skipped.push(name);
|
|
11871
12028
|
if (outcome === "skipped-exists") skippedExisting.push(name);
|
|
11872
12029
|
};
|
|
11873
|
-
const realRoot = await
|
|
12030
|
+
const realRoot = await fs47.realpath(catalogRoot).catch(() => null);
|
|
11874
12031
|
for (const item of items) {
|
|
11875
12032
|
if (item.type !== "config") continue;
|
|
11876
12033
|
const sourcePath = path56.resolve(catalogRoot, item.path);
|
|
@@ -11880,7 +12037,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
11880
12037
|
warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
|
|
11881
12038
|
continue;
|
|
11882
12039
|
}
|
|
11883
|
-
const realSource = await
|
|
12040
|
+
const realSource = await fs47.realpath(sourcePath).catch(() => null);
|
|
11884
12041
|
if (!realRoot || !realSource) {
|
|
11885
12042
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
11886
12043
|
continue;
|
|
@@ -11890,7 +12047,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
11890
12047
|
warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
|
|
11891
12048
|
continue;
|
|
11892
12049
|
}
|
|
11893
|
-
const stat = await
|
|
12050
|
+
const stat = await fs47.lstat(sourcePath).catch(() => null);
|
|
11894
12051
|
if (!stat) {
|
|
11895
12052
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
11896
12053
|
continue;
|
|
@@ -11904,7 +12061,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
11904
12061
|
const dest = path56.join(projectRoot, filename);
|
|
11905
12062
|
record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
|
|
11906
12063
|
} else if (stat.isDirectory()) {
|
|
11907
|
-
const entries = await
|
|
12064
|
+
const entries = await fs47.readdir(sourcePath);
|
|
11908
12065
|
for (const entry of entries) {
|
|
11909
12066
|
const src = path56.join(sourcePath, entry);
|
|
11910
12067
|
const dest = path56.join(projectRoot, entry);
|
|
@@ -11916,7 +12073,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
11916
12073
|
}
|
|
11917
12074
|
async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
|
|
11918
12075
|
const destDisplay = path56.relative(projectRoot, dest) || path56.basename(dest);
|
|
11919
|
-
const srcStat = await
|
|
12076
|
+
const srcStat = await fs47.lstat(src).catch(() => null);
|
|
11920
12077
|
if (!srcStat) {
|
|
11921
12078
|
warn(`Skipping ${path56.basename(src)}: source not found`);
|
|
11922
12079
|
return "skipped-refused";
|
|
@@ -11925,7 +12082,7 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
|
|
|
11925
12082
|
warn(`Skipping ${path56.basename(src)}: source is a symlink`);
|
|
11926
12083
|
return "skipped-refused";
|
|
11927
12084
|
}
|
|
11928
|
-
const exists2 = await
|
|
12085
|
+
const exists2 = await fs47.pathExists(dest);
|
|
11929
12086
|
if (exists2 && !opts.force) {
|
|
11930
12087
|
warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
|
|
11931
12088
|
return "skipped-exists";
|
|
@@ -11935,16 +12092,16 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
|
|
|
11935
12092
|
return "scaffolded";
|
|
11936
12093
|
}
|
|
11937
12094
|
if (exists2 && opts.force) {
|
|
11938
|
-
await
|
|
12095
|
+
await fs47.remove(dest);
|
|
11939
12096
|
}
|
|
11940
|
-
await
|
|
11941
|
-
await
|
|
12097
|
+
await fs47.ensureDir(path56.dirname(dest));
|
|
12098
|
+
await fs47.copy(src, dest, {
|
|
11942
12099
|
overwrite: true,
|
|
11943
12100
|
dereference: false,
|
|
11944
12101
|
// A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
|
|
11945
12102
|
filter: (s) => {
|
|
11946
12103
|
try {
|
|
11947
|
-
return !
|
|
12104
|
+
return !fs47.lstatSync(s).isSymbolicLink();
|
|
11948
12105
|
} catch {
|
|
11949
12106
|
return false;
|
|
11950
12107
|
}
|
|
@@ -12057,7 +12214,6 @@ async function resolveBaseRef(root) {
|
|
|
12057
12214
|
}
|
|
12058
12215
|
|
|
12059
12216
|
// src/commands/decisions-guard.ts
|
|
12060
|
-
init_exec();
|
|
12061
12217
|
var PR_CREATE_PATTERN = /\bgh\s+pr\s+create\b/;
|
|
12062
12218
|
function readPayload(options) {
|
|
12063
12219
|
if (options.stdinPayload != null) {
|
|
@@ -12083,11 +12239,20 @@ async function runDecisionsGuard(options = {}) {
|
|
|
12083
12239
|
const baseRef = await resolveBaseRef(root);
|
|
12084
12240
|
if (!baseRef) return;
|
|
12085
12241
|
const range = `${baseRef}...HEAD`;
|
|
12086
|
-
const commitMessages =
|
|
12242
|
+
const commitMessages = await readCommitMessages(root, range);
|
|
12087
12243
|
const result = await runDecisionsCheck(root, { range, commitMessages });
|
|
12244
|
+
if (result.triggered && !result.satisfied && result.recordErrors.length > 0) {
|
|
12245
|
+
const what = result.missingDecision ? "this change needs an ADR, and a decision record in it is misplaced or invalid" : "a decision record in this change is misplaced or invalid";
|
|
12246
|
+
const why = result.missingDecision ? result.reasons.filter((r) => !r.startsWith("no new") && r !== result.recordWayOut) : result.recordErrors;
|
|
12247
|
+
emitPreToolUseDeny(
|
|
12248
|
+
`I didn't run that: ${what} (${why.join("; ")}). ${result.recordWayOut} Then retry.`
|
|
12249
|
+
);
|
|
12250
|
+
return;
|
|
12251
|
+
}
|
|
12088
12252
|
if (result.triggered && !result.satisfied) {
|
|
12253
|
+
const rel = relativeDecisionPath(await resolveDecisionsDir(root), root);
|
|
12089
12254
|
emitPreToolUseDeny(
|
|
12090
|
-
`I didn't run that \u2014 this change needs an ADR before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under
|
|
12255
|
+
`I didn't run that \u2014 this change needs an ADR before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under ${rel}/, then retry, or put [adr-skip] on its own line in a commit body for non-security changes.`
|
|
12091
12256
|
);
|
|
12092
12257
|
}
|
|
12093
12258
|
}
|
|
@@ -12104,6 +12269,14 @@ async function nextDecisionNumber(root) {
|
|
|
12104
12269
|
}
|
|
12105
12270
|
|
|
12106
12271
|
// src/decisions/suggest.ts
|
|
12272
|
+
function printRecordFailures(checks) {
|
|
12273
|
+
const lines = /* @__PURE__ */ new Set([
|
|
12274
|
+
...checks.flatMap((check) => check.recordErrors),
|
|
12275
|
+
...checks.map((check) => check.recordWayOut).filter((wayOut) => wayOut.length > 0)
|
|
12276
|
+
]);
|
|
12277
|
+
for (const line2 of lines) process.stdout.write(`${line2}
|
|
12278
|
+
`);
|
|
12279
|
+
}
|
|
12107
12280
|
function draftTemplate(number, title) {
|
|
12108
12281
|
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
12109
12282
|
return `# ADR-${number}: ${title}
|
|
@@ -12136,11 +12309,18 @@ _(Why this over alternatives \u2014 the line agents read for "why".)_
|
|
|
12136
12309
|
`;
|
|
12137
12310
|
}
|
|
12138
12311
|
async function runDecisionsSuggest(root, opts = {}) {
|
|
12139
|
-
|
|
12140
|
-
|
|
12141
|
-
|
|
12142
|
-
|
|
12143
|
-
|
|
12312
|
+
if (!opts.title) {
|
|
12313
|
+
const stagedCheck = await runDecisionsCheck(root, { staged: true });
|
|
12314
|
+
const unstagedCheck = await runDecisionsCheck(root, {});
|
|
12315
|
+
const triggered = stagedCheck.triggered || unstagedCheck.triggered;
|
|
12316
|
+
if (!triggered) return;
|
|
12317
|
+
if (stagedCheck.triggered && stagedCheck.satisfied) return;
|
|
12318
|
+
const wantsDraft = (check) => check.missingDecision && check.recordErrors.length === 0;
|
|
12319
|
+
if (!wantsDraft(stagedCheck) && !wantsDraft(unstagedCheck)) {
|
|
12320
|
+
if (!opts.fromHook) printRecordFailures([stagedCheck, unstagedCheck]);
|
|
12321
|
+
return;
|
|
12322
|
+
}
|
|
12323
|
+
}
|
|
12144
12324
|
const number = await nextDecisionNumber(root);
|
|
12145
12325
|
const title = opts.title ?? "Decision from current changes";
|
|
12146
12326
|
const draft = draftTemplate(number, title);
|
|
@@ -12168,9 +12348,7 @@ init_logger();
|
|
|
12168
12348
|
async function runDecisions(sub, arg, options = {}) {
|
|
12169
12349
|
const root = process.cwd();
|
|
12170
12350
|
if (sub === "check") {
|
|
12171
|
-
const commitMessages = options.range != null ?
|
|
12172
|
-
(m) => m.runGit(["log", "--format=%B", toAncestryLogRange(options.range)], { cwd: root })
|
|
12173
|
-
)).stdout : "";
|
|
12351
|
+
const commitMessages = options.range != null ? await readCommitMessages(root, options.range) : [];
|
|
12174
12352
|
const result = await runDecisionsCheck(root, {
|
|
12175
12353
|
staged: options.staged,
|
|
12176
12354
|
range: options.range,
|
|
@@ -12188,9 +12366,17 @@ async function runDecisions(sub, arg, options = {}) {
|
|
|
12188
12366
|
log(`decisions gate: satisfied (${result.reasons.join("; ")})`);
|
|
12189
12367
|
return;
|
|
12190
12368
|
}
|
|
12191
|
-
for (const reason of result.reasons) warn(reason);
|
|
12369
|
+
for (const reason of result.reasons) if (reason !== result.recordWayOut) warn(reason);
|
|
12370
|
+
if (result.recordErrors.length > 0) {
|
|
12371
|
+
const what = result.missingDecision ? "this change needs an ADR, and a decision record in it is misplaced or invalid" : "a decision record in this change is misplaced or invalid";
|
|
12372
|
+
const why = result.missingDecision ? result.reasons.filter((r) => !r.startsWith("no new") && r !== result.recordWayOut) : result.recordErrors;
|
|
12373
|
+
error(`decisions gate: ${what}. Why: ${why.join("; ")}. ${result.recordWayOut}`);
|
|
12374
|
+
process.exitCode = 1;
|
|
12375
|
+
return;
|
|
12376
|
+
}
|
|
12377
|
+
const rel = relativeDecisionPath(await resolveDecisionsDir(root), root);
|
|
12192
12378
|
error(
|
|
12193
|
-
`decisions gate: decision-worthy change without a valid ADR under
|
|
12379
|
+
`decisions gate: decision-worthy change without a valid ADR under ${rel}/. Why: ${result.reasons.filter((r) => !r.startsWith("no new")).join("; ") || "see above"}. Run \`haus decisions suggest\`, add ${rel}/NNNN-*.md + README row, or put [adr-skip] on its own line with a justification in the PR body or a commit body (ignored for security paths).`
|
|
12194
12380
|
);
|
|
12195
12381
|
process.exitCode = 1;
|
|
12196
12382
|
return;
|
|
@@ -12720,7 +12906,7 @@ function matchDangerousCommandShape(command) {
|
|
|
12720
12906
|
}
|
|
12721
12907
|
|
|
12722
12908
|
// src/security/guard-file-access.ts
|
|
12723
|
-
import
|
|
12909
|
+
import fs48 from "node:fs";
|
|
12724
12910
|
import path58 from "node:path";
|
|
12725
12911
|
function matchesDenyTier(candidate) {
|
|
12726
12912
|
const normalized = normalizePathForMatch(candidate);
|
|
@@ -12728,11 +12914,11 @@ function matchesDenyTier(candidate) {
|
|
|
12728
12914
|
}
|
|
12729
12915
|
function resolveOnDisk(candidate) {
|
|
12730
12916
|
try {
|
|
12731
|
-
const resolved =
|
|
12917
|
+
const resolved = fs48.realpathSync(candidate);
|
|
12732
12918
|
return resolved === path58.resolve(candidate) ? {} : { resolved };
|
|
12733
12919
|
} catch {
|
|
12734
12920
|
try {
|
|
12735
|
-
if (
|
|
12921
|
+
if (fs48.lstatSync(candidate).isSymbolicLink()) return { unverifiable: true };
|
|
12736
12922
|
} catch {
|
|
12737
12923
|
}
|
|
12738
12924
|
const parent = path58.dirname(candidate);
|
|
@@ -14861,7 +15047,7 @@ import path63 from "node:path";
|
|
|
14861
15047
|
|
|
14862
15048
|
// src/new-project/create-project.ts
|
|
14863
15049
|
import path62 from "node:path";
|
|
14864
|
-
import
|
|
15050
|
+
import fs49 from "fs-extra";
|
|
14865
15051
|
init_exec();
|
|
14866
15052
|
init_logger();
|
|
14867
15053
|
|
|
@@ -15049,7 +15235,7 @@ async function recordPackageManager(projectPath, pm, deps) {
|
|
|
15049
15235
|
const pkgPath = path62.join(projectPath, "package.json");
|
|
15050
15236
|
let pkg;
|
|
15051
15237
|
try {
|
|
15052
|
-
pkg = await
|
|
15238
|
+
pkg = await fs49.readJson(pkgPath);
|
|
15053
15239
|
} catch {
|
|
15054
15240
|
return {};
|
|
15055
15241
|
}
|
|
@@ -15064,7 +15250,7 @@ async function recordPackageManager(projectPath, pm, deps) {
|
|
|
15064
15250
|
note: `installed ${pm} is ${version}, which haus does not support \u2014 the package manager was not recorded, so the scanner will report it as unknown until you upgrade`
|
|
15065
15251
|
};
|
|
15066
15252
|
}
|
|
15067
|
-
await
|
|
15253
|
+
await fs49.writeJson(pkgPath, { ...pkg, packageManager: field }, { spaces: 2 });
|
|
15068
15254
|
return {};
|
|
15069
15255
|
}
|
|
15070
15256
|
var CONFIG_TOOLING = {
|
|
@@ -15082,7 +15268,7 @@ async function ensureConfigTooling(projectPath, scaffolded) {
|
|
|
15082
15268
|
const pkgPath = path62.join(projectPath, "package.json");
|
|
15083
15269
|
let pkg;
|
|
15084
15270
|
try {
|
|
15085
|
-
pkg = await
|
|
15271
|
+
pkg = await fs49.readJson(pkgPath);
|
|
15086
15272
|
} catch {
|
|
15087
15273
|
return [];
|
|
15088
15274
|
}
|
|
@@ -15106,7 +15292,7 @@ async function ensureConfigTooling(projectPath, scaffolded) {
|
|
|
15106
15292
|
}
|
|
15107
15293
|
}
|
|
15108
15294
|
if (!changed) return [];
|
|
15109
|
-
await
|
|
15295
|
+
await fs49.writeJson(pkgPath, { ...pkg, devDependencies, scripts }, { spaces: 2 });
|
|
15110
15296
|
return added;
|
|
15111
15297
|
}
|
|
15112
15298
|
async function createProject(opts, deps = defaultDeps) {
|
|
@@ -15119,7 +15305,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
15119
15305
|
};
|
|
15120
15306
|
const stack = getStack(opts.stackId);
|
|
15121
15307
|
if (!stack) return fail("preflight", `Unknown stack "${opts.stackId}". Run \`haus new --list\`.`);
|
|
15122
|
-
const parentStat = await
|
|
15308
|
+
const parentStat = await fs49.stat(path62.dirname(projectPath)).catch(() => null);
|
|
15123
15309
|
if (!parentStat?.isDirectory()) {
|
|
15124
15310
|
return fail(
|
|
15125
15311
|
"preflight",
|
|
@@ -15132,7 +15318,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
15132
15318
|
`Invalid project name "${opts.name}" (lowercase letters, digits, . _ - only).`
|
|
15133
15319
|
);
|
|
15134
15320
|
}
|
|
15135
|
-
if (await
|
|
15321
|
+
if (await fs49.pathExists(projectPath)) {
|
|
15136
15322
|
return fail("preflight", `Target ${projectPath} already exists \u2014 refusing to write into it.`);
|
|
15137
15323
|
}
|
|
15138
15324
|
const scaffoldArgs = stack.scaffold.args.map(
|
|
@@ -15208,13 +15394,13 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
15208
15394
|
});
|
|
15209
15395
|
if (scaffold.exitCode !== 0) {
|
|
15210
15396
|
const why = scaffold.killed ? `${pinned} did not complete: ${scaffold.stderr.trim() || `timed out after ${SCAFFOLD_TIMEOUT_MS}ms`}. Scaffolders that stall this way are usually waiting on a prompt despite --yes \u2014 run it yourself to see: npx ${npxArgs.join(" ")}` : `${pinned} exited ${scaffold.exitCode}.`;
|
|
15211
|
-
const leftBehind = await
|
|
15397
|
+
const leftBehind = await fs49.pathExists(projectPath) ? ` The directory was left for inspection \u2014 review it and remove it manually if unwanted (haus never auto-deletes).` : "";
|
|
15212
15398
|
return fail("scaffold", `${why}${leftBehind}`);
|
|
15213
15399
|
}
|
|
15214
15400
|
steps.push({ step: "scaffold", status: "done" });
|
|
15215
15401
|
phase = "git";
|
|
15216
15402
|
if (!opts.json) log("\u25B6 git: init/branch/commit");
|
|
15217
|
-
if (!await
|
|
15403
|
+
if (!await fs49.pathExists(path62.join(projectPath, ".git"))) {
|
|
15218
15404
|
const init = await deps.git(["init"], { cwd: projectPath });
|
|
15219
15405
|
if (init.exitCode !== 0) return fail("git", `git init failed: ${init.stderr}`);
|
|
15220
15406
|
}
|
|
@@ -15525,7 +15711,9 @@ async function runRecommend(options) {
|
|
|
15525
15711
|
const root = process.cwd();
|
|
15526
15712
|
const context = await readContextOrScan(root);
|
|
15527
15713
|
const include = parseIdList(options.include);
|
|
15714
|
+
const declaredRoles = parseIdList(options.declareRole);
|
|
15528
15715
|
if (include.length > 0) await addProjectIncludedIds(root, include);
|
|
15716
|
+
if (declaredRoles.length > 0) await addProjectDeclaredRoles(root, declaredRoles);
|
|
15529
15717
|
const activePluginIds = pluginIdsForInstall(await activeGlobalPluginIds(root));
|
|
15530
15718
|
let result;
|
|
15531
15719
|
try {
|
|
@@ -15546,7 +15734,7 @@ async function runRecommend(options) {
|
|
|
15546
15734
|
log(`Skipped: ${result.skipped.length}`);
|
|
15547
15735
|
if (result.optInEligible && result.optInEligible.length > 0) {
|
|
15548
15736
|
log(
|
|
15549
|
-
`Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`)`
|
|
15737
|
+
`Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`, or with \`haus recommend --declare-role <role>\` for role-gated items)`
|
|
15550
15738
|
);
|
|
15551
15739
|
}
|
|
15552
15740
|
for (const w of result.warnings) log(`- WARN: ${w}`);
|
|
@@ -15760,7 +15948,7 @@ async function runScan(options) {
|
|
|
15760
15948
|
|
|
15761
15949
|
// src/commands/undo.ts
|
|
15762
15950
|
import path65 from "node:path";
|
|
15763
|
-
import
|
|
15951
|
+
import fs50 from "fs-extra";
|
|
15764
15952
|
|
|
15765
15953
|
// src/claude/managed-paths.ts
|
|
15766
15954
|
init_paths();
|
|
@@ -15779,7 +15967,7 @@ init_paths();
|
|
|
15779
15967
|
async function collectCoreManagedPaths(root) {
|
|
15780
15968
|
const existing = [];
|
|
15781
15969
|
for (const abs of coreManagedAbsolutePaths(root)) {
|
|
15782
|
-
if (await
|
|
15970
|
+
if (await fs50.pathExists(abs)) existing.push(abs);
|
|
15783
15971
|
}
|
|
15784
15972
|
return existing;
|
|
15785
15973
|
}
|
|
@@ -15809,19 +15997,19 @@ async function collectLockTrackedPaths(root) {
|
|
|
15809
15997
|
}
|
|
15810
15998
|
async function settingsHasHausContent(root) {
|
|
15811
15999
|
const settingsPath = claudePath(root, "settings.json");
|
|
15812
|
-
if (!await
|
|
16000
|
+
if (!await fs50.pathExists(settingsPath)) return false;
|
|
15813
16001
|
const settings = await readProjectSettings(root);
|
|
15814
16002
|
return settings._haus != null;
|
|
15815
16003
|
}
|
|
15816
16004
|
async function claudeMdHasHausBlock(root) {
|
|
15817
16005
|
const filePath = path65.join(root, "CLAUDE.md");
|
|
15818
|
-
if (!await
|
|
15819
|
-
const text = await
|
|
16006
|
+
if (!await fs50.pathExists(filePath)) return false;
|
|
16007
|
+
const text = await fs50.readFile(filePath, "utf8");
|
|
15820
16008
|
return text.includes(BLOCK_BEGIN);
|
|
15821
16009
|
}
|
|
15822
16010
|
async function stripProjectSettings(root) {
|
|
15823
16011
|
const settingsPath = claudePath(root, "settings.json");
|
|
15824
|
-
if (!await
|
|
16012
|
+
if (!await fs50.pathExists(settingsPath)) return false;
|
|
15825
16013
|
let settings = await readProjectSettings(root);
|
|
15826
16014
|
settings = stripHausHooks(
|
|
15827
16015
|
stripHausAsk(stripHausAllow(stripHausDeny(stripHausEnabledPlugins(settings))))
|
|
@@ -15832,30 +16020,30 @@ async function stripProjectSettings(root) {
|
|
|
15832
16020
|
log(`Stripped haus rules from ${path65.relative(root, settingsPath)} (user settings preserved).`);
|
|
15833
16021
|
return true;
|
|
15834
16022
|
}
|
|
15835
|
-
await
|
|
16023
|
+
await fs50.remove(settingsPath);
|
|
15836
16024
|
log(`Removed ${path65.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
15837
16025
|
return true;
|
|
15838
16026
|
}
|
|
15839
16027
|
async function stripRootClaudeMd(root) {
|
|
15840
16028
|
const filePath = path65.join(root, "CLAUDE.md");
|
|
15841
|
-
if (!await
|
|
15842
|
-
const prev = await
|
|
16029
|
+
if (!await fs50.pathExists(filePath)) return false;
|
|
16030
|
+
const prev = await fs50.readFile(filePath, "utf8");
|
|
15843
16031
|
if (!prev.includes(BLOCK_BEGIN)) return false;
|
|
15844
16032
|
const next = stripHausBlock(prev);
|
|
15845
16033
|
if (next.length === 0) {
|
|
15846
|
-
await
|
|
16034
|
+
await fs50.remove(filePath);
|
|
15847
16035
|
log("Removed CLAUDE.md (only contained haus import block).");
|
|
15848
16036
|
} else {
|
|
15849
|
-
await
|
|
16037
|
+
await fs50.writeFile(filePath, next, "utf8");
|
|
15850
16038
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
15851
16039
|
}
|
|
15852
16040
|
return true;
|
|
15853
16041
|
}
|
|
15854
16042
|
async function pruneDirIfEmpty(dir) {
|
|
15855
|
-
if (!await
|
|
16043
|
+
if (!await fs50.pathExists(dir)) return;
|
|
15856
16044
|
try {
|
|
15857
|
-
const entries = await
|
|
15858
|
-
if (entries.length === 0) await
|
|
16045
|
+
const entries = await fs50.readdir(dir);
|
|
16046
|
+
if (entries.length === 0) await fs50.remove(dir);
|
|
15859
16047
|
} catch {
|
|
15860
16048
|
}
|
|
15861
16049
|
}
|
|
@@ -15864,11 +16052,11 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
|
|
|
15864
16052
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
15865
16053
|
const backupRoot = hausPath(root, "backups", dirBackupDirName("undo", stamp));
|
|
15866
16054
|
for (const abs of managedAbsPaths) {
|
|
15867
|
-
if (!await
|
|
16055
|
+
if (!await fs50.pathExists(abs)) continue;
|
|
15868
16056
|
const rel = path65.relative(root, abs);
|
|
15869
16057
|
const backupPath = path65.join(backupRoot, rel);
|
|
15870
|
-
await
|
|
15871
|
-
await
|
|
16058
|
+
await fs50.ensureDir(path65.dirname(backupPath));
|
|
16059
|
+
await fs50.copy(abs, backupPath);
|
|
15872
16060
|
}
|
|
15873
16061
|
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path65.relative(root, backupRoot)}.`);
|
|
15874
16062
|
}
|
|
@@ -15910,8 +16098,8 @@ User-owned .claude/ files will be preserved.`
|
|
|
15910
16098
|
}
|
|
15911
16099
|
await backupManagedFilesBeforeUndo(root, managed);
|
|
15912
16100
|
for (const abs of coreManaged) {
|
|
15913
|
-
if (!await
|
|
15914
|
-
await
|
|
16101
|
+
if (!await fs50.pathExists(abs)) continue;
|
|
16102
|
+
await fs50.remove(abs);
|
|
15915
16103
|
log(`Removed ${path65.relative(root, abs)}`);
|
|
15916
16104
|
}
|
|
15917
16105
|
for (const { id, existing } of lockRemovable) {
|
|
@@ -15926,7 +16114,7 @@ User-owned .claude/ files will be preserved.`
|
|
|
15926
16114
|
|
|
15927
16115
|
// src/install/uninstall.ts
|
|
15928
16116
|
import path66 from "node:path";
|
|
15929
|
-
import
|
|
16117
|
+
import fs51 from "fs-extra";
|
|
15930
16118
|
init_fs();
|
|
15931
16119
|
init_logger();
|
|
15932
16120
|
init_paths();
|
|
@@ -15960,7 +16148,7 @@ async function runUninstall(options = {}) {
|
|
|
15960
16148
|
);
|
|
15961
16149
|
}
|
|
15962
16150
|
for (const entry of manifest.files) {
|
|
15963
|
-
const exists2 =
|
|
16151
|
+
const exists2 = fs51.pathExistsSync(entry.destPath);
|
|
15964
16152
|
if (!exists2) continue;
|
|
15965
16153
|
const content2 = await readText(entry.destPath);
|
|
15966
16154
|
if (content2 === void 0) continue;
|
|
@@ -15978,7 +16166,7 @@ async function runUninstall(options = {}) {
|
|
|
15978
16166
|
result.skipped.push(entry.destPath);
|
|
15979
16167
|
continue;
|
|
15980
16168
|
}
|
|
15981
|
-
await
|
|
16169
|
+
await fs51.remove(entry.destPath);
|
|
15982
16170
|
await pruneEmptyDir(path66.dirname(entry.destPath));
|
|
15983
16171
|
result.deleted.push(entry.destPath);
|
|
15984
16172
|
}
|
|
@@ -15990,14 +16178,14 @@ async function runUninstall(options = {}) {
|
|
|
15990
16178
|
result.hooksStripped = true;
|
|
15991
16179
|
const hausDir = path66.join(globalClaudeDir(), "haus");
|
|
15992
16180
|
const manifestPath2 = hausManifestPath();
|
|
15993
|
-
if (
|
|
15994
|
-
await
|
|
16181
|
+
if (fs51.pathExistsSync(manifestPath2)) {
|
|
16182
|
+
await fs51.remove(manifestPath2);
|
|
15995
16183
|
}
|
|
15996
16184
|
await removeInstallConfig();
|
|
15997
|
-
if (
|
|
16185
|
+
if (fs51.pathExistsSync(hausDir)) {
|
|
15998
16186
|
try {
|
|
15999
|
-
const remaining = await
|
|
16000
|
-
if (remaining.length === 0) await
|
|
16187
|
+
const remaining = await fs51.readdir(hausDir);
|
|
16188
|
+
if (remaining.length === 0) await fs51.remove(hausDir);
|
|
16001
16189
|
} catch {
|
|
16002
16190
|
}
|
|
16003
16191
|
}
|
|
@@ -16073,7 +16261,7 @@ init_forbidden_content();
|
|
|
16073
16261
|
init_manifest_item_fields();
|
|
16074
16262
|
init_path_safety();
|
|
16075
16263
|
init_validation_rules2();
|
|
16076
|
-
import
|
|
16264
|
+
import fs52 from "node:fs";
|
|
16077
16265
|
import path67 from "node:path";
|
|
16078
16266
|
var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
16079
16267
|
var ITEM_ID_PATTERN = /^haus\./;
|
|
@@ -16234,7 +16422,7 @@ function checkRequiredFrontmatter(text, label) {
|
|
|
16234
16422
|
}
|
|
16235
16423
|
function readForAudit(absPath, label, failures) {
|
|
16236
16424
|
try {
|
|
16237
|
-
return
|
|
16425
|
+
return fs52.readFileSync(absPath, "utf8");
|
|
16238
16426
|
} catch (err) {
|
|
16239
16427
|
failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
16240
16428
|
return null;
|
|
@@ -16266,7 +16454,7 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
16266
16454
|
const absPath = path67.join(manifestDir, item.path);
|
|
16267
16455
|
if (item.type === "skill") {
|
|
16268
16456
|
const skillMd = path67.join(absPath, "SKILL.md");
|
|
16269
|
-
if (!
|
|
16457
|
+
if (!fs52.existsSync(skillMd)) {
|
|
16270
16458
|
failures.push(`${item.id}: missing ${path67.relative(manifestDir, skillMd)}`);
|
|
16271
16459
|
continue;
|
|
16272
16460
|
}
|
|
@@ -16277,7 +16465,7 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
16277
16465
|
...auditForbiddenTagsInText(text, `${item.id}: ${path67.relative(manifestDir, skillMd)}`)
|
|
16278
16466
|
);
|
|
16279
16467
|
} else if (item.type === "agent") {
|
|
16280
|
-
if (!
|
|
16468
|
+
if (!fs52.existsSync(absPath)) {
|
|
16281
16469
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
16282
16470
|
continue;
|
|
16283
16471
|
}
|
|
@@ -16287,13 +16475,13 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
16287
16475
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
16288
16476
|
failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
|
|
16289
16477
|
} else if (item.type === "template") {
|
|
16290
|
-
if (!
|
|
16478
|
+
if (!fs52.existsSync(absPath)) {
|
|
16291
16479
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
16292
16480
|
continue;
|
|
16293
16481
|
}
|
|
16294
16482
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
16295
16483
|
} else if (item.type === "command") {
|
|
16296
|
-
if (!
|
|
16484
|
+
if (!fs52.existsSync(absPath)) {
|
|
16297
16485
|
failures.push(`${item.id}: missing command file ${item.path}`);
|
|
16298
16486
|
continue;
|
|
16299
16487
|
}
|
|
@@ -16303,15 +16491,15 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
16303
16491
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
16304
16492
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
16305
16493
|
} else if (item.type === "config") {
|
|
16306
|
-
if (!
|
|
16494
|
+
if (!fs52.existsSync(absPath)) {
|
|
16307
16495
|
failures.push(`${item.id}: missing config path ${item.path}`);
|
|
16308
16496
|
}
|
|
16309
16497
|
} else if (item.type === "rule") {
|
|
16310
|
-
if (!
|
|
16498
|
+
if (!fs52.existsSync(absPath)) {
|
|
16311
16499
|
failures.push(`${item.id}: missing rule file ${item.path}`);
|
|
16312
16500
|
}
|
|
16313
16501
|
} else if (item.type === "asset") {
|
|
16314
|
-
if (!
|
|
16502
|
+
if (!fs52.existsSync(absPath)) {
|
|
16315
16503
|
failures.push(`${item.id}: missing asset file ${item.path}`);
|
|
16316
16504
|
}
|
|
16317
16505
|
}
|
|
@@ -16346,7 +16534,7 @@ function resolveItemType(relPath, pathTypeMap) {
|
|
|
16346
16534
|
function walkAllFiles(dir, fn, failures) {
|
|
16347
16535
|
let entries;
|
|
16348
16536
|
try {
|
|
16349
|
-
entries =
|
|
16537
|
+
entries = fs52.readdirSync(dir, { withFileTypes: true });
|
|
16350
16538
|
} catch (err) {
|
|
16351
16539
|
failures.push(
|
|
16352
16540
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -16380,7 +16568,7 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
16380
16568
|
const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
|
|
16381
16569
|
for (const dir of CONTENT_ROOTS) {
|
|
16382
16570
|
const abs = path67.join(manifestDir, dir);
|
|
16383
|
-
if (!
|
|
16571
|
+
if (!fs52.existsSync(abs)) continue;
|
|
16384
16572
|
walkAllFiles(
|
|
16385
16573
|
abs,
|
|
16386
16574
|
(file) => {
|
|
@@ -16397,7 +16585,7 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
16397
16585
|
function walkMd(dir, fn, failures) {
|
|
16398
16586
|
let entries;
|
|
16399
16587
|
try {
|
|
16400
|
-
entries =
|
|
16588
|
+
entries = fs52.readdirSync(dir, { withFileTypes: true });
|
|
16401
16589
|
} catch (err) {
|
|
16402
16590
|
failures.push(
|
|
16403
16591
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -16416,7 +16604,7 @@ function auditMarkdownContent(manifestDir, items) {
|
|
|
16416
16604
|
const pathTypeMap = buildItemPathTypeMap(items);
|
|
16417
16605
|
for (const dir of ["skills", "agents", "templates", "commands", "sources"]) {
|
|
16418
16606
|
const abs = path67.join(manifestDir, dir);
|
|
16419
|
-
if (!
|
|
16607
|
+
if (!fs52.existsSync(abs)) continue;
|
|
16420
16608
|
const dirType = DIR_ITEM_TYPE[dir] ?? "";
|
|
16421
16609
|
walkMd(
|
|
16422
16610
|
abs,
|
|
@@ -16492,7 +16680,7 @@ init_logger();
|
|
|
16492
16680
|
// src/workspace/worktree/add.ts
|
|
16493
16681
|
init_fs();
|
|
16494
16682
|
import path75 from "node:path";
|
|
16495
|
-
import
|
|
16683
|
+
import fs56 from "fs-extra";
|
|
16496
16684
|
|
|
16497
16685
|
// src/workspace/worktree/git-worktree.ts
|
|
16498
16686
|
init_exec();
|
|
@@ -16613,12 +16801,12 @@ async function hasUnpushedWork(cwd) {
|
|
|
16613
16801
|
|
|
16614
16802
|
// src/workspace/worktree/hydrate.ts
|
|
16615
16803
|
import path71 from "node:path";
|
|
16616
|
-
import
|
|
16804
|
+
import fs54 from "fs-extra";
|
|
16617
16805
|
|
|
16618
16806
|
// src/workspace/worktree/cow-copy.ts
|
|
16619
16807
|
init_exec();
|
|
16620
16808
|
import path69 from "node:path";
|
|
16621
|
-
import
|
|
16809
|
+
import fs53 from "fs-extra";
|
|
16622
16810
|
async function getMacFilesystemType(dir) {
|
|
16623
16811
|
const df = await runCommand("df", ["-P", dir]);
|
|
16624
16812
|
if (df.exitCode !== 0) return void 0;
|
|
@@ -16649,14 +16837,14 @@ async function detectCowStrategy(dir) {
|
|
|
16649
16837
|
return "unknown-platform";
|
|
16650
16838
|
}
|
|
16651
16839
|
async function cowCopyDir(src, dest) {
|
|
16652
|
-
const srcExists = await
|
|
16840
|
+
const srcExists = await fs53.pathExists(src);
|
|
16653
16841
|
if (!srcExists)
|
|
16654
16842
|
return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
|
|
16655
16843
|
const strategy = await detectCowStrategy(path69.dirname(dest));
|
|
16656
16844
|
if (strategy === "unsupported" || strategy === "unknown-platform") {
|
|
16657
16845
|
return { strategy, attempted: false, ok: false };
|
|
16658
16846
|
}
|
|
16659
|
-
await
|
|
16847
|
+
await fs53.ensureDir(path69.dirname(dest));
|
|
16660
16848
|
const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
|
|
16661
16849
|
const result = await runCommand("cp", args);
|
|
16662
16850
|
return {
|
|
@@ -16849,11 +17037,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
|
16849
17037
|
for (const target of HYDRATION_TARGETS) {
|
|
16850
17038
|
const src = path71.join(mainMemberPath, target);
|
|
16851
17039
|
const dest = path71.join(worktreeMemberPath, target);
|
|
16852
|
-
if (!await
|
|
16853
|
-
const destExists = await
|
|
17040
|
+
if (!await fs54.pathExists(src)) continue;
|
|
17041
|
+
const destExists = await fs54.pathExists(dest);
|
|
16854
17042
|
if (!opts.force && destExists) continue;
|
|
16855
17043
|
if (opts.force && destExists) {
|
|
16856
|
-
await
|
|
17044
|
+
await fs54.remove(dest);
|
|
16857
17045
|
}
|
|
16858
17046
|
const copy = await cowCopyDir(src, dest);
|
|
16859
17047
|
targets.push({ target, copy });
|
|
@@ -16867,7 +17055,7 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
|
16867
17055
|
// src/workspace/worktree/local-files.ts
|
|
16868
17056
|
init_exec();
|
|
16869
17057
|
import path72 from "node:path";
|
|
16870
|
-
import
|
|
17058
|
+
import fs55 from "fs-extra";
|
|
16871
17059
|
var MACHINE_LOCAL_FILES = [
|
|
16872
17060
|
"docker-compose.local.yml",
|
|
16873
17061
|
path72.join(".claude", "settings.local.json")
|
|
@@ -16881,11 +17069,11 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
|
|
|
16881
17069
|
const copied = [];
|
|
16882
17070
|
for (const rel of MACHINE_LOCAL_FILES) {
|
|
16883
17071
|
const src = path72.join(mainMemberPath, rel);
|
|
16884
|
-
if (!await
|
|
17072
|
+
if (!await fs55.pathExists(src)) continue;
|
|
16885
17073
|
if (await isTracked(mainMemberPath, rel)) continue;
|
|
16886
17074
|
const dest = path72.join(worktreeMemberPath, rel);
|
|
16887
|
-
await
|
|
16888
|
-
await
|
|
17075
|
+
await fs55.ensureDir(path72.dirname(dest));
|
|
17076
|
+
await fs55.copy(src, dest, { overwrite: true });
|
|
16889
17077
|
copied.push(rel);
|
|
16890
17078
|
}
|
|
16891
17079
|
return copied;
|
|
@@ -16973,7 +17161,7 @@ async function runAdd(opts) {
|
|
|
16973
17161
|
}
|
|
16974
17162
|
const branch = opts.branch ?? opts.slug;
|
|
16975
17163
|
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
16976
|
-
if (await
|
|
17164
|
+
if (await fs56.pathExists(wsWorktreePath)) {
|
|
16977
17165
|
return {
|
|
16978
17166
|
ok: false,
|
|
16979
17167
|
error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate --materialize\` from inside it to check out any missing members (e.g. a worktree created by plain \`git worktree add\`), or \`remove\` first to start over.`
|
|
@@ -17091,7 +17279,7 @@ async function runDryRun(args) {
|
|
|
17091
17279
|
import { spawn } from "node:child_process";
|
|
17092
17280
|
import fsSync from "node:fs";
|
|
17093
17281
|
import path81 from "node:path";
|
|
17094
|
-
import
|
|
17282
|
+
import fs58 from "fs-extra";
|
|
17095
17283
|
|
|
17096
17284
|
// src/workspace/worktree/doctor.ts
|
|
17097
17285
|
import { existsSync as existsSync7 } from "node:fs";
|
|
@@ -17100,7 +17288,7 @@ import path78 from "node:path";
|
|
|
17100
17288
|
// src/workspace/worktree/hydrate-lock.ts
|
|
17101
17289
|
init_paths();
|
|
17102
17290
|
import path76 from "node:path";
|
|
17103
|
-
import
|
|
17291
|
+
import fs57 from "fs-extra";
|
|
17104
17292
|
var STALE_LOCK_MS = 30 * 60 * 1e3;
|
|
17105
17293
|
function locksDir(mainRoot) {
|
|
17106
17294
|
return hausPath(mainRoot, "locks");
|
|
@@ -17135,7 +17323,7 @@ function defaultIsAlive(pid) {
|
|
|
17135
17323
|
}
|
|
17136
17324
|
async function readOwnerPid(lockPath) {
|
|
17137
17325
|
try {
|
|
17138
|
-
const raw = (await
|
|
17326
|
+
const raw = (await fs57.readFile(path76.join(lockPath, "pid"), "utf8")).trim();
|
|
17139
17327
|
if (raw === "") return void 0;
|
|
17140
17328
|
const pid = Number.parseInt(raw, 10);
|
|
17141
17329
|
return Number.isInteger(pid) && pid > 0 ? pid : void 0;
|
|
@@ -17144,13 +17332,13 @@ async function readOwnerPid(lockPath) {
|
|
|
17144
17332
|
}
|
|
17145
17333
|
}
|
|
17146
17334
|
async function claim(lockPath, pid) {
|
|
17147
|
-
await
|
|
17335
|
+
await fs57.ensureDir(path76.dirname(lockPath));
|
|
17148
17336
|
try {
|
|
17149
|
-
await
|
|
17337
|
+
await fs57.mkdir(lockPath);
|
|
17150
17338
|
} catch {
|
|
17151
17339
|
return false;
|
|
17152
17340
|
}
|
|
17153
|
-
await
|
|
17341
|
+
await fs57.writeFile(path76.join(lockPath, "pid"), String(pid), "utf8");
|
|
17154
17342
|
return true;
|
|
17155
17343
|
}
|
|
17156
17344
|
async function acquireHydrateLock(mainRoot, slug, deps = {}) {
|
|
@@ -17169,7 +17357,7 @@ async function acquireHydrateLock(mainRoot, slug, deps = {}) {
|
|
|
17169
17357
|
}
|
|
17170
17358
|
if (alive) return { acquired: false, reason: "live", ownerPid, lockPath };
|
|
17171
17359
|
} else {
|
|
17172
|
-
const stat = await
|
|
17360
|
+
const stat = await fs57.stat(lockPath).catch(() => void 0);
|
|
17173
17361
|
if (stat === void 0) {
|
|
17174
17362
|
return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", lockPath };
|
|
17175
17363
|
}
|
|
@@ -17177,18 +17365,18 @@ async function acquireHydrateLock(mainRoot, slug, deps = {}) {
|
|
|
17177
17365
|
return { acquired: false, reason: "starting", lockPath };
|
|
17178
17366
|
}
|
|
17179
17367
|
}
|
|
17180
|
-
await
|
|
17368
|
+
await fs57.remove(lockPath);
|
|
17181
17369
|
return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", ownerPid, lockPath };
|
|
17182
17370
|
}
|
|
17183
17371
|
async function recordHydrateLockOwner(mainRoot, slug, pid) {
|
|
17184
|
-
await
|
|
17372
|
+
await fs57.writeFile(path76.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
|
|
17185
17373
|
}
|
|
17186
17374
|
async function releaseHydrateLock(mainRoot, slug) {
|
|
17187
|
-
await
|
|
17375
|
+
await fs57.remove(hydrateLockPath(mainRoot, slug));
|
|
17188
17376
|
}
|
|
17189
17377
|
async function readHydrateLock(mainRoot, slug) {
|
|
17190
17378
|
const lockPath = hydrateLockPath(mainRoot, slug);
|
|
17191
|
-
const stat = await
|
|
17379
|
+
const stat = await fs57.stat(lockPath).catch(() => void 0);
|
|
17192
17380
|
if (stat === void 0) return { held: false };
|
|
17193
17381
|
const ownerPid = await readOwnerPid(lockPath);
|
|
17194
17382
|
return {
|
|
@@ -17568,11 +17756,11 @@ async function consumeLastRunFailure(mainRoot, slug) {
|
|
|
17568
17756
|
const file = hydrateLastRunPath(mainRoot, slug);
|
|
17569
17757
|
let parsed;
|
|
17570
17758
|
try {
|
|
17571
|
-
parsed = JSON.parse(await
|
|
17759
|
+
parsed = JSON.parse(await fs58.readFile(file, "utf8"));
|
|
17572
17760
|
} catch {
|
|
17573
17761
|
return void 0;
|
|
17574
17762
|
}
|
|
17575
|
-
await
|
|
17763
|
+
await fs58.remove(file).catch(() => void 0);
|
|
17576
17764
|
if (parsed && parsed.ok === false) return parsed;
|
|
17577
17765
|
return void 0;
|
|
17578
17766
|
}
|
|
@@ -17644,7 +17832,7 @@ async function runAutoHydrate(opts = {}, deps = {}) {
|
|
|
17644
17832
|
reason: "could not determine the haus CLI entry point to re-invoke"
|
|
17645
17833
|
};
|
|
17646
17834
|
}
|
|
17647
|
-
await
|
|
17835
|
+
await fs58.ensureDir(logsDir(mainRoot));
|
|
17648
17836
|
appendLog(
|
|
17649
17837
|
logPath,
|
|
17650
17838
|
`autohydrate: forking hydrate --materialize for "${slug}" (${missingMembers.join(", ")})`
|
|
@@ -17713,21 +17901,21 @@ async function runDetachedHydration() {
|
|
|
17713
17901
|
async function writeLastRun(mainRoot, slug, ok, failures) {
|
|
17714
17902
|
const file = hydrateLastRunPath(mainRoot, slug);
|
|
17715
17903
|
if (ok) {
|
|
17716
|
-
await
|
|
17904
|
+
await fs58.remove(file).catch(() => void 0);
|
|
17717
17905
|
return;
|
|
17718
17906
|
}
|
|
17719
17907
|
const record = { slug, finishedAt: (/* @__PURE__ */ new Date()).toISOString(), ok, failures };
|
|
17720
|
-
await
|
|
17721
|
-
await
|
|
17908
|
+
await fs58.ensureDir(path81.dirname(file));
|
|
17909
|
+
await fs58.writeJson(file, record, { spaces: 2 }).catch(() => void 0);
|
|
17722
17910
|
}
|
|
17723
17911
|
|
|
17724
17912
|
// src/workspace/worktree/dematerialize.ts
|
|
17725
17913
|
import path83 from "node:path";
|
|
17726
|
-
import
|
|
17914
|
+
import fs60 from "fs-extra";
|
|
17727
17915
|
|
|
17728
17916
|
// src/workspace/worktree/member-targets.ts
|
|
17729
17917
|
import path82 from "node:path";
|
|
17730
|
-
import
|
|
17918
|
+
import fs59 from "fs-extra";
|
|
17731
17919
|
function normalizeStateMembers(state, allMembers) {
|
|
17732
17920
|
return state?.members.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath })) ?? allMembers.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath }));
|
|
17733
17921
|
}
|
|
@@ -17752,7 +17940,7 @@ function resolveMemberTargets(wsWorktreePath, stateMembers, allMembers) {
|
|
|
17752
17940
|
async function filterExistingTargets(targets) {
|
|
17753
17941
|
const existing = [];
|
|
17754
17942
|
for (const target of targets) {
|
|
17755
|
-
if (await
|
|
17943
|
+
if (await fs59.pathExists(target.path)) existing.push(target);
|
|
17756
17944
|
}
|
|
17757
17945
|
return existing;
|
|
17758
17946
|
}
|
|
@@ -17797,7 +17985,7 @@ async function runDematerialize(opts) {
|
|
|
17797
17985
|
} catch (err) {
|
|
17798
17986
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
17799
17987
|
}
|
|
17800
|
-
if (!await
|
|
17988
|
+
if (!await fs60.pathExists(wsWorktreePath)) {
|
|
17801
17989
|
return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
|
|
17802
17990
|
}
|
|
17803
17991
|
const lock = await readHydrateLock(workspaceRoot, slug);
|
|
@@ -17831,8 +18019,8 @@ async function runDematerialize(opts) {
|
|
|
17831
18019
|
const failed = [];
|
|
17832
18020
|
for (const folder of unresolvedDroppedMembers) {
|
|
17833
18021
|
const droppedPath = path83.join(wsWorktreePath, folder);
|
|
17834
|
-
if (await
|
|
17835
|
-
await
|
|
18022
|
+
if (await fs60.pathExists(droppedPath)) {
|
|
18023
|
+
await fs60.remove(droppedPath);
|
|
17836
18024
|
failed.push({ repo: folder, error: unresolvedDroppedFailureReason() });
|
|
17837
18025
|
}
|
|
17838
18026
|
}
|
|
@@ -17847,14 +18035,14 @@ async function runDematerialize(opts) {
|
|
|
17847
18035
|
|
|
17848
18036
|
// src/workspace/worktree/list.ts
|
|
17849
18037
|
import path84 from "node:path";
|
|
17850
|
-
import
|
|
18038
|
+
import fs61 from "fs-extra";
|
|
17851
18039
|
async function runList2() {
|
|
17852
18040
|
const resolved = await resolveWorkspaceForWorktree();
|
|
17853
18041
|
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
17854
18042
|
const { rootInfo, workspaceRoot } = resolved;
|
|
17855
18043
|
const dir = worktreesDir(workspaceRoot);
|
|
17856
|
-
if (!await
|
|
17857
|
-
const entries = await
|
|
18044
|
+
if (!await fs61.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
|
|
18045
|
+
const entries = await fs61.readdir(dir, { withFileTypes: true });
|
|
17858
18046
|
const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
17859
18047
|
const configuredMembers = await readMembers(rootInfo);
|
|
17860
18048
|
const worktrees = [];
|
|
@@ -17865,7 +18053,7 @@ async function runList2() {
|
|
|
17865
18053
|
const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
|
|
17866
18054
|
for (const m of source) {
|
|
17867
18055
|
const memberPath = path84.join(wtPath, m.folder);
|
|
17868
|
-
const materialized = await
|
|
18056
|
+
const materialized = await fs61.pathExists(memberPath);
|
|
17869
18057
|
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
17870
18058
|
members.push({
|
|
17871
18059
|
id: m.id,
|
|
@@ -17888,12 +18076,12 @@ async function runList2() {
|
|
|
17888
18076
|
// src/workspace/worktree/reclaim.ts
|
|
17889
18077
|
import { existsSync as existsSync9 } from "node:fs";
|
|
17890
18078
|
import path85 from "node:path";
|
|
17891
|
-
import
|
|
18079
|
+
import fs62 from "fs-extra";
|
|
17892
18080
|
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
17893
18081
|
async function lastTouchedMs(worktreeRoot) {
|
|
17894
18082
|
const candidates = [worktreeRoot, path85.join(worktreeRoot, ".haus-worktree.json")];
|
|
17895
18083
|
try {
|
|
17896
|
-
const dotGit = await
|
|
18084
|
+
const dotGit = await fs62.readFile(path85.join(worktreeRoot, ".git"), "utf8");
|
|
17897
18085
|
const match = /^gitdir:\s*(.+)$/m.exec(dotGit);
|
|
17898
18086
|
if (match?.[1]) {
|
|
17899
18087
|
const gitdir = match[1].trim();
|
|
@@ -17903,7 +18091,7 @@ async function lastTouchedMs(worktreeRoot) {
|
|
|
17903
18091
|
}
|
|
17904
18092
|
let newest = 0;
|
|
17905
18093
|
for (const candidate of candidates) {
|
|
17906
|
-
const stat = await
|
|
18094
|
+
const stat = await fs62.stat(candidate).catch(() => void 0);
|
|
17907
18095
|
if (stat && stat.mtimeMs > newest) newest = stat.mtimeMs;
|
|
17908
18096
|
}
|
|
17909
18097
|
return newest;
|
|
@@ -17930,7 +18118,7 @@ async function runReclaim(opts = {}) {
|
|
|
17930
18118
|
if (!existsSync9(dir)) {
|
|
17931
18119
|
return { ok: true, dryRun: !opts.write, olderThanDays, candidates: [], reclaimed: 0 };
|
|
17932
18120
|
}
|
|
17933
|
-
const allEntries = (await
|
|
18121
|
+
const allEntries = (await fs62.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
17934
18122
|
const entries = allEntries.filter((name) => isValidWorktreeSlug(name));
|
|
17935
18123
|
const unaddressable = allEntries.filter((name) => !isValidWorktreeSlug(name));
|
|
17936
18124
|
const members = await readMembers(rootInfo);
|
|
@@ -18011,13 +18199,13 @@ async function runReclaim(opts = {}) {
|
|
|
18011
18199
|
}
|
|
18012
18200
|
|
|
18013
18201
|
// src/workspace/worktree/remove.ts
|
|
18014
|
-
import
|
|
18202
|
+
import fs63 from "fs-extra";
|
|
18015
18203
|
async function runRemove2(opts) {
|
|
18016
18204
|
const resolved = await resolveWorkspaceForWorktree();
|
|
18017
18205
|
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
18018
18206
|
const { rootInfo, workspaceRoot } = resolved;
|
|
18019
18207
|
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
18020
|
-
if (!await
|
|
18208
|
+
if (!await fs63.pathExists(wsWorktreePath)) {
|
|
18021
18209
|
return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
|
|
18022
18210
|
}
|
|
18023
18211
|
const state = await readWorktreeState(wsWorktreePath);
|
|
@@ -18085,8 +18273,8 @@ async function runRemove2(opts) {
|
|
|
18085
18273
|
}
|
|
18086
18274
|
await pruneWorktrees(workspaceRoot);
|
|
18087
18275
|
}
|
|
18088
|
-
if (wsRemoveOk && await
|
|
18089
|
-
await
|
|
18276
|
+
if (wsRemoveOk && await fs63.pathExists(wsWorktreePath)) {
|
|
18277
|
+
await fs63.remove(wsWorktreePath);
|
|
18090
18278
|
}
|
|
18091
18279
|
return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
|
|
18092
18280
|
}
|
|
@@ -18859,17 +19047,17 @@ init_logger();
|
|
|
18859
19047
|
|
|
18860
19048
|
// src/workspace/link-context/link.ts
|
|
18861
19049
|
import path90 from "node:path";
|
|
18862
|
-
import
|
|
19050
|
+
import fs66 from "fs-extra";
|
|
18863
19051
|
init_fs();
|
|
18864
19052
|
init_logger();
|
|
18865
19053
|
|
|
18866
19054
|
// src/workspace/link-context/plan.ts
|
|
18867
19055
|
import path89 from "node:path";
|
|
18868
|
-
import
|
|
19056
|
+
import fs65 from "fs-extra";
|
|
18869
19057
|
|
|
18870
19058
|
// src/workspace/link-context/scan-source.ts
|
|
18871
19059
|
import path88 from "node:path";
|
|
18872
|
-
import
|
|
19060
|
+
import fs64 from "fs-extra";
|
|
18873
19061
|
var ASSET_SUBDIR = {
|
|
18874
19062
|
skill: "skills",
|
|
18875
19063
|
agent: "agents",
|
|
@@ -18877,20 +19065,20 @@ var ASSET_SUBDIR = {
|
|
|
18877
19065
|
};
|
|
18878
19066
|
async function hasLinkableContext(member) {
|
|
18879
19067
|
for (const subdir of Object.values(ASSET_SUBDIR)) {
|
|
18880
|
-
if (await
|
|
19068
|
+
if (await fs64.pathExists(path88.join(member.absPath, ".claude", subdir))) return true;
|
|
18881
19069
|
}
|
|
18882
19070
|
return false;
|
|
18883
19071
|
}
|
|
18884
19072
|
async function collectSkills(member) {
|
|
18885
19073
|
const dir = path88.join(member.absPath, ".claude", "skills");
|
|
18886
|
-
if (!await
|
|
18887
|
-
const names = await
|
|
19074
|
+
if (!await fs64.pathExists(dir)) return [];
|
|
19075
|
+
const names = await fs64.readdir(dir);
|
|
18888
19076
|
const out = [];
|
|
18889
19077
|
for (const name of [...names].sort()) {
|
|
18890
19078
|
const abs = path88.join(dir, name);
|
|
18891
|
-
const stat = await
|
|
19079
|
+
const stat = await fs64.lstat(abs);
|
|
18892
19080
|
if (!stat.isDirectory()) continue;
|
|
18893
|
-
if (!await
|
|
19081
|
+
if (!await fs64.pathExists(path88.join(abs, "SKILL.md"))) continue;
|
|
18894
19082
|
out.push({ type: "skill", name, sourceRelPath: path88.posix.join(".claude", "skills", name) });
|
|
18895
19083
|
}
|
|
18896
19084
|
return out;
|
|
@@ -18898,13 +19086,13 @@ async function collectSkills(member) {
|
|
|
18898
19086
|
async function collectFlatMarkdown(member, type) {
|
|
18899
19087
|
const subdir = ASSET_SUBDIR[type];
|
|
18900
19088
|
const dir = path88.join(member.absPath, ".claude", subdir);
|
|
18901
|
-
if (!await
|
|
18902
|
-
const names = await
|
|
19089
|
+
if (!await fs64.pathExists(dir)) return [];
|
|
19090
|
+
const names = await fs64.readdir(dir);
|
|
18903
19091
|
const out = [];
|
|
18904
19092
|
for (const fileName of [...names].sort()) {
|
|
18905
19093
|
if (!fileName.endsWith(".md")) continue;
|
|
18906
19094
|
const abs = path88.join(dir, fileName);
|
|
18907
|
-
if (!(await
|
|
19095
|
+
if (!(await fs64.lstat(abs)).isFile()) continue;
|
|
18908
19096
|
const name = fileName.slice(0, -".md".length);
|
|
18909
19097
|
out.push({ type, name, sourceRelPath: path88.posix.join(".claude", subdir, fileName) });
|
|
18910
19098
|
}
|
|
@@ -18938,7 +19126,7 @@ async function buildLinkPlan(members) {
|
|
|
18938
19126
|
const byKey = /* @__PURE__ */ new Map();
|
|
18939
19127
|
const membersById = new Map(members.map((m) => [m.id, m]));
|
|
18940
19128
|
for (const member of members) {
|
|
18941
|
-
if (!await
|
|
19129
|
+
if (!await fs65.pathExists(member.absPath)) {
|
|
18942
19130
|
skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
|
|
18943
19131
|
continue;
|
|
18944
19132
|
}
|
|
@@ -19019,15 +19207,15 @@ async function runLinkContext(workspaceRoot, opts = {}) {
|
|
|
19019
19207
|
const destRel = destRelPathFor(entry.type, entry.destKey);
|
|
19020
19208
|
const destAbs = path90.join(workspaceRoot, destRel);
|
|
19021
19209
|
if (write) {
|
|
19022
|
-
await
|
|
19023
|
-
if (await
|
|
19024
|
-
await
|
|
19210
|
+
await fs66.ensureDir(path90.dirname(destAbs));
|
|
19211
|
+
if (await fs66.pathExists(destAbs)) await fs66.remove(destAbs);
|
|
19212
|
+
await fs66.copy(entry.sourceAbsPath, destAbs, {
|
|
19025
19213
|
overwrite: true,
|
|
19026
19214
|
errorOnExist: false,
|
|
19027
19215
|
dereference: false,
|
|
19028
19216
|
filter: (s) => {
|
|
19029
19217
|
try {
|
|
19030
|
-
return !
|
|
19218
|
+
return !fs66.lstatSync(s).isSymbolicLink();
|
|
19031
19219
|
} catch {
|
|
19032
19220
|
return false;
|
|
19033
19221
|
}
|
|
@@ -19056,7 +19244,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
|
|
|
19056
19244
|
warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
|
|
19057
19245
|
continue;
|
|
19058
19246
|
}
|
|
19059
|
-
if (await
|
|
19247
|
+
if (await fs66.pathExists(abs)) await fs66.remove(abs);
|
|
19060
19248
|
}
|
|
19061
19249
|
}
|
|
19062
19250
|
if (write) {
|
|
@@ -19112,7 +19300,7 @@ import path92 from "node:path";
|
|
|
19112
19300
|
|
|
19113
19301
|
// src/claude/write-workspace-claude-md.ts
|
|
19114
19302
|
import path91 from "node:path";
|
|
19115
|
-
import
|
|
19303
|
+
import fs67 from "fs-extra";
|
|
19116
19304
|
init_fs();
|
|
19117
19305
|
init_logger();
|
|
19118
19306
|
init_paths();
|
|
@@ -19140,8 +19328,8 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
|
19140
19328
|
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path91.join(workspaceRoot, "CLAUDE.md");
|
|
19141
19329
|
const say = opts.quiet ? () => {
|
|
19142
19330
|
} : log;
|
|
19143
|
-
const existed = await
|
|
19144
|
-
const prev = existed ? await
|
|
19331
|
+
const existed = await fs67.pathExists(filePath);
|
|
19332
|
+
const prev = existed ? await fs67.readFile(filePath, "utf8") : "";
|
|
19145
19333
|
const next = opts.collision ? `${block}
|
|
19146
19334
|
` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
|
|
19147
19335
|
const printable = displayPath(workspaceRoot, filePath);
|
|
@@ -19423,7 +19611,7 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
|
|
|
19423
19611
|
// src/commands/workspace/undo.ts
|
|
19424
19612
|
import { existsSync as existsSync12, statSync as statSync2 } from "node:fs";
|
|
19425
19613
|
import path93 from "node:path";
|
|
19426
|
-
import
|
|
19614
|
+
import fs68 from "fs-extra";
|
|
19427
19615
|
init_fs();
|
|
19428
19616
|
init_logger();
|
|
19429
19617
|
init_paths();
|
|
@@ -19474,7 +19662,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
19474
19662
|
warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
|
|
19475
19663
|
continue;
|
|
19476
19664
|
}
|
|
19477
|
-
if (await
|
|
19665
|
+
if (await fs68.pathExists(abs)) {
|
|
19478
19666
|
log(`[dry-run] Would remove ${entry.path} (linked ${entry.type} from ${entry.repo})`);
|
|
19479
19667
|
}
|
|
19480
19668
|
}
|
|
@@ -19484,11 +19672,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
19484
19672
|
hausPath(workspaceRoot, "WORKSPACE.md")
|
|
19485
19673
|
];
|
|
19486
19674
|
for (const abs of workspaceArtifacts) {
|
|
19487
|
-
if (await
|
|
19675
|
+
if (await fs68.pathExists(abs)) {
|
|
19488
19676
|
log(`[dry-run] Would remove ${path93.relative(workspaceRoot, abs)}`);
|
|
19489
19677
|
}
|
|
19490
19678
|
}
|
|
19491
|
-
if (!await
|
|
19679
|
+
if (!await fs68.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
|
|
19492
19680
|
const claudeMd = await readText(path93.join(workspaceRoot, "CLAUDE.md"));
|
|
19493
19681
|
if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
|
|
19494
19682
|
log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
|
|
@@ -19528,26 +19716,26 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
19528
19716
|
warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
|
|
19529
19717
|
continue;
|
|
19530
19718
|
}
|
|
19531
|
-
if (await
|
|
19532
|
-
await
|
|
19719
|
+
if (await fs68.pathExists(abs)) {
|
|
19720
|
+
await fs68.remove(abs);
|
|
19533
19721
|
log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
|
|
19534
19722
|
}
|
|
19535
19723
|
}
|
|
19536
19724
|
for (const rel of WORKSPACE_AGGREGATE_FILES) {
|
|
19537
19725
|
const abs = hausPath(workspaceRoot, rel);
|
|
19538
|
-
if (await
|
|
19539
|
-
await
|
|
19726
|
+
if (await fs68.pathExists(abs)) {
|
|
19727
|
+
await fs68.remove(abs);
|
|
19540
19728
|
log(`Removed ${path93.relative(workspaceRoot, abs)}`);
|
|
19541
19729
|
}
|
|
19542
19730
|
}
|
|
19543
19731
|
const manifestFile = manifestPath(workspaceRoot);
|
|
19544
|
-
if (await
|
|
19545
|
-
await
|
|
19732
|
+
if (await fs68.pathExists(manifestFile)) {
|
|
19733
|
+
await fs68.remove(manifestFile);
|
|
19546
19734
|
log(`Removed ${path93.relative(workspaceRoot, manifestFile)}`);
|
|
19547
19735
|
}
|
|
19548
19736
|
const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
|
|
19549
|
-
if (await
|
|
19550
|
-
await
|
|
19737
|
+
if (await fs68.pathExists(workspaceMdPath)) {
|
|
19738
|
+
await fs68.remove(workspaceMdPath);
|
|
19551
19739
|
log(`Removed ${path93.relative(workspaceRoot, workspaceMdPath)}`);
|
|
19552
19740
|
} else {
|
|
19553
19741
|
const claudeMdPath = path93.join(workspaceRoot, "CLAUDE.md");
|
|
@@ -19559,19 +19747,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
19559
19747
|
end: WORKSPACE_BLOCK_END
|
|
19560
19748
|
});
|
|
19561
19749
|
if (next.length === 0) {
|
|
19562
|
-
await
|
|
19750
|
+
await fs68.remove(claudeMdPath);
|
|
19563
19751
|
log("Removed CLAUDE.md (only contained the workspace haus import block).");
|
|
19564
19752
|
} else {
|
|
19565
|
-
await
|
|
19753
|
+
await fs68.writeFile(claudeMdPath, next, "utf8");
|
|
19566
19754
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
19567
19755
|
}
|
|
19568
19756
|
} else if (prev && hasLegacyWorkspaceBlock(prev)) {
|
|
19569
19757
|
const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
|
|
19570
19758
|
if (next.length === 0) {
|
|
19571
|
-
await
|
|
19759
|
+
await fs68.remove(claudeMdPath);
|
|
19572
19760
|
log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
|
|
19573
19761
|
} else {
|
|
19574
|
-
await
|
|
19762
|
+
await fs68.writeFile(claudeMdPath, next, "utf8");
|
|
19575
19763
|
log(
|
|
19576
19764
|
"Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
|
|
19577
19765
|
);
|
|
@@ -19743,6 +19931,9 @@ program.command("scan").description(
|
|
|
19743
19931
|
program.command("recommend").description("Score catalog items against the scanned context and write recommendation.json").option("--json").option(
|
|
19744
19932
|
"--include <ids...>",
|
|
19745
19933
|
"Force opt-in catalog items into the recommendation as manual selections (space- or comma-separated ids)"
|
|
19934
|
+
).option(
|
|
19935
|
+
"--declare-role <roles...>",
|
|
19936
|
+
"Declare a repo role (persisted in .haus-workflow/haus.config.json declaredRoles) so role-gated opt-in items are recommended"
|
|
19746
19937
|
).action(runRecommend);
|
|
19747
19938
|
program.command("doctor").description(
|
|
19748
19939
|
"Validate project setup: hooks contract, managed files, cache freshness, CLI version"
|
|
@@ -19841,7 +20032,10 @@ guard.command("bash").description("Block dangerous shell commands (run by the Cl
|
|
|
19841
20032
|
function registerDecisionsCommand(name) {
|
|
19842
20033
|
const cmd = program.command(name).description("Architecture decision records (ADR) gate and drafts");
|
|
19843
20034
|
cmd.command("check").option("--staged", "Check staged changes only").option("--range <range>", "Git revision range (e.g. origin/main..HEAD)").action((opts) => runDecisions("check", void 0, opts));
|
|
19844
|
-
cmd.command("suggest").option("--from-hook", "Emit JSON for Claude Code Stop hook").option(
|
|
20035
|
+
cmd.command("suggest").option("--from-hook", "Emit JSON for Claude Code Stop hook").option(
|
|
20036
|
+
"--title <title>",
|
|
20037
|
+
"Draft title; with a title the draft is printed even when the diff is not decision-worthy"
|
|
20038
|
+
).action(
|
|
19845
20039
|
(opts) => runDecisions("suggest", void 0, opts)
|
|
19846
20040
|
);
|
|
19847
20041
|
cmd.command("next-number").action(() => runDecisions("next-number"));
|