@goodtek/vibeops 3.1.6 → 3.1.8

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,21 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.1.8 - 2026-10-17
8
+
9
+ ### Changed
10
+
11
+ - **Time values follow official documentation** (D-009, TASK-033). `merge.waitTimeoutSeconds` default 900 → 21600 (GitHub-hosted job limit; max 432000, the self-hosted limit) — the wait still ends as soon as checks finish. `merge.emptyRollupGraceSeconds` default 30 → 0: name CI outside GitHub Actions in `requiredChecks`. `task merge` / `task release` confirm the merge with one read instead of polling (the merge APIs answer synchronously). `scripts/npm-publish.sh` waits for the registry with back-off and jitter within a few-minute budget.
12
+ - **`vibeops init` makes the integration branch the default branch** of a repository it creates on GitHub (`gh repo edit --default-branch`) or GitLab (project API `default_branch`), so PRs and clones start from `develop`. Existing repositories are not changed; on failure the manual command is printed (TASK-035).
13
+
14
+ ## 3.1.7 - 2026-10-17
15
+
16
+ ### Fixed
17
+
18
+ - **`task ship --new-cycle` missed work already committed on the task branch** ("Nothing to do — merged with no new changes"). With no uncommitted files it now compares the branch with the fetched `<remote>/<base>` by content (earlier cycles were squash-merged, so commit counts would mislead) and opens the new PR for what is not merged yet (TASK-032).
19
+ - **`-m` was ignored in a new PR cycle**, so the commit used the TASK title and dropped trailers. The new cycle now resolves the message like a normal ship (body and trailers kept); `-m` with nothing to commit warns instead of making an empty commit (TASK-032).
20
+ - **PR body listed other TASKs' changes** after the base branch was merged into the task branch: the Commits / Files lists came from the TASK's Base Commit. They are now computed against the freshly fetched `<remote>/<base>` (commits without merges, files from the merge base); the Base Commit is only a fallback when the fetch fails (TASK-031).
21
+
7
22
  ## 3.1.6 - 2026-10-17
8
23
 
9
24
  ### Fixed
package/README.md CHANGED
@@ -141,6 +141,7 @@ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
141
141
  ## Flags (common)
142
142
 
143
143
  - **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--git-host github|gitlab`, `--allow-no-remote`, `--cwd`
144
+ When `init` creates the repository on GitHub / GitLab (interactive), it pushes `main` and the integration branch and makes the integration branch (`develop` with the GitFlow preset) the repository's default branch; an existing repository's default branch is not changed.
144
145
  - **`task add`**: `--dry-run`, `--non-interactive --idea "…"`
145
146
  - **`task del`**: `--dry-run`, `--force`, `--no-remote-delete`, `--no-close-mr`
146
147
  - **`task ship`**: `-m/--message`, `--new-cycle`, `--no-commit`, `--dry-run`, `--no-pr`, `--non-interactive`, `--allow-open-mr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`
@@ -220,8 +221,9 @@ Before calling `gh pr merge` / `glab mr merge`:
220
221
  - **GitLab:** the MR head pipeline must be `success` or `skipped`. `failed`, `canceled`, `manual` and unknown statuses refuse; `created` / `pending` / `running` / `scheduled` / … wait.
221
222
  - **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.
222
223
  - **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`.
223
- - **No checks at all → refuse** unless `allowNoChecks: true`. "No checks" means: empty rollup, no GitHub Actions run and no expected workflow for the head commit (GitLab: no pipeline). For CI outside GitHub Actions (commit statuses / check apps), the empty list is re-polled for `emptyRollupGraceSeconds` first; list such checks in `requiredChecks` so the gate waits for them by name.
224
- - **Waiting** polls the host (`gh pr view` + the Actions REST API) every `pollIntervalSeconds` (default 10 s, the default of `gh pr checks --watch`) until the state changes, bounded by `waitTimeoutSeconds` (default 900 s). Host read errors back off exponentially with jitter (capped at 60 s, GitHub's guidance for rate-limited clients).
224
+ - **No checks at all → refuse** unless `allowNoChecks: true`. "No checks" means: empty rollup, no GitHub Actions run and no expected workflow for the head commit (GitLab: no pipeline). For CI outside GitHub Actions (commit statuses / check apps), list such checks in `requiredChecks` so the gate waits for them by name (`emptyRollupGraceSeconds`, default 0, can re-poll an empty list first).
225
+ - **Waiting** polls the host (`gh pr view` + the Actions REST API) every `pollIntervalSeconds` (default 10 s, the default of `gh pr checks --watch`) until the state changes, bounded by `waitTimeoutSeconds` (default 21600 s, the GitHub-hosted job limit). Host read errors back off exponentially with jitter (capped at 60 s, GitHub's guidance for rate-limited clients).
226
+ - **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.
225
227
  - **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.
226
228
  - **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.
227
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).
@@ -233,9 +235,9 @@ Before calling `gh pr merge` / `glab mr merge`:
233
235
  "merge": {
234
236
  "requiredChecks": ["build", "lint*"],
235
237
  "releaseRequiredChecks": ["release smoke"],
236
- "waitTimeoutSeconds": 900,
238
+ "waitTimeoutSeconds": 21600,
237
239
  "pollIntervalSeconds": 10,
238
- "emptyRollupGraceSeconds": 30,
240
+ "emptyRollupGraceSeconds": 0,
239
241
  "allowNoChecks": false
240
242
  }
241
243
  }
@@ -245,9 +247,9 @@ Before calling `gh pr merge` / `glab mr merge`:
245
247
  |-----|---------|---------|---------|
246
248
  | `requiredChecks` | `[]` | array of non-empty strings | Checks that must be present and green for `task merge`. A missing one refuses: `required check "X" never ran`. |
247
249
  | `releaseRequiredChecks` | `[]` | array of non-empty strings | Same for `task release` (integration → production). Shown by `task release --dry-run`. |
248
- | `waitTimeoutSeconds` | `900` | integer 1–7200 | Max wait for pending checks. |
249
- | `pollIntervalSeconds` | `10` | integer 1–300, ≤ timeout | Poll interval while waiting (same default as `gh pr checks --watch`). |
250
- | `emptyRollupGraceSeconds` | `30` | integer 0–600 | Only when no GitHub Actions workflow is expected: how long an empty check list is re-polled before "no checks" applies (time for external CI to report). |
250
+ | `waitTimeoutSeconds` | `21600` | integer 1–432000 | Upper bound for pending checks. The wait ends as soon as every check finished; the bound only stops a check that never does. Default = the 6-hour job limit of GitHub-hosted runners; self-hosted runners allow up to 5 days ([GitHub Actions limits](https://docs.github.com/actions/reference/limits)). |
251
+ | `pollIntervalSeconds` | `10` | integer ≥ 1, ≤ timeout | Poll interval while waiting (same default as `gh pr checks --watch`). |
252
+ | `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. |
251
253
  | `allowNoChecks` | `false` | boolean | Merge a PR that has no checks at all (repo without CI). Prints one warning per such merge. |
252
254
 
253
255
  - Check names are exact (`CheckRun.name` / `StatusContext.context`) or a `*` glob (`"lint*"`, `"* · linux"`). Required checks are GitHub-only; GitLab gates on the head pipeline.
@@ -3,7 +3,7 @@ import { install, printReport } from "../bootstrap/installer.js";
3
3
  import { buildConfig, isVibeopsProject, readConfig, writeConfig } from "../lib/config.js";
4
4
  import { gitAddAll, gitBranchExists, gitCheckout, gitCommit, gitCreateBranch, gitInit, gitPullFastForwardOnly, gitPush, gitRemoteBranchExists, gitRemoteUrl, gitSetDefaultBranch, gitStatusPorcelain, hasAnyCommit, isGitRepository, } from "../lib/git.js";
5
5
  import { formatGitPolicySummary, askGitPolicy, parseGitPolicyArg, resolvePreset } from "../lib/git-policy.js";
6
- import { ensureOriginRemote } from "../lib/git-remote.js";
6
+ import { ensureOriginRemote, setRemoteDefaultBranch } from "../lib/git-remote.js";
7
7
  import { askInitClients, formatClientsList, parseClientsArg, } from "../lib/init-clients.js";
8
8
  import { askInput, askYesNo } from "../lib/inquirer-helpers.js";
9
9
  import { cyan, dim, green, log, yellow } from "../lib/logger.js";
@@ -272,6 +272,19 @@ export async function initCommand(options = {}) {
272
272
  await gitCheckout(projectRoot, gitConfig.integrationBranch);
273
273
  await gitPush(projectRoot, remoteName, gitConfig.integrationBranch, true);
274
274
  log.ok(`pushed ${gitConfig.integrationBranch} → ${remoteName}`);
275
+ // A repository init just created defaults to the integration branch, so PRs and
276
+ // clones start from it; an existing repository's setting is left to its owner.
277
+ if (remote.created === true) {
278
+ if (await setRemoteDefaultBranch(projectRoot, gitConfig.host, gitConfig.integrationBranch)) {
279
+ log.ok(`default branch → ${gitConfig.integrationBranch}`);
280
+ }
281
+ else {
282
+ log.warn(`Could not set the default branch to ${gitConfig.integrationBranch}.`);
283
+ log.info(dim(gitConfig.host === "gitlab"
284
+ ? ` glab api --method PUT projects/:id --raw-field default_branch=${gitConfig.integrationBranch}`
285
+ : ` gh repo edit --default-branch ${gitConfig.integrationBranch}`));
286
+ }
287
+ }
275
288
  }
276
289
  pushedBaseline = true;
277
290
  }
@@ -297,6 +297,8 @@ async function runNewCycleFromShip(ctx) {
297
297
  recreateBranch: options.recreateBranch,
298
298
  skipLlm: options.skipLlm,
299
299
  allowOpenMr: options.allowOpenMr,
300
+ message: options.message,
301
+ nonInteractive: options.nonInteractive,
300
302
  });
301
303
  }
302
304
  /** State/PR mismatch: Status is Shipped but no open/merged PR was found. */
@@ -145,10 +145,33 @@ async function createOnHost(cwd, remote, host, defaultBranch) {
145
145
  await runGit(cwd, ["remote", "rename", "origin", remote]);
146
146
  }
147
147
  }
148
- return { remote, url, host };
148
+ return { remote, url, host, created: true };
149
149
  }
150
150
  const url = await createGithubRepo(cwd, name);
151
- return { remote, url, host };
151
+ return { remote, url, host, created: true };
152
+ }
153
+ /**
154
+ * Make `branch` the host's default branch (official CLIs: `gh repo edit --default-branch`,
155
+ * cli.github.com/manual/gh_repo_edit; GitLab project API `default_branch` via `glab api`,
156
+ * docs.gitlab.com/api/projects/#edit-a-project; `:id` placeholder: docs.gitlab.com/cli/api/).
157
+ * Returns false (caller prints the manual step) when the CLI call fails.
158
+ */
159
+ export async function setRemoteDefaultBranch(cwd, host, branch) {
160
+ try {
161
+ if (host === "gitlab") {
162
+ await execFileAsync("glab", ["api", "--method", "PUT", "projects/:id", "--raw-field", `default_branch=${branch}`], { cwd, maxBuffer: 1024 * 1024 });
163
+ }
164
+ else {
165
+ await execFileAsync("gh", ["repo", "edit", "--default-branch", branch], {
166
+ cwd,
167
+ maxBuffer: 1024 * 1024,
168
+ });
169
+ }
170
+ return true;
171
+ }
172
+ catch {
173
+ return false;
174
+ }
152
175
  }
153
176
  /**
154
177
  * Ensures `origin` (or named remote) exists.
package/dist/lib/git.js CHANGED
@@ -490,8 +490,10 @@ export async function gitAllChangedFilesSinceTaskStart(baseCommit, cwd) {
490
490
  const all = Array.from(new Set([...workingTree, ...committed]));
491
491
  return { working, staged, untracked, committed, workingTree, all };
492
492
  }
493
- export async function gitLogOneline(cwd, range) {
493
+ export async function gitLogOneline(cwd, range, options = {}) {
494
494
  const args = ["log", "--oneline", "--no-decorate"];
495
+ if (options.noMerges === true)
496
+ args.push("--no-merges");
495
497
  if (typeof range === "string" && range.length > 0)
496
498
  args.push(range);
497
499
  const res = await tryGit(cwd, args);
@@ -1,4 +1,10 @@
1
1
  import { gitIsAncestor, runGit } from "./git.js";
2
+ /** Fetch `<remote>/<baseBranch>` into its remote-tracking ref and return that ref. Throws on failure. */
3
+ export async function fetchBaseBranch(cwd, remote, baseBranch) {
4
+ const baseRef = `refs/remotes/${remote}/${baseBranch}`;
5
+ await runGit(cwd, ["fetch", "--no-tags", remote, `+refs/heads/${baseBranch}:${baseRef}`]);
6
+ return baseRef;
7
+ }
2
8
  /**
3
9
  * The MR/PR head must contain the current tip of its base branch.
4
10
  *
@@ -11,7 +17,7 @@ export async function checkBaseInHead(opts) {
11
17
  const { cwd, remote, baseBranch, headSha } = opts;
12
18
  const baseRef = `refs/remotes/${remote}/${baseBranch}`;
13
19
  try {
14
- await runGit(cwd, ["fetch", "--no-tags", remote, `+refs/heads/${baseBranch}:${baseRef}`]);
20
+ await fetchBaseBranch(cwd, remote, baseBranch);
15
21
  if (!(await commitExists(cwd, headSha))) {
16
22
  await runGit(cwd, ["fetch", "--no-tags", remote, headSha]);
17
23
  }
@@ -1,20 +1,32 @@
1
1
  import { join } from "node:path";
2
2
  import { readTextOrNull } from "./filesystem.js";
3
3
  import { VIBEOPS_CONFIG_FILE } from "./paths.js";
4
+ /**
5
+ * Job execution limits of GitHub Actions (docs.github.com/actions/reference/limits):
6
+ * "up to 6 hours" per job on GitHub-hosted runners, "up to 5 days" on self-hosted ones.
7
+ * A pending check cannot legitimately outlast its job, so these bound the wait.
8
+ */
9
+ const GITHUB_HOSTED_JOB_LIMIT_SECONDS = 6 * 60 * 60;
10
+ const SELF_HOSTED_JOB_LIMIT_SECONDS = 5 * 24 * 60 * 60;
4
11
  export const MERGE_GATE_DEFAULTS = {
5
12
  requiredChecks: [],
6
13
  releaseRequiredChecks: [],
7
- waitTimeoutSeconds: 900,
14
+ // The wait ends as soon as the host reports every check finished; this only bounds a
15
+ // check that never finishes. Self-hosted runners: raise it up to the 5-day job limit.
16
+ waitTimeoutSeconds: GITHUB_HOSTED_JOB_LIMIT_SECONDS,
8
17
  // Same default as `gh pr checks --watch --interval` (cli.github.com/manual/gh_pr_checks).
9
18
  pollIntervalSeconds: 10,
10
- emptyRollupGraceSeconds: 30,
19
+ // No grace by default: CI outside GitHub Actions is awaited by name via `requiredChecks`
20
+ // (like required status checks in branch protection), not by guessing how long it takes
21
+ // to report.
22
+ emptyRollupGraceSeconds: 0,
11
23
  allowNoChecks: false,
12
24
  };
13
- /** Inclusive bounds for the numeric settings (seconds). */
25
+ /** Inclusive bounds for the numeric settings (seconds); cross-checks below keep them ≤ the wait. */
14
26
  export const MERGE_GATE_LIMITS = {
15
- waitTimeoutSeconds: { min: 1, max: 7_200 },
16
- pollIntervalSeconds: { min: 1, max: 300 },
17
- emptyRollupGraceSeconds: { min: 0, max: 600 },
27
+ waitTimeoutSeconds: { min: 1, max: SELF_HOSTED_JOB_LIMIT_SECONDS },
28
+ pollIntervalSeconds: { min: 1, max: SELF_HOSTED_JOB_LIMIT_SECONDS },
29
+ emptyRollupGraceSeconds: { min: 0, max: SELF_HOSTED_JOB_LIMIT_SECONDS },
18
30
  };
19
31
  const KNOWN_KEYS = new Set([
20
32
  "requiredChecks",
@@ -91,6 +103,9 @@ export function parseMergeConfig(raw) {
91
103
  }
92
104
  cfg.allowNoChecks = o.allowNoChecks;
93
105
  }
106
+ if (cfg.emptyRollupGraceSeconds > cfg.waitTimeoutSeconds) {
107
+ throw new MergeConfigError("`emptyRollupGraceSeconds` must not exceed `waitTimeoutSeconds`.");
108
+ }
94
109
  if (cfg.pollIntervalSeconds > cfg.waitTimeoutSeconds) {
95
110
  throw new MergeConfigError("`pollIntervalSeconds` must not exceed `waitTimeoutSeconds`.");
96
111
  }
@@ -156,7 +156,9 @@ export async function refreshCodexOAuthTokens(refreshToken) {
156
156
  }
157
157
  /**
158
158
  * Returns a usable access token, refreshing and persisting to auth.json when
159
- * the JWT is near expiry (same pattern as Hermes `resolve_codex_runtime_credentials`).
159
+ * the JWT is near expiry. `skewSeconds` is clock-skew leeway on the `exp` claim:
160
+ * RFC 7519 §4.1.4 allows "some small leeway, usually no more than a few minutes, to
161
+ * account for clock skew" (rfc-editor.org/rfc/rfc7519#section-4.1.4); 120 s stays within it.
160
162
  */
161
163
  export async function resolveCodexOAuthAccessToken(skewSeconds = 120) {
162
164
  const loaded = await readAuthPayload();
@@ -318,20 +318,6 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
318
318
  baseBranch: parsed.baseRefName ?? null,
319
319
  };
320
320
  }
321
- /** Poll host until MR/PR state is merged (or timeout). */
322
- export async function waitForMergeRequestMerged(cwd, host, url, options = {}) {
323
- const timeoutMs = options.timeoutMs ?? 120_000;
324
- const intervalMs = options.intervalMs ?? 2_000;
325
- const deadline = Date.now() + timeoutMs;
326
- while (Date.now() < deadline) {
327
- const details = await getMergeRequestDetails(cwd, host, url);
328
- if (details?.state === "merged" && details.mergedAt !== null) {
329
- return true;
330
- }
331
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
332
- }
333
- return false;
334
- }
335
321
  function mergeRequestReadinessFromDetails(details) {
336
322
  if (details === null)
337
323
  return null;
@@ -1,22 +1,21 @@
1
1
  import { mergeRequestLabel } from "./git-host.js";
2
- import { getMergeRequestDetails, waitForMergeRequestMerged, } from "./pr-create.js";
2
+ import { getMergeRequestDetails } from "./pr-create.js";
3
+ /**
4
+ * Read the MR/PR once after `gh pr merge` / `glab mr merge` returned. Both merge APIs
5
+ * answer synchronously — GitHub `200` with `merged: true`
6
+ * (docs.github.com/rest/pulls/pulls#merge-a-pull-request), GitLab the merge request with
7
+ * `state: "merged"` (docs.gitlab.com/api/merge_requests/#merge-a-merge-request; VibeOps
8
+ * merges with `--auto-merge=false`) — so there is nothing to poll for. Not merged here
9
+ * means the host queued it (merge queue / auto-merge): report, do not wait.
10
+ */
3
11
  export async function assertMergeRequestMerged(input) {
4
12
  const label = mergeRequestLabel(input.host);
5
- if (input.wait !== false) {
6
- const merged = await waitForMergeRequestMerged(input.cwd, input.host, input.url);
7
- if (!merged) {
8
- return {
9
- ok: false,
10
- message: `${label} merge did not complete (still open or auto-merge pending). Wait for Merged on the host, or rerun \`vibeops task merge\`.`,
11
- };
12
- }
13
- }
14
13
  const details = await getMergeRequestDetails(input.cwd, input.host, input.url);
15
14
  if (details?.state !== "merged" || details.mergedAt === null) {
16
15
  const state = details?.state ?? "unknown";
17
16
  return {
18
17
  ok: false,
19
- message: `${label} is ${state}, not merged into ${input.integrationBranch}. Do not run task sync until the host shows Merged.`,
18
+ message: `${label} is ${state}, not merged into ${input.integrationBranch} (merge queue or auto-merge pending?). Wait for Merged on the host before task sync, or rerun \`vibeops task merge\`.`,
20
19
  };
21
20
  }
22
21
  return { ok: true };
@@ -1,17 +1,33 @@
1
1
  import { resolve } from "node:path";
2
2
  import { readText } from "./filesystem.js";
3
- import { listWorkingTreeRelPaths } from "./git.js";
3
+ import { listWorkingTreeRelPaths, runGit } from "./git.js";
4
4
  import { bold, cyan, dim, green, log, yellow } from "./logger.js";
5
+ import { fetchBaseBranch } from "./merge-base-freshness.js";
5
6
  import { projectPaths } from "./paths.js";
6
7
  import { prNumberFromUrl } from "./pr-create.js";
7
8
  import { relPath } from "./task-context.js";
8
- import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, } from "./task-git-commit.js";
9
+ import { resolveShipCommitMessage } from "./task-commit-msg.js";
10
+ import { commitDirtyWorkingTree, docsCommitMessageFor } from "./task-git-commit.js";
9
11
  import { finishTaskWithPullRequest } from "./task-push-pr.js";
10
12
  import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "./task-ship-llm.js";
11
13
  import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "./task-reship.js";
12
14
  import { hasNonEmptySection, parseTaskFilename, readGitContext } from "./task.js";
13
15
  import { prRefLabel } from "./task-ship-state.js";
14
16
  import { runUnderTaskLock } from "./task-lock.js";
17
+ /**
18
+ * Files this cycle would change on the base: after integrating `<remote>/<base>` the tree
19
+ * difference to the base is exactly the work not merged yet (earlier cycles' commits were
20
+ * squash-merged, so counting commits would report them again). With `--no-integrate` the
21
+ * branch may lack newer base changes, so compare against the merge base instead.
22
+ */
23
+ async function unmergedFiles(cwd, remote, baseBranch, integrated) {
24
+ const baseRef = await fetchBaseBranch(cwd, remote, baseBranch);
25
+ const { stdout } = await runGit(cwd, integrated ? ["diff", "--name-only", baseRef, "HEAD"] : ["diff", "--name-only", `${baseRef}...HEAD`]);
26
+ return stdout
27
+ .split("\n")
28
+ .map((l) => l.trim())
29
+ .filter((l) => l.length > 0);
30
+ }
15
31
  async function commitReshipMetadata(opts) {
16
32
  if (opts.dryRun) {
17
33
  log.info(dim(" would commit new-cycle metadata (Status stays Shipped)"));
@@ -147,13 +163,39 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
147
163
  process.exitCode = 1;
148
164
  return;
149
165
  }
166
+ const parts = parseTaskFilename(taskFile);
150
167
  const dirtyBefore = await listWorkingTreeRelPaths(cwd);
151
- if (dirtyBefore.length === 0) {
152
- log.info(`Nothing to do — ${taskId} is merged with no new changes. Create a new TASK for new work (${cyan("vibeops task add")}).`);
153
- return;
168
+ if (dirtyBefore.length > 0) {
169
+ const message = await resolveShipCommitMessage({
170
+ cwd,
171
+ taskId: parts.id,
172
+ title: meta.title,
173
+ provided: options.message,
174
+ nonInteractive: options.nonInteractive === true,
175
+ });
176
+ await commitDirtyWorkingTree(cwd, message, false);
177
+ }
178
+ else {
179
+ // Work may already be committed on the task branch (not merged into the base yet).
180
+ let pending;
181
+ try {
182
+ pending = await unmergedFiles(cwd, remote, integrationBranch, options.noIntegrate !== true);
183
+ }
184
+ catch (e) {
185
+ const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
186
+ log.error(`Could not compare ${taskId}'s branch with ${remote}/${integrationBranch}: ${msg}`);
187
+ process.exitCode = 1;
188
+ return;
189
+ }
190
+ if (pending.length === 0) {
191
+ log.info(`Nothing to do — ${taskId} is merged with no new changes. Create a new TASK for new work (${cyan("vibeops task add")}).`);
192
+ return;
193
+ }
194
+ log.info(`No uncommitted changes; ${pending.length} file(s) committed on ${initialCtx.taskBranch} are not on ${remote}/${integrationBranch} yet — opening the PR for them.`);
195
+ if (options.message?.trim()) {
196
+ log.warn("-m/--message not used: there is nothing new to commit (existing commits keep their messages).");
197
+ }
154
198
  }
155
- const parts = parseTaskFilename(taskFile);
156
- await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
157
199
  let gitCtx = await readGitContext(taskFile);
158
200
  if (gitCtx === null) {
159
201
  log.error("Git Context missing after commit.");
@@ -5,6 +5,7 @@ import { detectGitHost, mergeRequestLabel } from "./git-host.js";
5
5
  import { requireGitConfig } from "./git-config.js";
6
6
  import { gitCommitsAhead, gitDiffNameOnly, gitLogOneline, gitPush, gitRemoteUrl, readGitInfo, } from "./git.js";
7
7
  import { dim, log } from "./logger.js";
8
+ import { fetchBaseBranch } from "./merge-base-freshness.js";
8
9
  import { createMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
9
10
  import { fallbackTaskPr, generateTaskPrWithLlm } from "./task-pr-llm.js";
10
11
  import { readGitContext, readTaskFile } from "./task.js";
@@ -15,9 +16,24 @@ function mergeRequestAlreadyExistsMessage(msg) {
15
16
  lower.includes("merge request already exists") ||
16
17
  lower.includes("pull request already exists"));
17
18
  }
18
- async function buildDiffSummary(cwd, gitCtx) {
19
- const files = await gitDiffNameOnly(cwd, `${gitCtx.baseCommit}..HEAD`);
20
- const commits = await gitLogOneline(cwd, `${gitCtx.baseCommit}..HEAD`);
19
+ /**
20
+ * What the MR/PR changes is measured from the freshly fetched `<remote>/<base>`, not the
21
+ * TASK's Base Commit: after the base is merged into the task branch (required by the
22
+ * stale-base check), `<Base Commit>..HEAD` also contains other TASKs' commits. Falls back
23
+ * to the Base Commit only when the base branch cannot be fetched.
24
+ */
25
+ async function resolveDiffBase(cwd, remote, gitCtx) {
26
+ try {
27
+ return await fetchBaseBranch(cwd, remote, gitCtx.baseBranch);
28
+ }
29
+ catch {
30
+ log.warn(`Could not fetch ${remote}/${gitCtx.baseBranch} — listing changes since the TASK's Base Commit.`);
31
+ return gitCtx.baseCommit;
32
+ }
33
+ }
34
+ async function buildDiffSummary(cwd, base) {
35
+ const files = await gitDiffNameOnly(cwd, `${base}...HEAD`);
36
+ const commits = await gitLogOneline(cwd, `${base}..HEAD`, { noMerges: true });
21
37
  const lines = [];
22
38
  if (commits.length > 0) {
23
39
  lines.push("Commits:");
@@ -63,7 +79,8 @@ export async function finishTaskWithPullRequest(opts) {
63
79
  if (git.branch !== gitCtx.taskBranch) {
64
80
  log.warn(`Current branch is ${git.branch ?? "(detached)"}, not ${gitCtx.taskBranch}. Push may fail.`);
65
81
  }
66
- const ahead = await gitCommitsAhead(opts.cwd, gitCtx.baseCommit, "HEAD");
82
+ const diffBase = await resolveDiffBase(opts.cwd, gitCfg.remote, gitCtx);
83
+ const ahead = await gitCommitsAhead(opts.cwd, diffBase, "HEAD");
67
84
  if (ahead === 0) {
68
85
  log.warn("No commits ahead of base — pushing branch anyway.");
69
86
  }
@@ -113,7 +130,7 @@ export async function finishTaskWithPullRequest(opts) {
113
130
  const preference = config?.llm?.provider ?? "auto";
114
131
  const meta = await readTaskFile(opts.taskFile);
115
132
  const taskBody = await readText(opts.taskFile);
116
- const diffSummary = await buildDiffSummary(opts.cwd, gitCtx);
133
+ const diffSummary = await buildDiffSummary(opts.cwd, diffBase);
117
134
  const prInput = {
118
135
  taskId: meta.id,
119
136
  title: meta.title,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "3.1.6",
3
+ "version": "3.1.8",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",