@goodtek/vibeops 2.5.2 → 3.1.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.
@@ -1,12 +1,13 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
+ import { MergeConfigError, mergeGateTiming, readMergeConfig } from "../lib/merge-config.js";
3
4
  import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
4
5
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
5
6
  import { gitRemoteUrl } from "../lib/git.js";
6
7
  import { bold, cyan, dim, log } from "../lib/logger.js";
7
8
  import { projectPaths } from "../lib/paths.js";
8
9
  import { taskNotFoundMessage } from "../lib/resolve-task.js";
9
- import { getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
10
+ import { getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
10
11
  import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
11
12
  import { relPath } from "../lib/task-context.js";
12
13
  import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
@@ -19,6 +20,12 @@ function resolveMergeMethod(opts) {
19
20
  return "merge";
20
21
  return "squash";
21
22
  }
23
+ function mergeFailureHint(error) {
24
+ if (error instanceof MergeGateError) {
25
+ return "Not merged. Fix the failing checks (re-run `vibeops task ship` to push a fix and re-run CI), then rerun `vibeops task merge`.";
26
+ }
27
+ return "Merge in the host UI if branch protection blocks CLI merge.";
28
+ }
22
29
  export async function taskMergeCommand(taskRef, options = {}) {
23
30
  const cwd = resolve(options.cwd ?? process.cwd());
24
31
  const dryRun = options.dryRun === true;
@@ -35,6 +42,20 @@ export async function taskMergeCommand(taskRef, options = {}) {
35
42
  }
36
43
  throw e;
37
44
  }
45
+ let mergeCfg;
46
+ try {
47
+ mergeCfg = await readMergeConfig(cwd);
48
+ }
49
+ catch (e) {
50
+ if (e instanceof MergeConfigError) {
51
+ log.error(e.message);
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ throw e;
56
+ }
57
+ const requiredChecks = mergeCfg.requiredChecks;
58
+ const gate = { ...mergeGateTiming(mergeCfg), allowNoChecks: mergeCfg.allowNoChecks };
38
59
  const target = await resolveLifecycleTarget(paths, cwd, taskRef);
39
60
  if (target === null) {
40
61
  if (taskRef?.trim()) {
@@ -74,6 +95,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
74
95
  log.info(` ${dim("MR/PR")} ${mergeRequestUrl}`);
75
96
  log.info(` ${dim("target")} ${ctx.baseBranch} (${gitCfg.integrationBranch})`);
76
97
  log.info(` ${dim("method")} ${method}`);
98
+ log.info(` ${dim("checks")} all green${requiredChecks.length > 0 ? ` + required: ${requiredChecks.join(", ")}` : ""}${mergeCfg.allowNoChecks ? dim(" (allowNoChecks: true)") : ""}`);
77
99
  log.blank();
78
100
  if (dryRun) {
79
101
  await mergeMergeRequest({
@@ -81,6 +103,8 @@ export async function taskMergeCommand(taskRef, options = {}) {
81
103
  host,
82
104
  url: mergeRequestUrl,
83
105
  method,
106
+ requiredChecks,
107
+ gate,
84
108
  dryRun: true,
85
109
  });
86
110
  log.blank();
@@ -112,7 +136,8 @@ export async function taskMergeCommand(taskRef, options = {}) {
112
136
  url: mergeRequestUrl,
113
137
  method,
114
138
  waitForCi: true,
115
- immediate: true,
139
+ requiredChecks,
140
+ gate,
116
141
  });
117
142
  const verified = await assertMergeRequestMerged({
118
143
  cwd,
@@ -130,7 +155,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
130
155
  catch (e) {
131
156
  const msg = e instanceof Error ? e.message : String(e);
132
157
  log.error(`Merge failed: ${msg}`);
133
- log.info(dim("Merge in the host UI if branch protection blocks CLI merge."));
158
+ log.info(dim(mergeFailureHint(e)));
134
159
  process.exitCode = 1;
135
160
  return;
136
161
  }
@@ -149,7 +174,8 @@ export async function taskMergeCommand(taskRef, options = {}) {
149
174
  url: mergeRequestUrl,
150
175
  method,
151
176
  waitForCi: true,
152
- immediate: true,
177
+ requiredChecks,
178
+ gate,
153
179
  });
154
180
  const verified = await assertMergeRequestMerged({
155
181
  cwd,
@@ -167,6 +193,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
167
193
  catch (e) {
168
194
  const msg = e instanceof Error ? e.message : String(e);
169
195
  log.error(`Merge failed: ${msg}`);
196
+ log.info(dim(mergeFailureHint(e)));
170
197
  process.exitCode = 1;
171
198
  return;
172
199
  }
@@ -1,10 +1,11 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
+ import { MergeConfigError, mergeGateTiming, readMergeConfig } from "../lib/merge-config.js";
3
4
  import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
4
5
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
5
6
  import { gitRemoteUrl } from "../lib/git.js";
6
7
  import { bold, dim, log } from "../lib/logger.js";
7
- import { createMergeRequest, getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
8
+ import { createMergeRequest, getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
8
9
  import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
9
10
  function resolveReleaseMergeMethod(opts) {
10
11
  if (opts.rebase === true)
@@ -29,6 +30,19 @@ export async function taskReleaseCommand(options = {}) {
29
30
  throw e;
30
31
  }
31
32
  const { integrationBranch, productionBranch, remote } = gitCfg;
33
+ let mergeCfg;
34
+ try {
35
+ mergeCfg = await readMergeConfig(cwd);
36
+ }
37
+ catch (e) {
38
+ if (e instanceof MergeConfigError) {
39
+ log.error(e.message);
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+ throw e;
44
+ }
45
+ const requiredChecks = mergeCfg.releaseRequiredChecks;
32
46
  if (integrationBranch === productionBranch) {
33
47
  log.info("Trunk policy: integration and production are the same branch — no release PR step.");
34
48
  return;
@@ -52,7 +66,11 @@ export async function taskReleaseCommand(options = {}) {
52
66
  log.info(bold("dry-run — would:"));
53
67
  log.info(` · ${label}: ${integrationBranch} → ${productionBranch}`);
54
68
  if (options.noMerge !== true) {
55
- log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}, wait for CI)`);
69
+ log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}) only when all checks are green`);
70
+ log.info(` · required checks: ${requiredChecks.length > 0 ? requiredChecks.join(", ") : "(none configured)"}`);
71
+ if (mergeCfg.allowNoChecks) {
72
+ log.info(" · allowNoChecks: true (a release PR with no checks would merge)");
73
+ }
56
74
  }
57
75
  return;
58
76
  }
@@ -101,7 +119,8 @@ export async function taskReleaseCommand(options = {}) {
101
119
  url: releaseUrl,
102
120
  method,
103
121
  waitForCi: true,
104
- immediate: true,
122
+ requiredChecks,
123
+ gate: { ...mergeGateTiming(mergeCfg), allowNoChecks: mergeCfg.allowNoChecks },
105
124
  });
106
125
  const verified = await assertMergeRequestMerged({
107
126
  cwd,
@@ -119,7 +138,12 @@ export async function taskReleaseCommand(options = {}) {
119
138
  catch (e) {
120
139
  const msg = e instanceof Error ? e.message : String(e);
121
140
  log.error(`Release merge failed: ${msg}`);
122
- log.info(dim(`Complete merge in the host UI: ${releaseUrl}`));
141
+ if (e instanceof MergeGateError) {
142
+ log.info(dim(`Not merged. Fix the failing checks on ${releaseUrl}, then rerun task release.`));
143
+ }
144
+ else {
145
+ log.info(dim(`Complete merge in the host UI: ${releaseUrl}`));
146
+ }
123
147
  process.exitCode = 1;
124
148
  }
125
149
  }
@@ -15,6 +15,7 @@ import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, write
15
15
  import { detectShipPrState, prRefLabel, resolveShipAction, } from "../lib/task-ship-state.js";
16
16
  import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
17
17
  import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
18
+ import { assertTaskIdFreeOnRemote } from "../lib/task-id-allocation.js";
18
19
  function describePushFailure(message) {
19
20
  const lower = message.toLowerCase();
20
21
  if (lower.includes("protected") ||
@@ -245,6 +246,12 @@ async function runUpdateOpenPr(ctx) {
245
246
  log.info(`Nothing to ship (working tree clean, PR ${prRefLabel(prCtx.number)} up to date).`);
246
247
  return;
247
248
  }
249
+ const idFree = await assertTaskIdFreeOnRemote(cwd, remote, gitCtx.taskBranch);
250
+ if (!idFree.ok) {
251
+ log.error(idFree.message);
252
+ process.exitCode = 1;
253
+ return;
254
+ }
248
255
  try {
249
256
  await gitPush(cwd, remote, gitCtx.taskBranch, false);
250
257
  log.ok(`Pushed ${gitCtx.taskBranch} → ${remote}`);
@@ -1,11 +1,13 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
3
  import { detectGitHost } from "../lib/git-host.js";
4
- import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitFetchRemote, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitSwitchToBranch, readGitInfo, } from "../lib/git.js";
4
+ import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitFetchRemote, gitRemoteBranchExists, gitRemoteUrl, readGitInfo, } from "../lib/git.js";
5
+ import { branchCheckedOutElsewhere, fastForwardLocalIntegration, leaveTaskBranch, logLocalIntegrationUpdate, } from "../lib/git-worktree.js";
5
6
  import { bold, cyan, dim, log } from "../lib/logger.js";
6
7
  import { projectPaths } from "../lib/paths.js";
7
8
  import { taskNotFoundMessage } from "../lib/resolve-task.js";
8
9
  import { relPath } from "../lib/task-context.js";
10
+ import { runUnderTaskLock } from "../lib/task-lock.js";
9
11
  import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
10
12
  import { checkTaskSyncReady } from "../lib/task-sync-guard.js";
11
13
  import { readTaskFile } from "../lib/task.js";
@@ -13,6 +15,13 @@ function isProtectedBranch(name, integration, production) {
13
15
  return name === integration || name === production || name === "main" || name === "master";
14
16
  }
15
17
  export async function taskSyncCommand(taskRef, options = {}) {
18
+ const cwd = resolve(options.cwd ?? process.cwd());
19
+ if (options.dryRun === true)
20
+ return taskSyncUnlocked(taskRef, options);
21
+ // D-006: branch switch / stash / delete are serialized across worktrees.
22
+ await runUnderTaskLock(cwd, "task sync", () => taskSyncUnlocked(taskRef, options));
23
+ }
24
+ async function taskSyncUnlocked(taskRef, options) {
16
25
  const cwd = resolve(options.cwd ?? process.cwd());
17
26
  const dryRun = options.dryRun === true;
18
27
  const paths = projectPaths(cwd);
@@ -57,10 +66,20 @@ export async function taskSyncCommand(taskRef, options = {}) {
57
66
  if (meta.status === "shipped" && options.force !== true) {
58
67
  log.info(dim("Would verify MR is merged and integration branch contains task commits before cleanup."));
59
68
  }
69
+ const owner = (await readGitInfo(cwd)).isRepo
70
+ ? await branchCheckedOutElsewhere(cwd, integrationBranch)
71
+ : null;
60
72
  log.info(bold("dry-run — would:"));
61
73
  log.info(` · git fetch ${remote} --prune`);
62
- log.info(` · git switch ${integrationBranch}`);
63
- log.info(` · git pull --ff-only ${remote} ${integrationBranch}`);
74
+ log.info(` · verify the MR is merged (${remote}/${integrationBranch} contains the task commits)`);
75
+ if (owner === null) {
76
+ log.info(` · if on ${taskBranch}: git switch ${integrationBranch}`);
77
+ log.info(` · fast-forward local ${integrationBranch} to ${remote}/${integrationBranch} (--ff-only)`);
78
+ }
79
+ else {
80
+ log.info(` · if on ${taskBranch}: git switch --detach ${remote}/${integrationBranch}`);
81
+ log.info(dim(` · ${integrationBranch} is checked out in worktree ${owner} — left unchanged`));
82
+ }
64
83
  log.info(` · git branch -D ${taskBranch}`);
65
84
  if (options.noRemoteDelete !== true) {
66
85
  log.info(` · git push ${remote} --delete ${taskBranch} (if exists)`);
@@ -76,9 +95,6 @@ export async function taskSyncCommand(taskRef, options = {}) {
76
95
  process.exitCode = 1;
77
96
  return;
78
97
  }
79
- if (git.branch === taskBranch) {
80
- log.info(dim(`On ${taskBranch} — switching to ${integrationBranch} first.`));
81
- }
82
98
  try {
83
99
  await gitFetchRemote(cwd, remote);
84
100
  log.ok(`Fetched ${remote} (--prune)`);
@@ -87,32 +103,11 @@ export async function taskSyncCommand(taskRef, options = {}) {
87
103
  const msg = e instanceof Error ? e.message : String(e);
88
104
  log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
89
105
  }
90
- const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
91
- if (!switched) {
92
- log.error(`Integration branch "${integrationBranch}" not found locally or on ${remote}.`);
93
- process.exitCode = 1;
94
- return;
95
- }
96
- log.ok(`On ${integrationBranch}`);
97
- if (await gitRemoteBranchExists(cwd, remote, integrationBranch)) {
98
- try {
99
- await gitPullFastForwardOnly(cwd, remote, integrationBranch);
100
- log.ok(`Up to date with ${remote}/${integrationBranch} (--ff-only)`);
101
- }
102
- catch (e) {
103
- const msg = e instanceof Error ? e.message : String(e);
104
- log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Resolve locally, then rerun.`);
105
- process.exitCode = 1;
106
- return;
107
- }
108
- }
109
- else {
110
- log.warn(`No ${remote}/${integrationBranch} — skipped pull.`);
111
- }
112
106
  const remoteUrl = await gitRemoteUrl(cwd, remote);
113
107
  const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
114
108
  ? detectGitHost(remoteUrl)
115
109
  : gitCfg.host;
110
+ // Verify the merge BEFORE moving HEAD or touching any branch.
116
111
  const syncGuard = await checkTaskSyncReady({
117
112
  cwd,
118
113
  taskFile,
@@ -131,6 +126,43 @@ export async function taskSyncCommand(taskRef, options = {}) {
131
126
  return;
132
127
  }
133
128
  log.ok(`Ready to sync — ${integrationBranch} contains the task commits.`);
129
+ // Worktree-safe (D-006): leave the task branch without requiring the
130
+ // integration branch when another worktree has it checked out.
131
+ if (git.branch === taskBranch) {
132
+ const left = await leaveTaskBranch(cwd, remote, integrationBranch);
133
+ if (!left.ok) {
134
+ log.error(left.message);
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+ log.ok(left.on === "integration" ? `On ${integrationBranch}` : `Detached at ${left.ref}`);
139
+ }
140
+ if (await gitRemoteBranchExists(cwd, remote, integrationBranch)) {
141
+ try {
142
+ const update = await fastForwardLocalIntegration(cwd, remote, integrationBranch);
143
+ if (update.kind === "up_to_date") {
144
+ log.ok(`Local ${integrationBranch} is up to date with ${remote}/${integrationBranch}`);
145
+ }
146
+ else if (update.kind === "not_fast_forward") {
147
+ // Same outcome as the former `git pull --ff-only` failure: stop before cleanup.
148
+ log.error(`Local ${integrationBranch} has commits not on ${remote}/${integrationBranch} — cannot fast-forward (never forced). Push or reset it, then rerun.`);
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ else {
153
+ logLocalIntegrationUpdate(update, remote, integrationBranch);
154
+ }
155
+ }
156
+ catch (e) {
157
+ const msg = e instanceof Error ? e.message : String(e);
158
+ log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Resolve locally, then rerun.`);
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ }
163
+ else {
164
+ log.warn(`No ${remote}/${integrationBranch} — skipped fast-forward.`);
165
+ }
134
166
  if (await gitBranchExists(cwd, taskBranch)) {
135
167
  try {
136
168
  // Guard verified integration contains the work — squash merges leave task/* SHAs
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Classify GitHub `statusCheckRollup` items and evaluate the merge gate
3
+ * (`task merge` / `task release` refuse to merge on red or missing checks).
4
+ *
5
+ * GitHub returns two item shapes:
6
+ * - `CheckRun` (GitHub Actions, check apps): `status` + `conclusion`, no `state`.
7
+ * - `StatusContext` (commit status API): `state`, no `status`/`conclusion`.
8
+ */
9
+ /** GitHub GraphQL `CheckStatusState` values other than COMPLETED. */
10
+ const CHECK_RUN_IN_FLIGHT = new Set(["REQUESTED", "QUEUED", "IN_PROGRESS", "WAITING", "PENDING"]);
11
+ /** GitHub GraphQL `CheckConclusionState`, split into pass / fail (exhaustive). */
12
+ const CHECK_RUN_PASS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
13
+ const CHECK_RUN_FAIL = new Set([
14
+ "FAILURE",
15
+ "CANCELLED",
16
+ "TIMED_OUT",
17
+ "ACTION_REQUIRED",
18
+ "STARTUP_FAILURE",
19
+ "STALE",
20
+ ]);
21
+ /** GitHub GraphQL `StatusState` (exhaustive): SUCCESS | PENDING | EXPECTED | FAILURE | ERROR. */
22
+ const STATUS_CONTEXT_PENDING = new Set(["PENDING", "EXPECTED"]);
23
+ const STATUS_CONTEXT_FAIL = new Set(["FAILURE", "ERROR"]);
24
+ function upper(raw) {
25
+ return (raw ?? "").trim().toUpperCase();
26
+ }
27
+ function isCheckRun(item) {
28
+ if (item.__typename === "CheckRun")
29
+ return true;
30
+ if (item.__typename === "StatusContext")
31
+ return false;
32
+ // Unknown typename: infer from fields.
33
+ return (item.status ?? null) !== null || (item.conclusion ?? null) !== null;
34
+ }
35
+ export function classifyRollupItem(item) {
36
+ if (isCheckRun(item)) {
37
+ const name = (item.name ?? "").trim() || "(unnamed check run)";
38
+ const status = upper(item.status);
39
+ const conclusion = upper(item.conclusion);
40
+ if (CHECK_RUN_IN_FLIGHT.has(status)) {
41
+ return { name, kind: "CheckRun", verdict: "pending", detail: status };
42
+ }
43
+ if (status !== "COMPLETED") {
44
+ // Missing or undocumented status — fail closed.
45
+ return { name, kind: "CheckRun", verdict: "failed", detail: status || "MISSING_STATUS" };
46
+ }
47
+ if (CHECK_RUN_PASS.has(conclusion)) {
48
+ return { name, kind: "CheckRun", verdict: "passed", detail: conclusion };
49
+ }
50
+ if (CHECK_RUN_FAIL.has(conclusion)) {
51
+ return { name, kind: "CheckRun", verdict: "failed", detail: conclusion };
52
+ }
53
+ // Completed with a missing or undocumented conclusion — fail closed.
54
+ return {
55
+ name,
56
+ kind: "CheckRun",
57
+ verdict: "failed",
58
+ detail: conclusion || "MISSING_CONCLUSION",
59
+ };
60
+ }
61
+ const name = (item.context ?? item.name ?? "").trim() || "(unnamed status)";
62
+ const state = upper(item.state);
63
+ if (state === "SUCCESS") {
64
+ return { name, kind: "StatusContext", verdict: "passed", detail: state };
65
+ }
66
+ if (STATUS_CONTEXT_PENDING.has(state)) {
67
+ return { name, kind: "StatusContext", verdict: "pending", detail: state };
68
+ }
69
+ if (STATUS_CONTEXT_FAIL.has(state)) {
70
+ return { name, kind: "StatusContext", verdict: "failed", detail: state };
71
+ }
72
+ // Missing or undocumented state — fail closed.
73
+ return { name, kind: "StatusContext", verdict: "failed", detail: state || "MISSING_STATE" };
74
+ }
75
+ export function classifyRollup(items) {
76
+ return (items ?? []).map(classifyRollupItem);
77
+ }
78
+ /** Exact name, or a `*` glob (e.g. `lint*`, `* · linux`). */
79
+ export function checkNameMatches(name, pattern) {
80
+ const p = pattern.trim();
81
+ if (p.length === 0)
82
+ return false;
83
+ if (!p.includes("*"))
84
+ return name === p;
85
+ const re = new RegExp(`^${p
86
+ .split("*")
87
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
88
+ .join(".*")}$`);
89
+ return re.test(name);
90
+ }
91
+ /**
92
+ * Evaluate classified checks against optional required patterns.
93
+ * - any failed → red
94
+ * - any pending, or a required check not (yet) present → pending
95
+ * - otherwise green (including "no checks at all" when nothing is required)
96
+ */
97
+ export function evaluateCheckGate(checks, requiredChecks = []) {
98
+ const failed = checks.filter((c) => c.verdict === "failed");
99
+ const pending = checks.filter((c) => c.verdict === "pending");
100
+ const passed = checks.filter((c) => c.verdict === "passed");
101
+ const missingRequired = requiredChecks
102
+ .map((p) => p.trim())
103
+ .filter((p) => p.length > 0)
104
+ .filter((p) => !checks.some((c) => checkNameMatches(c.name, p)));
105
+ let state = "green";
106
+ if (failed.length > 0)
107
+ state = "red";
108
+ else if (pending.length > 0 || missingRequired.length > 0)
109
+ state = "pending";
110
+ return { state, failed, pending, passed, missingRequired };
111
+ }
112
+ function describe(c) {
113
+ return `"${c.name}" (${c.detail})`;
114
+ }
115
+ /** Human-readable lines for a refused merge. */
116
+ export function formatCheckGateProblems(gate) {
117
+ const lines = [];
118
+ if (gate.failed.length > 0) {
119
+ lines.push(`failed checks: ${gate.failed.map(describe).join(", ")}`);
120
+ }
121
+ if (gate.pending.length > 0) {
122
+ lines.push(`checks still pending: ${gate.pending.map(describe).join(", ")}`);
123
+ }
124
+ for (const p of gate.missingRequired) {
125
+ lines.push(`required check "${p}" never ran`);
126
+ }
127
+ return lines;
128
+ }
@@ -64,6 +64,7 @@ export async function readConfig(root) {
64
64
  parsed.schemaVersion === VIBEOPS_CONFIG_SCHEMA_VERSION) {
65
65
  const llm = parseLlmBlock(parsed.llm);
66
66
  const git = parseGitBlock(parsed.git);
67
+ const merge = parsed.merge;
67
68
  const clients = parseClientsBlock(parsed.clients) ?? ["cursor"];
68
69
  return {
69
70
  name: parsed.name,
@@ -73,6 +74,7 @@ export async function readConfig(root) {
73
74
  clients,
74
75
  ...(git ? { git } : {}),
75
76
  ...(llm ? { llm } : {}),
77
+ ...(merge !== undefined ? { merge } : {}),
76
78
  };
77
79
  }
78
80
  return null;
@@ -90,6 +92,7 @@ export function buildConfig(name, clients, git, existing, nowIso) {
90
92
  clients,
91
93
  git,
92
94
  llm: existing?.llm ?? { provider: "auto" },
95
+ ...(existing?.merge !== undefined ? { merge: existing.merge } : {}),
93
96
  };
94
97
  }
95
98
  export function configToJson(config) {