@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 fs42 from "fs-extra";
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 isAgentContext(line2, nearestHeading) {
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
- return AGENT_WORD.test(blanked) || AGENT_WORD.test(nearestHeading);
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 positional = KEYWORD_BEFORE.test(before) || ROUTER_BEFORE.test(before) || KEYWORD_AFTER.test(after) || // Two co-listed installed items make the run an item list. One is not enough:
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 && !(KEBAB_NAME.test(span.text) && looseContext)) continue;
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 fs40 from "fs-extra";
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 fs39 from "fs-extra";
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 resolvePackageJsonPair(root, file, opts) {
9182
- const empty = "{}";
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
- let leftRev = parsed.left;
9192
- if (parsed.triple) {
9193
- const mb = await runGit(["merge-base", parsed.left, parsed.right], { cwd: root });
9194
- leftRev = mb.exitCode === 0 ? mb.stdout.trim() : parsed.left;
9195
- }
9196
- const before2 = await readBlob(root, `${leftRev}:${file}`) ?? empty;
9197
- const after2 = await readBlob(root, `${parsed.right}:${file}`) ?? empty;
9198
- return { before: before2, after: after2 };
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-only"] : ["diff", "--name-only", opts.range ?? "HEAD"];
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.split("\n").map((f) => f.trim()).filter(Boolean);
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 fs37 from "fs-extra";
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 fs37.pathExists(primary)) return primary;
9321
+ if (await fs38.pathExists(primary)) return primary;
9269
9322
  const legacy = path45.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
9270
- if (await fs37.pathExists(legacy)) return legacy;
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 fs38 from "fs-extra";
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
- async function validateIndexSync(decisionsDir, decisionFiles) {
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 fs38.pathExists(readmePath)) {
9311
- return ["docs/decisions/README.md is missing"];
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.includes("[adr-skip]");
9498
+ return textLines(text).some((line2) => line2.trimStart().startsWith(SKIP_DIRECTIVE));
9411
9499
  }
9412
- async function checkSatisfaction(input2) {
9413
- const errors = [];
9414
- const decisionsPrefix = `${input2.decisionsRel}/`;
9415
- const newDecisionFiles = input2.changedFiles.filter(
9416
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path47.basename(f))
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 (newDecisionFiles.length === 0) {
9419
- errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
9420
- return errors;
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
- for (const rel of newDecisionFiles) {
9424
- const abs = path47.join(input2.root, rel);
9425
- const content2 = await readText(abs) ?? "";
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
- if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
9428
- }
9429
- const indexErrors = await validateIndexSync(
9430
- decisionsDir,
9431
- newDecisionFiles.map((f) => path47.basename(f))
9432
- );
9433
- errors.push(...indexErrors);
9434
- return errors;
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 skipText = `${opts.prBody ?? ""}
9470
- ${opts.commitMessages ?? ""}`;
9471
- if (hasSkipToken(skipText) && !securityTriggered) {
9472
- reasons.push("[adr-skip] present \u2014 gate waived (non-security change)");
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 (hasSkipToken(skipText) && securityTriggered) {
9476
- reasons.push("[adr-skip] ignored for security/auth path changes");
9635
+ if (skip && securityTriggered) {
9636
+ reasons.push("security/auth path changes ignore [adr-skip], even on its own line");
9477
9637
  }
9478
- const decisionsDir = await resolveDecisionsDir(root);
9479
- const decisionsRel = relativeDecisionPath(decisionsDir, root);
9480
- const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
9481
- let satisfactionErrors = await checkSatisfaction({
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(...satisfactionErrors);
9506
- reasons.push(WAYS_OUT);
9507
- return { triggered: true, satisfied: false, reasons, securityTriggered };
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 fs39.pathExists(decisionsDir)) return [];
9511
- const entries = await fs39.readdir(decisionsDir);
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 fs40.pathExists(primary);
9521
- const hasLegacy = await fs40.pathExists(legacy);
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 fs40.pathExists(readmePath)) {
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 fs40.readdir(decisionsDir)).filter(
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 fs41 from "fs-extra";
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 fs41.pathExists(candidate)) {
9783
+ if (await fs42.pathExists(candidate)) {
9627
9784
  try {
9628
- await fs41.access(candidate, fs41.constants.W_OK);
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 fs41.pathExists(manifestPathIn(to))) {
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 fs41.pathExists(entry.destPath)) {
9840
+ if (!await fs42.pathExists(entry.destPath)) {
9684
9841
  rewritten.push({ ...entry, destPath: newDest });
9685
9842
  continue;
9686
9843
  }
9687
- if (await fs41.pathExists(newDest)) {
9844
+ if (await fs42.pathExists(newDest)) {
9688
9845
  if (await sameContent(entry.destPath, newDest)) {
9689
9846
  if (!dryRun) {
9690
- await fs41.remove(entry.destPath);
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 fs41.move(entry.destPath, newDest);
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 fs41.remove(legacyManifestPath);
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 fs42.pathExists(resolved)) {
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 fs42.pathExists(workflowPath);
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 fs42.pathExists(cachePath) ? cachePath : bundledPath;
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 fs42.pathExists(workflowConfigPath);
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 fs42.readFile(workflowConfigPath, "utf8");
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 fs42.pathExists(
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 fs42.pathExists(path50.join(root, "docs", "SUMMARY.md"))) {
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 (declaredRoles / declaredStacks), re-run the docs pass to regenerate deep-context.json, or `haus remove <id>` if the item is genuinely no longer wanted"
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 fs42.pathExists(abs)) {
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 fs43 from "fs-extra";
10926
+ import fs44 from "fs-extra";
10770
10927
  init_fs();
10771
10928
  init_paths();
10772
10929
  async function isHausProject(root) {
10773
- if (await fs43.pathExists(hausPath(root, "recommendation.json"))) return true;
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 fs45 from "fs-extra";
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 fs44 from "fs-extra";
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 (fs44.pathExistsSync(p)) await fs44.remove(p);
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 fs45.readdir(dir)).length > 0) break;
10970
- await fs45.remove(dir);
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 !fs45.statSync(destPath).isFile();
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(fs45.readFileSync(pkgPath, "utf8"));
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 fs45.readdirSync(skillDir, { recursive: true })) {
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 (!fs45.statSync(abs).isFile()) continue;
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 (!fs45.pathExistsSync(channelDir)) return entries;
11020
- for (const fileName of fs45.readdirSync(channelDir)) {
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 (fs45.pathExistsSync(skillsDir)) {
11035
- for (const skillName of fs45.readdirSync(skillsDir)) {
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 (fs45.pathExistsSync(skillFile)) {
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 = fs45.pathExistsSync(entry.destPath);
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 (!fs45.pathExistsSync(entry.destPath)) continue;
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 fs45.remove(entry.destPath);
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 fs46 from "fs-extra";
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 fs46.realpath(catalogRoot).catch(() => null);
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 fs46.realpath(sourcePath).catch(() => null);
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 fs46.lstat(sourcePath).catch(() => null);
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 fs46.readdir(sourcePath);
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 fs46.lstat(src).catch(() => null);
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 fs46.pathExists(dest);
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 fs46.remove(dest);
12095
+ await fs47.remove(dest);
11939
12096
  }
11940
- await fs46.ensureDir(path56.dirname(dest));
11941
- await fs46.copy(src, dest, {
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 !fs46.lstatSync(s).isSymbolicLink();
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 = (await runGit(["log", "--format=%B", toAncestryLogRange(range)], { cwd: root })).stdout;
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 docs/decisions/, then retry \u2014 or use [adr-skip] for non-security changes.`
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
- const stagedCheck = await runDecisionsCheck(root, { staged: true });
12140
- const unstagedCheck = await runDecisionsCheck(root, {});
12141
- const triggered = stagedCheck.triggered || unstagedCheck.triggered;
12142
- if (!triggered) return;
12143
- if (stagedCheck.triggered && stagedCheck.satisfied) return;
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 ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
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 docs/decisions/. Why: ${result.reasons.filter((r) => !r.startsWith("no new")).join("; ") || "see above"}. Run \`haus decisions suggest\`, add docs/decisions/NNNN-*.md + README row, or use [adr-skip] with justification (ignored for security paths).`
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 fs47 from "node:fs";
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 = fs47.realpathSync(candidate);
12917
+ const resolved = fs48.realpathSync(candidate);
12732
12918
  return resolved === path58.resolve(candidate) ? {} : { resolved };
12733
12919
  } catch {
12734
12920
  try {
12735
- if (fs47.lstatSync(candidate).isSymbolicLink()) return { unverifiable: true };
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 fs48 from "fs-extra";
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 fs48.readJson(pkgPath);
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 fs48.writeJson(pkgPath, { ...pkg, packageManager: field }, { spaces: 2 });
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 fs48.readJson(pkgPath);
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 fs48.writeJson(pkgPath, { ...pkg, devDependencies, scripts }, { spaces: 2 });
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 fs48.stat(path62.dirname(projectPath)).catch(() => null);
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 fs48.pathExists(projectPath)) {
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 fs48.pathExists(projectPath) ? ` The directory was left for inspection \u2014 review it and remove it manually if unwanted (haus never auto-deletes).` : "";
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 fs48.pathExists(path62.join(projectPath, ".git"))) {
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 fs49 from "fs-extra";
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 fs49.pathExists(abs)) existing.push(abs);
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 fs49.pathExists(settingsPath)) return false;
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 fs49.pathExists(filePath)) return false;
15819
- const text = await fs49.readFile(filePath, "utf8");
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 fs49.pathExists(settingsPath)) return false;
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 fs49.remove(settingsPath);
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 fs49.pathExists(filePath)) return false;
15842
- const prev = await fs49.readFile(filePath, "utf8");
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 fs49.remove(filePath);
16034
+ await fs50.remove(filePath);
15847
16035
  log("Removed CLAUDE.md (only contained haus import block).");
15848
16036
  } else {
15849
- await fs49.writeFile(filePath, next, "utf8");
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 fs49.pathExists(dir)) return;
16043
+ if (!await fs50.pathExists(dir)) return;
15856
16044
  try {
15857
- const entries = await fs49.readdir(dir);
15858
- if (entries.length === 0) await fs49.remove(dir);
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 fs49.pathExists(abs)) continue;
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 fs49.ensureDir(path65.dirname(backupPath));
15871
- await fs49.copy(abs, backupPath);
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 fs49.pathExists(abs)) continue;
15914
- await fs49.remove(abs);
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 fs50 from "fs-extra";
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 = fs50.pathExistsSync(entry.destPath);
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 fs50.remove(entry.destPath);
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 (fs50.pathExistsSync(manifestPath2)) {
15994
- await fs50.remove(manifestPath2);
16181
+ if (fs51.pathExistsSync(manifestPath2)) {
16182
+ await fs51.remove(manifestPath2);
15995
16183
  }
15996
16184
  await removeInstallConfig();
15997
- if (fs50.pathExistsSync(hausDir)) {
16185
+ if (fs51.pathExistsSync(hausDir)) {
15998
16186
  try {
15999
- const remaining = await fs50.readdir(hausDir);
16000
- if (remaining.length === 0) await fs50.remove(hausDir);
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 fs51 from "node:fs";
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 fs51.readFileSync(absPath, "utf8");
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 (!fs51.existsSync(skillMd)) {
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 (!fs51.existsSync(absPath)) {
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 (!fs51.existsSync(absPath)) {
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 (!fs51.existsSync(absPath)) {
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 (!fs51.existsSync(absPath)) {
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 (!fs51.existsSync(absPath)) {
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 (!fs51.existsSync(absPath)) {
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 = fs51.readdirSync(dir, { withFileTypes: true });
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 (!fs51.existsSync(abs)) continue;
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 = fs51.readdirSync(dir, { withFileTypes: true });
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 (!fs51.existsSync(abs)) continue;
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 fs55 from "fs-extra";
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 fs53 from "fs-extra";
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 fs52 from "fs-extra";
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 fs52.pathExists(src);
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 fs52.ensureDir(path69.dirname(dest));
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 fs53.pathExists(src)) continue;
16853
- const destExists = await fs53.pathExists(dest);
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 fs53.remove(dest);
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 fs54 from "fs-extra";
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 fs54.pathExists(src)) continue;
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 fs54.ensureDir(path72.dirname(dest));
16888
- await fs54.copy(src, dest, { overwrite: true });
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 fs55.pathExists(wsWorktreePath)) {
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 fs57 from "fs-extra";
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 fs56 from "fs-extra";
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 fs56.readFile(path76.join(lockPath, "pid"), "utf8")).trim();
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 fs56.ensureDir(path76.dirname(lockPath));
17335
+ await fs57.ensureDir(path76.dirname(lockPath));
17148
17336
  try {
17149
- await fs56.mkdir(lockPath);
17337
+ await fs57.mkdir(lockPath);
17150
17338
  } catch {
17151
17339
  return false;
17152
17340
  }
17153
- await fs56.writeFile(path76.join(lockPath, "pid"), String(pid), "utf8");
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 fs56.stat(lockPath).catch(() => void 0);
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 fs56.remove(lockPath);
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 fs56.writeFile(path76.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
17372
+ await fs57.writeFile(path76.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
17185
17373
  }
17186
17374
  async function releaseHydrateLock(mainRoot, slug) {
17187
- await fs56.remove(hydrateLockPath(mainRoot, slug));
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 fs56.stat(lockPath).catch(() => void 0);
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 fs57.readFile(file, "utf8"));
17759
+ parsed = JSON.parse(await fs58.readFile(file, "utf8"));
17572
17760
  } catch {
17573
17761
  return void 0;
17574
17762
  }
17575
- await fs57.remove(file).catch(() => void 0);
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 fs57.ensureDir(logsDir(mainRoot));
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 fs57.remove(file).catch(() => void 0);
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 fs57.ensureDir(path81.dirname(file));
17721
- await fs57.writeJson(file, record, { spaces: 2 }).catch(() => void 0);
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 fs59 from "fs-extra";
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 fs58 from "fs-extra";
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 fs58.pathExists(target.path)) existing.push(target);
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 fs59.pathExists(wsWorktreePath)) {
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 fs59.pathExists(droppedPath)) {
17835
- await fs59.remove(droppedPath);
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 fs60 from "fs-extra";
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 fs60.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
17857
- const entries = await fs60.readdir(dir, { withFileTypes: true });
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 fs60.pathExists(memberPath);
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 fs61 from "fs-extra";
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 fs61.readFile(path85.join(worktreeRoot, ".git"), "utf8");
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 fs61.stat(candidate).catch(() => void 0);
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 fs61.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
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 fs62 from "fs-extra";
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 fs62.pathExists(wsWorktreePath)) {
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 fs62.pathExists(wsWorktreePath)) {
18089
- await fs62.remove(wsWorktreePath);
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 fs65 from "fs-extra";
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 fs64 from "fs-extra";
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 fs63 from "fs-extra";
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 fs63.pathExists(path88.join(member.absPath, ".claude", subdir))) return true;
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 fs63.pathExists(dir)) return [];
18887
- const names = await fs63.readdir(dir);
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 fs63.lstat(abs);
19079
+ const stat = await fs64.lstat(abs);
18892
19080
  if (!stat.isDirectory()) continue;
18893
- if (!await fs63.pathExists(path88.join(abs, "SKILL.md"))) continue;
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 fs63.pathExists(dir)) return [];
18902
- const names = await fs63.readdir(dir);
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 fs63.lstat(abs)).isFile()) continue;
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 fs64.pathExists(member.absPath)) {
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 fs65.ensureDir(path90.dirname(destAbs));
19023
- if (await fs65.pathExists(destAbs)) await fs65.remove(destAbs);
19024
- await fs65.copy(entry.sourceAbsPath, destAbs, {
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 !fs65.lstatSync(s).isSymbolicLink();
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 fs65.pathExists(abs)) await fs65.remove(abs);
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 fs66 from "fs-extra";
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 fs66.pathExists(filePath);
19144
- const prev = existed ? await fs66.readFile(filePath, "utf8") : "";
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 fs67 from "fs-extra";
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 fs67.pathExists(abs)) {
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 fs67.pathExists(abs)) {
19675
+ if (await fs68.pathExists(abs)) {
19488
19676
  log(`[dry-run] Would remove ${path93.relative(workspaceRoot, abs)}`);
19489
19677
  }
19490
19678
  }
19491
- if (!await fs67.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
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 fs67.pathExists(abs)) {
19532
- await fs67.remove(abs);
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 fs67.pathExists(abs)) {
19539
- await fs67.remove(abs);
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 fs67.pathExists(manifestFile)) {
19545
- await fs67.remove(manifestFile);
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 fs67.pathExists(workspaceMdPath)) {
19550
- await fs67.remove(workspaceMdPath);
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 fs67.remove(claudeMdPath);
19750
+ await fs68.remove(claudeMdPath);
19563
19751
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
19564
19752
  } else {
19565
- await fs67.writeFile(claudeMdPath, next, "utf8");
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 fs67.remove(claudeMdPath);
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 fs67.writeFile(claudeMdPath, next, "utf8");
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("--title <title>", "Draft title").action(
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"));