@haus-tech/haus-workflow 3.1.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.0.0](https://github.com/WeAreHausTech/haus-workflow/compare/v3.1.2...v4.0.0) (2026-08-20)
4
+
5
+ ### ⚠ BREAKING CHANGES
6
+
7
+ - **init:** stdout is `{ scan, recommendation, hooks,
8
+ repoName, warnings }`, not scan-then-recommend documents plus
9
+ human doctor lines. Callers that parsed the first document as
10
+ the scan must read `envelope.scan`.
11
+
12
+ ### Bug Fixes
13
+
14
+ - **init:** emit one JSON envelope on --json ([#318](https://github.com/WeAreHausTech/haus-workflow/issues/318)) ([2657428](https://github.com/WeAreHausTech/haus-workflow/commit/2657428a47a2459d3a1ccdc3a9137f9553ccb3d7)), closes [#306](https://github.com/WeAreHausTech/haus-workflow/issues/306)
15
+ - surface worktree hydrate install failure diagnostics ([#319](https://github.com/WeAreHausTech/haus-workflow/issues/319)) ([3fb6a53](https://github.com/WeAreHausTech/haus-workflow/commit/3fb6a53c00471e5b4c9aa0658cd19806785db717))
16
+
3
17
  ## [3.1.2](https://github.com/WeAreHausTech/haus-workflow/compare/v3.1.1...v3.1.2) (2026-08-20)
4
18
 
5
19
  ### Bug Fixes
package/dist/cli.js CHANGED
@@ -10014,6 +10014,9 @@ async function hasMultipleSiblingRepos(root) {
10014
10014
  init_fs();
10015
10015
  init_logger();
10016
10016
  init_paths();
10017
+ function emitJsonEnvelope(json, quiet, envelope) {
10018
+ if (json && !quiet) log(JSON.stringify(envelope, null, 2));
10019
+ }
10017
10020
  async function readPriorManualIncludes(root) {
10018
10021
  const prior = await readJson(hausPath(root, "recommendation.json"));
10019
10022
  if (!prior?.recommended) return [];
@@ -10021,16 +10024,12 @@ async function readPriorManualIncludes(root) {
10021
10024
  }
10022
10025
  async function runSetupCore(root, opts) {
10023
10026
  const { json, apply, dryRun, quiet, confirm: confirm2, force } = opts;
10024
- const say = quiet ? () => {
10027
+ const say = quiet || json ? () => {
10025
10028
  } : log;
10026
10029
  const scanResult = await scanProject(root);
10027
- if (json) {
10028
- say(JSON.stringify(scanResult, null, 2));
10029
- } else {
10030
- say("Haus scan complete");
10031
- say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
10032
- say(`Package manager: ${scanResult.packageManager}`);
10033
- }
10030
+ say("Haus scan complete");
10031
+ say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
10032
+ say(`Package manager: ${scanResult.packageManager}`);
10034
10033
  const siblingWarning = await hasMultipleSiblingRepos(root) ? [SIBLING_REPO_WARNING] : [];
10035
10034
  const context = await readContextOrScan(root);
10036
10035
  const priorManualIncludes = await readPriorManualIncludes(root);
@@ -10043,39 +10042,39 @@ async function runSetupCore(root, opts) {
10043
10042
  const warningLines2 = [
10044
10043
  .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
10045
10044
  ];
10046
- if (json) {
10047
- say(JSON.stringify(recommendation, null, 2));
10048
- } else {
10049
- say("Haus recommendation ready");
10050
- say("Recommended: 0");
10051
- say(`Skipped: ${recommendation.skipped.length}`);
10052
- say(`Repo: ${context.repoName}`);
10053
- for (const warning of warningLines2) say(`- WARN: ${warning}`);
10054
- }
10045
+ say("Haus recommendation ready");
10046
+ say("Recommended: 0");
10047
+ say(`Skipped: ${recommendation.skipped.length}`);
10048
+ say(`Repo: ${context.repoName}`);
10049
+ for (const warning of warningLines2) say(`- WARN: ${warning}`);
10055
10050
  const supersededCount = recommendation.skipped.filter(
10056
10051
  (s) => s.skipReasons?.some((r) => r.code === "superseded-by-global-plugin")
10057
10052
  ).length;
10058
10053
  const message2 = supersededCount > 0 ? `No catalog items matched this project after ${supersededCount} item(s) were gated as already provided by an installed global plugin \u2014 refusing to write recommendation.json/haus.lock.json. Those skills are available globally; re-run with \`haus init --force\` to write anyway.` : "No catalog items matched this project at all (zero signal) \u2014 refusing to write recommendation.json/haus.lock.json. Re-run with `haus init --force` to write anyway.";
10059
10054
  warn(message2);
10060
10055
  process.exitCode = 1;
10056
+ const warnings = [...warningLines2, message2];
10057
+ emitJsonEnvelope(json, quiet, {
10058
+ scan: scanResult,
10059
+ recommendation,
10060
+ hooks: null,
10061
+ repoName: context.repoName,
10062
+ warnings
10063
+ });
10061
10064
  return {
10062
10065
  root,
10063
10066
  repoName: context.repoName,
10064
10067
  roles: scanResult.repoRoles,
10065
10068
  recommendedCount: 0,
10066
- warnings: [...warningLines2, message2],
10069
+ warnings,
10067
10070
  hooksOk: false,
10068
10071
  written: []
10069
10072
  };
10070
10073
  }
10071
10074
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
10072
- if (json) {
10073
- say(JSON.stringify(recommendation, null, 2));
10074
- } else {
10075
- say("Haus recommendation ready");
10076
- say(`Recommended: ${recommendation.recommended.length}`);
10077
- say(`Skipped: ${recommendation.skipped.length}`);
10078
- }
10075
+ say("Haus recommendation ready");
10076
+ say(`Recommended: ${recommendation.recommended.length}`);
10077
+ say(`Skipped: ${recommendation.skipped.length}`);
10079
10078
  const hooks = await verifyProjectSettingsHooksContract(root);
10080
10079
  const warningLines = [
10081
10080
  .../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
@@ -10100,12 +10099,23 @@ async function runSetupCore(root, opts) {
10100
10099
  hooksOk,
10101
10100
  written: []
10102
10101
  };
10103
- if (!apply) return baseResult;
10102
+ const envelope = (hooksResult) => ({
10103
+ scan: scanResult,
10104
+ recommendation,
10105
+ hooks: hooksResult,
10106
+ repoName: context.repoName,
10107
+ warnings: warningLines
10108
+ });
10109
+ if (!apply) {
10110
+ emitJsonEnvelope(json, quiet, envelope(hooks));
10111
+ return baseResult;
10112
+ }
10104
10113
  if (confirm2) {
10105
10114
  const approved = await confirm2();
10106
10115
  if (!approved) {
10107
10116
  say("Setup reviewed. No files written.");
10108
10117
  say("Next step: run `haus apply --write` when ready.");
10118
+ emitJsonEnvelope(json, quiet, envelope(hooks));
10109
10119
  return baseResult;
10110
10120
  }
10111
10121
  }
@@ -10115,6 +10125,7 @@ async function runSetupCore(root, opts) {
10115
10125
  if (sync.formatRefusal) {
10116
10126
  error(sync.formatRefusal);
10117
10127
  process.exitCode = 1;
10128
+ emitJsonEnvelope(json, quiet, envelope(hooks));
10118
10129
  return baseResult;
10119
10130
  }
10120
10131
  if (sync.newItems.length > 0) {
@@ -10142,6 +10153,7 @@ async function runSetupCore(root, opts) {
10142
10153
  } else {
10143
10154
  say(`- HOOKS OK: ${hooksAfter.message}`);
10144
10155
  }
10156
+ emitJsonEnvelope(json, quiet, envelope(hooksAfter));
10145
10157
  return { ...baseResult, hooksOk: hooksOkAfter, written: files };
10146
10158
  }
10147
10159
 
@@ -11926,9 +11938,72 @@ async function computeLockfileSignals(dir) {
11926
11938
  hasDotnetProject
11927
11939
  };
11928
11940
  }
11941
+ var INSTALL_OUTPUT_CAP = 8192;
11942
+ var INSTALL_TAIL_LINES = 8;
11943
+ function capTail(text, max = INSTALL_OUTPUT_CAP) {
11944
+ return text.length <= max ? text : text.slice(-max);
11945
+ }
11946
+ function nonemptyLines(text) {
11947
+ return text.split(/\r?\n/).filter((line2) => line2.trim().length > 0);
11948
+ }
11949
+ function findRequiredYarnrcEnvVars(content2) {
11950
+ const required = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
11951
+ const names = /* @__PURE__ */ new Set();
11952
+ for (const line2 of content2.split(/\r?\n/)) {
11953
+ if (line2.trimStart().startsWith("#")) continue;
11954
+ required.lastIndex = 0;
11955
+ let match;
11956
+ while ((match = required.exec(line2)) !== null) {
11957
+ const name = match[1];
11958
+ if (name !== void 0) names.add(name);
11959
+ }
11960
+ }
11961
+ return [...names];
11962
+ }
11963
+ function missingYarnrcEnvVars(content2, env) {
11964
+ return findRequiredYarnrcEnvVars(content2).filter((name) => env[name] === void 0);
11965
+ }
11966
+ function missingYarnrcEnvVarsInDir(dir, env) {
11967
+ const yarnrcPath = path59.join(dir, ".yarnrc.yml");
11968
+ if (!existsSync3(yarnrcPath)) return [];
11969
+ try {
11970
+ return missingYarnrcEnvVars(readFileSync3(yarnrcPath, "utf8"), env);
11971
+ } catch {
11972
+ return [];
11973
+ }
11974
+ }
11975
+ function tailInstallOutput(stdout2, stderr) {
11976
+ const errLines = nonemptyLines(stderr).slice(-INSTALL_TAIL_LINES);
11977
+ const remaining = INSTALL_TAIL_LINES - errLines.length;
11978
+ const outLines = remaining > 0 ? nonemptyLines(stdout2).slice(-remaining) : [];
11979
+ return [...errLines, ...outLines].join("\n");
11980
+ }
11981
+ function summarizeInstall(install) {
11982
+ if (install.plan.manager === "none") return "no install step (no recognized lockfile)";
11983
+ const envNotes = (install.missingEnvVars ?? []).map(
11984
+ (name) => `${name} not set in environment \u2014 yarn install will fail`
11985
+ );
11986
+ if (!install.ran) {
11987
+ const skip = `install skipped (${install.skippedReason ?? "unavailable"})`;
11988
+ return envNotes.length > 0 ? [skip, ...envNotes].join("\n ") : skip;
11989
+ }
11990
+ if (install.ok) return `hydrated via ${install.plan.manager}`;
11991
+ const head = `install FAILED via ${install.plan.manager}`;
11992
+ const tail = tailInstallOutput(install.stdout ?? "", install.stderr ?? "");
11993
+ const details = [...envNotes, ...tail.length > 0 ? tail.split("\n") : []];
11994
+ return details.length > 0 ? [head, ...details].join("\n ") : head;
11995
+ }
11996
+ function withMissingEnv(result, missingEnvVars) {
11997
+ return missingEnvVars.length > 0 ? { ...result, missingEnvVars } : result;
11998
+ }
11929
11999
  async function runInstallPlan(dir, plan, deps = {}) {
12000
+ const env = deps.env ?? process.env;
12001
+ const missingEnvVars = plan.manager === "yarn" ? missingYarnrcEnvVarsInDir(dir, env) : [];
11930
12002
  if (plan.manager === "none") {
11931
- return { plan, ran: false, ok: true, skippedReason: "no recognized lockfile" };
12003
+ return withMissingEnv(
12004
+ { plan, ran: false, ok: true, skippedReason: "no recognized lockfile" },
12005
+ missingEnvVars
12006
+ );
11932
12007
  }
11933
12008
  const probe = deps.probe ?? probeCommand;
11934
12009
  const probed = await probe(plan.command);
@@ -11936,25 +12011,41 @@ async function runInstallPlan(dir, plan, deps = {}) {
11936
12011
  case "available":
11937
12012
  break;
11938
12013
  case "absent":
11939
- return { plan, ran: false, ok: true, skippedReason: `${plan.command} not found on PATH` };
12014
+ return withMissingEnv(
12015
+ { plan, ran: false, ok: true, skippedReason: `${plan.command} not found on PATH` },
12016
+ missingEnvVars
12017
+ );
11940
12018
  case "inconclusive":
11941
- return {
11942
- plan,
11943
- ran: false,
11944
- ok: true,
11945
- skippedReason: `could not determine whether ${plan.command} is on PATH (${probed.reason})`
11946
- };
12019
+ return withMissingEnv(
12020
+ {
12021
+ plan,
12022
+ ran: false,
12023
+ ok: true,
12024
+ skippedReason: `could not determine whether ${plan.command} is on PATH (${probed.reason})`
12025
+ },
12026
+ missingEnvVars
12027
+ );
11947
12028
  default:
11948
12029
  return assertNeverCommandProbe(probed);
11949
12030
  }
11950
- const result = await runCommand(plan.command, plan.args, { cwd: dir });
11951
- return {
11952
- plan,
11953
- ran: true,
11954
- ok: result.exitCode === 0,
11955
- exitCode: result.exitCode,
11956
- stderr: result.exitCode === 0 ? void 0 : result.stderr
11957
- };
12031
+ const run = deps.run ?? runCommand;
12032
+ const result = await run(plan.command, plan.args, { cwd: dir });
12033
+ if (result.exitCode === 0) {
12034
+ return withMissingEnv({ plan, ran: true, ok: true, exitCode: 0 }, missingEnvVars);
12035
+ }
12036
+ const stdout2 = capTail(result.stdout);
12037
+ const stderr = capTail(result.stderr);
12038
+ return withMissingEnv(
12039
+ {
12040
+ plan,
12041
+ ran: true,
12042
+ ok: false,
12043
+ exitCode: result.exitCode,
12044
+ stdout: stdout2.length > 0 ? stdout2 : void 0,
12045
+ stderr: stderr.length > 0 ? stderr : void 0
12046
+ },
12047
+ missingEnvVars
12048
+ );
11958
12049
  }
11959
12050
 
11960
12051
  // src/workspace/worktree/hydrate.ts
@@ -12629,10 +12720,11 @@ function emitJson(result) {
12629
12720
  log(JSON.stringify(result, null, 2));
12630
12721
  }
12631
12722
  function describeInstall(hydration) {
12632
- if (hydration.install.plan.manager === "none") return "no install step (no recognized lockfile)";
12633
- if (!hydration.install.ran)
12634
- return `install skipped (${hydration.install.skippedReason ?? "unavailable"})`;
12635
- return hydration.install.ok ? `hydrated via ${hydration.install.plan.manager}` : `install FAILED via ${hydration.install.plan.manager}`;
12723
+ return summarizeInstall(hydration.install);
12724
+ }
12725
+ function emitMemberLine(hydration, line2) {
12726
+ if (hydration?.install.ran && !hydration.install.ok) warn(line2);
12727
+ else log(line2);
12636
12728
  }
12637
12729
  async function handleAdd(opts) {
12638
12730
  if (!opts.slug) {
@@ -12677,7 +12769,10 @@ async function handleAdd(opts) {
12677
12769
  for (const m of result.members) {
12678
12770
  if (m.status === "ok") {
12679
12771
  const hydrationNote = m.hydration ? describeInstall(m.hydration) : "not hydrated (--no-hydrate)";
12680
- log(` ${m.member}: ${m.branchAction} "${m.branch}" \u2014 ${hydrationNote} (${m.durationMs}ms)`);
12772
+ emitMemberLine(
12773
+ m.hydration,
12774
+ ` ${m.member}: ${m.branchAction} "${m.branch}" \u2014 ${hydrationNote} (${m.durationMs}ms)`
12775
+ );
12681
12776
  } else {
12682
12777
  warn(` ${m.member}: FAILED \u2014 ${m.error}`);
12683
12778
  }
@@ -12721,7 +12816,10 @@ async function handleHydrate(opts) {
12721
12816
  for (const r of result.results) {
12722
12817
  if (r.status === "ok" && r.hydration) {
12723
12818
  const materializedNote = r.branchAction ? `materialized (${r.branchAction}), ` : "";
12724
- log(` ${r.member}: ${materializedNote}${describeInstall(r.hydration)}`);
12819
+ emitMemberLine(
12820
+ r.hydration,
12821
+ ` ${r.member}: ${materializedNote}${describeInstall(r.hydration)}`
12822
+ );
12725
12823
  } else {
12726
12824
  warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
12727
12825
  }
@@ -14058,7 +14156,7 @@ backups.command("prune").description("Delete old backups by age and/or count (re
14058
14156
  })
14059
14157
  );
14060
14158
  program.command("explain-recommendation").description("Print a per-item breakdown of the current recommendation, with skip reasons").option("--json").action(runExplainRecommendation);
14061
- program.command("init").description("Full setup: scan, recommend, and optionally apply Claude files (safe to re-run)").option("--dry-run", "Preview the `.claude/` write; scan artifacts are still written").option("--json", "Machine-readable scan + recommendation documents (requires --dry-run)").option(
14159
+ program.command("init").description("Full setup: scan, recommend, and optionally apply Claude files (safe to re-run)").option("--dry-run", "Preview the `.claude/` write; scan artifacts are still written").option("--json", "Emit one JSON envelope on stdout (requires --dry-run)").option(
14062
14160
  "--force",
14063
14161
  "Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
14064
14162
  ).action(runInit);
@@ -7,11 +7,11 @@ commands; they read plain language and approve.
7
7
  Do this in order:
8
8
 
9
9
  1. **Detect.** Run `haus init --json --dry-run`. Read the JSON yourself —
10
- do not show it. Translate what was detected into one or two plain sentences,
11
- e.g. "This looks like a Next.js website using Yarn. I found unit tests but no
12
- end-to-end tests." If the detection status is `unknown` or `partial`, say so
13
- honestly ("I couldn't fully recognise this stack, so I'll apply the general
14
- workflow and security guidance").
10
+ do not show it. Stdout is one envelope: `{ scan, recommendation, hooks, repoName, warnings }`.
11
+ Translate `scan` into one or two plain sentences, e.g. "This looks like a Next.js
12
+ website using Yarn. I found unit tests but no end-to-end tests." If
13
+ `scan.detectionStatus` is `unknown` or `partial`, say so honestly ("I couldn't fully
14
+ recognise this stack, so I'll apply the general workflow and security guidance").
15
15
 
16
16
  2. **Apply the basics.** Run `haus apply --write`. Read the result. This installs
17
17
  the core guardrails and helpers — including the documentation skill haus uses
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haus-tech/haus-workflow",
3
- "version": "3.1.2",
3
+ "version": "4.0.0",
4
4
  "description": "Haus AI workflow CLI for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {