@goodtek/vibeops 3.1.6 → 3.1.7

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,14 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.1.7 - 2026-10-17
8
+
9
+ ### Fixed
10
+
11
+ - **`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).
12
+ - **`-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).
13
+ - **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).
14
+
7
15
  ## 3.1.6 - 2026-10-17
8
16
 
9
17
  ### Fixed
@@ -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. */
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);
@@ -7,11 +7,17 @@ import { gitIsAncestor, runGit } from "./git.js";
7
7
  * into the base since; a squash merge then lands that revert. Fetches the base
8
8
  * branch and the head commit first; any fetch / read failure fails closed.
9
9
  */
10
+ /** Fetch `<remote>/<baseBranch>` into its remote-tracking ref and return that ref. Throws on failure. */
11
+ export async function fetchBaseBranch(cwd, remote, baseBranch) {
12
+ const baseRef = `refs/remotes/${remote}/${baseBranch}`;
13
+ await runGit(cwd, ["fetch", "--no-tags", remote, `+refs/heads/${baseBranch}:${baseRef}`]);
14
+ return baseRef;
15
+ }
10
16
  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,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.7",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",