@haus-tech/haus-workflow 5.10.0 → 5.11.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 {
@@ -2624,7 +2617,7 @@ var DEFAULT_GLOBAL_PLUGINS = [
2624
2617
  plugin: "superpowers",
2625
2618
  marketplace: "superpowers-dev",
2626
2619
  url: "https://github.com/obra/superpowers.git",
2627
- ref: "v6.4.1",
2620
+ ref: "v6.4.2",
2628
2621
  activation: "always"
2629
2622
  },
2630
2623
  {
@@ -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
@@ -9044,14 +9045,14 @@ async function readScannedMarkdown(root) {
9044
9045
 
9045
9046
  // src/decisions/doctor.ts
9046
9047
  import path48 from "node:path";
9047
- import fs40 from "fs-extra";
9048
+ import fs41 from "fs-extra";
9048
9049
  init_fs();
9049
9050
 
9050
9051
  // src/decisions/check.ts
9051
9052
  init_fs();
9052
9053
  init_paths();
9053
9054
  import path47 from "node:path";
9054
- import fs39 from "fs-extra";
9055
+ import fs40 from "fs-extra";
9055
9056
 
9056
9057
  // src/decisions/dep-bump.ts
9057
9058
  var DEP_SECTIONS = [
@@ -9112,6 +9113,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
9112
9113
  init_exec();
9113
9114
  init_fs();
9114
9115
  import path44 from "node:path";
9116
+ import fs37 from "fs-extra";
9115
9117
  var NUL = String.fromCharCode(0);
9116
9118
  function parseNumstat(output) {
9117
9119
  const fields = output.split(NUL);
@@ -9138,6 +9140,27 @@ function parseNumstat(output) {
9138
9140
  }
9139
9141
  return { linesByFile, linesAdded, linesRemoved };
9140
9142
  }
9143
+ function parseNameStatus(output) {
9144
+ const fields = output.split(NUL);
9145
+ const files = [];
9146
+ const presentFiles = [];
9147
+ const renames = [];
9148
+ for (let i = 0; i < fields.length; i++) {
9149
+ const status = fields[i];
9150
+ if (!status) continue;
9151
+ const step = /^[RC]/.test(status) ? 2 : 1;
9152
+ const from = fields[i + 1];
9153
+ const file = fields[i + step];
9154
+ i += step;
9155
+ if (!file) continue;
9156
+ files.push(file);
9157
+ if (status !== "D") presentFiles.push(file);
9158
+ if (status.startsWith("R") && from) {
9159
+ renames.push({ from, to: file, similarity: Number(status.slice(1)) });
9160
+ }
9161
+ }
9162
+ return { files, presentFiles, renames };
9163
+ }
9141
9164
  function isPackageJsonPath(file) {
9142
9165
  return file === "package.json" || file.endsWith("/package.json");
9143
9166
  }
@@ -9169,6 +9192,10 @@ function toAncestryLogRange(range) {
9169
9192
  if (!parsed) return `${range}..HEAD`;
9170
9193
  return `${parsed.left}..${parsed.right}`;
9171
9194
  }
9195
+ async function readCommitMessages(root, range) {
9196
+ const log2 = await runGit(["log", "-z", "--format=%B", toAncestryLogRange(range)], { cwd: root });
9197
+ return log2.stdout.split(NUL).filter((message2) => message2.trim().length > 0);
9198
+ }
9172
9199
  async function getCurrentBranch(root) {
9173
9200
  const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root });
9174
9201
  if (result.exitCode !== 0) return void 0;
@@ -9178,33 +9205,45 @@ async function getCurrentBranch(root) {
9178
9205
  if (gha) return gha;
9179
9206
  return void 0;
9180
9207
  }
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
- }
9208
+ async function diffBaseRev(root, opts) {
9209
+ if (opts.staged) return "HEAD";
9188
9210
  const range = opts.range ?? "HEAD";
9189
9211
  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;
9212
+ if (!parsed) return range;
9213
+ if (!parsed.triple) return parsed.left;
9214
+ const mb = await runGit(["merge-base", parsed.left, parsed.right], { cwd: root });
9215
+ return mb.exitCode === 0 ? mb.stdout.trim() : parsed.left;
9216
+ }
9217
+ async function resolvePackageJsonPair(root, file, opts) {
9218
+ const empty = "{}";
9219
+ const before = await readBlob(root, `${await diffBaseRev(root, opts)}:${file}`) ?? empty;
9220
+ const after = await readDiffContent(root, file, opts) ?? empty;
9202
9221
  return { before, after };
9203
9222
  }
9223
+ async function existsAtDiffBase(root, relPath, opts) {
9224
+ const base = await diffBaseRev(root, opts);
9225
+ return (await runGit(["cat-file", "-e", `${base}:${relPath}`], { cwd: root })).exitCode === 0;
9226
+ }
9227
+ async function readDiffContent(root, file, opts) {
9228
+ if (opts.staged) return await readBlob(root, `:${file}`) ?? void 0;
9229
+ const parsed = parseDiffRange(opts.range ?? "HEAD");
9230
+ if (parsed) return await readBlob(root, `${parsed.right}:${file}`) ?? void 0;
9231
+ return readText(path44.join(root, file));
9232
+ }
9233
+ async function listDiffDirectory(root, dirRel, opts) {
9234
+ const prefix = `${dirRel}/`;
9235
+ const listDirect = async (args) => (await runGit(args, { cwd: root })).stdout.split(NUL).filter((rel) => rel.startsWith(prefix) && !rel.slice(prefix.length).includes("/"));
9236
+ if (opts.staged) return listDirect(["ls-files", "-z", "--", prefix]);
9237
+ const parsed = parseDiffRange(opts.range ?? "HEAD");
9238
+ if (parsed) return listDirect(["ls-tree", "-z", "--name-only", parsed.right, "--", prefix]);
9239
+ const dir = path44.join(root, dirRel);
9240
+ if (!await fs37.pathExists(dir)) return [];
9241
+ return (await fs37.readdir(dir)).map((name) => `${prefix}${name}`);
9242
+ }
9204
9243
  async function collectDiffStats(root, opts) {
9205
- const gitArgs = opts.staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only", opts.range ?? "HEAD"];
9244
+ const gitArgs = opts.staged ? ["diff", "--cached", "--name-status", "-z"] : ["diff", "--name-status", "-z", opts.range ?? "HEAD"];
9206
9245
  const names = await runGit(gitArgs, { cwd: root });
9207
- const files = names.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
9246
+ const { files, presentFiles, renames } = parseNameStatus(names.stdout);
9208
9247
  const numstatArgs = opts.staged ? ["diff", "--cached", "--numstat", "-z"] : ["diff", "--numstat", "-z", opts.range ?? "HEAD"];
9209
9248
  const numstat = await runGit(numstatArgs, { cwd: root });
9210
9249
  const { linesByFile, linesAdded, linesRemoved } = parseNumstat(numstat.stdout);
@@ -9215,6 +9254,8 @@ async function collectDiffStats(root, opts) {
9215
9254
  }
9216
9255
  return {
9217
9256
  files,
9257
+ presentFiles,
9258
+ renames,
9218
9259
  linesAdded,
9219
9260
  linesRemoved,
9220
9261
  linesByFile,
@@ -9262,12 +9303,12 @@ function firstMatchingRegex(filePath, patterns) {
9262
9303
 
9263
9304
  // src/decisions/paths.ts
9264
9305
  import path45 from "node:path";
9265
- import fs37 from "fs-extra";
9306
+ import fs38 from "fs-extra";
9266
9307
  async function resolveDecisionsDir(root) {
9267
9308
  const primary = path45.join(root, DECISIONS_TRIGGERS.decisionsDir);
9268
- if (await fs37.pathExists(primary)) return primary;
9309
+ if (await fs38.pathExists(primary)) return primary;
9269
9310
  const legacy = path45.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
9270
- if (await fs37.pathExists(legacy)) return legacy;
9311
+ if (await fs38.pathExists(legacy)) return legacy;
9271
9312
  return primary;
9272
9313
  }
9273
9314
  function relativeDecisionPath(decisionsDir, root) {
@@ -9277,7 +9318,7 @@ function relativeDecisionPath(decisionsDir, root) {
9277
9318
  // src/decisions/validate.ts
9278
9319
  init_fs();
9279
9320
  import path46 from "node:path";
9280
- import fs38 from "fs-extra";
9321
+ import fs39 from "fs-extra";
9281
9322
  function validateDecisionContent(content2) {
9282
9323
  const errors = [];
9283
9324
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -9305,10 +9346,38 @@ function decisionNumberFromFile(name) {
9305
9346
  const match = /^(\d{4})-/.exec(name);
9306
9347
  return match?.[1] ?? null;
9307
9348
  }
9308
- async function validateIndexSync(decisionsDir, decisionFiles) {
9349
+ var ADR_HEADING_RE = /^#\s+ADR-\d{4}\b/;
9350
+ function firstHeading(content2) {
9351
+ const lines = content2.split(/\r?\n/);
9352
+ let start = 0;
9353
+ if (lines[0]?.trim() === "---") {
9354
+ const end = lines.findIndex((line2, i) => i > 0 && line2.trim() === "---");
9355
+ if (end > 0) start = end + 1;
9356
+ }
9357
+ let fence;
9358
+ for (const line2 of lines.slice(start)) {
9359
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line2)?.[1];
9360
+ if (marker != null) {
9361
+ if (fence == null) fence = marker;
9362
+ else if (marker[0] === fence[0] && marker.length >= fence.length) fence = void 0;
9363
+ continue;
9364
+ }
9365
+ if (fence == null && /^#{1,6}(\s|$)/.test(line2)) return line2;
9366
+ }
9367
+ return void 0;
9368
+ }
9369
+ function isAdrShaped(relPath, content2) {
9370
+ const segments = relPath.replace(/\\/g, "/").toLowerCase().split("/");
9371
+ const dirs = segments.slice(0, -1);
9372
+ if (!/\.md$/i.test(relPath) || dirs.includes("templates")) return false;
9373
+ if (dirs.includes("adr")) return true;
9374
+ const heading = firstHeading(content2);
9375
+ return heading != null && ADR_HEADING_RE.test(heading);
9376
+ }
9377
+ async function validateIndexSync(decisionsDir, decisionFiles, decisionsRel) {
9309
9378
  const readmePath = path46.join(decisionsDir, "README.md");
9310
- if (!await fs38.pathExists(readmePath)) {
9311
- return ["docs/decisions/README.md is missing"];
9379
+ if (!await fs39.pathExists(readmePath)) {
9380
+ return [`${decisionsRel}/README.md is missing`];
9312
9381
  }
9313
9382
  const readme = await readText(readmePath) ?? "";
9314
9383
  const errors = [];
@@ -9322,6 +9391,9 @@ async function validateIndexSync(decisionsDir, decisionFiles) {
9322
9391
  }
9323
9392
 
9324
9393
  // src/decisions/check.ts
9394
+ function noRecordFailure() {
9395
+ return { missingDecision: false, recordErrors: [], recordWayOut: "" };
9396
+ }
9325
9397
  var MANIFEST_LOCK_BASENAMES = /* @__PURE__ */ new Set([
9326
9398
  "package.json",
9327
9399
  "yarn.lock",
@@ -9406,34 +9478,92 @@ async function readGateMode(root) {
9406
9478
  const cfg = await readJson(hausPath(root, "adr-gate.json"));
9407
9479
  return cfg?.mode === "off" ? "off" : "on";
9408
9480
  }
9481
+ var SKIP_DIRECTIVE = "[adr-skip]";
9482
+ function textLines(text) {
9483
+ return text.split(/\r\n|\r|\n/);
9484
+ }
9409
9485
  function hasSkipToken(text) {
9410
- return text.includes("[adr-skip]");
9486
+ return textLines(text).some((line2) => line2.trimStart().startsWith(SKIP_DIRECTIVE));
9411
9487
  }
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))
9488
+ function commitHasSkipDirective(message2) {
9489
+ return hasSkipToken(textLines(message2).slice(1).join("\n"));
9490
+ }
9491
+ async function migratedRecords(root, decisionsRel, renames, diff) {
9492
+ const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
9493
+ if (decisionsRel === legacyRel) return /* @__PURE__ */ new Set();
9494
+ const moved = renames.filter(
9495
+ (r) => r.similarity === 100 && r.from.startsWith(`${legacyRel}/`) && r.to.startsWith(`${decisionsRel}/`)
9496
+ );
9497
+ if (moved.length === 0 || await existsAtDiffBase(root, decisionsRel, diff)) return /* @__PURE__ */ new Set();
9498
+ return new Set(moved.map((r) => r.to));
9499
+ }
9500
+ async function duplicateNumbers(input2, records) {
9501
+ if (records.length === 0) return [];
9502
+ const numberOf = (rel) => decisionNumberFromFile(path47.basename(rel));
9503
+ const listed = (await listDiffDirectory(input2.root, input2.decisionsRel, input2.diff)).filter(
9504
+ (rel) => isDecisionFileName(path47.basename(rel))
9417
9505
  );
9418
- if (newDecisionFiles.length === 0) {
9419
- errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
9420
- return errors;
9506
+ const messages = [];
9507
+ for (const num of new Set(records.map(numberOf))) {
9508
+ if (num == null) continue;
9509
+ const changed = records.filter((rel) => numberOf(rel) === num).sort();
9510
+ const others = listed.filter((rel) => numberOf(rel) === num && !changed.includes(rel)).sort();
9511
+ if (changed.length + others.length < 2) continue;
9512
+ messages.push(
9513
+ `duplicate ADR number ${num}: ${[...changed, ...others].join(", ")}; take the next free number (haus decisions next-number)`
9514
+ );
9421
9515
  }
9516
+ return messages;
9517
+ }
9518
+ async function auditChangedRecords(input2) {
9519
+ const decisionsPrefix = `${input2.decisionsRel}/`;
9422
9520
  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) ?? "";
9521
+ const audit = {
9522
+ records: [],
9523
+ misplaced: [],
9524
+ failing: [],
9525
+ duplicates: [],
9526
+ errors: []
9527
+ };
9528
+ const indexErrors = /* @__PURE__ */ new Set();
9529
+ for (const rel of input2.changedFiles) {
9530
+ if (!/\.md$/i.test(rel)) continue;
9531
+ const inside = rel.startsWith(decisionsPrefix);
9532
+ if (inside && !isDecisionFileName(path47.basename(rel))) continue;
9533
+ const content2 = await readDiffContent(input2.root, rel, input2.diff);
9534
+ if (content2 === void 0) continue;
9535
+ if (!inside) {
9536
+ if (!isAdrShaped(rel, content2)) continue;
9537
+ audit.misplaced.push(rel);
9538
+ audit.errors.push(`no ADR outside ${decisionsPrefix}: ${rel}`);
9539
+ continue;
9540
+ }
9541
+ audit.records.push(rel);
9426
9542
  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;
9543
+ const index = await validateIndexSync(decisionsDir, [path47.basename(rel)], input2.decisionsRel);
9544
+ if (!valid.ok) audit.errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
9545
+ for (const e of index) indexErrors.add(e);
9546
+ if (!valid.ok || index.length > 0) audit.failing.push(rel);
9547
+ }
9548
+ audit.duplicates = await duplicateNumbers(input2, audit.records);
9549
+ audit.errors.push(...indexErrors, ...audit.duplicates);
9550
+ return audit;
9551
+ }
9552
+ function waysOut(decisionsRel) {
9553
+ 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.`;
9554
+ }
9555
+ function recordWayOut(decisionsRel, audit, answersTrigger) {
9556
+ if (audit.errors.length === 0) return "";
9557
+ const answers = answersTrigger ? "; a record moved there also answers the trigger" : "";
9558
+ const steps = [
9559
+ ...audit.misplaced.length > 0 ? [
9560
+ `Move ${audit.misplaced.join(", ")} into ${decisionsRel}/ under the next free number (haus decisions next-number) with the house heading${answers}.`
9561
+ ] : [],
9562
+ ...audit.duplicates.map((duplicate) => `${duplicate}.`),
9563
+ ...audit.failing.length > 0 ? [`Fix ${audit.failing.join(", ")} in place.`] : []
9564
+ ];
9565
+ 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
9566
  }
9436
- var WAYS_OUT = "Write an ADR under docs/decisions/ (haus decisions suggest), or use [adr-skip] for non-security changes.";
9437
9567
  async function runDecisionsCheck(root, opts = {}) {
9438
9568
  const reasons = [];
9439
9569
  if (await readGateMode(root) === "off") {
@@ -9441,7 +9571,8 @@ async function runDecisionsCheck(root, opts = {}) {
9441
9571
  triggered: false,
9442
9572
  satisfied: true,
9443
9573
  reasons: ["gate disabled via .haus-workflow/adr-gate.json"],
9444
- securityTriggered: false
9574
+ securityTriggered: false,
9575
+ ...noRecordFailure()
9445
9576
  };
9446
9577
  }
9447
9578
  const stats = await collectDiffStats(root, opts);
@@ -9452,73 +9583,77 @@ async function runDecisionsCheck(root, opts = {}) {
9452
9583
  triggered: false,
9453
9584
  satisfied: true,
9454
9585
  reasons: ["catalog-sync branch \u2014 exempt"],
9455
- securityTriggered: false
9586
+ securityTriggered: false,
9587
+ ...noRecordFailure()
9456
9588
  };
9457
9589
  }
9590
+ const decisionsDir = await resolveDecisionsDir(root);
9591
+ const decisionsRel = relativeDecisionPath(decisionsDir, root);
9592
+ const diff = { staged: opts.staged, range: opts.range };
9593
+ const migrated = await migratedRecords(root, decisionsRel, stats.renames ?? [], diff);
9594
+ const changed = await auditChangedRecords({
9595
+ root,
9596
+ decisionsRel,
9597
+ changedFiles: (stats.presentFiles ?? stats.files).filter((rel) => !migrated.has(rel)),
9598
+ diff
9599
+ });
9458
9600
  const triggerReasons = explainDecisionTrigger(stats);
9459
- const triggered = triggerReasons.length > 0;
9601
+ const triggered = triggerReasons.length > 0 || changed.errors.length > 0;
9460
9602
  if (!triggered) {
9461
9603
  return {
9462
9604
  triggered: false,
9463
9605
  satisfied: true,
9464
9606
  reasons: ["no decision-worthy changes"],
9465
- securityTriggered
9607
+ securityTriggered,
9608
+ ...noRecordFailure()
9466
9609
  };
9467
9610
  }
9468
9611
  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 };
9474
- }
9475
- if (hasSkipToken(skipText) && securityTriggered) {
9476
- reasons.push("[adr-skip] ignored for security/auth path changes");
9612
+ const skip = hasSkipToken(opts.prBody ?? "") || (opts.commitMessages ?? []).some(commitHasSkipDirective);
9613
+ const waived = skip && !securityTriggered;
9614
+ if (waived && changed.errors.length === 0) {
9615
+ reasons.push("gate waived by [adr-skip] on its own line (non-security change)");
9616
+ return { triggered: true, satisfied: true, reasons, securityTriggered, ...noRecordFailure() };
9617
+ }
9618
+ if (waived) {
9619
+ reasons.push(
9620
+ "the trigger rules are waived by [adr-skip] on its own line, but a failing record is not"
9621
+ );
9477
9622
  }
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
- }
9623
+ if (skip && securityTriggered) {
9624
+ reasons.push("security/auth path changes ignore [adr-skip], even on its own line");
9495
9625
  }
9496
- if (satisfactionErrors.length === 0) {
9626
+ const missingDecision = !waived && triggerReasons.length > 0 && changed.records.length === 0;
9627
+ const wayOut = recordWayOut(decisionsRel, changed, missingDecision);
9628
+ const recordFields = { missingDecision, recordErrors: changed.errors, recordWayOut: wayOut };
9629
+ if (!missingDecision && changed.errors.length === 0) {
9497
9630
  reasons.push("decision record present");
9498
- return {
9499
- triggered: true,
9500
- satisfied: true,
9501
- reasons,
9502
- securityTriggered
9503
- };
9631
+ return { triggered: true, satisfied: true, reasons, securityTriggered, ...recordFields };
9504
9632
  }
9505
- reasons.push(...satisfactionErrors);
9506
- reasons.push(WAYS_OUT);
9507
- return { triggered: true, satisfied: false, reasons, securityTriggered };
9633
+ if (missingDecision) reasons.push(`no new or updated decision under ${decisionsRel}/`);
9634
+ reasons.push(...changed.errors);
9635
+ if (missingDecision && changed.errors.length === 0) reasons.push(waysOut(decisionsRel));
9636
+ if (wayOut) reasons.push(wayOut);
9637
+ return { triggered: true, satisfied: false, reasons, securityTriggered, ...recordFields };
9508
9638
  }
9509
9639
  async function listDecisionNumbers(decisionsDir) {
9510
- if (!await fs39.pathExists(decisionsDir)) return [];
9511
- const entries = await fs39.readdir(decisionsDir);
9640
+ if (!await fs40.pathExists(decisionsDir)) return [];
9641
+ const entries = await fs40.readdir(decisionsDir);
9512
9642
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
9513
9643
  }
9514
9644
 
9515
9645
  // src/decisions/doctor.ts
9646
+ async function countMarkdownFiles(dir) {
9647
+ if (!(await fs41.stat(dir)).isDirectory()) return 0;
9648
+ const entries = await fs41.readdir(dir, { recursive: true, withFileTypes: true });
9649
+ return entries.filter((entry) => entry.isFile() && /\.md$/i.test(entry.name)).length;
9650
+ }
9516
9651
  async function auditDecisionsLayout(root, profile) {
9517
9652
  const findings = [];
9518
9653
  const primary = path48.join(root, DECISIONS_TRIGGERS.decisionsDir);
9519
9654
  const legacy = path48.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
9520
- const hasPrimary = await fs40.pathExists(primary);
9521
- const hasLegacy = await fs40.pathExists(legacy);
9655
+ const hasPrimary = await fs41.pathExists(primary);
9656
+ const hasLegacy = await fs41.pathExists(legacy);
9522
9657
  if (!hasPrimary && hasLegacy) {
9523
9658
  if (PROFILES[profile].writesDecisionsSeed) {
9524
9659
  findings.push({
@@ -9531,10 +9666,20 @@ async function auditDecisionsLayout(root, profile) {
9531
9666
  return findings;
9532
9667
  }
9533
9668
  if (!hasPrimary) return findings;
9669
+ if (hasLegacy) {
9670
+ const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
9671
+ const primaryRel = DECISIONS_TRIGGERS.decisionsDir;
9672
+ findings.push({
9673
+ level: "advisory",
9674
+ line: `- DECISIONS: a second ADR home exists at ${legacyRel}/ (${await countMarkdownFiles(legacy)} file(s)); records there are invisible to the gate`,
9675
+ 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`,
9676
+ 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`
9677
+ });
9678
+ }
9534
9679
  const decisionsDir = await resolveDecisionsDir(root);
9535
9680
  const rel = relativeDecisionPath(decisionsDir, root);
9536
9681
  const readmePath = path48.join(decisionsDir, "README.md");
9537
- if (!await fs40.pathExists(readmePath)) {
9682
+ if (!await fs41.pathExists(readmePath)) {
9538
9683
  findings.push({
9539
9684
  level: "advisory",
9540
9685
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -9544,7 +9689,7 @@ async function auditDecisionsLayout(root, profile) {
9544
9689
  return findings;
9545
9690
  }
9546
9691
  const readme = await readText(readmePath);
9547
- const entries = (await fs40.readdir(decisionsDir)).filter(
9692
+ const entries = (await fs41.readdir(decisionsDir)).filter(
9548
9693
  (f) => f.endsWith(".md") && f !== "README.md"
9549
9694
  );
9550
9695
  for (const file of entries) {
@@ -9593,7 +9738,7 @@ init_fs();
9593
9738
  init_logger();
9594
9739
  init_paths();
9595
9740
  import path49 from "node:path";
9596
- import fs41 from "fs-extra";
9741
+ import fs42 from "fs-extra";
9597
9742
  function manifestPathIn(configDir) {
9598
9743
  return path49.join(configDir, "haus", "install-manifest.json");
9599
9744
  }
@@ -9623,9 +9768,9 @@ async function pruneVacatedDirs(root, vacatedFiles) {
9623
9768
  async function destinationWritable(dir) {
9624
9769
  let candidate = path49.resolve(dir);
9625
9770
  for (; ; ) {
9626
- if (await fs41.pathExists(candidate)) {
9771
+ if (await fs42.pathExists(candidate)) {
9627
9772
  try {
9628
- await fs41.access(candidate, fs41.constants.W_OK);
9773
+ await fs42.access(candidate, fs42.constants.W_OK);
9629
9774
  return true;
9630
9775
  } catch (err) {
9631
9776
  return err instanceof Error ? err.message : String(err);
@@ -9657,7 +9802,7 @@ async function migrateGlobalConfigDir(options = {}) {
9657
9802
  if (!legacy || !Array.isArray(legacy.files)) {
9658
9803
  return { ...base, reason: "no install manifest under ~/.claude" };
9659
9804
  }
9660
- if (await fs41.pathExists(manifestPathIn(to))) {
9805
+ if (await fs42.pathExists(manifestPathIn(to))) {
9661
9806
  return { ...base, reason: "an install manifest already exists in the resolved config dir" };
9662
9807
  }
9663
9808
  const writable = await destinationWritable(to);
@@ -9680,14 +9825,14 @@ async function migrateGlobalConfigDir(options = {}) {
9680
9825
  continue;
9681
9826
  }
9682
9827
  const newDest = path49.join(to, rel);
9683
- if (!await fs41.pathExists(entry.destPath)) {
9828
+ if (!await fs42.pathExists(entry.destPath)) {
9684
9829
  rewritten.push({ ...entry, destPath: newDest });
9685
9830
  continue;
9686
9831
  }
9687
- if (await fs41.pathExists(newDest)) {
9832
+ if (await fs42.pathExists(newDest)) {
9688
9833
  if (await sameContent(entry.destPath, newDest)) {
9689
9834
  if (!dryRun) {
9690
- await fs41.remove(entry.destPath);
9835
+ await fs42.remove(entry.destPath);
9691
9836
  vacated.push(entry.destPath);
9692
9837
  }
9693
9838
  moved.push(newDest);
@@ -9704,7 +9849,7 @@ async function migrateGlobalConfigDir(options = {}) {
9704
9849
  continue;
9705
9850
  }
9706
9851
  try {
9707
- await fs41.move(entry.destPath, newDest);
9852
+ await fs42.move(entry.destPath, newDest);
9708
9853
  vacated.push(entry.destPath);
9709
9854
  moved.push(newDest);
9710
9855
  rewritten.push({ ...entry, destPath: newDest });
@@ -9729,7 +9874,7 @@ async function migrateGlobalConfigDir(options = {}) {
9729
9874
  }
9730
9875
  try {
9731
9876
  await writeJson(manifestPathIn(to), { ...legacy, files: rewritten });
9732
- await fs41.remove(legacyManifestPath);
9877
+ await fs42.remove(legacyManifestPath);
9733
9878
  vacated.push(legacyManifestPath);
9734
9879
  } catch (err) {
9735
9880
  return {
@@ -10027,7 +10172,7 @@ async function runDoctor(options) {
10027
10172
  const resolved = path50.resolve(root, target);
10028
10173
  const relFromRoot = path50.relative(root, resolved);
10029
10174
  const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path50.sep}`) || path50.isAbsolute(relFromRoot);
10030
- if (escapesRoot || !await fs42.pathExists(resolved)) {
10175
+ if (escapesRoot || !await fs43.pathExists(resolved)) {
10031
10176
  flag(
10032
10177
  `- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
10033
10178
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -10045,7 +10190,7 @@ async function runDoctor(options) {
10045
10190
  );
10046
10191
  }
10047
10192
  const workflowPath = hausPath(root, "WORKFLOW.md");
10048
- const workflowExists = await fs42.pathExists(workflowPath);
10193
+ const workflowExists = await fs43.pathExists(workflowPath);
10049
10194
  if (!workflowExists) {
10050
10195
  flag(
10051
10196
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -10068,7 +10213,7 @@ async function runDoctor(options) {
10068
10213
  "templates",
10069
10214
  path50.basename(PROFILES[profile].workflowTemplateRel)
10070
10215
  );
10071
- const templatePath = await fs42.pathExists(cachePath) ? cachePath : bundledPath;
10216
+ const templatePath = await fs43.pathExists(cachePath) ? cachePath : bundledPath;
10072
10217
  const templateContent = await readText(templatePath);
10073
10218
  const verdict = checkManagedTamper(bodyContent, managedHeader.hash, templateContent ?? null);
10074
10219
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -10096,7 +10241,7 @@ async function runDoctor(options) {
10096
10241
  }
10097
10242
  }
10098
10243
  const workflowConfigPath = hausPath(root, "workflow-config.md");
10099
- const workflowConfigExists = await fs42.pathExists(workflowConfigPath);
10244
+ const workflowConfigExists = await fs43.pathExists(workflowConfigPath);
10100
10245
  if (!workflowConfigExists) {
10101
10246
  flag(
10102
10247
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -10104,7 +10249,7 @@ async function runDoctor(options) {
10104
10249
  "haus apply --write"
10105
10250
  );
10106
10251
  } else {
10107
- const cfg = await fs42.readFile(workflowConfigPath, "utf8");
10252
+ const cfg = await fs43.readFile(workflowConfigPath, "utf8");
10108
10253
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
10109
10254
  if (unfilled > 0) {
10110
10255
  suggest(
@@ -10116,11 +10261,11 @@ async function runDoctor(options) {
10116
10261
  ok("- .haus-workflow/workflow-config.md: OK (project-owned)");
10117
10262
  }
10118
10263
  }
10119
- const docsSkillInstalled = await fs42.pathExists(
10264
+ const docsSkillInstalled = await fs43.pathExists(
10120
10265
  path50.join(root, ".claude", "skills", "writing-documentation", "SKILL.md")
10121
10266
  );
10122
10267
  if (docsSkillInstalled) {
10123
- if (await fs42.pathExists(path50.join(root, "docs", "SUMMARY.md"))) {
10268
+ if (await fs43.pathExists(path50.join(root, "docs", "SUMMARY.md"))) {
10124
10269
  ok("- PROJECT DOCS: OK (docs/SUMMARY.md present)");
10125
10270
  } else if (PROFILES[profile].runsDocsPass) {
10126
10271
  suggest(
@@ -10449,7 +10594,7 @@ async function runDoctor(options) {
10449
10594
  suggest(
10450
10595
  `- 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
10596
  `${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"
10597
+ "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
10598
  );
10454
10599
  }
10455
10600
  }
@@ -10684,7 +10829,7 @@ async function runDoctor(options) {
10684
10829
  let onDisk = false;
10685
10830
  for (const rel of row.paths ?? []) {
10686
10831
  const abs = resolveContainedPath(root, rel);
10687
- if (abs !== null && await fs42.pathExists(abs)) {
10832
+ if (abs !== null && await fs43.pathExists(abs)) {
10688
10833
  onDisk = true;
10689
10834
  break;
10690
10835
  }
@@ -10766,11 +10911,11 @@ init_github_rate_limit();
10766
10911
  init_private_catalog_access();
10767
10912
 
10768
10913
  // src/claude/refresh-project.ts
10769
- import fs43 from "fs-extra";
10914
+ import fs44 from "fs-extra";
10770
10915
  init_fs();
10771
10916
  init_paths();
10772
10917
  async function isHausProject(root) {
10773
- if (await fs43.pathExists(hausPath(root, "recommendation.json"))) return true;
10918
+ if (await fs44.pathExists(hausPath(root, "recommendation.json"))) return true;
10774
10919
  const settings = await readJsonDetailed(claudePath(root, "settings.json"));
10775
10920
  if (settings.status === "ok" && settings.value._haus != null) return true;
10776
10921
  const lock = await readJsonDetailed(hausPath(root, "haus.lock.json"));
@@ -10792,7 +10937,7 @@ async function refreshProjectApply(root) {
10792
10937
 
10793
10938
  // src/install/apply.ts
10794
10939
  import path52 from "node:path";
10795
- import fs45 from "fs-extra";
10940
+ import fs46 from "fs-extra";
10796
10941
  init_fs();
10797
10942
  init_logger();
10798
10943
  init_paths();
@@ -10902,7 +11047,7 @@ ${content2}`;
10902
11047
  // src/install/internal-channel.ts
10903
11048
  init_fs();
10904
11049
  import path51 from "node:path";
10905
- import fs44 from "fs-extra";
11050
+ import fs45 from "fs-extra";
10906
11051
  var INSTALL_CONFIG_SCHEMA = "haus-install-config/1";
10907
11052
  var INTERNAL_NAME_PREFIXES = ["request", "slack-cowork"];
10908
11053
  function internalPrefixMatch(name) {
@@ -10936,7 +11081,7 @@ async function writeInstallConfig(config) {
10936
11081
  }
10937
11082
  async function removeInstallConfig() {
10938
11083
  const p = installConfigPath();
10939
- if (fs44.pathExistsSync(p)) await fs44.remove(p);
11084
+ if (fs45.pathExistsSync(p)) await fs45.remove(p);
10940
11085
  }
10941
11086
  function envOverride() {
10942
11087
  const raw = process.env.HAUS_INTERNAL_CHANNEL?.trim().toLowerCase();
@@ -10966,8 +11111,8 @@ async function pruneEmptyDirsUpward(dirs, stopAt) {
10966
11111
  let dir = start;
10967
11112
  while (dir.startsWith(stopAt) && dir !== stopAt) {
10968
11113
  try {
10969
- if ((await fs45.readdir(dir)).length > 0) break;
10970
- await fs45.remove(dir);
11114
+ if ((await fs46.readdir(dir)).length > 0) break;
11115
+ await fs46.remove(dir);
10971
11116
  } catch {
10972
11117
  break;
10973
11118
  }
@@ -10980,7 +11125,7 @@ function hashContent(content2) {
10980
11125
  }
10981
11126
  function destFileMissing(destPath) {
10982
11127
  try {
10983
- return !fs45.statSync(destPath).isFile();
11128
+ return !fs46.statSync(destPath).isFile();
10984
11129
  } catch {
10985
11130
  return true;
10986
11131
  }
@@ -10988,7 +11133,7 @@ function destFileMissing(destPath) {
10988
11133
  function sourceVersion() {
10989
11134
  try {
10990
11135
  const pkgPath = path52.join(packageRoot(), "package.json");
10991
- const pkg = JSON.parse(fs45.readFileSync(pkgPath, "utf8"));
11136
+ const pkg = JSON.parse(fs46.readFileSync(pkgPath, "utf8"));
10992
11137
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
10993
11138
  } catch {
10994
11139
  return "haus@0.0.0";
@@ -11000,10 +11145,10 @@ function globalSrcDir() {
11000
11145
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
11001
11146
  const skillDir = path52.join(skillsDir, skillName);
11002
11147
  const entries = [];
11003
- for (const rel of fs45.readdirSync(skillDir, { recursive: true })) {
11148
+ for (const rel of fs46.readdirSync(skillDir, { recursive: true })) {
11004
11149
  if (rel === "SKILL.md") continue;
11005
11150
  const abs = path52.join(skillDir, rel);
11006
- if (!fs45.statSync(abs).isFile()) continue;
11151
+ if (!fs46.statSync(abs).isFile()) continue;
11007
11152
  const posixRel = rel.split(path52.sep).join("/");
11008
11153
  entries.push({
11009
11154
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
@@ -11016,8 +11161,8 @@ function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
11016
11161
  function collectFlatChannel(srcDir, claudeDir, dirName, idPrefix) {
11017
11162
  const entries = [];
11018
11163
  const channelDir = path52.join(srcDir, dirName);
11019
- if (!fs45.pathExistsSync(channelDir)) return entries;
11020
- for (const fileName of fs45.readdirSync(channelDir)) {
11164
+ if (!fs46.pathExistsSync(channelDir)) return entries;
11165
+ for (const fileName of fs46.readdirSync(channelDir)) {
11021
11166
  if (!fileName.endsWith(".md")) continue;
11022
11167
  const name = fileName.slice(0, -".md".length);
11023
11168
  entries.push({
@@ -11031,10 +11176,10 @@ function collectFlatChannel(srcDir, claudeDir, dirName, idPrefix) {
11031
11176
  function collectSourceFiles(srcDir, claudeDir) {
11032
11177
  const entries = [];
11033
11178
  const skillsDir = path52.join(srcDir, "skills");
11034
- if (fs45.pathExistsSync(skillsDir)) {
11035
- for (const skillName of fs45.readdirSync(skillsDir)) {
11179
+ if (fs46.pathExistsSync(skillsDir)) {
11180
+ for (const skillName of fs46.readdirSync(skillsDir)) {
11036
11181
  const skillFile = path52.join(skillsDir, skillName, "SKILL.md");
11037
- if (fs45.pathExistsSync(skillFile)) {
11182
+ if (fs46.pathExistsSync(skillFile)) {
11038
11183
  entries.push({
11039
11184
  stableId: `skill.${skillName}`,
11040
11185
  srcRelPath: path52.join("library", "global", "skills", skillName, "SKILL.md"),
@@ -11107,7 +11252,7 @@ async function applyInstall(options = {}) {
11107
11252
  }
11108
11253
  continue;
11109
11254
  }
11110
- const destExists = fs45.pathExistsSync(entry.destPath);
11255
+ const destExists = fs46.pathExistsSync(entry.destPath);
11111
11256
  if (destExists) {
11112
11257
  const currentContent = await readText(entry.destPath);
11113
11258
  if (currentContent !== void 0) {
@@ -11167,13 +11312,13 @@ async function applyInstall(options = {}) {
11167
11312
  manifestFiles.push(entry);
11168
11313
  continue;
11169
11314
  }
11170
- if (!fs45.pathExistsSync(entry.destPath)) continue;
11315
+ if (!fs46.pathExistsSync(entry.destPath)) continue;
11171
11316
  const content2 = await readText(entry.destPath);
11172
11317
  if (!content2) continue;
11173
11318
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
11174
11319
  const currentHash = hashContent(content2);
11175
11320
  if (hasHeader && currentHash === entry.hash) {
11176
- if (!dryRun) await fs45.remove(entry.destPath);
11321
+ if (!dryRun) await fs46.remove(entry.destPath);
11177
11322
  emptiedDirs.add(path52.dirname(entry.destPath));
11178
11323
  result.deleted.push(entry.destPath);
11179
11324
  } else {
@@ -11857,7 +12002,7 @@ function isCatalogItemInstallable(item) {
11857
12002
  // src/install/scaffold.ts
11858
12003
  init_logger();
11859
12004
  import path56 from "node:path";
11860
- import fs46 from "fs-extra";
12005
+ import fs47 from "fs-extra";
11861
12006
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
11862
12007
  const scaffolded = [];
11863
12008
  const skipped = [];
@@ -11870,7 +12015,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
11870
12015
  skipped.push(name);
11871
12016
  if (outcome === "skipped-exists") skippedExisting.push(name);
11872
12017
  };
11873
- const realRoot = await fs46.realpath(catalogRoot).catch(() => null);
12018
+ const realRoot = await fs47.realpath(catalogRoot).catch(() => null);
11874
12019
  for (const item of items) {
11875
12020
  if (item.type !== "config") continue;
11876
12021
  const sourcePath = path56.resolve(catalogRoot, item.path);
@@ -11880,7 +12025,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
11880
12025
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
11881
12026
  continue;
11882
12027
  }
11883
- const realSource = await fs46.realpath(sourcePath).catch(() => null);
12028
+ const realSource = await fs47.realpath(sourcePath).catch(() => null);
11884
12029
  if (!realRoot || !realSource) {
11885
12030
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
11886
12031
  continue;
@@ -11890,7 +12035,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
11890
12035
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
11891
12036
  continue;
11892
12037
  }
11893
- const stat = await fs46.lstat(sourcePath).catch(() => null);
12038
+ const stat = await fs47.lstat(sourcePath).catch(() => null);
11894
12039
  if (!stat) {
11895
12040
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
11896
12041
  continue;
@@ -11904,7 +12049,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
11904
12049
  const dest = path56.join(projectRoot, filename);
11905
12050
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
11906
12051
  } else if (stat.isDirectory()) {
11907
- const entries = await fs46.readdir(sourcePath);
12052
+ const entries = await fs47.readdir(sourcePath);
11908
12053
  for (const entry of entries) {
11909
12054
  const src = path56.join(sourcePath, entry);
11910
12055
  const dest = path56.join(projectRoot, entry);
@@ -11916,7 +12061,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
11916
12061
  }
11917
12062
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
11918
12063
  const destDisplay = path56.relative(projectRoot, dest) || path56.basename(dest);
11919
- const srcStat = await fs46.lstat(src).catch(() => null);
12064
+ const srcStat = await fs47.lstat(src).catch(() => null);
11920
12065
  if (!srcStat) {
11921
12066
  warn(`Skipping ${path56.basename(src)}: source not found`);
11922
12067
  return "skipped-refused";
@@ -11925,7 +12070,7 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
11925
12070
  warn(`Skipping ${path56.basename(src)}: source is a symlink`);
11926
12071
  return "skipped-refused";
11927
12072
  }
11928
- const exists2 = await fs46.pathExists(dest);
12073
+ const exists2 = await fs47.pathExists(dest);
11929
12074
  if (exists2 && !opts.force) {
11930
12075
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
11931
12076
  return "skipped-exists";
@@ -11935,16 +12080,16 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
11935
12080
  return "scaffolded";
11936
12081
  }
11937
12082
  if (exists2 && opts.force) {
11938
- await fs46.remove(dest);
12083
+ await fs47.remove(dest);
11939
12084
  }
11940
- await fs46.ensureDir(path56.dirname(dest));
11941
- await fs46.copy(src, dest, {
12085
+ await fs47.ensureDir(path56.dirname(dest));
12086
+ await fs47.copy(src, dest, {
11942
12087
  overwrite: true,
11943
12088
  dereference: false,
11944
12089
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
11945
12090
  filter: (s) => {
11946
12091
  try {
11947
- return !fs46.lstatSync(s).isSymbolicLink();
12092
+ return !fs47.lstatSync(s).isSymbolicLink();
11948
12093
  } catch {
11949
12094
  return false;
11950
12095
  }
@@ -12057,7 +12202,6 @@ async function resolveBaseRef(root) {
12057
12202
  }
12058
12203
 
12059
12204
  // src/commands/decisions-guard.ts
12060
- init_exec();
12061
12205
  var PR_CREATE_PATTERN = /\bgh\s+pr\s+create\b/;
12062
12206
  function readPayload(options) {
12063
12207
  if (options.stdinPayload != null) {
@@ -12083,11 +12227,20 @@ async function runDecisionsGuard(options = {}) {
12083
12227
  const baseRef = await resolveBaseRef(root);
12084
12228
  if (!baseRef) return;
12085
12229
  const range = `${baseRef}...HEAD`;
12086
- const commitMessages = (await runGit(["log", "--format=%B", toAncestryLogRange(range)], { cwd: root })).stdout;
12230
+ const commitMessages = await readCommitMessages(root, range);
12087
12231
  const result = await runDecisionsCheck(root, { range, commitMessages });
12232
+ if (result.triggered && !result.satisfied && result.recordErrors.length > 0) {
12233
+ 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";
12234
+ const why = result.missingDecision ? result.reasons.filter((r) => !r.startsWith("no new") && r !== result.recordWayOut) : result.recordErrors;
12235
+ emitPreToolUseDeny(
12236
+ `I didn't run that: ${what} (${why.join("; ")}). ${result.recordWayOut} Then retry.`
12237
+ );
12238
+ return;
12239
+ }
12088
12240
  if (result.triggered && !result.satisfied) {
12241
+ const rel = relativeDecisionPath(await resolveDecisionsDir(root), root);
12089
12242
  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.`
12243
+ `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
12244
  );
12092
12245
  }
12093
12246
  }
@@ -12104,6 +12257,14 @@ async function nextDecisionNumber(root) {
12104
12257
  }
12105
12258
 
12106
12259
  // src/decisions/suggest.ts
12260
+ function printRecordFailures(checks) {
12261
+ const lines = /* @__PURE__ */ new Set([
12262
+ ...checks.flatMap((check) => check.recordErrors),
12263
+ ...checks.map((check) => check.recordWayOut).filter((wayOut) => wayOut.length > 0)
12264
+ ]);
12265
+ for (const line2 of lines) process.stdout.write(`${line2}
12266
+ `);
12267
+ }
12107
12268
  function draftTemplate(number, title) {
12108
12269
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
12109
12270
  return `# ADR-${number}: ${title}
@@ -12136,11 +12297,18 @@ _(Why this over alternatives \u2014 the line agents read for "why".)_
12136
12297
  `;
12137
12298
  }
12138
12299
  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;
12300
+ if (!opts.title) {
12301
+ const stagedCheck = await runDecisionsCheck(root, { staged: true });
12302
+ const unstagedCheck = await runDecisionsCheck(root, {});
12303
+ const triggered = stagedCheck.triggered || unstagedCheck.triggered;
12304
+ if (!triggered) return;
12305
+ if (stagedCheck.triggered && stagedCheck.satisfied) return;
12306
+ const wantsDraft = (check) => check.missingDecision && check.recordErrors.length === 0;
12307
+ if (!wantsDraft(stagedCheck) && !wantsDraft(unstagedCheck)) {
12308
+ if (!opts.fromHook) printRecordFailures([stagedCheck, unstagedCheck]);
12309
+ return;
12310
+ }
12311
+ }
12144
12312
  const number = await nextDecisionNumber(root);
12145
12313
  const title = opts.title ?? "Decision from current changes";
12146
12314
  const draft = draftTemplate(number, title);
@@ -12168,9 +12336,7 @@ init_logger();
12168
12336
  async function runDecisions(sub, arg, options = {}) {
12169
12337
  const root = process.cwd();
12170
12338
  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 : "";
12339
+ const commitMessages = options.range != null ? await readCommitMessages(root, options.range) : [];
12174
12340
  const result = await runDecisionsCheck(root, {
12175
12341
  staged: options.staged,
12176
12342
  range: options.range,
@@ -12188,9 +12354,17 @@ async function runDecisions(sub, arg, options = {}) {
12188
12354
  log(`decisions gate: satisfied (${result.reasons.join("; ")})`);
12189
12355
  return;
12190
12356
  }
12191
- for (const reason of result.reasons) warn(reason);
12357
+ for (const reason of result.reasons) if (reason !== result.recordWayOut) warn(reason);
12358
+ if (result.recordErrors.length > 0) {
12359
+ 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";
12360
+ const why = result.missingDecision ? result.reasons.filter((r) => !r.startsWith("no new") && r !== result.recordWayOut) : result.recordErrors;
12361
+ error(`decisions gate: ${what}. Why: ${why.join("; ")}. ${result.recordWayOut}`);
12362
+ process.exitCode = 1;
12363
+ return;
12364
+ }
12365
+ const rel = relativeDecisionPath(await resolveDecisionsDir(root), root);
12192
12366
  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).`
12367
+ `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
12368
  );
12195
12369
  process.exitCode = 1;
12196
12370
  return;
@@ -12720,7 +12894,7 @@ function matchDangerousCommandShape(command) {
12720
12894
  }
12721
12895
 
12722
12896
  // src/security/guard-file-access.ts
12723
- import fs47 from "node:fs";
12897
+ import fs48 from "node:fs";
12724
12898
  import path58 from "node:path";
12725
12899
  function matchesDenyTier(candidate) {
12726
12900
  const normalized = normalizePathForMatch(candidate);
@@ -12728,11 +12902,11 @@ function matchesDenyTier(candidate) {
12728
12902
  }
12729
12903
  function resolveOnDisk(candidate) {
12730
12904
  try {
12731
- const resolved = fs47.realpathSync(candidate);
12905
+ const resolved = fs48.realpathSync(candidate);
12732
12906
  return resolved === path58.resolve(candidate) ? {} : { resolved };
12733
12907
  } catch {
12734
12908
  try {
12735
- if (fs47.lstatSync(candidate).isSymbolicLink()) return { unverifiable: true };
12909
+ if (fs48.lstatSync(candidate).isSymbolicLink()) return { unverifiable: true };
12736
12910
  } catch {
12737
12911
  }
12738
12912
  const parent = path58.dirname(candidate);
@@ -14861,7 +15035,7 @@ import path63 from "node:path";
14861
15035
 
14862
15036
  // src/new-project/create-project.ts
14863
15037
  import path62 from "node:path";
14864
- import fs48 from "fs-extra";
15038
+ import fs49 from "fs-extra";
14865
15039
  init_exec();
14866
15040
  init_logger();
14867
15041
 
@@ -15049,7 +15223,7 @@ async function recordPackageManager(projectPath, pm, deps) {
15049
15223
  const pkgPath = path62.join(projectPath, "package.json");
15050
15224
  let pkg;
15051
15225
  try {
15052
- pkg = await fs48.readJson(pkgPath);
15226
+ pkg = await fs49.readJson(pkgPath);
15053
15227
  } catch {
15054
15228
  return {};
15055
15229
  }
@@ -15064,7 +15238,7 @@ async function recordPackageManager(projectPath, pm, deps) {
15064
15238
  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
15239
  };
15066
15240
  }
15067
- await fs48.writeJson(pkgPath, { ...pkg, packageManager: field }, { spaces: 2 });
15241
+ await fs49.writeJson(pkgPath, { ...pkg, packageManager: field }, { spaces: 2 });
15068
15242
  return {};
15069
15243
  }
15070
15244
  var CONFIG_TOOLING = {
@@ -15082,7 +15256,7 @@ async function ensureConfigTooling(projectPath, scaffolded) {
15082
15256
  const pkgPath = path62.join(projectPath, "package.json");
15083
15257
  let pkg;
15084
15258
  try {
15085
- pkg = await fs48.readJson(pkgPath);
15259
+ pkg = await fs49.readJson(pkgPath);
15086
15260
  } catch {
15087
15261
  return [];
15088
15262
  }
@@ -15106,7 +15280,7 @@ async function ensureConfigTooling(projectPath, scaffolded) {
15106
15280
  }
15107
15281
  }
15108
15282
  if (!changed) return [];
15109
- await fs48.writeJson(pkgPath, { ...pkg, devDependencies, scripts }, { spaces: 2 });
15283
+ await fs49.writeJson(pkgPath, { ...pkg, devDependencies, scripts }, { spaces: 2 });
15110
15284
  return added;
15111
15285
  }
15112
15286
  async function createProject(opts, deps = defaultDeps) {
@@ -15119,7 +15293,7 @@ async function createProject(opts, deps = defaultDeps) {
15119
15293
  };
15120
15294
  const stack = getStack(opts.stackId);
15121
15295
  if (!stack) return fail("preflight", `Unknown stack "${opts.stackId}". Run \`haus new --list\`.`);
15122
- const parentStat = await fs48.stat(path62.dirname(projectPath)).catch(() => null);
15296
+ const parentStat = await fs49.stat(path62.dirname(projectPath)).catch(() => null);
15123
15297
  if (!parentStat?.isDirectory()) {
15124
15298
  return fail(
15125
15299
  "preflight",
@@ -15132,7 +15306,7 @@ async function createProject(opts, deps = defaultDeps) {
15132
15306
  `Invalid project name "${opts.name}" (lowercase letters, digits, . _ - only).`
15133
15307
  );
15134
15308
  }
15135
- if (await fs48.pathExists(projectPath)) {
15309
+ if (await fs49.pathExists(projectPath)) {
15136
15310
  return fail("preflight", `Target ${projectPath} already exists \u2014 refusing to write into it.`);
15137
15311
  }
15138
15312
  const scaffoldArgs = stack.scaffold.args.map(
@@ -15208,13 +15382,13 @@ async function createProject(opts, deps = defaultDeps) {
15208
15382
  });
15209
15383
  if (scaffold.exitCode !== 0) {
15210
15384
  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).` : "";
15385
+ 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
15386
  return fail("scaffold", `${why}${leftBehind}`);
15213
15387
  }
15214
15388
  steps.push({ step: "scaffold", status: "done" });
15215
15389
  phase = "git";
15216
15390
  if (!opts.json) log("\u25B6 git: init/branch/commit");
15217
- if (!await fs48.pathExists(path62.join(projectPath, ".git"))) {
15391
+ if (!await fs49.pathExists(path62.join(projectPath, ".git"))) {
15218
15392
  const init = await deps.git(["init"], { cwd: projectPath });
15219
15393
  if (init.exitCode !== 0) return fail("git", `git init failed: ${init.stderr}`);
15220
15394
  }
@@ -15525,7 +15699,9 @@ async function runRecommend(options) {
15525
15699
  const root = process.cwd();
15526
15700
  const context = await readContextOrScan(root);
15527
15701
  const include = parseIdList(options.include);
15702
+ const declaredRoles = parseIdList(options.declareRole);
15528
15703
  if (include.length > 0) await addProjectIncludedIds(root, include);
15704
+ if (declaredRoles.length > 0) await addProjectDeclaredRoles(root, declaredRoles);
15529
15705
  const activePluginIds = pluginIdsForInstall(await activeGlobalPluginIds(root));
15530
15706
  let result;
15531
15707
  try {
@@ -15546,7 +15722,7 @@ async function runRecommend(options) {
15546
15722
  log(`Skipped: ${result.skipped.length}`);
15547
15723
  if (result.optInEligible && result.optInEligible.length > 0) {
15548
15724
  log(
15549
- `Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`)`
15725
+ `Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`, or with \`haus recommend --declare-role <role>\` for role-gated items)`
15550
15726
  );
15551
15727
  }
15552
15728
  for (const w of result.warnings) log(`- WARN: ${w}`);
@@ -15760,7 +15936,7 @@ async function runScan(options) {
15760
15936
 
15761
15937
  // src/commands/undo.ts
15762
15938
  import path65 from "node:path";
15763
- import fs49 from "fs-extra";
15939
+ import fs50 from "fs-extra";
15764
15940
 
15765
15941
  // src/claude/managed-paths.ts
15766
15942
  init_paths();
@@ -15779,7 +15955,7 @@ init_paths();
15779
15955
  async function collectCoreManagedPaths(root) {
15780
15956
  const existing = [];
15781
15957
  for (const abs of coreManagedAbsolutePaths(root)) {
15782
- if (await fs49.pathExists(abs)) existing.push(abs);
15958
+ if (await fs50.pathExists(abs)) existing.push(abs);
15783
15959
  }
15784
15960
  return existing;
15785
15961
  }
@@ -15809,19 +15985,19 @@ async function collectLockTrackedPaths(root) {
15809
15985
  }
15810
15986
  async function settingsHasHausContent(root) {
15811
15987
  const settingsPath = claudePath(root, "settings.json");
15812
- if (!await fs49.pathExists(settingsPath)) return false;
15988
+ if (!await fs50.pathExists(settingsPath)) return false;
15813
15989
  const settings = await readProjectSettings(root);
15814
15990
  return settings._haus != null;
15815
15991
  }
15816
15992
  async function claudeMdHasHausBlock(root) {
15817
15993
  const filePath = path65.join(root, "CLAUDE.md");
15818
- if (!await fs49.pathExists(filePath)) return false;
15819
- const text = await fs49.readFile(filePath, "utf8");
15994
+ if (!await fs50.pathExists(filePath)) return false;
15995
+ const text = await fs50.readFile(filePath, "utf8");
15820
15996
  return text.includes(BLOCK_BEGIN);
15821
15997
  }
15822
15998
  async function stripProjectSettings(root) {
15823
15999
  const settingsPath = claudePath(root, "settings.json");
15824
- if (!await fs49.pathExists(settingsPath)) return false;
16000
+ if (!await fs50.pathExists(settingsPath)) return false;
15825
16001
  let settings = await readProjectSettings(root);
15826
16002
  settings = stripHausHooks(
15827
16003
  stripHausAsk(stripHausAllow(stripHausDeny(stripHausEnabledPlugins(settings))))
@@ -15832,30 +16008,30 @@ async function stripProjectSettings(root) {
15832
16008
  log(`Stripped haus rules from ${path65.relative(root, settingsPath)} (user settings preserved).`);
15833
16009
  return true;
15834
16010
  }
15835
- await fs49.remove(settingsPath);
16011
+ await fs50.remove(settingsPath);
15836
16012
  log(`Removed ${path65.relative(root, settingsPath)} (no user-owned settings remained).`);
15837
16013
  return true;
15838
16014
  }
15839
16015
  async function stripRootClaudeMd(root) {
15840
16016
  const filePath = path65.join(root, "CLAUDE.md");
15841
- if (!await fs49.pathExists(filePath)) return false;
15842
- const prev = await fs49.readFile(filePath, "utf8");
16017
+ if (!await fs50.pathExists(filePath)) return false;
16018
+ const prev = await fs50.readFile(filePath, "utf8");
15843
16019
  if (!prev.includes(BLOCK_BEGIN)) return false;
15844
16020
  const next = stripHausBlock(prev);
15845
16021
  if (next.length === 0) {
15846
- await fs49.remove(filePath);
16022
+ await fs50.remove(filePath);
15847
16023
  log("Removed CLAUDE.md (only contained haus import block).");
15848
16024
  } else {
15849
- await fs49.writeFile(filePath, next, "utf8");
16025
+ await fs50.writeFile(filePath, next, "utf8");
15850
16026
  log("Removed haus import block from CLAUDE.md (user content preserved).");
15851
16027
  }
15852
16028
  return true;
15853
16029
  }
15854
16030
  async function pruneDirIfEmpty(dir) {
15855
- if (!await fs49.pathExists(dir)) return;
16031
+ if (!await fs50.pathExists(dir)) return;
15856
16032
  try {
15857
- const entries = await fs49.readdir(dir);
15858
- if (entries.length === 0) await fs49.remove(dir);
16033
+ const entries = await fs50.readdir(dir);
16034
+ if (entries.length === 0) await fs50.remove(dir);
15859
16035
  } catch {
15860
16036
  }
15861
16037
  }
@@ -15864,11 +16040,11 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
15864
16040
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
15865
16041
  const backupRoot = hausPath(root, "backups", dirBackupDirName("undo", stamp));
15866
16042
  for (const abs of managedAbsPaths) {
15867
- if (!await fs49.pathExists(abs)) continue;
16043
+ if (!await fs50.pathExists(abs)) continue;
15868
16044
  const rel = path65.relative(root, abs);
15869
16045
  const backupPath = path65.join(backupRoot, rel);
15870
- await fs49.ensureDir(path65.dirname(backupPath));
15871
- await fs49.copy(abs, backupPath);
16046
+ await fs50.ensureDir(path65.dirname(backupPath));
16047
+ await fs50.copy(abs, backupPath);
15872
16048
  }
15873
16049
  log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path65.relative(root, backupRoot)}.`);
15874
16050
  }
@@ -15910,8 +16086,8 @@ User-owned .claude/ files will be preserved.`
15910
16086
  }
15911
16087
  await backupManagedFilesBeforeUndo(root, managed);
15912
16088
  for (const abs of coreManaged) {
15913
- if (!await fs49.pathExists(abs)) continue;
15914
- await fs49.remove(abs);
16089
+ if (!await fs50.pathExists(abs)) continue;
16090
+ await fs50.remove(abs);
15915
16091
  log(`Removed ${path65.relative(root, abs)}`);
15916
16092
  }
15917
16093
  for (const { id, existing } of lockRemovable) {
@@ -15926,7 +16102,7 @@ User-owned .claude/ files will be preserved.`
15926
16102
 
15927
16103
  // src/install/uninstall.ts
15928
16104
  import path66 from "node:path";
15929
- import fs50 from "fs-extra";
16105
+ import fs51 from "fs-extra";
15930
16106
  init_fs();
15931
16107
  init_logger();
15932
16108
  init_paths();
@@ -15960,7 +16136,7 @@ async function runUninstall(options = {}) {
15960
16136
  );
15961
16137
  }
15962
16138
  for (const entry of manifest.files) {
15963
- const exists2 = fs50.pathExistsSync(entry.destPath);
16139
+ const exists2 = fs51.pathExistsSync(entry.destPath);
15964
16140
  if (!exists2) continue;
15965
16141
  const content2 = await readText(entry.destPath);
15966
16142
  if (content2 === void 0) continue;
@@ -15978,7 +16154,7 @@ async function runUninstall(options = {}) {
15978
16154
  result.skipped.push(entry.destPath);
15979
16155
  continue;
15980
16156
  }
15981
- await fs50.remove(entry.destPath);
16157
+ await fs51.remove(entry.destPath);
15982
16158
  await pruneEmptyDir(path66.dirname(entry.destPath));
15983
16159
  result.deleted.push(entry.destPath);
15984
16160
  }
@@ -15990,14 +16166,14 @@ async function runUninstall(options = {}) {
15990
16166
  result.hooksStripped = true;
15991
16167
  const hausDir = path66.join(globalClaudeDir(), "haus");
15992
16168
  const manifestPath2 = hausManifestPath();
15993
- if (fs50.pathExistsSync(manifestPath2)) {
15994
- await fs50.remove(manifestPath2);
16169
+ if (fs51.pathExistsSync(manifestPath2)) {
16170
+ await fs51.remove(manifestPath2);
15995
16171
  }
15996
16172
  await removeInstallConfig();
15997
- if (fs50.pathExistsSync(hausDir)) {
16173
+ if (fs51.pathExistsSync(hausDir)) {
15998
16174
  try {
15999
- const remaining = await fs50.readdir(hausDir);
16000
- if (remaining.length === 0) await fs50.remove(hausDir);
16175
+ const remaining = await fs51.readdir(hausDir);
16176
+ if (remaining.length === 0) await fs51.remove(hausDir);
16001
16177
  } catch {
16002
16178
  }
16003
16179
  }
@@ -16073,7 +16249,7 @@ init_forbidden_content();
16073
16249
  init_manifest_item_fields();
16074
16250
  init_path_safety();
16075
16251
  init_validation_rules2();
16076
- import fs51 from "node:fs";
16252
+ import fs52 from "node:fs";
16077
16253
  import path67 from "node:path";
16078
16254
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
16079
16255
  var ITEM_ID_PATTERN = /^haus\./;
@@ -16234,7 +16410,7 @@ function checkRequiredFrontmatter(text, label) {
16234
16410
  }
16235
16411
  function readForAudit(absPath, label, failures) {
16236
16412
  try {
16237
- return fs51.readFileSync(absPath, "utf8");
16413
+ return fs52.readFileSync(absPath, "utf8");
16238
16414
  } catch (err) {
16239
16415
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
16240
16416
  return null;
@@ -16266,7 +16442,7 @@ function auditShippedFiles(manifestDir, items) {
16266
16442
  const absPath = path67.join(manifestDir, item.path);
16267
16443
  if (item.type === "skill") {
16268
16444
  const skillMd = path67.join(absPath, "SKILL.md");
16269
- if (!fs51.existsSync(skillMd)) {
16445
+ if (!fs52.existsSync(skillMd)) {
16270
16446
  failures.push(`${item.id}: missing ${path67.relative(manifestDir, skillMd)}`);
16271
16447
  continue;
16272
16448
  }
@@ -16277,7 +16453,7 @@ function auditShippedFiles(manifestDir, items) {
16277
16453
  ...auditForbiddenTagsInText(text, `${item.id}: ${path67.relative(manifestDir, skillMd)}`)
16278
16454
  );
16279
16455
  } else if (item.type === "agent") {
16280
- if (!fs51.existsSync(absPath)) {
16456
+ if (!fs52.existsSync(absPath)) {
16281
16457
  failures.push(`${item.id}: missing agent file ${item.path}`);
16282
16458
  continue;
16283
16459
  }
@@ -16287,13 +16463,13 @@ function auditShippedFiles(manifestDir, items) {
16287
16463
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
16288
16464
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
16289
16465
  } else if (item.type === "template") {
16290
- if (!fs51.existsSync(absPath)) {
16466
+ if (!fs52.existsSync(absPath)) {
16291
16467
  failures.push(`${item.id}: missing template file ${item.path}`);
16292
16468
  continue;
16293
16469
  }
16294
16470
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
16295
16471
  } else if (item.type === "command") {
16296
- if (!fs51.existsSync(absPath)) {
16472
+ if (!fs52.existsSync(absPath)) {
16297
16473
  failures.push(`${item.id}: missing command file ${item.path}`);
16298
16474
  continue;
16299
16475
  }
@@ -16303,15 +16479,15 @@ function auditShippedFiles(manifestDir, items) {
16303
16479
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
16304
16480
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
16305
16481
  } else if (item.type === "config") {
16306
- if (!fs51.existsSync(absPath)) {
16482
+ if (!fs52.existsSync(absPath)) {
16307
16483
  failures.push(`${item.id}: missing config path ${item.path}`);
16308
16484
  }
16309
16485
  } else if (item.type === "rule") {
16310
- if (!fs51.existsSync(absPath)) {
16486
+ if (!fs52.existsSync(absPath)) {
16311
16487
  failures.push(`${item.id}: missing rule file ${item.path}`);
16312
16488
  }
16313
16489
  } else if (item.type === "asset") {
16314
- if (!fs51.existsSync(absPath)) {
16490
+ if (!fs52.existsSync(absPath)) {
16315
16491
  failures.push(`${item.id}: missing asset file ${item.path}`);
16316
16492
  }
16317
16493
  }
@@ -16346,7 +16522,7 @@ function resolveItemType(relPath, pathTypeMap) {
16346
16522
  function walkAllFiles(dir, fn, failures) {
16347
16523
  let entries;
16348
16524
  try {
16349
- entries = fs51.readdirSync(dir, { withFileTypes: true });
16525
+ entries = fs52.readdirSync(dir, { withFileTypes: true });
16350
16526
  } catch (err) {
16351
16527
  failures.push(
16352
16528
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -16380,7 +16556,7 @@ function auditDiskOrphans(manifestDir, items) {
16380
16556
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
16381
16557
  for (const dir of CONTENT_ROOTS) {
16382
16558
  const abs = path67.join(manifestDir, dir);
16383
- if (!fs51.existsSync(abs)) continue;
16559
+ if (!fs52.existsSync(abs)) continue;
16384
16560
  walkAllFiles(
16385
16561
  abs,
16386
16562
  (file) => {
@@ -16397,7 +16573,7 @@ function auditDiskOrphans(manifestDir, items) {
16397
16573
  function walkMd(dir, fn, failures) {
16398
16574
  let entries;
16399
16575
  try {
16400
- entries = fs51.readdirSync(dir, { withFileTypes: true });
16576
+ entries = fs52.readdirSync(dir, { withFileTypes: true });
16401
16577
  } catch (err) {
16402
16578
  failures.push(
16403
16579
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -16416,7 +16592,7 @@ function auditMarkdownContent(manifestDir, items) {
16416
16592
  const pathTypeMap = buildItemPathTypeMap(items);
16417
16593
  for (const dir of ["skills", "agents", "templates", "commands", "sources"]) {
16418
16594
  const abs = path67.join(manifestDir, dir);
16419
- if (!fs51.existsSync(abs)) continue;
16595
+ if (!fs52.existsSync(abs)) continue;
16420
16596
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
16421
16597
  walkMd(
16422
16598
  abs,
@@ -16492,7 +16668,7 @@ init_logger();
16492
16668
  // src/workspace/worktree/add.ts
16493
16669
  init_fs();
16494
16670
  import path75 from "node:path";
16495
- import fs55 from "fs-extra";
16671
+ import fs56 from "fs-extra";
16496
16672
 
16497
16673
  // src/workspace/worktree/git-worktree.ts
16498
16674
  init_exec();
@@ -16613,12 +16789,12 @@ async function hasUnpushedWork(cwd) {
16613
16789
 
16614
16790
  // src/workspace/worktree/hydrate.ts
16615
16791
  import path71 from "node:path";
16616
- import fs53 from "fs-extra";
16792
+ import fs54 from "fs-extra";
16617
16793
 
16618
16794
  // src/workspace/worktree/cow-copy.ts
16619
16795
  init_exec();
16620
16796
  import path69 from "node:path";
16621
- import fs52 from "fs-extra";
16797
+ import fs53 from "fs-extra";
16622
16798
  async function getMacFilesystemType(dir) {
16623
16799
  const df = await runCommand("df", ["-P", dir]);
16624
16800
  if (df.exitCode !== 0) return void 0;
@@ -16649,14 +16825,14 @@ async function detectCowStrategy(dir) {
16649
16825
  return "unknown-platform";
16650
16826
  }
16651
16827
  async function cowCopyDir(src, dest) {
16652
- const srcExists = await fs52.pathExists(src);
16828
+ const srcExists = await fs53.pathExists(src);
16653
16829
  if (!srcExists)
16654
16830
  return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
16655
16831
  const strategy = await detectCowStrategy(path69.dirname(dest));
16656
16832
  if (strategy === "unsupported" || strategy === "unknown-platform") {
16657
16833
  return { strategy, attempted: false, ok: false };
16658
16834
  }
16659
- await fs52.ensureDir(path69.dirname(dest));
16835
+ await fs53.ensureDir(path69.dirname(dest));
16660
16836
  const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
16661
16837
  const result = await runCommand("cp", args);
16662
16838
  return {
@@ -16849,11 +17025,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
16849
17025
  for (const target of HYDRATION_TARGETS) {
16850
17026
  const src = path71.join(mainMemberPath, target);
16851
17027
  const dest = path71.join(worktreeMemberPath, target);
16852
- if (!await fs53.pathExists(src)) continue;
16853
- const destExists = await fs53.pathExists(dest);
17028
+ if (!await fs54.pathExists(src)) continue;
17029
+ const destExists = await fs54.pathExists(dest);
16854
17030
  if (!opts.force && destExists) continue;
16855
17031
  if (opts.force && destExists) {
16856
- await fs53.remove(dest);
17032
+ await fs54.remove(dest);
16857
17033
  }
16858
17034
  const copy = await cowCopyDir(src, dest);
16859
17035
  targets.push({ target, copy });
@@ -16867,7 +17043,7 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
16867
17043
  // src/workspace/worktree/local-files.ts
16868
17044
  init_exec();
16869
17045
  import path72 from "node:path";
16870
- import fs54 from "fs-extra";
17046
+ import fs55 from "fs-extra";
16871
17047
  var MACHINE_LOCAL_FILES = [
16872
17048
  "docker-compose.local.yml",
16873
17049
  path72.join(".claude", "settings.local.json")
@@ -16881,11 +17057,11 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
16881
17057
  const copied = [];
16882
17058
  for (const rel of MACHINE_LOCAL_FILES) {
16883
17059
  const src = path72.join(mainMemberPath, rel);
16884
- if (!await fs54.pathExists(src)) continue;
17060
+ if (!await fs55.pathExists(src)) continue;
16885
17061
  if (await isTracked(mainMemberPath, rel)) continue;
16886
17062
  const dest = path72.join(worktreeMemberPath, rel);
16887
- await fs54.ensureDir(path72.dirname(dest));
16888
- await fs54.copy(src, dest, { overwrite: true });
17063
+ await fs55.ensureDir(path72.dirname(dest));
17064
+ await fs55.copy(src, dest, { overwrite: true });
16889
17065
  copied.push(rel);
16890
17066
  }
16891
17067
  return copied;
@@ -16973,7 +17149,7 @@ async function runAdd(opts) {
16973
17149
  }
16974
17150
  const branch = opts.branch ?? opts.slug;
16975
17151
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
16976
- if (await fs55.pathExists(wsWorktreePath)) {
17152
+ if (await fs56.pathExists(wsWorktreePath)) {
16977
17153
  return {
16978
17154
  ok: false,
16979
17155
  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 +17267,7 @@ async function runDryRun(args) {
17091
17267
  import { spawn } from "node:child_process";
17092
17268
  import fsSync from "node:fs";
17093
17269
  import path81 from "node:path";
17094
- import fs57 from "fs-extra";
17270
+ import fs58 from "fs-extra";
17095
17271
 
17096
17272
  // src/workspace/worktree/doctor.ts
17097
17273
  import { existsSync as existsSync7 } from "node:fs";
@@ -17100,7 +17276,7 @@ import path78 from "node:path";
17100
17276
  // src/workspace/worktree/hydrate-lock.ts
17101
17277
  init_paths();
17102
17278
  import path76 from "node:path";
17103
- import fs56 from "fs-extra";
17279
+ import fs57 from "fs-extra";
17104
17280
  var STALE_LOCK_MS = 30 * 60 * 1e3;
17105
17281
  function locksDir(mainRoot) {
17106
17282
  return hausPath(mainRoot, "locks");
@@ -17135,7 +17311,7 @@ function defaultIsAlive(pid) {
17135
17311
  }
17136
17312
  async function readOwnerPid(lockPath) {
17137
17313
  try {
17138
- const raw = (await fs56.readFile(path76.join(lockPath, "pid"), "utf8")).trim();
17314
+ const raw = (await fs57.readFile(path76.join(lockPath, "pid"), "utf8")).trim();
17139
17315
  if (raw === "") return void 0;
17140
17316
  const pid = Number.parseInt(raw, 10);
17141
17317
  return Number.isInteger(pid) && pid > 0 ? pid : void 0;
@@ -17144,13 +17320,13 @@ async function readOwnerPid(lockPath) {
17144
17320
  }
17145
17321
  }
17146
17322
  async function claim(lockPath, pid) {
17147
- await fs56.ensureDir(path76.dirname(lockPath));
17323
+ await fs57.ensureDir(path76.dirname(lockPath));
17148
17324
  try {
17149
- await fs56.mkdir(lockPath);
17325
+ await fs57.mkdir(lockPath);
17150
17326
  } catch {
17151
17327
  return false;
17152
17328
  }
17153
- await fs56.writeFile(path76.join(lockPath, "pid"), String(pid), "utf8");
17329
+ await fs57.writeFile(path76.join(lockPath, "pid"), String(pid), "utf8");
17154
17330
  return true;
17155
17331
  }
17156
17332
  async function acquireHydrateLock(mainRoot, slug, deps = {}) {
@@ -17169,7 +17345,7 @@ async function acquireHydrateLock(mainRoot, slug, deps = {}) {
17169
17345
  }
17170
17346
  if (alive) return { acquired: false, reason: "live", ownerPid, lockPath };
17171
17347
  } else {
17172
- const stat = await fs56.stat(lockPath).catch(() => void 0);
17348
+ const stat = await fs57.stat(lockPath).catch(() => void 0);
17173
17349
  if (stat === void 0) {
17174
17350
  return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", lockPath };
17175
17351
  }
@@ -17177,18 +17353,18 @@ async function acquireHydrateLock(mainRoot, slug, deps = {}) {
17177
17353
  return { acquired: false, reason: "starting", lockPath };
17178
17354
  }
17179
17355
  }
17180
- await fs56.remove(lockPath);
17356
+ await fs57.remove(lockPath);
17181
17357
  return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", ownerPid, lockPath };
17182
17358
  }
17183
17359
  async function recordHydrateLockOwner(mainRoot, slug, pid) {
17184
- await fs56.writeFile(path76.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
17360
+ await fs57.writeFile(path76.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
17185
17361
  }
17186
17362
  async function releaseHydrateLock(mainRoot, slug) {
17187
- await fs56.remove(hydrateLockPath(mainRoot, slug));
17363
+ await fs57.remove(hydrateLockPath(mainRoot, slug));
17188
17364
  }
17189
17365
  async function readHydrateLock(mainRoot, slug) {
17190
17366
  const lockPath = hydrateLockPath(mainRoot, slug);
17191
- const stat = await fs56.stat(lockPath).catch(() => void 0);
17367
+ const stat = await fs57.stat(lockPath).catch(() => void 0);
17192
17368
  if (stat === void 0) return { held: false };
17193
17369
  const ownerPid = await readOwnerPid(lockPath);
17194
17370
  return {
@@ -17568,11 +17744,11 @@ async function consumeLastRunFailure(mainRoot, slug) {
17568
17744
  const file = hydrateLastRunPath(mainRoot, slug);
17569
17745
  let parsed;
17570
17746
  try {
17571
- parsed = JSON.parse(await fs57.readFile(file, "utf8"));
17747
+ parsed = JSON.parse(await fs58.readFile(file, "utf8"));
17572
17748
  } catch {
17573
17749
  return void 0;
17574
17750
  }
17575
- await fs57.remove(file).catch(() => void 0);
17751
+ await fs58.remove(file).catch(() => void 0);
17576
17752
  if (parsed && parsed.ok === false) return parsed;
17577
17753
  return void 0;
17578
17754
  }
@@ -17644,7 +17820,7 @@ async function runAutoHydrate(opts = {}, deps = {}) {
17644
17820
  reason: "could not determine the haus CLI entry point to re-invoke"
17645
17821
  };
17646
17822
  }
17647
- await fs57.ensureDir(logsDir(mainRoot));
17823
+ await fs58.ensureDir(logsDir(mainRoot));
17648
17824
  appendLog(
17649
17825
  logPath,
17650
17826
  `autohydrate: forking hydrate --materialize for "${slug}" (${missingMembers.join(", ")})`
@@ -17713,21 +17889,21 @@ async function runDetachedHydration() {
17713
17889
  async function writeLastRun(mainRoot, slug, ok, failures) {
17714
17890
  const file = hydrateLastRunPath(mainRoot, slug);
17715
17891
  if (ok) {
17716
- await fs57.remove(file).catch(() => void 0);
17892
+ await fs58.remove(file).catch(() => void 0);
17717
17893
  return;
17718
17894
  }
17719
17895
  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);
17896
+ await fs58.ensureDir(path81.dirname(file));
17897
+ await fs58.writeJson(file, record, { spaces: 2 }).catch(() => void 0);
17722
17898
  }
17723
17899
 
17724
17900
  // src/workspace/worktree/dematerialize.ts
17725
17901
  import path83 from "node:path";
17726
- import fs59 from "fs-extra";
17902
+ import fs60 from "fs-extra";
17727
17903
 
17728
17904
  // src/workspace/worktree/member-targets.ts
17729
17905
  import path82 from "node:path";
17730
- import fs58 from "fs-extra";
17906
+ import fs59 from "fs-extra";
17731
17907
  function normalizeStateMembers(state, allMembers) {
17732
17908
  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
17909
  }
@@ -17752,7 +17928,7 @@ function resolveMemberTargets(wsWorktreePath, stateMembers, allMembers) {
17752
17928
  async function filterExistingTargets(targets) {
17753
17929
  const existing = [];
17754
17930
  for (const target of targets) {
17755
- if (await fs58.pathExists(target.path)) existing.push(target);
17931
+ if (await fs59.pathExists(target.path)) existing.push(target);
17756
17932
  }
17757
17933
  return existing;
17758
17934
  }
@@ -17797,7 +17973,7 @@ async function runDematerialize(opts) {
17797
17973
  } catch (err) {
17798
17974
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
17799
17975
  }
17800
- if (!await fs59.pathExists(wsWorktreePath)) {
17976
+ if (!await fs60.pathExists(wsWorktreePath)) {
17801
17977
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
17802
17978
  }
17803
17979
  const lock = await readHydrateLock(workspaceRoot, slug);
@@ -17831,8 +18007,8 @@ async function runDematerialize(opts) {
17831
18007
  const failed = [];
17832
18008
  for (const folder of unresolvedDroppedMembers) {
17833
18009
  const droppedPath = path83.join(wsWorktreePath, folder);
17834
- if (await fs59.pathExists(droppedPath)) {
17835
- await fs59.remove(droppedPath);
18010
+ if (await fs60.pathExists(droppedPath)) {
18011
+ await fs60.remove(droppedPath);
17836
18012
  failed.push({ repo: folder, error: unresolvedDroppedFailureReason() });
17837
18013
  }
17838
18014
  }
@@ -17847,14 +18023,14 @@ async function runDematerialize(opts) {
17847
18023
 
17848
18024
  // src/workspace/worktree/list.ts
17849
18025
  import path84 from "node:path";
17850
- import fs60 from "fs-extra";
18026
+ import fs61 from "fs-extra";
17851
18027
  async function runList2() {
17852
18028
  const resolved = await resolveWorkspaceForWorktree();
17853
18029
  if (!resolved.ok) return { ok: false, error: resolved.reason };
17854
18030
  const { rootInfo, workspaceRoot } = resolved;
17855
18031
  const dir = worktreesDir(workspaceRoot);
17856
- if (!await fs60.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
17857
- const entries = await fs60.readdir(dir, { withFileTypes: true });
18032
+ if (!await fs61.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
18033
+ const entries = await fs61.readdir(dir, { withFileTypes: true });
17858
18034
  const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
17859
18035
  const configuredMembers = await readMembers(rootInfo);
17860
18036
  const worktrees = [];
@@ -17865,7 +18041,7 @@ async function runList2() {
17865
18041
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
17866
18042
  for (const m of source) {
17867
18043
  const memberPath = path84.join(wtPath, m.folder);
17868
- const materialized = await fs60.pathExists(memberPath);
18044
+ const materialized = await fs61.pathExists(memberPath);
17869
18045
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
17870
18046
  members.push({
17871
18047
  id: m.id,
@@ -17888,12 +18064,12 @@ async function runList2() {
17888
18064
  // src/workspace/worktree/reclaim.ts
17889
18065
  import { existsSync as existsSync9 } from "node:fs";
17890
18066
  import path85 from "node:path";
17891
- import fs61 from "fs-extra";
18067
+ import fs62 from "fs-extra";
17892
18068
  var MS_PER_DAY = 24 * 60 * 60 * 1e3;
17893
18069
  async function lastTouchedMs(worktreeRoot) {
17894
18070
  const candidates = [worktreeRoot, path85.join(worktreeRoot, ".haus-worktree.json")];
17895
18071
  try {
17896
- const dotGit = await fs61.readFile(path85.join(worktreeRoot, ".git"), "utf8");
18072
+ const dotGit = await fs62.readFile(path85.join(worktreeRoot, ".git"), "utf8");
17897
18073
  const match = /^gitdir:\s*(.+)$/m.exec(dotGit);
17898
18074
  if (match?.[1]) {
17899
18075
  const gitdir = match[1].trim();
@@ -17903,7 +18079,7 @@ async function lastTouchedMs(worktreeRoot) {
17903
18079
  }
17904
18080
  let newest = 0;
17905
18081
  for (const candidate of candidates) {
17906
- const stat = await fs61.stat(candidate).catch(() => void 0);
18082
+ const stat = await fs62.stat(candidate).catch(() => void 0);
17907
18083
  if (stat && stat.mtimeMs > newest) newest = stat.mtimeMs;
17908
18084
  }
17909
18085
  return newest;
@@ -17930,7 +18106,7 @@ async function runReclaim(opts = {}) {
17930
18106
  if (!existsSync9(dir)) {
17931
18107
  return { ok: true, dryRun: !opts.write, olderThanDays, candidates: [], reclaimed: 0 };
17932
18108
  }
17933
- const allEntries = (await fs61.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
18109
+ const allEntries = (await fs62.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
17934
18110
  const entries = allEntries.filter((name) => isValidWorktreeSlug(name));
17935
18111
  const unaddressable = allEntries.filter((name) => !isValidWorktreeSlug(name));
17936
18112
  const members = await readMembers(rootInfo);
@@ -18011,13 +18187,13 @@ async function runReclaim(opts = {}) {
18011
18187
  }
18012
18188
 
18013
18189
  // src/workspace/worktree/remove.ts
18014
- import fs62 from "fs-extra";
18190
+ import fs63 from "fs-extra";
18015
18191
  async function runRemove2(opts) {
18016
18192
  const resolved = await resolveWorkspaceForWorktree();
18017
18193
  if (!resolved.ok) return { ok: false, error: resolved.reason };
18018
18194
  const { rootInfo, workspaceRoot } = resolved;
18019
18195
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
18020
- if (!await fs62.pathExists(wsWorktreePath)) {
18196
+ if (!await fs63.pathExists(wsWorktreePath)) {
18021
18197
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
18022
18198
  }
18023
18199
  const state = await readWorktreeState(wsWorktreePath);
@@ -18085,8 +18261,8 @@ async function runRemove2(opts) {
18085
18261
  }
18086
18262
  await pruneWorktrees(workspaceRoot);
18087
18263
  }
18088
- if (wsRemoveOk && await fs62.pathExists(wsWorktreePath)) {
18089
- await fs62.remove(wsWorktreePath);
18264
+ if (wsRemoveOk && await fs63.pathExists(wsWorktreePath)) {
18265
+ await fs63.remove(wsWorktreePath);
18090
18266
  }
18091
18267
  return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
18092
18268
  }
@@ -18859,17 +19035,17 @@ init_logger();
18859
19035
 
18860
19036
  // src/workspace/link-context/link.ts
18861
19037
  import path90 from "node:path";
18862
- import fs65 from "fs-extra";
19038
+ import fs66 from "fs-extra";
18863
19039
  init_fs();
18864
19040
  init_logger();
18865
19041
 
18866
19042
  // src/workspace/link-context/plan.ts
18867
19043
  import path89 from "node:path";
18868
- import fs64 from "fs-extra";
19044
+ import fs65 from "fs-extra";
18869
19045
 
18870
19046
  // src/workspace/link-context/scan-source.ts
18871
19047
  import path88 from "node:path";
18872
- import fs63 from "fs-extra";
19048
+ import fs64 from "fs-extra";
18873
19049
  var ASSET_SUBDIR = {
18874
19050
  skill: "skills",
18875
19051
  agent: "agents",
@@ -18877,20 +19053,20 @@ var ASSET_SUBDIR = {
18877
19053
  };
18878
19054
  async function hasLinkableContext(member) {
18879
19055
  for (const subdir of Object.values(ASSET_SUBDIR)) {
18880
- if (await fs63.pathExists(path88.join(member.absPath, ".claude", subdir))) return true;
19056
+ if (await fs64.pathExists(path88.join(member.absPath, ".claude", subdir))) return true;
18881
19057
  }
18882
19058
  return false;
18883
19059
  }
18884
19060
  async function collectSkills(member) {
18885
19061
  const dir = path88.join(member.absPath, ".claude", "skills");
18886
- if (!await fs63.pathExists(dir)) return [];
18887
- const names = await fs63.readdir(dir);
19062
+ if (!await fs64.pathExists(dir)) return [];
19063
+ const names = await fs64.readdir(dir);
18888
19064
  const out = [];
18889
19065
  for (const name of [...names].sort()) {
18890
19066
  const abs = path88.join(dir, name);
18891
- const stat = await fs63.lstat(abs);
19067
+ const stat = await fs64.lstat(abs);
18892
19068
  if (!stat.isDirectory()) continue;
18893
- if (!await fs63.pathExists(path88.join(abs, "SKILL.md"))) continue;
19069
+ if (!await fs64.pathExists(path88.join(abs, "SKILL.md"))) continue;
18894
19070
  out.push({ type: "skill", name, sourceRelPath: path88.posix.join(".claude", "skills", name) });
18895
19071
  }
18896
19072
  return out;
@@ -18898,13 +19074,13 @@ async function collectSkills(member) {
18898
19074
  async function collectFlatMarkdown(member, type) {
18899
19075
  const subdir = ASSET_SUBDIR[type];
18900
19076
  const dir = path88.join(member.absPath, ".claude", subdir);
18901
- if (!await fs63.pathExists(dir)) return [];
18902
- const names = await fs63.readdir(dir);
19077
+ if (!await fs64.pathExists(dir)) return [];
19078
+ const names = await fs64.readdir(dir);
18903
19079
  const out = [];
18904
19080
  for (const fileName of [...names].sort()) {
18905
19081
  if (!fileName.endsWith(".md")) continue;
18906
19082
  const abs = path88.join(dir, fileName);
18907
- if (!(await fs63.lstat(abs)).isFile()) continue;
19083
+ if (!(await fs64.lstat(abs)).isFile()) continue;
18908
19084
  const name = fileName.slice(0, -".md".length);
18909
19085
  out.push({ type, name, sourceRelPath: path88.posix.join(".claude", subdir, fileName) });
18910
19086
  }
@@ -18938,7 +19114,7 @@ async function buildLinkPlan(members) {
18938
19114
  const byKey = /* @__PURE__ */ new Map();
18939
19115
  const membersById = new Map(members.map((m) => [m.id, m]));
18940
19116
  for (const member of members) {
18941
- if (!await fs64.pathExists(member.absPath)) {
19117
+ if (!await fs65.pathExists(member.absPath)) {
18942
19118
  skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
18943
19119
  continue;
18944
19120
  }
@@ -19019,15 +19195,15 @@ async function runLinkContext(workspaceRoot, opts = {}) {
19019
19195
  const destRel = destRelPathFor(entry.type, entry.destKey);
19020
19196
  const destAbs = path90.join(workspaceRoot, destRel);
19021
19197
  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, {
19198
+ await fs66.ensureDir(path90.dirname(destAbs));
19199
+ if (await fs66.pathExists(destAbs)) await fs66.remove(destAbs);
19200
+ await fs66.copy(entry.sourceAbsPath, destAbs, {
19025
19201
  overwrite: true,
19026
19202
  errorOnExist: false,
19027
19203
  dereference: false,
19028
19204
  filter: (s) => {
19029
19205
  try {
19030
- return !fs65.lstatSync(s).isSymbolicLink();
19206
+ return !fs66.lstatSync(s).isSymbolicLink();
19031
19207
  } catch {
19032
19208
  return false;
19033
19209
  }
@@ -19056,7 +19232,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
19056
19232
  warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
19057
19233
  continue;
19058
19234
  }
19059
- if (await fs65.pathExists(abs)) await fs65.remove(abs);
19235
+ if (await fs66.pathExists(abs)) await fs66.remove(abs);
19060
19236
  }
19061
19237
  }
19062
19238
  if (write) {
@@ -19112,7 +19288,7 @@ import path92 from "node:path";
19112
19288
 
19113
19289
  // src/claude/write-workspace-claude-md.ts
19114
19290
  import path91 from "node:path";
19115
- import fs66 from "fs-extra";
19291
+ import fs67 from "fs-extra";
19116
19292
  init_fs();
19117
19293
  init_logger();
19118
19294
  init_paths();
@@ -19140,8 +19316,8 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
19140
19316
  const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path91.join(workspaceRoot, "CLAUDE.md");
19141
19317
  const say = opts.quiet ? () => {
19142
19318
  } : log;
19143
- const existed = await fs66.pathExists(filePath);
19144
- const prev = existed ? await fs66.readFile(filePath, "utf8") : "";
19319
+ const existed = await fs67.pathExists(filePath);
19320
+ const prev = existed ? await fs67.readFile(filePath, "utf8") : "";
19145
19321
  const next = opts.collision ? `${block}
19146
19322
  ` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
19147
19323
  const printable = displayPath(workspaceRoot, filePath);
@@ -19423,7 +19599,7 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
19423
19599
  // src/commands/workspace/undo.ts
19424
19600
  import { existsSync as existsSync12, statSync as statSync2 } from "node:fs";
19425
19601
  import path93 from "node:path";
19426
- import fs67 from "fs-extra";
19602
+ import fs68 from "fs-extra";
19427
19603
  init_fs();
19428
19604
  init_logger();
19429
19605
  init_paths();
@@ -19474,7 +19650,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
19474
19650
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
19475
19651
  continue;
19476
19652
  }
19477
- if (await fs67.pathExists(abs)) {
19653
+ if (await fs68.pathExists(abs)) {
19478
19654
  log(`[dry-run] Would remove ${entry.path} (linked ${entry.type} from ${entry.repo})`);
19479
19655
  }
19480
19656
  }
@@ -19484,11 +19660,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
19484
19660
  hausPath(workspaceRoot, "WORKSPACE.md")
19485
19661
  ];
19486
19662
  for (const abs of workspaceArtifacts) {
19487
- if (await fs67.pathExists(abs)) {
19663
+ if (await fs68.pathExists(abs)) {
19488
19664
  log(`[dry-run] Would remove ${path93.relative(workspaceRoot, abs)}`);
19489
19665
  }
19490
19666
  }
19491
- if (!await fs67.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
19667
+ if (!await fs68.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
19492
19668
  const claudeMd = await readText(path93.join(workspaceRoot, "CLAUDE.md"));
19493
19669
  if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
19494
19670
  log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
@@ -19528,26 +19704,26 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
19528
19704
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
19529
19705
  continue;
19530
19706
  }
19531
- if (await fs67.pathExists(abs)) {
19532
- await fs67.remove(abs);
19707
+ if (await fs68.pathExists(abs)) {
19708
+ await fs68.remove(abs);
19533
19709
  log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
19534
19710
  }
19535
19711
  }
19536
19712
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
19537
19713
  const abs = hausPath(workspaceRoot, rel);
19538
- if (await fs67.pathExists(abs)) {
19539
- await fs67.remove(abs);
19714
+ if (await fs68.pathExists(abs)) {
19715
+ await fs68.remove(abs);
19540
19716
  log(`Removed ${path93.relative(workspaceRoot, abs)}`);
19541
19717
  }
19542
19718
  }
19543
19719
  const manifestFile = manifestPath(workspaceRoot);
19544
- if (await fs67.pathExists(manifestFile)) {
19545
- await fs67.remove(manifestFile);
19720
+ if (await fs68.pathExists(manifestFile)) {
19721
+ await fs68.remove(manifestFile);
19546
19722
  log(`Removed ${path93.relative(workspaceRoot, manifestFile)}`);
19547
19723
  }
19548
19724
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
19549
- if (await fs67.pathExists(workspaceMdPath)) {
19550
- await fs67.remove(workspaceMdPath);
19725
+ if (await fs68.pathExists(workspaceMdPath)) {
19726
+ await fs68.remove(workspaceMdPath);
19551
19727
  log(`Removed ${path93.relative(workspaceRoot, workspaceMdPath)}`);
19552
19728
  } else {
19553
19729
  const claudeMdPath = path93.join(workspaceRoot, "CLAUDE.md");
@@ -19559,19 +19735,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
19559
19735
  end: WORKSPACE_BLOCK_END
19560
19736
  });
19561
19737
  if (next.length === 0) {
19562
- await fs67.remove(claudeMdPath);
19738
+ await fs68.remove(claudeMdPath);
19563
19739
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
19564
19740
  } else {
19565
- await fs67.writeFile(claudeMdPath, next, "utf8");
19741
+ await fs68.writeFile(claudeMdPath, next, "utf8");
19566
19742
  log("Removed haus import block from CLAUDE.md (user content preserved).");
19567
19743
  }
19568
19744
  } else if (prev && hasLegacyWorkspaceBlock(prev)) {
19569
19745
  const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
19570
19746
  if (next.length === 0) {
19571
- await fs67.remove(claudeMdPath);
19747
+ await fs68.remove(claudeMdPath);
19572
19748
  log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
19573
19749
  } else {
19574
- await fs67.writeFile(claudeMdPath, next, "utf8");
19750
+ await fs68.writeFile(claudeMdPath, next, "utf8");
19575
19751
  log(
19576
19752
  "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
19577
19753
  );
@@ -19743,6 +19919,9 @@ program.command("scan").description(
19743
19919
  program.command("recommend").description("Score catalog items against the scanned context and write recommendation.json").option("--json").option(
19744
19920
  "--include <ids...>",
19745
19921
  "Force opt-in catalog items into the recommendation as manual selections (space- or comma-separated ids)"
19922
+ ).option(
19923
+ "--declare-role <roles...>",
19924
+ "Declare a repo role (persisted in .haus-workflow/haus.config.json declaredRoles) so role-gated opt-in items are recommended"
19746
19925
  ).action(runRecommend);
19747
19926
  program.command("doctor").description(
19748
19927
  "Validate project setup: hooks contract, managed files, cache freshness, CLI version"
@@ -19841,7 +20020,10 @@ guard.command("bash").description("Block dangerous shell commands (run by the Cl
19841
20020
  function registerDecisionsCommand(name) {
19842
20021
  const cmd = program.command(name).description("Architecture decision records (ADR) gate and drafts");
19843
20022
  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(
20023
+ cmd.command("suggest").option("--from-hook", "Emit JSON for Claude Code Stop hook").option(
20024
+ "--title <title>",
20025
+ "Draft title; with a title the draft is printed even when the diff is not decision-worthy"
20026
+ ).action(
19845
20027
  (opts) => runDecisions("suggest", void 0, opts)
19846
20028
  );
19847
20029
  cmd.command("next-number").action(() => runDecisions("next-number"));