@goodtek/vibeops 3.1.8 → 3.2.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
@@ -4,6 +4,25 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.2.0 - 2026-10-18
8
+
9
+ ### Fixed
10
+
11
+ - **`task ship --new-cycle` recorded the TASK's own commit as Base Commit** (the task branch HEAD after the follow-up commit). It now records where the cycle starts on the base: the merge base of the task branch and the freshly fetched `<remote>/<base>` (TASK-050).
12
+
13
+ ### Added
14
+
15
+ - **`task merge` updates a stale PR branch** (GitHub, `merge.updateBranch`, default on): update-branch merges the base into the PR branch on the host (no force push), the gate waits for the new head's CI and re-checks with the same rules, repeating while bot PRs keep moving the base, within `waitTimeoutSeconds`; conflicts stop. `task ship`'s push-rejected hint now says merge (`git pull --no-rebase`), never rebase + force-push (D-014, TASK-048).
16
+ - **`vibeops status` shows the repository baseline**: the baseline checks, mode, the persona crew and its pinned catalog commit, and other remote branches (`--json` → `baseline`) (TASK-047).
17
+ - **`task release --tag`**: after the release merge, tag the merge commit `v<package.json version>` and push the tag (refuses when the commit is not on the production branch, the version is not semver, or the tag already exists) — merge into production and tagging for publish become one step (D-013, TASK-046).
18
+ - **Baseline checks before `task add` / `task ship`** (`.vibeops.json` → `baseline`): `branch-policy` (integration ≠ production), `default-branch` (remote default = integration), and with `baseline.crew` set `personas` / `agents-contract`. `mode`: `warn` (default), `enforce` (stop, exit 1), `off` (D-012, TASK-045).
19
+
20
+ ## 3.1.9 - 2026-10-17
21
+
22
+ ### Fixed
23
+
24
+ - **The merge gate was blocked by GitHub's dynamic workflow runs.** A failed Dependabot run on the head commit (`event: dynamic`, `dynamic/dependabot/dependabot-updates`) refused a PR whose CI was green — and such runs cannot be re-run. Dynamic runs and `(dynamic)` check runs are now ignored; a `(dynamic)` check named in `requiredChecks` is still required (D-011, TASK-043).
25
+
7
26
  ## 3.1.8 - 2026-10-17
8
27
 
9
28
  ### Changed
package/README.md CHANGED
@@ -147,7 +147,7 @@ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
147
147
  - **`task ship`**: `-m/--message`, `--new-cycle`, `--no-commit`, `--dry-run`, `--no-pr`, `--non-interactive`, `--allow-open-mr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`
148
148
  - **`task merge`**: `--dry-run`, `--merge`, `--rebase`
149
149
  - **`task sync`**: `--dry-run`, `--no-remote-delete`, `--force`
150
- - **`task release`**: `--dry-run`, `--no-merge`, `--merge`, `--rebase`
150
+ - **`task release`**: `--dry-run`, `--no-merge`, `--merge`, `--rebase`, `--tag` (after the merge, tag the release merge commit `v<package.json version>` and push it; never moves an existing tag)
151
151
  - **`pull`**: `--dry-run`
152
152
  - **`status`**: `--json`
153
153
 
@@ -201,6 +201,26 @@ A branch counts only when its name has the **generated form** `task/<NNN>` or `t
201
201
 
202
202
  If a configured remote cannot be fetched, `task add` stops without creating a TASK file. Across machines, `task ship` re-checks the remote (`git ls-remote`) right before pushing and refuses if another task branch with the same number already exists there.
203
203
 
204
+ ## Baseline checks
205
+
206
+ `task add` and `task ship` first check that the repository follows the VibeOps flow (`.vibeops.json` → `baseline`):
207
+
208
+ | Check | OK when |
209
+ |-------|---------|
210
+ | `branch-policy` | `git.integrationBranch` ≠ `git.productionBranch` (e.g. `develop` → `main`) |
211
+ | `default-branch` | the remote's default branch (`git ls-remote --symref <remote> HEAD`) is the integration branch |
212
+ | `personas` | with `baseline.crew` set: `.personas/<crew>/SOURCE` exists |
213
+ | `agents-contract` | with `baseline.crew` set: AGENTS.md has the `<!-- getpersona:<crew> -->` section |
214
+
215
+ ```json
216
+ { "baseline": { "mode": "warn", "crew": "vibe-crew" } }
217
+ ```
218
+
219
+ - `mode`: `warn` (default — print each failing check with a fix and continue), `enforce` (stop with exit code 1, nothing created or pushed), `off`.
220
+ - `crew`: persona crew to require; omitted → the crew checks are skipped.
221
+ - `vibeops status` shows the same checks (`Baseline …`), the crew's pinned catalog commit, and remote branches other than integration / production / `task/*` / `sync/*` (`status --json` → `baseline`).
222
+ - The block is validated strictly (unknown keys / values are errors and stop the command).
223
+
204
224
  ## Merge gate
205
225
 
206
226
  > **3.0.0 breaking:** a PR/MR with **no checks at all** is refused by default. Repos without CI: set `"merge": {"allowNoChecks": true}` in `.vibeops.json`. Requires **gh ≥ 2.13.0**.
@@ -218,6 +238,7 @@ Before calling `gh pr merge` / `glab mr merge`:
218
238
  - **Expected workflows:** every `.github/workflows/*.yml|yaml` at the head commit whose `on:` triggers `pull_request` for this PR, using GitHub's filter rules — `branches` / `branches-ignore` against the base branch, `paths` / `paths-ignore` against the PR's changed files (first 300, like GitHub), and `types` (a custom list must contain both `opened` and `synchronize`). Disabled workflows are skipped; `pull_request_target`, `workflow_call` and other events are not awaited. Each expected workflow must have a `pull_request` run for the head SHA — until it appears the gate **waits** (`workflow … (pull_request) has not started for <sha> yet`), then refuses at `waitTimeoutSeconds`.
219
239
  - **All runs for the head SHA** (any event, latest run per workflow + event) must be `completed` with `success`, `neutral` or `skipped`. A queued / in-progress / waiting run waits; any other conclusion refuses immediately — including `startup_failure` (no jobs, so nothing in the rollup) and `action_required` (fork PR awaiting approval).
220
240
  - The Actions API is required: if it cannot be read, the gate keeps waiting (back-off) and refuses at the timeout — it never merges blind.
241
+ - **Dynamic workflows are not CI:** runs GitHub generates on the fly — `event: dynamic`, e.g. `dynamic/dependabot/dependabot-updates`, the jobs that open Dependabot PRs — and check runs suffixed `(dynamic)` are ignored. GitHub documents them as "generated for a specific run and … not stored in your repository's `.github/workflows` directory" ([Dependabot on GitHub Actions runners](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/about-dependabot-on-github-actions-runners)); a failed one says nothing about the commit and cannot be re-run. A `(dynamic)` check named in `requiredChecks` is still required.
221
242
  - **GitLab:** the MR head pipeline must be `success` or `skipped`. `failed`, `canceled`, `manual` and unknown statuses refuse; `created` / `pending` / `running` / `scheduled` / … wait.
222
243
  - **Host output is verified:** if `gh pr view` lacks or has empty `state`, `mergeable`, `headRefOid`, or `statusCheckRollup` is not a list (GitLab: `state`, `sha`, `merge_status`, `detailed_merge_status`, `head_pipeline`), the merge is refused.
223
244
  - **Failed check → refuse immediately** (exit code 1, checks named). **Pending → wait** up to `waitTimeoutSeconds`, then refuse. There is no override flag: fix CI (re-run `task ship`) and rerun `task merge`.
@@ -226,7 +247,7 @@ Before calling `gh pr merge` / `glab mr merge`:
226
247
  - **After the merge:** `gh pr merge` / `glab mr merge` return the result synchronously (GitHub `merged: true`, GitLab `state: "merged"`), so VibeOps reads the PR/MR once to confirm. Not merged at that point (merge queue / auto-merge) is reported, not waited for.
227
248
  - **The merge is pinned to the checked commit:** right before merging the gate re-reads the PR head; if it moved, the new head is evaluated from scratch. The merge itself uses `gh pr merge --match-head-commit <sha>` (requires **gh ≥ 2.13.0**) / `glab mr merge --auto-merge=false --sha <sha>` (verified with glab 1.106), so a push between the last check and the merge makes the host refuse. A GitLab `405` re-runs the gate and retries once with the same flags.
228
249
  - **Unreadable Git Context stops `task ship` / `task add`:** a TASK file whose `## Git Context` cannot be read (e.g. an edit left `## ## Git Context`) makes `task ship` exit 1 before committing or pushing, naming the line; `task add` refuses to "resume" such a TASK (it would write a second section) and names the file and line.
229
- - **`task merge` refuses a stale base:** the head commit the gate approved must contain the current `<remote>/<base>` (`git merge-base --is-ancestor`, after fetching the base branch and the head commit). A head that does not — the base moved since the branch was pushed, or the branch was rebuilt on an old base (e.g. `git reset --soft <stale origin/develop>` + commit) — can silently revert work merged into the base since, and a squash merge lands that revert. Bring the branch up to date (`git fetch` + `git merge <remote>/<base>`, test, `task ship`), wait for CI, then rerun `task merge`. A fetch failure refuses (fail closed). `task release` does not apply this check (with merge-commit releases the production branch is normally not an ancestor of the integration branch).
250
+ - **`task merge` refuses a stale base:** the head commit the gate approved must contain the current `<remote>/<base>` (`git merge-base --is-ancestor`, after fetching the base branch and the head commit). A head that does not — the base moved since the branch was pushed, or the branch was rebuilt on an old base (e.g. `git reset --soft <stale origin/develop>` + commit) — can silently revert work merged into the base since, and a squash merge lands that revert. On GitHub (`merge.updateBranch`, default `true`) `task merge` brings the branch up to date itself: [update-branch](https://docs.github.com/rest/pulls/pulls#update-a-pull-request-branch) merges the base into the PR branch on the host (a merge commit — no rebase, no force push), then the gate waits for the new head and checks it with the same rules, again if the base moved meanwhile, all within `waitTimeoutSeconds`; a merge conflict stops with the local-merge instruction. Otherwise (GitLab, or `updateBranch: false`) bring the branch up to date yourself (`git fetch` + `git merge <remote>/<base>`, test, `task ship`), wait for CI, then rerun `task merge`. A fetch failure refuses (fail closed). `task release` does not apply this check (with merge-commit releases the production branch is normally not an ancestor of the integration branch).
230
251
 
231
252
  ### Configuration (`.vibeops.json` → `merge`)
232
253
 
@@ -251,6 +272,7 @@ Before calling `gh pr merge` / `glab mr merge`:
251
272
  | `pollIntervalSeconds` | `10` | integer ≥ 1, ≤ timeout | Poll interval while waiting (same default as `gh pr checks --watch`). |
252
273
  | `emptyRollupGraceSeconds` | `0` | integer ≥ 0, ≤ timeout | Only when no GitHub Actions workflow is expected: how long an empty check list is re-polled before "no checks" applies. Default `0`: name CI outside GitHub Actions in `requiredChecks` so the gate waits for it by name, instead of guessing how long it takes to report. |
253
274
  | `allowNoChecks` | `false` | boolean | Merge a PR that has no checks at all (repo without CI). Prints one warning per such merge. |
275
+ | `updateBranch` | `true` | boolean | `task merge` on GitHub: update a stale PR branch with update-branch (merge, no force push) and re-check, instead of refusing. |
254
276
 
255
277
  - Check names are exact (`CheckRun.name` / `StatusContext.context`) or a `*` glob (`"lint*"`, `"* · linux"`). Required checks are GitHub-only; GitLab gates on the head pipeline.
256
278
  - The block is validated strictly: it must be an object, unknown keys (typos) and wrong types are errors, and `task merge` / `task release` exit 1 naming the key.
package/dist/cli.js CHANGED
@@ -161,9 +161,11 @@ task
161
161
  .option("--squash", "Squash merge (default is merge commit for release)")
162
162
  .option("--merge", "Merge commit (default for release)")
163
163
  .option("--rebase", "Rebase merge")
164
+ .option("--tag", "After the merge, tag the release merge commit v<package.json version> and push it")
164
165
  .option("--cwd <path>", "Target directory")
165
166
  .action(async (opts) => {
166
167
  await taskReleaseCommand({
168
+ tag: Boolean(opts.tag),
167
169
  dryRun: Boolean(opts.dryRun),
168
170
  noMerge: opts.merge === false,
169
171
  merge: Boolean(opts.merge),
@@ -6,6 +6,7 @@ import { readGitInfo } from "../lib/git.js";
6
6
  import { buildLlmStatusReport } from "../lib/llm-status.js";
7
7
  import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
8
8
  import { projectPaths } from "../lib/paths.js";
9
+ import { summarizeBaseline } from "../lib/project-baseline.js";
9
10
  import { VERSION } from "../version.js";
10
11
  import { computeNextHint, hintToLines, hintToText, listInProgressTasks, pickFocusTask, relPath, taskBranchExistsFor, } from "../lib/task-context.js";
11
12
  import { hasOpenTaskMergeRequest, isMergeRequestMerged, resolveTaskMergeRequestUrl, taskNeedsSync, } from "../lib/task-effective-status.js";
@@ -77,6 +78,7 @@ async function buildReport(cwd) {
77
78
  hasLocalChanges: git.dirty === true,
78
79
  });
79
80
  const llm = isVibeopsProject ? await buildLlmStatusReport(cwd) : null;
81
+ const baseline = isVibeopsProject && config?.git && git.isRepo ? await summarizeBaseline(cwd, config.git) : null;
80
82
  return {
81
83
  vibeopsVersion: VERSION,
82
84
  isVibeopsProject,
@@ -100,6 +102,7 @@ async function buildReport(cwd) {
100
102
  nextHint: hintToText(hint, cwd, focus),
101
103
  nextLines: [...hintToLines(hint, cwd, focus)],
102
104
  llm,
105
+ baseline,
103
106
  };
104
107
  }
105
108
  function filledLabel(ok) {
@@ -183,6 +186,23 @@ function printDetails(report) {
183
186
  if (hostBits.length > 0) {
184
187
  log.info(` ${dim("Git")} ${hostBits.join(" · ")}`);
185
188
  }
189
+ if (report.baseline) {
190
+ const b = report.baseline;
191
+ if (b.configError !== null) {
192
+ log.info(` ${dim("Baseline")} ${yellow(b.configError)}`);
193
+ }
194
+ const failing = b.checks.filter((c) => !c.ok);
195
+ const crew = b.crew !== null ? ` · ${b.crew}${b.crewCommit ? ` ${b.crewCommit.slice(0, 7)}` : ""}` : "";
196
+ log.info(` ${dim("Baseline")} ${failing.length === 0
197
+ ? `ok (${b.checks.map((c) => c.id).join(", ")})`
198
+ : yellow(failing.map((c) => `${c.id}: ${c.detail}`).join("; "))} · mode ${b.mode}${crew}`);
199
+ if (b.otherBranches === null) {
200
+ log.info(` ${dim("Branches")} ${dim("(remote branches unreadable)")}`);
201
+ }
202
+ else if (b.otherBranches.length > 0) {
203
+ log.info(` ${dim("Branches")} other remote branches: ${b.otherBranches.join(", ")}`);
204
+ }
205
+ }
186
206
  if (report.llm) {
187
207
  const active = report.llm.active !== null
188
208
  ? `${report.llm.active} (ok)`
@@ -3,6 +3,7 @@ import { pathExists, writeText } from "../lib/filesystem.js";
3
3
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
4
4
  import { ensureIntegrationSynced, printIntegrationSyncDiagnosis, } from "../lib/git-integration-sync.js";
5
5
  import { askInput } from "../lib/inquirer-helpers.js";
6
+ import { preflightBaseline } from "../lib/project-baseline.js";
6
7
  import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
7
8
  import { projectPaths } from "../lib/paths.js";
8
9
  import { slugify } from "../lib/slug.js";
@@ -80,6 +81,10 @@ export async function taskAddCommand(opts = {}) {
80
81
  const gitCfg = await loadGitConfigOrNull(root, dryRun);
81
82
  if (!dryRun && gitCfg === null)
82
83
  return;
84
+ if (gitCfg !== null && !(await preflightBaseline(root, gitCfg, "task add"))) {
85
+ process.exitCode = 1;
86
+ return;
87
+ }
83
88
  let lockCfg;
84
89
  try {
85
90
  lockCfg = await readTaskLockConfig(root);
@@ -108,7 +108,11 @@ export async function taskMergeCommand(taskRef, options = {}) {
108
108
  method,
109
109
  requiredChecks,
110
110
  gate,
111
- requireBaseInHead: { remote: gitCfg.remote, baseBranch: ctx.baseBranch },
111
+ requireBaseInHead: {
112
+ remote: gitCfg.remote,
113
+ baseBranch: ctx.baseBranch,
114
+ updateBranch: mergeCfg.updateBranch,
115
+ },
112
116
  dryRun: true,
113
117
  });
114
118
  log.blank();
@@ -142,7 +146,11 @@ export async function taskMergeCommand(taskRef, options = {}) {
142
146
  waitForCi: true,
143
147
  requiredChecks,
144
148
  gate,
145
- requireBaseInHead: { remote: gitCfg.remote, baseBranch: ctx.baseBranch },
149
+ requireBaseInHead: {
150
+ remote: gitCfg.remote,
151
+ baseBranch: ctx.baseBranch,
152
+ updateBranch: mergeCfg.updateBranch,
153
+ },
146
154
  });
147
155
  const verified = await assertMergeRequestMerged({
148
156
  cwd,
@@ -181,7 +189,11 @@ export async function taskMergeCommand(taskRef, options = {}) {
181
189
  waitForCi: true,
182
190
  requiredChecks,
183
191
  gate,
184
- requireBaseInHead: { remote: gitCfg.remote, baseBranch: ctx.baseBranch },
192
+ requireBaseInHead: {
193
+ remote: gitCfg.remote,
194
+ baseBranch: ctx.baseBranch,
195
+ updateBranch: mergeCfg.updateBranch,
196
+ },
185
197
  });
186
198
  const verified = await assertMergeRequestMerged({
187
199
  cwd,
@@ -5,7 +5,8 @@ import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-
5
5
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
6
6
  import { gitRemoteUrl } from "../lib/git.js";
7
7
  import { bold, dim, log } from "../lib/logger.js";
8
- import { createMergeRequest, findMergeRequestByBranches, getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
8
+ import { createMergeRequest, findMergeRequestByBranches, getMergeRequestDetails, getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
9
+ import { tagReleaseCommit } from "../lib/release-tag.js";
9
10
  import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
10
11
  function resolveReleaseMergeMethod(opts) {
11
12
  if (opts.rebase === true)
@@ -17,6 +18,11 @@ function resolveReleaseMergeMethod(opts) {
17
18
  export async function taskReleaseCommand(options = {}) {
18
19
  const cwd = resolve(options.cwd ?? process.cwd());
19
20
  const dryRun = options.dryRun === true;
21
+ if (options.tag === true && options.noMerge === true) {
22
+ log.error("--tag needs the merge: it tags the release merge commit (drop --no-merge).");
23
+ process.exitCode = 1;
24
+ return;
25
+ }
20
26
  let gitCfg;
21
27
  try {
22
28
  gitCfg = await requireGitConfig(cwd);
@@ -72,6 +78,9 @@ export async function taskReleaseCommand(options = {}) {
72
78
  if (mergeCfg.allowNoChecks) {
73
79
  log.info(" · allowNoChecks: true (a release PR with no checks would merge)");
74
80
  }
81
+ if (options.tag === true) {
82
+ log.info(` · tag the release merge commit v<package.json version> and push the tag to ${remote}`);
83
+ }
75
84
  }
76
85
  return;
77
86
  }
@@ -157,6 +166,19 @@ export async function taskReleaseCommand(options = {}) {
157
166
  return;
158
167
  }
159
168
  log.ok(`Release ${label} merged into ${productionBranch}.`);
169
+ if (options.tag === true) {
170
+ const merged = await getMergeRequestDetails(cwd, host, releaseUrl);
171
+ const commit = merged?.mergeCommitSha ?? null;
172
+ const tagged = commit === null
173
+ ? { ok: false, message: `the host did not report the merge commit of ${releaseUrl}` }
174
+ : await tagReleaseCommit({ cwd, remote, productionBranch, commit });
175
+ if (!tagged.ok) {
176
+ log.error(`Release merged, but not tagged: ${tagged.message}.`);
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+ log.ok(`Tagged ${tagged.tag} → ${tagged.commit.slice(0, 7)} and pushed to ${remote}.`);
181
+ }
160
182
  }
161
183
  catch (e) {
162
184
  const msg = e instanceof Error ? e.message : String(e);
@@ -1,6 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { readText } from "../lib/filesystem.js";
3
3
  import { requireGitConfig } from "../lib/git-config.js";
4
+ import { preflightBaseline } from "../lib/project-baseline.js";
4
5
  import { gitCommitsAhead, gitHeadCommit, gitPush, gitRemoteBranchExists, readGitInfo, } from "../lib/git.js";
5
6
  import { askYesNo, isInteractiveSession } from "../lib/inquirer-helpers.js";
6
7
  import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
@@ -28,7 +29,7 @@ function describePushFailure(message) {
28
29
  lower.includes("fetch first") ||
29
30
  lower.includes("rejected") ||
30
31
  lower.includes("behind")) {
31
- return "Push rejected — the remote branch has commits you don't. Pull/rebase the task branch, then rerun.";
32
+ return "Push rejected — the remote branch has commits you don't. Merge them in (git pull --no-rebase), then rerun; never rebase + force-push a shared task branch.";
32
33
  }
33
34
  return `git push failed: ${message}`;
34
35
  }
@@ -330,6 +331,11 @@ export async function taskShipCommand(taskRef, options = {}) {
330
331
  log.info(` ${dim("PR")} ${prRefLabel(prCtx.number)} (${prCtx.state})`);
331
332
  }
332
333
  log.blank();
334
+ const baselineGit = await requireGitConfig(cwd).catch(() => null);
335
+ if (baselineGit !== null && !(await preflightBaseline(cwd, baselineGit, "task ship"))) {
336
+ process.exitCode = 1;
337
+ return;
338
+ }
333
339
  const ctx = {
334
340
  cwd,
335
341
  taskId,
@@ -94,6 +94,15 @@ export function checkNameMatches(name, pattern) {
94
94
  * - any pending, or a required check not (yet) present → pending
95
95
  * - otherwise green (including "no checks at all" when nothing is required)
96
96
  */
97
+ /**
98
+ * Check runs GitHub suffixes with " (dynamic)" belong to dynamic workflows (see
99
+ * `isDynamicRun` in github-actions-gate.ts), not to the commit's CI: dropped unless a required
100
+ * check pattern names them.
101
+ */
102
+ export function withoutDynamicChecks(checks, requiredChecks = []) {
103
+ return checks.filter((c) => !(c.kind === "CheckRun" && c.name.endsWith(" (dynamic)")) ||
104
+ requiredChecks.some((p) => checkNameMatches(c.name, p)));
105
+ }
97
106
  export function evaluateCheckGate(checks, requiredChecks = []) {
98
107
  const failed = checks.filter((c) => c.verdict === "failed");
99
108
  const pending = checks.filter((c) => c.verdict === "pending");
@@ -65,6 +65,7 @@ export async function readConfig(root) {
65
65
  const llm = parseLlmBlock(parsed.llm);
66
66
  const git = parseGitBlock(parsed.git);
67
67
  const merge = parsed.merge;
68
+ const baseline = parsed.baseline;
68
69
  const clients = parseClientsBlock(parsed.clients) ?? ["cursor"];
69
70
  return {
70
71
  name: parsed.name,
@@ -75,6 +76,7 @@ export async function readConfig(root) {
75
76
  ...(git ? { git } : {}),
76
77
  ...(llm ? { llm } : {}),
77
78
  ...(merge !== undefined ? { merge } : {}),
79
+ ...(baseline !== undefined ? { baseline } : {}),
78
80
  };
79
81
  }
80
82
  return null;
@@ -93,6 +95,7 @@ export function buildConfig(name, clients, git, existing, nowIso) {
93
95
  git,
94
96
  llm: existing?.llm ?? { provider: "auto" },
95
97
  ...(existing?.merge !== undefined ? { merge: existing.merge } : {}),
98
+ ...(existing?.baseline !== undefined ? { baseline: existing.baseline } : {}),
96
99
  };
97
100
  }
98
101
  export function configToJson(config) {
@@ -171,6 +171,17 @@ export async function workflowExpectedForPullRequest(yamlText, ctx) {
171
171
  return { expected: true };
172
172
  }
173
173
  const RUN_PASS = new Set(["success", "neutral", "skipped"]);
174
+ /**
175
+ * GitHub-generated runs that are not the commit's CI: "dynamic workflows are generated for a
176
+ * specific run and are not stored in your repository's `.github/workflows` directory" — e.g.
177
+ * the jobs that generate Dependabot pull requests, `dynamic/dependabot/dependabot-updates`
178
+ * (docs.github.com/code-security/dependabot/working-with-dependabot/about-dependabot-on-github-actions-runners).
179
+ * Their result says nothing about the head commit, and a failed one cannot be re-run, so the
180
+ * gate ignores them (required checks named in `.vibeops.json` are still judged by name).
181
+ */
182
+ export function isDynamicRun(run) {
183
+ return run.event === "dynamic" || run.path.startsWith("dynamic/");
184
+ }
174
185
  function runPath(run) {
175
186
  return run.path.split("@")[0];
176
187
  }
@@ -180,11 +191,13 @@ function describeRun(run, detail) {
180
191
  /**
181
192
  * Latest run per (workflow, event) must be completed and passing; every
182
193
  * expected workflow must have a `pull_request` run. Unknown statuses /
183
- * conclusions fail closed.
194
+ * conclusions fail closed. Dynamic runs ({@link isDynamicRun}) are ignored.
184
195
  */
185
196
  export function evaluateActionsRuns(runs, expectedPaths) {
186
197
  const latest = new Map();
187
198
  for (const run of runs) {
199
+ if (isDynamicRun(run))
200
+ continue;
188
201
  const key = `${runPath(run)}\u0000${run.event}`;
189
202
  const prev = latest.get(key);
190
203
  if (prev === undefined || run.id > prev.id)
@@ -21,6 +21,8 @@ export const MERGE_GATE_DEFAULTS = {
21
21
  // to report.
22
22
  emptyRollupGraceSeconds: 0,
23
23
  allowNoChecks: false,
24
+ // GitHub's own "Update branch" (merge, no force push) — see `updateGitHubPullBranch`.
25
+ updateBranch: true,
24
26
  };
25
27
  /** Inclusive bounds for the numeric settings (seconds); cross-checks below keep them ≤ the wait. */
26
28
  export const MERGE_GATE_LIMITS = {
@@ -35,6 +37,7 @@ const KNOWN_KEYS = new Set([
35
37
  "pollIntervalSeconds",
36
38
  "emptyRollupGraceSeconds",
37
39
  "allowNoChecks",
40
+ "updateBranch",
38
41
  ]);
39
42
  /** Invalid `merge` block in `.vibeops.json`. Merge commands refuse to run. */
40
43
  export class MergeConfigError extends Error {
@@ -96,7 +99,14 @@ export function parseMergeConfig(raw) {
96
99
  ? MERGE_GATE_DEFAULTS.emptyRollupGraceSeconds
97
100
  : seconds("emptyRollupGraceSeconds", o.emptyRollupGraceSeconds),
98
101
  allowNoChecks: MERGE_GATE_DEFAULTS.allowNoChecks,
102
+ updateBranch: MERGE_GATE_DEFAULTS.updateBranch,
99
103
  };
104
+ if (o.updateBranch !== undefined) {
105
+ if (typeof o.updateBranch !== "boolean") {
106
+ throw new MergeConfigError("`updateBranch` must be true or false.");
107
+ }
108
+ cfg.updateBranch = o.updateBranch;
109
+ }
100
110
  if (o.allowNoChecks !== undefined) {
101
111
  if (typeof o.allowNoChecks !== "boolean") {
102
112
  throw new MergeConfigError("`allowNoChecks` must be true or false.");
@@ -1,9 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { mergeRequestLabel } from "./git-host.js";
4
- import { classifyRollup, evaluateCheckGate, formatCheckGateProblems, } from "./check-rollup.js";
4
+ import { classifyRollup, evaluateCheckGate, withoutDynamicChecks, formatCheckGateProblems, } from "./check-rollup.js";
5
5
  import { isMergeRequestReadyToMerge, isPipelineActive, pipelineGateState, pipelineStatusFromHost, } from "./merge-request-readiness.js";
6
- import { evaluateActionsRuns, listExpectedPullRequestWorkflows, listWorkflowRunsForSha, parseGitHubPullUrl, } from "./github-actions-gate.js";
6
+ import { evaluateActionsRuns, isDynamicRun, listExpectedPullRequestWorkflows, listWorkflowRunsForSha, parseGitHubPullUrl, } from "./github-actions-gate.js";
7
7
  import { dim, log } from "./logger.js";
8
8
  import { checkBaseInHead } from "./merge-base-freshness.js";
9
9
  import { MERGE_GATE_DEFAULTS, mergeGateTiming } from "./merge-config.js";
@@ -292,7 +292,10 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
292
292
  throw new MergeRequestShapeError("gh", problems);
293
293
  }
294
294
  const checks = classifyRollup(parsed.statusCheckRollup);
295
- const gate = evaluateCheckGate(checks);
295
+ // Dynamic workflows' check runs are not the commit's CI (see `withoutDynamicChecks`);
296
+ // required ones are still judged by name in the merge gate.
297
+ const ciChecks = withoutDynamicChecks(checks);
298
+ const gate = evaluateCheckGate(ciChecks);
296
299
  let pipelineStatus = "none";
297
300
  if (gate.state === "red") {
298
301
  pipelineStatus = "failed";
@@ -300,7 +303,7 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
300
303
  else if (gate.state === "pending") {
301
304
  pipelineStatus = "running";
302
305
  }
303
- else if (checks.length > 0) {
306
+ else if (ciChecks.length > 0) {
304
307
  pipelineStatus = "success";
305
308
  }
306
309
  const mergeable = parsed.mergeable?.trim().toUpperCase() ?? "";
@@ -340,11 +343,12 @@ export class MergeGateError extends Error {
340
343
  }
341
344
  function evaluateDetailsGate(details, requiredChecks) {
342
345
  if (details.checks !== null) {
343
- const gate = evaluateCheckGate(details.checks, requiredChecks);
346
+ const checks = withoutDynamicChecks(details.checks, requiredChecks);
347
+ const gate = evaluateCheckGate(checks, requiredChecks);
344
348
  return {
345
349
  state: gate.state,
346
350
  problems: formatCheckGateProblems(gate),
347
- noChecks: details.checks.length === 0,
351
+ noChecks: checks.length === 0,
348
352
  };
349
353
  }
350
354
  const state = pipelineGateState(details.pipelineStatus);
@@ -383,7 +387,7 @@ async function evaluateGitHubActions(cwd, url, details, expectedCache) {
383
387
  expected = await listExpectedPullRequestWorkflows(cwd, pr, sha, details.baseBranch);
384
388
  expectedCache.set(sha, expected);
385
389
  }
386
- const runs = await listWorkflowRunsForSha(cwd, pr, sha);
390
+ const runs = (await listWorkflowRunsForSha(cwd, pr, sha)).filter((r) => !isDynamicRun(r));
387
391
  return {
388
392
  result: evaluateActionsRuns(runs, expected.map((w) => w.path)),
389
393
  runCount: runs.length,
@@ -566,6 +570,39 @@ function isMergeHttp405(error) {
566
570
  const msg = error instanceof Error ? error.message : String(error);
567
571
  return msg.includes("405") || msg.toLowerCase().includes("method not allowed");
568
572
  }
573
+ /**
574
+ * GitHub "Update a pull request branch": merges the base branch into the PR branch on the
575
+ * host (a merge commit — no rebase, no force push) and answers `202 Accepted`; a
576
+ * non-matching `expected_head_sha` answers 422
577
+ * (docs.github.com/rest/pulls/pulls#update-a-pull-request-branch).
578
+ */
579
+ export async function updateGitHubPullBranch(cwd, url, expectedHeadSha) {
580
+ const pr = parseGitHubPullUrl(url);
581
+ if (pr === null)
582
+ return "error";
583
+ try {
584
+ await execFileAsync("gh", [
585
+ "api",
586
+ "--hostname",
587
+ pr.hostname,
588
+ "--method",
589
+ "PUT",
590
+ `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/update-branch`,
591
+ "--raw-field",
592
+ `expected_head_sha=${expectedHeadSha}`,
593
+ ], { cwd, maxBuffer: 1024 * 1024 });
594
+ return "accepted";
595
+ }
596
+ catch (e) {
597
+ const text = `${e.stderr ?? ""} ${e instanceof Error ? e.message : ""}`.toLowerCase();
598
+ if (text.includes("conflict"))
599
+ return "conflict";
600
+ if (text.includes("expected head sha") || text.includes("head branch was modified")) {
601
+ return "head-moved";
602
+ }
603
+ return "error";
604
+ }
605
+ }
569
606
  export async function closeMergeRequest(opts) {
570
607
  const ref = prRefFromUrl(opts.url);
571
608
  const label = mergeRequestLabel(opts.host);
@@ -595,8 +632,10 @@ export async function mergeMergeRequest(opts) {
595
632
  : "";
596
633
  log.info(`would check ${label} status checks (all green${required}) before merging`);
597
634
  if (opts.requireBaseInHead !== undefined) {
598
- const { remote, baseBranch } = opts.requireBaseInHead;
599
- log.info(`would refuse unless the head commit contains ${remote}/${baseBranch} (stale base)`);
635
+ const { remote, baseBranch, updateBranch } = opts.requireBaseInHead;
636
+ log.info(updateBranch === true && opts.host === "github"
637
+ ? `would update a stale branch from ${remote}/${baseBranch} on the host (update-branch, merge) and re-check`
638
+ : `would refuse unless the head commit contains ${remote}/${baseBranch} (stale base)`);
600
639
  }
601
640
  if (opts.host === "gitlab") {
602
641
  log.info(`would ${label} merge ${ref} (glab ${gitLabMergeArgs(ref, method, "<head sha>").join(" ")})`);
@@ -606,31 +645,56 @@ export async function mergeMergeRequest(opts) {
606
645
  }
607
646
  return;
608
647
  }
648
+ const timing = { ...mergeGateTiming(MERGE_GATE_DEFAULTS), ...opts.gate };
649
+ // One budget for the whole merge, including branch updates (`waitTimeoutSeconds`).
650
+ const deadline = Date.now() + timing.timeoutMs;
609
651
  const runGate = async () => {
610
- const gate = await waitForMergeGate(opts.cwd, opts.host, opts.url, {
611
- ...opts.gate,
612
- waitForPending: opts.waitForCi === true,
613
- requiredChecks: opts.requiredChecks ?? [],
614
- });
615
- if (!gate.ok)
616
- throw new MergeGateError(label, gate.problems);
617
- if (gate.alreadyMerged)
618
- return null;
619
- if (gate.details.headSha === null || gate.details.headSha.trim().length === 0) {
620
- // Strict read already guarantees this; never merge unpinned.
621
- throw new MergeGateError(label, ["host did not report the head commit SHA"]);
622
- }
623
- if (opts.requireBaseInHead !== undefined) {
624
- const fresh = await checkBaseInHead({
625
- cwd: opts.cwd,
626
- remote: opts.requireBaseInHead.remote,
627
- baseBranch: gate.details.baseBranch ?? opts.requireBaseInHead.baseBranch,
628
- headSha: gate.details.headSha,
652
+ let updateErrors = 0;
653
+ for (;;) {
654
+ const gate = await waitForMergeGate(opts.cwd, opts.host, opts.url, {
655
+ ...opts.gate,
656
+ timeoutMs: Math.max(1, deadline - Date.now()),
657
+ waitForPending: opts.waitForCi === true,
658
+ requiredChecks: opts.requiredChecks ?? [],
629
659
  });
630
- if (!fresh.ok)
660
+ if (!gate.ok)
661
+ throw new MergeGateError(label, gate.problems);
662
+ if (gate.alreadyMerged)
663
+ return null;
664
+ const headSha = gate.details.headSha;
665
+ if (headSha === null || headSha.trim().length === 0) {
666
+ // Strict read already guarantees this; never merge unpinned.
667
+ throw new MergeGateError(label, ["host did not report the head commit SHA"]);
668
+ }
669
+ const base = opts.requireBaseInHead;
670
+ if (base === undefined)
671
+ return gate.details;
672
+ const baseBranch = gate.details.baseBranch ?? base.baseBranch;
673
+ const fresh = await checkBaseInHead({ cwd: opts.cwd, remote: base.remote, baseBranch, headSha });
674
+ if (fresh.ok)
675
+ return gate.details;
676
+ const canUpdate = base.updateBranch === true && opts.host === "github" && fresh.problem.startsWith("stale base:");
677
+ if (!canUpdate)
631
678
  throw new MergeGateError(label, [fresh.problem]);
679
+ if (Date.now() >= deadline) {
680
+ throw new MergeGateError(label, [fresh.problem, "gave up updating the branch (waitTimeoutSeconds)"]);
681
+ }
682
+ const outcome = await updateGitHubPullBranch(opts.cwd, opts.url, headSha);
683
+ if (outcome === "conflict") {
684
+ throw new MergeGateError(label, [
685
+ fresh.problem,
686
+ `update-branch reports a merge conflict — merge ${base.remote}/${baseBranch} into the task branch locally, resolve, task ship`,
687
+ ]);
688
+ }
689
+ if (outcome === "error") {
690
+ // Read / API error: exponential back-off with full jitter, within the same budget.
691
+ await sleep(Math.min(errorBackoffMs(timing.intervalMs, updateErrors++), deadline - Date.now()));
692
+ continue;
693
+ }
694
+ updateErrors = 0;
695
+ log.info(dim(`${baseBranch} moved — updated the ${label} branch on the host (merge, no force push); re-checking the new head…`));
696
+ await waitForHeadChange(opts.cwd, opts.host, opts.url, headSha, deadline, timing.intervalMs, label);
632
697
  }
633
- return gate.details;
634
698
  };
635
699
  const details = await runGate();
636
700
  if (details === null)
@@ -658,6 +722,21 @@ export async function mergeMergeRequest(opts) {
658
722
  maxBuffer: 4 * 1024 * 1024,
659
723
  });
660
724
  }
725
+ /** After update-branch (202, applied asynchronously): wait until the PR reports a new head. */
726
+ async function waitForHeadChange(cwd, host, url, oldHead, deadline, intervalMs, label) {
727
+ for (;;) {
728
+ const details = await getMergeRequestDetails(cwd, host, url);
729
+ if (details !== null && (details.state === "merged" || (details.headSha !== null && details.headSha !== oldHead))) {
730
+ return;
731
+ }
732
+ if (Date.now() >= deadline) {
733
+ throw new MergeGateError(label, [
734
+ `update-branch was accepted but the ${label} head is still ${oldHead.slice(0, 7)} (gave up, waitTimeoutSeconds)`,
735
+ ]);
736
+ }
737
+ await sleep(Math.min(intervalMs, deadline - Date.now()));
738
+ }
739
+ }
661
740
  /** `gh pr merge` argv. `--match-head-commit` needs gh ≥ 2.13.0. */
662
741
  export function gitHubMergeArgs(ref, method, headSha) {
663
742
  return ["pr", "merge", ref, `--${method}`, "--match-head-commit", headSha];
@@ -0,0 +1,178 @@
1
+ import { join } from "node:path";
2
+ import { readTextOrNull } from "./filesystem.js";
3
+ import { runGit } from "./git.js";
4
+ import { dim, log } from "./logger.js";
5
+ import { VIBEOPS_CONFIG_FILE } from "./paths.js";
6
+ export const BASELINE_DEFAULTS = { mode: "warn", crew: null };
7
+ export class BaselineConfigError extends Error {
8
+ constructor(message) {
9
+ super(`Invalid \`baseline\` block in .vibeops.json: ${message}`);
10
+ this.name = "BaselineConfigError";
11
+ }
12
+ }
13
+ const MODES = ["off", "warn", "enforce"];
14
+ const CREW_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
+ /** Strict: object with only `mode` / `crew`. `undefined` → defaults. */
16
+ export function parseBaselineConfig(raw) {
17
+ if (raw === undefined)
18
+ return BASELINE_DEFAULTS;
19
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
20
+ throw new BaselineConfigError("must be an object.");
21
+ }
22
+ const o = raw;
23
+ const unknown = Object.keys(o).filter((k) => k !== "mode" && k !== "crew");
24
+ if (unknown.length > 0) {
25
+ throw new BaselineConfigError(`unknown key \`${unknown[0]}\` (known: mode, crew).`);
26
+ }
27
+ let mode = BASELINE_DEFAULTS.mode;
28
+ if (o.mode !== undefined) {
29
+ if (typeof o.mode !== "string" || !MODES.includes(o.mode)) {
30
+ throw new BaselineConfigError("`mode` must be one of off, warn, enforce.");
31
+ }
32
+ mode = o.mode;
33
+ }
34
+ let crew = null;
35
+ if (o.crew !== undefined) {
36
+ if (typeof o.crew !== "string" || !CREW_RE.test(o.crew)) {
37
+ throw new BaselineConfigError("`crew` must be a crew name like `vibe-crew`.");
38
+ }
39
+ crew = o.crew;
40
+ }
41
+ return { mode, crew };
42
+ }
43
+ export async function readBaselineConfig(root) {
44
+ const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
45
+ if (text === null)
46
+ return BASELINE_DEFAULTS;
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(text);
50
+ }
51
+ catch {
52
+ throw new BaselineConfigError("file is not valid JSON.");
53
+ }
54
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
55
+ return BASELINE_DEFAULTS;
56
+ }
57
+ return parseBaselineConfig(parsed.baseline);
58
+ }
59
+ /** The remote's default branch from `git ls-remote --symref <remote> HEAD`, or null. */
60
+ async function remoteDefaultBranch(cwd, remote) {
61
+ try {
62
+ const { stdout } = await runGit(cwd, ["ls-remote", "--symref", remote, "HEAD"]);
63
+ const m = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(stdout);
64
+ return m ? m[1] : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ export async function runBaselineChecks(cwd, git, cfg) {
71
+ const checks = [];
72
+ const gitflow = git.integrationBranch !== git.productionBranch;
73
+ checks.push({
74
+ id: "branch-policy",
75
+ ok: gitflow,
76
+ detail: gitflow
77
+ ? `${git.integrationBranch} (integration) → ${git.productionBranch} (production)`
78
+ : `integration and production are both ${git.integrationBranch}`,
79
+ fix: "set `git.integrationBranch` (e.g. develop) ≠ `git.productionBranch` (main) in .vibeops.json",
80
+ });
81
+ const def = await remoteDefaultBranch(cwd, git.remote);
82
+ checks.push({
83
+ id: "default-branch",
84
+ ok: def === git.integrationBranch,
85
+ detail: def === null
86
+ ? `could not read the default branch of ${git.remote}`
87
+ : `${git.remote} default branch is ${def}`,
88
+ fix: git.host === "gitlab"
89
+ ? `glab api --method PUT projects/:id --raw-field default_branch=${git.integrationBranch}`
90
+ : `gh repo edit --default-branch ${git.integrationBranch}`,
91
+ });
92
+ if (cfg.crew !== null) {
93
+ const source = await readTextOrNull(join(cwd, ".personas", cfg.crew, "SOURCE"));
94
+ checks.push({
95
+ id: "personas",
96
+ ok: source !== null,
97
+ detail: source !== null ? `.personas/${cfg.crew}/SOURCE present` : `.personas/${cfg.crew}/SOURCE missing`,
98
+ fix: `install the ${cfg.crew} crew (it writes .personas/${cfg.crew}/ and the AGENTS.md section)`,
99
+ });
100
+ const agents = await readTextOrNull(join(cwd, "AGENTS.md"));
101
+ const marker = `<!-- getpersona:${cfg.crew} -->`;
102
+ const has = agents !== null && agents.includes(marker);
103
+ checks.push({
104
+ id: "agents-contract",
105
+ ok: has,
106
+ detail: has ? `AGENTS.md has the ${cfg.crew} section` : `AGENTS.md lacks ${marker}`,
107
+ fix: `install the ${cfg.crew} crew (AGENTS.md section between ${marker} markers)`,
108
+ });
109
+ }
110
+ return checks;
111
+ }
112
+ /**
113
+ * Run the configured checks and print failures. Returns false only in `enforce` mode with a
114
+ * failing check (the caller stops); `warn` prints and continues; `off` skips.
115
+ */
116
+ export async function preflightBaseline(cwd, git, command) {
117
+ let cfg;
118
+ try {
119
+ cfg = await readBaselineConfig(cwd);
120
+ }
121
+ catch (e) {
122
+ log.error(e instanceof Error ? e.message : String(e));
123
+ return false;
124
+ }
125
+ if (cfg.mode === "off")
126
+ return true;
127
+ const failed = (await runBaselineChecks(cwd, git, cfg)).filter((c) => !c.ok);
128
+ if (failed.length === 0)
129
+ return true;
130
+ const enforce = cfg.mode === "enforce";
131
+ for (const c of failed) {
132
+ const line = `baseline ${c.id}: ${c.detail}`;
133
+ if (enforce)
134
+ log.error(line);
135
+ else
136
+ log.warn(line);
137
+ if (c.fix)
138
+ log.info(dim(` fix: ${c.fix}`));
139
+ }
140
+ if (enforce) {
141
+ log.info(dim(`${command} stopped (.vibeops.json baseline.mode: enforce).`));
142
+ return false;
143
+ }
144
+ return true;
145
+ }
146
+ /** Read-only summary for `vibeops status` (never throws). */
147
+ export async function summarizeBaseline(cwd, git) {
148
+ let cfg = BASELINE_DEFAULTS;
149
+ let configError = null;
150
+ try {
151
+ cfg = await readBaselineConfig(cwd);
152
+ }
153
+ catch (e) {
154
+ configError = e instanceof Error ? e.message : String(e);
155
+ }
156
+ const checks = await runBaselineChecks(cwd, git, cfg);
157
+ let crewCommit = null;
158
+ if (cfg.crew !== null) {
159
+ const source = await readTextOrNull(join(cwd, ".personas", cfg.crew, "SOURCE"));
160
+ crewCommit = source === null ? null : (/^commit:\s*(\S+)/m.exec(source)?.[1] ?? null);
161
+ }
162
+ let otherBranches = null;
163
+ try {
164
+ const { stdout } = await runGit(cwd, ["ls-remote", "--heads", git.remote]);
165
+ otherBranches = stdout
166
+ .split("\n")
167
+ .map((l) => l.split("\t")[1]?.replace(/^refs\/heads\//, "") ?? "")
168
+ .filter((b) => b.length > 0 &&
169
+ b !== git.integrationBranch &&
170
+ b !== git.productionBranch &&
171
+ !b.startsWith("task/") &&
172
+ !b.startsWith("sync/"));
173
+ }
174
+ catch {
175
+ otherBranches = null;
176
+ }
177
+ return { mode: cfg.mode, crew: cfg.crew, crewCommit, checks, otherBranches, configError };
178
+ }
@@ -0,0 +1,64 @@
1
+ import { fetchBaseBranch } from "./merge-base-freshness.js";
2
+ import { gitIsAncestor, runGit } from "./git.js";
3
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
4
+ /**
5
+ * Tag the release merge commit `v<package.json version>` and push the tag, so "merged into
6
+ * production" and "tagged for publishing" are one step. Refuses when the commit is not on the
7
+ * freshly fetched `<remote>/<production>`, when package.json has no plain semver `version`,
8
+ * or when the tag already exists locally or on the remote (tags are never moved).
9
+ */
10
+ export async function tagReleaseCommit(opts) {
11
+ const { cwd, remote, productionBranch, commit } = opts;
12
+ let prodRef;
13
+ try {
14
+ prodRef = await fetchBaseBranch(cwd, remote, productionBranch);
15
+ }
16
+ catch (e) {
17
+ return { ok: false, message: `could not fetch ${remote}/${productionBranch}: ${firstLine(e)}` };
18
+ }
19
+ if (!(await gitIsAncestor(cwd, commit, prodRef))) {
20
+ return { ok: false, message: `${short(commit)} is not on ${remote}/${productionBranch}` };
21
+ }
22
+ let version;
23
+ try {
24
+ const { stdout } = await runGit(cwd, ["show", `${commit}:package.json`]);
25
+ version = JSON.parse(stdout).version;
26
+ }
27
+ catch {
28
+ return { ok: false, message: `no readable package.json at ${short(commit)} to take the version from` };
29
+ }
30
+ if (typeof version !== "string" || !SEMVER_RE.test(version)) {
31
+ return { ok: false, message: `package.json version at ${short(commit)} is not a semver string` };
32
+ }
33
+ const tag = `v${version}`;
34
+ try {
35
+ await runGit(cwd, ["rev-parse", "-q", "--verify", `refs/tags/${tag}`]);
36
+ return { ok: false, message: `tag ${tag} already exists locally — tags are not moved` };
37
+ }
38
+ catch {
39
+ // absent locally: good
40
+ }
41
+ try {
42
+ const { stdout } = await runGit(cwd, ["ls-remote", "--tags", remote, `refs/tags/${tag}`]);
43
+ if (stdout.trim().length > 0) {
44
+ return { ok: false, message: `tag ${tag} already exists on ${remote} — tags are not moved` };
45
+ }
46
+ }
47
+ catch (e) {
48
+ return { ok: false, message: `could not list tags on ${remote}: ${firstLine(e)}` };
49
+ }
50
+ try {
51
+ await runGit(cwd, ["tag", tag, commit]);
52
+ await runGit(cwd, ["push", remote, `refs/tags/${tag}`]);
53
+ }
54
+ catch (e) {
55
+ return { ok: false, message: `could not create / push ${tag}: ${firstLine(e)}` };
56
+ }
57
+ return { ok: true, tag, commit };
58
+ }
59
+ function short(sha) {
60
+ return sha.slice(0, 7);
61
+ }
62
+ function firstLine(e) {
63
+ return (e instanceof Error ? e.message : String(e)).split("\n")[0];
64
+ }
@@ -206,7 +206,7 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
206
206
  log.info(dim("Updating open MR/PR (--allow-open-mr)."));
207
207
  }
208
208
  else {
209
- gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
209
+ gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd, remote);
210
210
  gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
211
211
  await commitReshipMetadata({
212
212
  cwd,
@@ -1,8 +1,9 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "./git-config.js";
3
- import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitDeleteBranch, gitFetchRemote, gitHeadCommit, gitMergeRemoteBranch, gitMergeInProgress, gitFetch, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, listUnmergedRelPaths, readGitInfo, } from "./git.js";
3
+ import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitDeleteBranch, gitFetchRemote, gitMergeRemoteBranch, gitMergeInProgress, gitFetch, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, listUnmergedRelPaths, readGitInfo, runGit, } from "./git.js";
4
4
  import { fastForwardLocalIntegration, leaveTaskBranch, logLocalIntegrationUpdate, resolveIntegrationBaseRef, } from "./git-worktree.js";
5
5
  import { dim, log } from "./logger.js";
6
+ import { fetchBaseBranch } from "./merge-base-freshness.js";
6
7
  import { findMergeRequestByBranches } from "./pr-create.js";
7
8
  import { normalizeTaskRef } from "./resolve-task.js";
8
9
  import { getTaskMergeRequestLifecycle, resolveGitHostForCwd, } from "./task-effective-status.js";
@@ -234,11 +235,26 @@ export async function assertMergeRequestNotOpen(cwd, taskFile, allowOpenMr) {
234
235
  log.error("Current MR/PR is still open. Merge or close it first, or rerun with --allow-open-mr.");
235
236
  return false;
236
237
  }
237
- export async function refreshGitContextBaseCommit(taskFile, gitCtx, cwd) {
238
- const head = await gitHeadCommit(cwd);
239
- if (head === null || head.length === 0)
238
+ /**
239
+ * Base Commit of a new PR cycle: where this cycle starts on the base branch — the merge base
240
+ * of the task branch and the freshly fetched `<remote>/<base>` (the base tip after the
241
+ * integration merge; the fork point with `--no-integrate`). Not the task branch HEAD, which
242
+ * after the follow-up commit is the TASK's own work. Fetch / read failure keeps the previous
243
+ * value (with a warning) rather than recording a wrong one.
244
+ */
245
+ export async function refreshGitContextBaseCommit(taskFile, gitCtx, cwd, remote) {
246
+ try {
247
+ const baseRef = await fetchBaseBranch(cwd, remote, gitCtx.baseBranch);
248
+ const { stdout } = await runGit(cwd, ["merge-base", "HEAD", baseRef]);
249
+ const base = stdout.trim();
250
+ if (base.length === 0)
251
+ return gitCtx;
252
+ return { ...gitCtx, baseCommit: base };
253
+ }
254
+ catch {
255
+ log.warn(`Could not read ${remote}/${gitCtx.baseBranch} — Base Commit left at ${gitCtx.baseCommit.slice(0, 7)}.`);
240
256
  return gitCtx;
241
- return { ...gitCtx, baseCommit: head };
257
+ }
242
258
  }
243
259
  export async function prepareGitContextForReship(cwd, taskFile, gitCtx) {
244
260
  const previous = [...(gitCtx.previousMergeRequestUrls ?? [])];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "3.1.8",
3
+ "version": "3.2.0",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",
@@ -62,7 +62,7 @@
62
62
  "devDependencies": {
63
63
  "@eslint/js": "10.0.1",
64
64
  "@types/node": "^20.14.0",
65
- "eslint": "10.11.0",
65
+ "eslint": "10.12.0",
66
66
  "tsx": "^4.23.15",
67
67
  "typescript": "^5.5.4",
68
68
  "typescript-eslint": "8.71.0"