@goodtek/vibeops 2.2.0 → 2.3.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,23 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.3.0 - 2026-06-09
8
+
9
+ ### Added
10
+
11
+ - **`vibeops task del [TASK-NNN]`** — cancel a TASK before merge: delete `docs/tasks/*.md`, close open MR/PR (unless `--no-close-mr`), delete local/remote `task/*` branch. Refuses when the MR is **merged** into integration.
12
+
13
+ ### Flags
14
+
15
+ - `--dry-run`, `--force` (dirty working tree), `--no-remote-delete`, `--no-close-mr`, `--cwd`
16
+
17
+ ## 2.2.1 - 2026-06-09
18
+
19
+ ### Fixed
20
+
21
+ - **`vibeops task reship --allow-open-mr`**: push then reuse the open MR/PR instead of failing on duplicate `gh pr create`. Skips reship metadata archive when updating the same PR.
22
+ - **`finishTaskWithPullRequest`**: push before checking for an open MR so follow-up commits reach the remote.
23
+
7
24
  ## 2.2.0 - 2026-06-09
8
25
 
9
26
  ### Added
package/README.md CHANGED
@@ -13,6 +13,7 @@ VibeOps bootstraps an **agent-friendly repo**, starts numbered **TASK** files on
13
13
  |---------|---------|
14
14
  | `vibeops init` | Core docs + agent packs (cursor / claude / codex) |
15
15
  | `vibeops task add` | New `TASK-NNN` file + task branch |
16
+ | `vibeops task del` | Cancel TASK before merge (md + branch + close open MR) |
16
17
  | `vibeops task ship` | LLM summary, commit, push, open MR/PR (Status → **Shipped**) |
17
18
  | `vibeops task reship` | Shipped follow-up: move uncommitted work to task branch, commit, new MR/PR (Status stays Shipped) |
18
19
  | `vibeops task merge` | Merge TASK MR/PR into integration branch (default: squash) |
@@ -97,6 +98,7 @@ pnpm install && pnpm build && pnpm smoke
97
98
 
98
99
  - **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--allow-no-remote`, `--cwd`
99
100
  - **`task add`**: `--dry-run`, `--non-interactive --idea "…"`
101
+ - **`task del`**: `--dry-run`, `--force`, `--no-remote-delete`, `--no-close-mr`
100
102
  - **`task ship`**: `--dry-run`, `--no-pr`
101
103
  - **`task reship`**: `--dry-run`, `--no-pr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`, `--allow-open-mr`
102
104
  - **`task merge`**: `--dry-run`, `--merge`, `--rebase`
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { initCommand } from "./commands/init.js";
5
5
  import { llmConnectCommand, llmStatusCommand, llmUseCommand } from "./commands/llm.js";
6
6
  import { statusCommand } from "./commands/status.js";
7
7
  import { taskAddCommand } from "./commands/task-add.js";
8
+ import { taskDelCommand } from "./commands/task-del.js";
8
9
  import { taskMergeCommand } from "./commands/task-merge.js";
9
10
  import { taskReleaseCommand } from "./commands/task-release.js";
10
11
  import { taskShipCommand } from "./commands/task-ship.js";
@@ -58,7 +59,9 @@ program
58
59
  gitHost: opts.gitHost,
59
60
  });
60
61
  });
61
- const task = program.command("task").description("TASK lifecycle (add · ship · reship · merge · sync · release)");
62
+ const task = program
63
+ .command("task")
64
+ .description("TASK lifecycle (add · del · ship · reship · merge · sync · release)");
62
65
  task
63
66
  .command("add")
64
67
  .description("Create next TASK-NNN, task branch, In Progress")
@@ -74,6 +77,23 @@ task
74
77
  cwd: opts.cwd,
75
78
  });
76
79
  });
80
+ task
81
+ .command("del [taskRef]")
82
+ .description("Delete TASK before merge: remove md, close open MR/PR, delete task branch")
83
+ .option("--dry-run", "Plan only")
84
+ .option("--force", "Proceed with a dirty working tree (changes may remain)")
85
+ .option("--no-remote-delete", "Keep the task branch on the remote")
86
+ .option("--no-close-mr", "Do not close an open MR/PR")
87
+ .option("--cwd <path>", "Target directory")
88
+ .action(async (taskRef, opts) => {
89
+ await taskDelCommand(taskRef, {
90
+ dryRun: Boolean(opts.dryRun),
91
+ force: Boolean(opts.force),
92
+ noRemoteDelete: Boolean(opts.noRemoteDelete),
93
+ noCloseMr: Boolean(opts.noCloseMr),
94
+ cwd: opts.cwd,
95
+ });
96
+ });
77
97
  task
78
98
  .command("ship [taskRef]")
79
99
  .description("Submit TASK: LLM summary, commit, push, open MR/PR (Status → Shipped)")
@@ -95,7 +115,7 @@ task
95
115
  .option("--no-integrate", "Skip merging integration branch into task branch")
96
116
  .option("--recreate-branch", "Create task branch from integration instead of reusing local/remote ref")
97
117
  .option("--skip-llm", "Do not run LLM for Result / Test Result")
98
- .option("--allow-open-mr", "Allow reship while the recorded MR is still open")
118
+ .option("--allow-open-mr", "Update the open MR/PR with new commits (merge before reship for a new PR)")
99
119
  .option("--cwd <path>", "Target directory")
100
120
  .action(async (taskRef, opts) => {
101
121
  await taskReshipCommand(taskRef, {
@@ -0,0 +1,104 @@
1
+ import { resolve } from "node:path";
2
+ import { bold, cyan, dim, log } from "../lib/logger.js";
3
+ import { projectPaths } from "../lib/paths.js";
4
+ import { relPath } from "../lib/task-context.js";
5
+ import { assertTaskDeletable, assertWorkingTreeForDel, closeOpenTaskMergeRequestIfNeeded, deleteTaskBranches, deleteTaskMarkdown, loadGitConfigForDel, resolveDelTarget, taskDelNotFoundMessage, validateTaskBranchForDel, } from "../lib/task-del.js";
6
+ import { readGitContext, statusDisplay } from "../lib/task.js";
7
+ export async function taskDelCommand(taskRef, options = {}) {
8
+ const cwd = resolve(options.cwd ?? process.cwd());
9
+ const dryRun = options.dryRun === true;
10
+ const paths = projectPaths(cwd);
11
+ const target = await resolveDelTarget(paths, cwd, taskRef);
12
+ if (target === null) {
13
+ log.error(taskDelNotFoundMessage(taskRef));
14
+ process.exitCode = 1;
15
+ return;
16
+ }
17
+ const { taskId, taskFile, meta, taskBranch } = target;
18
+ const relFile = relPath(cwd, taskFile);
19
+ log.info(bold(`vibeops task del ${taskId}`));
20
+ log.info(` ${dim("file")} ${relFile}`);
21
+ log.info(` ${dim("branch")} ${taskBranch}`);
22
+ log.info(` ${dim("status")} ${statusDisplay(meta.status)}`);
23
+ log.blank();
24
+ const deletable = await assertTaskDeletable(cwd, target);
25
+ if (!deletable.ok) {
26
+ log.error(deletable.reason);
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+ const gitCfg = dryRun
31
+ ? await loadGitConfigForDel(cwd).catch(() => null)
32
+ : await loadGitConfigForDel(cwd);
33
+ if (!dryRun && gitCfg === null) {
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ const integrationBranch = gitCfg?.integrationBranch ?? "develop";
38
+ const productionBranch = gitCfg?.productionBranch ?? "main";
39
+ const remote = gitCfg?.remote ?? "origin";
40
+ if (!validateTaskBranchForDel(taskBranch, integrationBranch, productionBranch)) {
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ if (dryRun) {
45
+ log.info(bold("dry-run — would:"));
46
+ const ctx = await readGitContext(taskFile);
47
+ if (ctx !== null) {
48
+ await closeOpenTaskMergeRequestIfNeeded({
49
+ cwd,
50
+ taskFile,
51
+ taskBranch,
52
+ baseBranch: ctx.baseBranch,
53
+ dryRun: true,
54
+ skipClose: options.noCloseMr === true,
55
+ });
56
+ }
57
+ log.info(` · git switch ${integrationBranch} (if on ${taskBranch})`);
58
+ await deleteTaskBranches({
59
+ cwd,
60
+ taskBranch,
61
+ integrationBranch,
62
+ remote,
63
+ dryRun: true,
64
+ noRemoteDelete: options.noRemoteDelete === true,
65
+ });
66
+ await deleteTaskMarkdown(taskFile, true);
67
+ log.blank();
68
+ log.info(`Next: ${cyan("vibeops task add")}`);
69
+ return;
70
+ }
71
+ if (!(await assertWorkingTreeForDel(cwd, options.force === true))) {
72
+ process.exitCode = 1;
73
+ return;
74
+ }
75
+ const ctx = await readGitContext(taskFile);
76
+ if (ctx === null) {
77
+ log.error("Git Context missing.");
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ await closeOpenTaskMergeRequestIfNeeded({
82
+ cwd,
83
+ taskFile,
84
+ taskBranch,
85
+ baseBranch: ctx.baseBranch,
86
+ dryRun: false,
87
+ skipClose: options.noCloseMr === true,
88
+ });
89
+ const branchesOk = await deleteTaskBranches({
90
+ cwd,
91
+ taskBranch,
92
+ integrationBranch,
93
+ remote,
94
+ dryRun: false,
95
+ noRemoteDelete: options.noRemoteDelete === true,
96
+ });
97
+ if (!branchesOk) {
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ await deleteTaskMarkdown(taskFile, false);
102
+ log.blank();
103
+ log.info(`Next: ${cyan("vibeops task add")}`);
104
+ }
@@ -44,7 +44,8 @@ export async function taskReshipCommand(taskRef, options = {}) {
44
44
  }
45
45
  const integrationBranch = gitCfg?.integrationBranch ?? "develop";
46
46
  const remote = gitCfg?.remote ?? "origin";
47
- if (!dryRun && !(await assertMergeRequestNotOpen(cwd, taskFile, options.allowOpenMr === true))) {
47
+ const updateOpenMr = options.allowOpenMr === true;
48
+ if (!dryRun && !(await assertMergeRequestNotOpen(cwd, taskFile, updateOpenMr))) {
48
49
  process.exitCode = 1;
49
50
  return;
50
51
  }
@@ -113,7 +114,12 @@ export async function taskReshipCommand(taskRef, options = {}) {
113
114
  log.blank();
114
115
  if (dryRun) {
115
116
  log.info(bold("dry-run — would also:"));
116
- log.info(" · feat commit + reship metadata, then push once and open MR/PR (unless --no-pr)");
117
+ if (updateOpenMr) {
118
+ log.info(" · feat commit, push, update open MR/PR (unless --no-pr)");
119
+ }
120
+ else {
121
+ log.info(" · feat commit + reship metadata, then push once and open MR/PR (unless --no-pr)");
122
+ }
117
123
  log.blank();
118
124
  log.info(`Next: ${cyan("vibeops task merge")}, then optional ${cyan("task sync")}`);
119
125
  return;
@@ -137,19 +143,24 @@ export async function taskReshipCommand(taskRef, options = {}) {
137
143
  process.exitCode = 1;
138
144
  return;
139
145
  }
140
- gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
141
- gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
142
- await commitReshipMetadata({
143
- cwd,
144
- taskFile,
145
- taskId: parts.id,
146
- dryRun: false,
147
- });
146
+ if (updateOpenMr) {
147
+ log.info(dim("Updating open MR/PR (--allow-open-mr)."));
148
+ }
149
+ else {
150
+ gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
151
+ gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
152
+ await commitReshipMetadata({
153
+ cwd,
154
+ taskFile,
155
+ taskId: parts.id,
156
+ dryRun: false,
157
+ });
158
+ }
148
159
  const prResult = await finishTaskWithPullRequest({
149
160
  cwd,
150
161
  taskFile,
151
162
  skipPr: options.noPr === true,
152
- forceNewMergeRequest: true,
163
+ forceNewMergeRequest: !updateOpenMr,
153
164
  });
154
165
  if (!prResult.ok) {
155
166
  log.error("Reship failed — fix push/MR, then rerun task reship.");
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, relative } from "node:path";
3
3
  export async function pathExists(path) {
4
4
  try {
@@ -45,6 +45,9 @@ export async function writeText(path, contents) {
45
45
  await ensureDir(dirname(path));
46
46
  await writeFile(path, contents, "utf-8");
47
47
  }
48
+ export async function deleteFile(path) {
49
+ await rm(path, { force: true });
50
+ }
48
51
  export async function walk(root) {
49
52
  const out = [];
50
53
  async function visit(dir) {
@@ -197,6 +197,25 @@ export async function getMergeRequestState(cwd, host, url) {
197
197
  return "unknown";
198
198
  }
199
199
  }
200
+ export async function closeMergeRequest(opts) {
201
+ const ref = prRefFromUrl(opts.url);
202
+ const label = mergeRequestLabel(opts.host);
203
+ if (opts.dryRun === true) {
204
+ log.info(`would close ${label} ${ref}`);
205
+ return;
206
+ }
207
+ if (opts.host === "gitlab") {
208
+ await execFileAsync("glab", ["mr", "close", ref], {
209
+ cwd: opts.cwd,
210
+ maxBuffer: 4 * 1024 * 1024,
211
+ });
212
+ return;
213
+ }
214
+ await execFileAsync("gh", ["pr", "close", ref], {
215
+ cwd: opts.cwd,
216
+ maxBuffer: 4 * 1024 * 1024,
217
+ });
218
+ }
200
219
  export async function mergeMergeRequest(opts) {
201
220
  const ref = prRefFromUrl(opts.url);
202
221
  const method = opts.method ?? "squash";
@@ -0,0 +1,193 @@
1
+ import { resolve } from "node:path";
2
+ import { deleteFile } from "./filesystem.js";
3
+ import { GitConfigError, requireGitConfig } from "./git-config.js";
4
+ import { mergeRequestLabel } from "./git-host.js";
5
+ import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists, gitSwitchToBranch, readGitInfo, } from "./git.js";
6
+ import { dim, log } from "./logger.js";
7
+ import { closeMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
8
+ import { normalizeTaskRef } from "./resolve-task.js";
9
+ import { getTaskMergeRequestLifecycle, resolveGitHostForCwd } from "./task-effective-status.js";
10
+ import { findTaskFile, loadActionableTasks, pickInProgressTask, readGitContext, readTaskFile, } from "./task.js";
11
+ export async function resolveDelTarget(paths, cwd, taskRef) {
12
+ const trimmed = taskRef?.trim();
13
+ let taskId;
14
+ let taskFile;
15
+ if (trimmed !== undefined && trimmed.length > 0) {
16
+ taskId = normalizeTaskRef(trimmed);
17
+ taskFile = await findTaskFile(paths.docsTasks, taskId);
18
+ }
19
+ else {
20
+ const inProgress = pickInProgressTask(await loadActionableTasks(paths.docsTasks));
21
+ if (inProgress === null) {
22
+ return null;
23
+ }
24
+ taskId = inProgress.id;
25
+ taskFile = inProgress.filePath;
26
+ }
27
+ if (taskFile === null) {
28
+ return null;
29
+ }
30
+ const meta = await readTaskFile(taskFile);
31
+ const ctx = await readGitContext(taskFile);
32
+ if (ctx === null) {
33
+ log.error(`No Git Context on ${taskId}. Cannot delete.`);
34
+ return null;
35
+ }
36
+ return { taskId, taskFile, meta, taskBranch: ctx.taskBranch };
37
+ }
38
+ export function taskDelNotFoundMessage(taskRef) {
39
+ if (taskRef?.trim()) {
40
+ return `TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
41
+ }
42
+ return "No In Progress TASK found. Pass `vibeops task del TASK-NNN`.";
43
+ }
44
+ export async function assertTaskDeletable(cwd, target) {
45
+ const lifecycle = await getTaskMergeRequestLifecycle(cwd, target.taskFile);
46
+ if (lifecycle === "merged") {
47
+ return {
48
+ ok: false,
49
+ reason: `${target.taskId} is merged into integration — task del is not allowed. ` +
50
+ "Revert on develop if needed, or keep the TASK file as history.",
51
+ };
52
+ }
53
+ return { ok: true };
54
+ }
55
+ export async function assertWorkingTreeForDel(cwd, allowDirty) {
56
+ const git = await readGitInfo(cwd);
57
+ if (git.dirty !== true)
58
+ return true;
59
+ const gov = await gitGovernanceOnlyDirty(cwd);
60
+ if (gov.onlyGovernance)
61
+ return true;
62
+ if (allowDirty) {
63
+ log.warn("Uncommitted changes remain in the working tree after delete.");
64
+ return true;
65
+ }
66
+ log.error("Working tree is dirty. Commit, stash, restore, or rerun with --force to discard uncommitted work.");
67
+ return false;
68
+ }
69
+ function isProtectedBranch(name, integration, production) {
70
+ return name === integration || name === production || name === "main" || name === "master";
71
+ }
72
+ export async function deleteTaskBranches(opts) {
73
+ const cwd = resolve(opts.cwd);
74
+ if (opts.dryRun) {
75
+ if (await gitBranchExists(cwd, opts.taskBranch)) {
76
+ log.info(dim(` would git branch -D ${opts.taskBranch}`));
77
+ }
78
+ if (opts.noRemoteDelete !== true &&
79
+ (await gitRemoteBranchExists(cwd, opts.remote, opts.taskBranch))) {
80
+ log.info(dim(` would git push ${opts.remote} --delete ${opts.taskBranch}`));
81
+ }
82
+ return true;
83
+ }
84
+ const git = await readGitInfo(cwd);
85
+ if (git.branch === opts.taskBranch) {
86
+ const switched = await gitSwitchToBranch(cwd, opts.integrationBranch, opts.remote);
87
+ if (!switched) {
88
+ log.error(`Integration branch "${opts.integrationBranch}" not found.`);
89
+ return false;
90
+ }
91
+ log.ok(`On ${opts.integrationBranch}`);
92
+ }
93
+ if (await gitBranchExists(cwd, opts.taskBranch)) {
94
+ try {
95
+ await gitDeleteBranch(cwd, opts.taskBranch, { force: true });
96
+ log.ok(`Deleted local branch ${opts.taskBranch}`);
97
+ }
98
+ catch (e) {
99
+ const msg = e instanceof Error ? e.message : String(e);
100
+ log.error(`Could not delete local ${opts.taskBranch}: ${msg}`);
101
+ return false;
102
+ }
103
+ }
104
+ else {
105
+ log.info(dim(`Local branch ${opts.taskBranch} not found — skipped.`));
106
+ }
107
+ if (opts.noRemoteDelete !== true &&
108
+ (await gitRemoteBranchExists(cwd, opts.remote, opts.taskBranch))) {
109
+ try {
110
+ await gitDeleteRemoteBranch(cwd, opts.remote, opts.taskBranch);
111
+ log.ok(`Deleted ${opts.remote}/${opts.taskBranch}`);
112
+ }
113
+ catch (e) {
114
+ const msg = e instanceof Error ? e.message : String(e);
115
+ log.warn(`Could not delete remote ${opts.taskBranch}: ${msg}`);
116
+ }
117
+ }
118
+ else if (opts.noRemoteDelete === true) {
119
+ log.info(dim("Remote branch delete skipped (--no-remote-delete)."));
120
+ }
121
+ return true;
122
+ }
123
+ export async function closeOpenTaskMergeRequestIfNeeded(opts) {
124
+ if (opts.skipClose) {
125
+ log.info(dim("Open MR/PR close skipped (--no-close-mr)."));
126
+ return true;
127
+ }
128
+ const lifecycle = await getTaskMergeRequestLifecycle(opts.cwd, opts.taskFile);
129
+ if (lifecycle !== "open")
130
+ return true;
131
+ const host = await resolveGitHostForCwd(opts.cwd);
132
+ if (host === null) {
133
+ log.warn("Could not resolve Git host — close the MR/PR manually.");
134
+ return true;
135
+ }
136
+ const open = await findMergeRequestByBranches({
137
+ cwd: opts.cwd,
138
+ host,
139
+ headBranch: opts.taskBranch,
140
+ baseBranch: opts.baseBranch,
141
+ state: "open",
142
+ });
143
+ if (open === null)
144
+ return true;
145
+ const label = mergeRequestLabel(host);
146
+ if (!(await probeMergeRequestCli(host))) {
147
+ log.warn(`Open ${label}: ${open.url} — close it manually before deleting the branch.`);
148
+ return true;
149
+ }
150
+ try {
151
+ await closeMergeRequest({ cwd: opts.cwd, host, url: open.url, dryRun: opts.dryRun });
152
+ if (!opts.dryRun)
153
+ log.ok(`Closed ${label}`);
154
+ }
155
+ catch (e) {
156
+ const msg = e instanceof Error ? e.message : String(e);
157
+ log.warn(`Could not close ${label}: ${msg}`);
158
+ log.info(dim(`Close manually: ${open.url}`));
159
+ }
160
+ return true;
161
+ }
162
+ export async function deleteTaskMarkdown(taskFile, dryRun) {
163
+ if (dryRun) {
164
+ log.info(dim(` would delete ${taskFile}`));
165
+ return;
166
+ }
167
+ await deleteFile(taskFile);
168
+ log.ok(`Deleted TASK file`);
169
+ }
170
+ export async function loadGitConfigForDel(cwd) {
171
+ try {
172
+ const cfg = await requireGitConfig(cwd);
173
+ return {
174
+ remote: cfg.remote,
175
+ integrationBranch: cfg.integrationBranch,
176
+ productionBranch: cfg.productionBranch,
177
+ };
178
+ }
179
+ catch (e) {
180
+ if (e instanceof GitConfigError) {
181
+ log.error(e.message);
182
+ return null;
183
+ }
184
+ throw e;
185
+ }
186
+ }
187
+ export function validateTaskBranchForDel(taskBranch, integrationBranch, productionBranch) {
188
+ if (isProtectedBranch(taskBranch, integrationBranch, productionBranch)) {
189
+ log.error(`Refusing to delete protected branch: ${taskBranch}`);
190
+ return false;
191
+ }
192
+ return true;
193
+ }
@@ -7,6 +7,12 @@ import { dim, log } from "./logger.js";
7
7
  import { createMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
8
8
  import { fallbackTaskPr, generateTaskPrWithLlm } from "./task-pr-llm.js";
9
9
  import { readGitContext, readTaskFile } from "./task.js";
10
+ function mergeRequestAlreadyExistsMessage(msg) {
11
+ const lower = msg.toLowerCase();
12
+ return (lower.includes("already exists") ||
13
+ lower.includes("merge request already exists") ||
14
+ lower.includes("pull request already exists"));
15
+ }
10
16
  async function buildDiffSummary(cwd, gitCtx) {
11
17
  const files = await gitDiffNameOnly(cwd, `${gitCtx.baseCommit}..HEAD`);
12
18
  const commits = await gitLogOneline(cwd, `${gitCtx.baseCommit}..HEAD`);
@@ -55,19 +61,6 @@ export async function finishTaskWithPullRequest(opts) {
55
61
  if (git.branch !== gitCtx.taskBranch) {
56
62
  log.warn(`Current branch is ${git.branch ?? "(detached)"}, not ${gitCtx.taskBranch}. Push may fail.`);
57
63
  }
58
- if (opts.skipPr !== true && opts.forceNewMergeRequest !== true) {
59
- const existing = await findMergeRequestByBranches({
60
- cwd: opts.cwd,
61
- host: gitCfg.host,
62
- headBranch: gitCtx.taskBranch,
63
- baseBranch: gitCtx.baseBranch,
64
- state: "open",
65
- });
66
- if (existing !== null) {
67
- log.info(dim(`Open ${label} already exists: ${existing.url}`));
68
- return { ok: true, mergeRequestUrl: existing.url, pushed: true };
69
- }
70
- }
71
64
  const ahead = await gitCommitsAhead(opts.cwd, gitCtx.baseCommit, "HEAD");
72
65
  if (ahead === 0) {
73
66
  log.warn("No commits ahead of base — pushing branch anyway.");
@@ -85,6 +78,20 @@ export async function finishTaskWithPullRequest(opts) {
85
78
  log.info(dim("Merge request creation skipped (--no-pr)."));
86
79
  return { ok: true, pushed: true };
87
80
  }
81
+ if (opts.forceNewMergeRequest !== true) {
82
+ const existing = await findMergeRequestByBranches({
83
+ cwd: opts.cwd,
84
+ host: gitCfg.host,
85
+ headBranch: gitCtx.taskBranch,
86
+ baseBranch: gitCtx.baseBranch,
87
+ state: "open",
88
+ });
89
+ if (existing !== null) {
90
+ log.ok(`Open ${label} updated: ${existing.url}`);
91
+ log.info(dim("MR/PR URL is on the host — not written to TASK md (use task merge)."));
92
+ return { ok: true, mergeRequestUrl: existing.url, pushed: true };
93
+ }
94
+ }
88
95
  const cliOk = await probeMergeRequestCli(gitCfg.host);
89
96
  if (!cliOk) {
90
97
  const tool = gitCfg.host === "gitlab" ? "glab" : "gh";
@@ -121,6 +128,20 @@ export async function finishTaskWithPullRequest(opts) {
121
128
  }
122
129
  catch (e) {
123
130
  const msg = e instanceof Error ? e.message : String(e);
131
+ if (mergeRequestAlreadyExistsMessage(msg)) {
132
+ const existing = await findMergeRequestByBranches({
133
+ cwd: opts.cwd,
134
+ host: gitCfg.host,
135
+ headBranch: gitCtx.taskBranch,
136
+ baseBranch: gitCtx.baseBranch,
137
+ state: "open",
138
+ });
139
+ if (existing !== null) {
140
+ log.ok(`Open ${label} updated: ${existing.url}`);
141
+ log.info(dim("MR/PR URL is on the host — not written to TASK md (use task merge)."));
142
+ return { ok: true, mergeRequestUrl: existing.url, pushed: true };
143
+ }
144
+ }
124
145
  log.error(`Could not create merge request: ${msg}`);
125
146
  log.info(dim(`Branch was pushed. Create MR manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`));
126
147
  return { ok: false, pushed: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "2.2.0",
3
+ "version": "2.3.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",