@goodtek/vibeops 2.1.2 → 2.1.3

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,15 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.1.3 - 2026-06-08
8
+
9
+ ### Changed
10
+
11
+ - **MR/PR resolution:** `task merge`, `status`, and `task sync` resolve the open PR by **`(taskBranch, baseBranch)`** via `gh` / `glab` (host is source of truth).
12
+ - **`task ship` / `task reship`:** no longer write `Merge Request` or `Pushed At` into TASK md after PR creation — **single push, clean working tree**, CI once per PR.
13
+ - **`task reship`:** archives the current open PR URL into `Previous Merge Requests` (from host lookup) in the reship metadata commit.
14
+ - **Legacy TASK files** that still have `Merge Request:` in Git Context remain supported as a fallback.
15
+
7
16
  ## 2.1.2 - 2026-06-08
8
17
 
9
18
  ### Changed
package/README.md CHANGED
@@ -106,7 +106,7 @@ pnpm install && pnpm build && pnpm smoke
106
106
 
107
107
  - **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`).
108
108
  - **`task add`**: `task/<slug>` from **integration** (e.g. `develop`).
109
- - **`task ship`**: commit implementation + ship metadata (Status **Shipped**) → push once → MR/PR.
109
+ - **`task ship`**: commit implementation + ship metadata (Status **Shipped**) → push once → MR/PR (URL on host; `task merge` resolves by branch).
110
110
  - **`task reship`**: Shipped TASK only; integrates `develop`; opens **new** PR (archives previous MR in Git Context).
111
111
  - **`task merge`**: merge MR/PR into integration (CLI or host UI; TASK md unchanged).
112
112
  - **`task sync`**: integration ff-only pull → delete `task/*` branches (TASK md unchanged).
@@ -8,7 +8,7 @@ import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
8
8
  import { projectPaths } from "../lib/paths.js";
9
9
  import { VERSION } from "../version.js";
10
10
  import { computeNextHint, hintToText, listInProgressTasks, pickFocusTask, relPath, taskBranchExistsFor, } from "../lib/task-context.js";
11
- import { isMergeRequestMerged, taskNeedsSync } from "../lib/task-effective-status.js";
11
+ import { hasOpenTaskMergeRequest, isMergeRequestMerged, resolveTaskMergeRequestUrl, taskNeedsSync, } from "../lib/task-effective-status.js";
12
12
  import { countTasks, hasNonEmptySection, isOnTaskBranch, loadActionableTasks, readGitContext, statusDisplay, } from "../lib/task.js";
13
13
  import { summarizeGoal } from "../lib/task-summary.js";
14
14
  async function buildReport(cwd) {
@@ -42,7 +42,7 @@ async function buildReport(cwd) {
42
42
  testFilled = hasNonEmptySection(body, "Test Result");
43
43
  const ctx = await readGitContext(focus.filePath);
44
44
  taskBranch = ctx?.taskBranch ?? null;
45
- mergeRequestUrl = ctx?.mergeRequestUrl ?? null;
45
+ mergeRequestUrl = await resolveTaskMergeRequestUrl(cwd, focus.filePath);
46
46
  onTaskBranch = isOnTaskBranch(git.branch, ctx);
47
47
  branchExists = await taskBranchExistsFor(cwd, focus);
48
48
  mergeRequestMerged = await isMergeRequestMerged(cwd, focus);
@@ -67,7 +67,9 @@ async function buildReport(cwd) {
67
67
  resultFilled,
68
68
  testFilled,
69
69
  onTaskBranch,
70
- hasMergeRequest: typeof mergeRequestUrl === "string" && mergeRequestUrl.length > 0,
70
+ hasMergeRequest: focus !== null
71
+ ? await hasOpenTaskMergeRequest(cwd, focus.filePath)
72
+ : false,
71
73
  mergeRequestMerged,
72
74
  needsSync,
73
75
  hasLocalChanges: git.dirty === true,
@@ -8,6 +8,7 @@ import { taskNotFoundMessage } from "../lib/resolve-task.js";
8
8
  import { getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
9
9
  import { relPath } from "../lib/task-context.js";
10
10
  import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
11
+ import { resolveOpenTaskMergeRequest } from "../lib/task-effective-status.js";
11
12
  import { readGitContext } from "../lib/task.js";
12
13
  function resolveMergeMethod(opts) {
13
14
  if (opts.rebase === true)
@@ -49,26 +50,26 @@ export async function taskMergeCommand(taskRef, options = {}) {
49
50
  process.exitCode = 1;
50
51
  return;
51
52
  }
52
- if (!ctx.mergeRequestUrl) {
53
- if (dryRun) {
54
- log.info(dim(`would merge ${ctx.taskBranch} → ${ctx.baseBranch} after MR exists (run task ship)`));
55
- log.blank();
56
- log.info(`Next: ${cyan("vibeops task sync")}`);
57
- return;
58
- }
59
- log.error("No Merge Request URL on TASK — run task ship first.");
53
+ const resolvedMr = dryRun
54
+ ? null
55
+ : await resolveOpenTaskMergeRequest(cwd, target.taskFile);
56
+ if (!dryRun && resolvedMr === null) {
57
+ log.error(`No open MR/PR for ${ctx.taskBranch} → ${ctx.baseBranch}. Run task ship or task reship first.`);
60
58
  process.exitCode = 1;
61
59
  return;
62
60
  }
61
+ const mergeRequestUrl = resolvedMr?.url ??
62
+ `(dry-run: would resolve open MR for ${ctx.taskBranch} → ${ctx.baseBranch})`;
63
63
  const remoteUrl = await gitRemoteUrl(cwd, gitCfg.remote);
64
- const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
65
- ? detectGitHost(remoteUrl)
66
- : gitCfg.host;
64
+ const host = resolvedMr?.host ??
65
+ (remoteUrl !== null && detectGitHost(remoteUrl) !== null
66
+ ? detectGitHost(remoteUrl)
67
+ : gitCfg.host);
67
68
  const method = resolveMergeMethod(options);
68
69
  const label = mergeRequestLabel(host);
69
70
  log.info(bold(`vibeops task merge ${target.taskId}`));
70
71
  log.info(` ${dim("file")} ${relPath(cwd, target.taskFile)}`);
71
- log.info(` ${dim("MR/PR")} ${ctx.mergeRequestUrl}`);
72
+ log.info(` ${dim("MR/PR")} ${mergeRequestUrl}`);
72
73
  log.info(` ${dim("target")} ${ctx.baseBranch} (${gitCfg.integrationBranch})`);
73
74
  log.info(` ${dim("method")} ${method}`);
74
75
  log.blank();
@@ -76,7 +77,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
76
77
  await mergeMergeRequest({
77
78
  cwd,
78
79
  host,
79
- url: ctx.mergeRequestUrl,
80
+ url: mergeRequestUrl,
80
81
  method,
81
82
  dryRun: true,
82
83
  });
@@ -84,7 +85,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
84
85
  log.info(`Next: ${cyan("vibeops task sync")}`);
85
86
  return;
86
87
  }
87
- const state = await getMergeRequestState(cwd, host, ctx.mergeRequestUrl);
88
+ const state = await getMergeRequestState(cwd, host, mergeRequestUrl);
88
89
  if (state === "merged") {
89
90
  log.ok(`${label} already merged.`);
90
91
  log.blank();
@@ -100,7 +101,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
100
101
  return;
101
102
  }
102
103
  try {
103
- await mergeMergeRequest({ cwd, host, url: ctx.mergeRequestUrl, method });
104
+ await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method });
104
105
  log.ok(`${label} merged into ${ctx.baseBranch}.`);
105
106
  }
106
107
  catch (e) {
@@ -119,7 +120,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
119
120
  else {
120
121
  log.warn(`Could not read ${label} state — attempting merge anyway.`);
121
122
  try {
122
- await mergeMergeRequest({ cwd, host, url: ctx.mergeRequestUrl, method });
123
+ await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method });
123
124
  log.ok(`${label} merge command completed.`);
124
125
  }
125
126
  catch (e) {
@@ -7,7 +7,7 @@ import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, } f
7
7
  import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
8
8
  import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "../lib/task-ship-llm.js";
9
9
  import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "../lib/task-reship.js";
10
- import { hasNonEmptySection, parseTaskFilename, readGitContext, upsertGitContext, } from "../lib/task.js";
10
+ import { hasNonEmptySection, parseTaskFilename, readGitContext, } from "../lib/task.js";
11
11
  import { listWorkingTreeRelPaths } from "../lib/git.js";
12
12
  async function commitReshipMetadata(opts) {
13
13
  if (opts.dryRun) {
@@ -139,8 +139,7 @@ export async function taskReshipCommand(taskRef, options = {}) {
139
139
  return;
140
140
  }
141
141
  gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
142
- gitCtx = await prepareGitContextForReship(taskFile, gitCtx);
143
- await upsertGitContext(taskFile, gitCtx);
142
+ gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
144
143
  await commitReshipMetadata({
145
144
  cwd,
146
145
  taskFile,
@@ -7,9 +7,9 @@ import { probeMergeRequestCli } from "../lib/pr-create.js";
7
7
  import { projectPaths } from "../lib/paths.js";
8
8
  import { taskNotFoundMessage } from "../lib/resolve-task.js";
9
9
  import { relPath } from "../lib/task-context.js";
10
- import { getMergeRequestStateForTask, isMergeRequestMerged, } from "../lib/task-effective-status.js";
10
+ import { getTaskMergeRequestLifecycle, isMergeRequestMerged, } from "../lib/task-effective-status.js";
11
11
  import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
12
- import { readGitContext, readTaskFile } from "../lib/task.js";
12
+ import { readTaskFile } from "../lib/task.js";
13
13
  function isProtectedBranch(name, integration, production) {
14
14
  return name === integration || name === production || name === "main" || name === "master";
15
15
  }
@@ -42,7 +42,6 @@ export async function taskSyncCommand(taskRef, options = {}) {
42
42
  }
43
43
  const { integrationBranch, productionBranch, remote } = gitCfg;
44
44
  const { taskId, taskBranch, taskFile } = target;
45
- const ctx = await readGitContext(taskFile);
46
45
  if (isProtectedBranch(taskBranch, integrationBranch, productionBranch)) {
47
46
  log.error(`Refusing to delete protected branch: ${taskBranch}`);
48
47
  process.exitCode = 1;
@@ -55,18 +54,18 @@ export async function taskSyncCommand(taskRef, options = {}) {
55
54
  log.info(` ${dim("note")} ${dim("Git cleanup only — TASK md stays Shipped")}`);
56
55
  log.blank();
57
56
  const meta = await readTaskFile(taskFile);
58
- if (ctx?.mergeRequestUrl && !dryRun) {
59
- const state = await getMergeRequestStateForTask(cwd, taskFile);
60
- if (state !== null) {
57
+ if (!dryRun) {
58
+ const lifecycle = await getTaskMergeRequestLifecycle(cwd, taskFile);
59
+ if (lifecycle !== "none") {
61
60
  const remoteUrl = await gitRemoteUrl(cwd, remote);
62
61
  const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
63
62
  ? detectGitHost(remoteUrl)
64
63
  : gitCfg.host;
65
64
  const label = mergeRequestLabel(host);
66
- if (state === "open") {
65
+ if (lifecycle === "open") {
67
66
  log.warn(`${yellow(label)} still open — merge with task merge or the host UI before sync.`);
68
67
  }
69
- else if (state === "merged") {
68
+ else if (lifecycle === "merged") {
70
69
  log.ok(`${label} merged.`);
71
70
  }
72
71
  }
@@ -64,6 +64,99 @@ export async function probeMergeRequestCli(host) {
64
64
  return false;
65
65
  }
66
66
  }
67
+ function normalizeGhMergeRequestState(raw) {
68
+ const state = raw.trim().toUpperCase();
69
+ if (state === "MERGED")
70
+ return "merged";
71
+ if (state === "OPEN")
72
+ return "open";
73
+ if (state === "CLOSED")
74
+ return "closed";
75
+ return "unknown";
76
+ }
77
+ function normalizeGlabMergeRequestState(raw) {
78
+ const state = raw.trim().toLowerCase();
79
+ if (state === "merged")
80
+ return "merged";
81
+ if (state === "opened" || state === "open")
82
+ return "open";
83
+ if (state === "closed")
84
+ return "closed";
85
+ return "unknown";
86
+ }
87
+ async function ghFindMergeRequestByBranches(opts) {
88
+ const state = opts.state ?? "open";
89
+ const { stdout } = await execFileAsync("gh", [
90
+ "pr",
91
+ "list",
92
+ "--head",
93
+ opts.headBranch,
94
+ "--base",
95
+ opts.baseBranch,
96
+ "--state",
97
+ state,
98
+ "--limit",
99
+ "1",
100
+ "--json",
101
+ "url,state",
102
+ ], { cwd: opts.cwd, maxBuffer: 1024 * 1024 });
103
+ const trimmed = stdout.trim();
104
+ if (trimmed.length === 0 || trimmed === "[]")
105
+ return null;
106
+ const rows = JSON.parse(trimmed);
107
+ const row = rows[0];
108
+ if (row?.url === undefined || row.url.length === 0)
109
+ return null;
110
+ return {
111
+ url: row.url,
112
+ state: normalizeGhMergeRequestState(row.state ?? ""),
113
+ };
114
+ }
115
+ async function glabFindMergeRequestByBranches(opts) {
116
+ const state = opts.state ?? "open";
117
+ const glabState = state === "open" ? "opened" : state === "merged" ? "merged" : state === "closed" ? "closed" : "all";
118
+ const { stdout } = await execFileAsync("glab", [
119
+ "mr",
120
+ "list",
121
+ "--source-branch",
122
+ opts.headBranch,
123
+ "--target-branch",
124
+ opts.baseBranch,
125
+ "-S",
126
+ glabState,
127
+ "-P",
128
+ "1",
129
+ "--output",
130
+ "json",
131
+ "web_url,state",
132
+ ], { cwd: opts.cwd, maxBuffer: 1024 * 1024 });
133
+ const trimmed = stdout.trim();
134
+ if (trimmed.length === 0 || trimmed === "[]")
135
+ return null;
136
+ const rows = JSON.parse(trimmed);
137
+ const row = rows[0];
138
+ const url = row?.web_url?.trim();
139
+ if (url === undefined || url.length === 0)
140
+ return null;
141
+ return {
142
+ url,
143
+ state: normalizeGlabMergeRequestState(row.state ?? ""),
144
+ };
145
+ }
146
+ /** Resolve MR/PR by `(headBranch, baseBranch)` — source of truth for ship/reship (no TASK URL). */
147
+ export async function findMergeRequestByBranches(opts) {
148
+ try {
149
+ if (!(await probeMergeRequestCli(opts.host)))
150
+ return null;
151
+ if (opts.host === "gitlab") {
152
+ return glabFindMergeRequestByBranches(opts);
153
+ }
154
+ return ghFindMergeRequestByBranches(opts);
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
67
160
  function prRefFromUrl(url) {
68
161
  const trimmed = url.trim();
69
162
  const pullMatch = /\/pull\/(\d+)/i.exec(trimmed);
@@ -1,37 +1,103 @@
1
1
  import { GitConfigError, requireGitConfig } from "./git-config.js";
2
2
  import { detectGitHost } from "./git-host.js";
3
3
  import { gitBranchExists, gitRemoteBranchExists, gitRemoteUrl, } from "./git.js";
4
- import { getMergeRequestState, probeMergeRequestCli } from "./pr-create.js";
4
+ import { findMergeRequestByBranches, getMergeRequestState, probeMergeRequestCli, } from "./pr-create.js";
5
5
  import { readGitContext } from "./task.js";
6
- /** MR state for a TASK file, or `null` when there is no MR URL or host CLI is unavailable. */
7
- export async function getMergeRequestStateForTask(cwd, taskFile) {
8
- const ctx = await readGitContext(taskFile);
9
- const url = ctx?.mergeRequestUrl?.trim();
10
- if (url === undefined || url.length === 0)
11
- return null;
12
- let host;
6
+ export async function resolveGitHostForCwd(cwd) {
13
7
  try {
14
8
  const gitCfg = await requireGitConfig(cwd);
15
9
  const remoteUrl = await gitRemoteUrl(cwd, gitCfg.remote);
16
- host =
17
- remoteUrl !== null && detectGitHost(remoteUrl) !== null
18
- ? detectGitHost(remoteUrl)
19
- : gitCfg.host;
10
+ if (remoteUrl !== null && detectGitHost(remoteUrl) !== null) {
11
+ return detectGitHost(remoteUrl);
12
+ }
13
+ return gitCfg.host;
20
14
  }
21
15
  catch (e) {
22
16
  if (e instanceof GitConfigError)
23
17
  return null;
24
18
  throw e;
25
19
  }
26
- if (!(await probeMergeRequestCli(host)))
20
+ }
21
+ async function readTaskGitContext(taskFile) {
22
+ return readGitContext(taskFile);
23
+ }
24
+ async function resolveFromBranches(cwd, ctx, state) {
25
+ try {
26
+ const host = await resolveGitHostForCwd(cwd);
27
+ if (host === null)
28
+ return null;
29
+ const found = await findMergeRequestByBranches({
30
+ cwd,
31
+ host,
32
+ headBranch: ctx.taskBranch,
33
+ baseBranch: ctx.baseBranch,
34
+ state,
35
+ });
36
+ if (found === null)
37
+ return null;
38
+ return { url: found.url, host, state: found.state, source: "branch" };
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ async function resolveFromTaskFileUrl(cwd, ctx) {
45
+ const url = ctx.mergeRequestUrl?.trim();
46
+ if (url === undefined || url.length === 0)
47
+ return null;
48
+ const host = await resolveGitHostForCwd(cwd);
49
+ if (host === null || !(await probeMergeRequestCli(host)))
27
50
  return null;
28
- return getMergeRequestState(cwd, host, url);
51
+ const state = await getMergeRequestState(cwd, host, url);
52
+ return { url, host, state, source: "task-file" };
53
+ }
54
+ /** Open MR/PR for a TASK — branch lookup first, legacy TASK URL fallback. */
55
+ export async function resolveOpenTaskMergeRequest(cwd, taskFile) {
56
+ const ctx = await readTaskGitContext(taskFile);
57
+ if (ctx === null)
58
+ return null;
59
+ const fromBranch = await resolveFromBranches(cwd, ctx, "open");
60
+ if (fromBranch !== null)
61
+ return fromBranch;
62
+ const legacy = await resolveFromTaskFileUrl(cwd, ctx);
63
+ if (legacy !== null && legacy.state === "open")
64
+ return legacy;
65
+ return null;
66
+ }
67
+ /** MR lifecycle for hints, sync, and status — prefers host branch lookup. */
68
+ export async function getTaskMergeRequestLifecycle(cwd, taskFile) {
69
+ const ctx = await readTaskGitContext(taskFile);
70
+ if (ctx === null)
71
+ return "none";
72
+ const open = await resolveFromBranches(cwd, ctx, "open");
73
+ if (open !== null)
74
+ return "open";
75
+ const merged = await resolveFromBranches(cwd, ctx, "merged");
76
+ if (merged !== null)
77
+ return "merged";
78
+ const legacy = await resolveFromTaskFileUrl(cwd, ctx);
79
+ if (legacy !== null)
80
+ return legacy.state;
81
+ return "none";
82
+ }
83
+ /** @deprecated Use {@link getTaskMergeRequestLifecycle} or {@link resolveOpenTaskMergeRequest}. */
84
+ export async function getMergeRequestStateForTask(cwd, taskFile) {
85
+ const lifecycle = await getTaskMergeRequestLifecycle(cwd, taskFile);
86
+ if (lifecycle === "none")
87
+ return null;
88
+ return lifecycle;
89
+ }
90
+ export async function hasOpenTaskMergeRequest(cwd, taskFile) {
91
+ return (await getTaskMergeRequestLifecycle(cwd, taskFile)) === "open";
92
+ }
93
+ export async function resolveTaskMergeRequestUrl(cwd, taskFile) {
94
+ const open = await resolveOpenTaskMergeRequest(cwd, taskFile);
95
+ return open?.url ?? null;
29
96
  }
30
97
  export async function isMergeRequestMerged(cwd, task) {
31
98
  if (task.status !== "shipped")
32
99
  return false;
33
- const state = await getMergeRequestStateForTask(cwd, task.filePath);
34
- return state === "merged";
100
+ return (await getTaskMergeRequestLifecycle(cwd, task.filePath)) === "merged";
35
101
  }
36
102
  /**
37
103
  * After ship: optional `task sync` when MR is merged (or no MR) but `task/*` still exists.
@@ -39,15 +105,12 @@ export async function isMergeRequestMerged(cwd, task) {
39
105
  export async function taskNeedsSync(cwd, task) {
40
106
  if (task.status !== "shipped")
41
107
  return false;
42
- const ctx = await readGitContext(task.filePath);
108
+ const ctx = await readTaskGitContext(task.filePath);
43
109
  if (ctx === null)
44
110
  return false;
45
- const mrUrl = ctx.mergeRequestUrl?.trim();
46
- if (mrUrl !== undefined && mrUrl.length > 0) {
47
- const state = await getMergeRequestStateForTask(cwd, task.filePath);
48
- if (state !== "merged")
49
- return false;
50
- }
111
+ const lifecycle = await getTaskMergeRequestLifecycle(cwd, task.filePath);
112
+ if (lifecycle === "open")
113
+ return false;
51
114
  if (await gitBranchExists(cwd, ctx.taskBranch))
52
115
  return true;
53
116
  try {
@@ -4,9 +4,9 @@ import { detectGitHost, mergeRequestLabel } from "./git-host.js";
4
4
  import { requireGitConfig } from "./git-config.js";
5
5
  import { gitCommitsAhead, gitDiffNameOnly, gitLogOneline, gitPush, gitRemoteUrl, readGitInfo, } from "./git.js";
6
6
  import { dim, log } from "./logger.js";
7
- import { createMergeRequest, probeMergeRequestCli } from "./pr-create.js";
7
+ import { createMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
8
8
  import { fallbackTaskPr, generateTaskPrWithLlm } from "./task-pr-llm.js";
9
- import { readGitContext, readTaskFile, upsertGitContext } from "./task.js";
9
+ import { readGitContext, readTaskFile } from "./task.js";
10
10
  async function buildDiffSummary(cwd, gitCtx) {
11
11
  const files = await gitDiffNameOnly(cwd, `${gitCtx.baseCommit}..HEAD`);
12
12
  const commits = await gitLogOneline(cwd, `${gitCtx.baseCommit}..HEAD`);
@@ -28,12 +28,7 @@ async function buildDiffSummary(cwd, gitCtx) {
28
28
  }
29
29
  return lines.join("\n") || "(no commits since task start)";
30
30
  }
31
- function isExistingMergeRequestUrl(url) {
32
- if (typeof url !== "string" || url.length === 0)
33
- return false;
34
- return url.startsWith("http://") || url.startsWith("https://");
35
- }
36
- /** Push task branch and open MR/PR (idempotent when URL already in Git Context). */
31
+ /** Push task branch and open MR/PR. Does not write MR URL into TASK md (host is source of truth). */
37
32
  export async function finishTaskWithPullRequest(opts) {
38
33
  let gitCfg = await requireGitConfig(opts.cwd);
39
34
  const gitCtx = await readGitContext(opts.taskFile);
@@ -41,8 +36,9 @@ export async function finishTaskWithPullRequest(opts) {
41
36
  log.warn("No Git Context on TASK — push and merge request skipped.");
42
37
  return { ok: true };
43
38
  }
39
+ const label = mergeRequestLabel(gitCfg.host);
44
40
  if (opts.dryRun) {
45
- log.info(dim(`would git push -u ${gitCfg.remote} ${gitCtx.taskBranch} and open ${mergeRequestLabel(gitCfg.host)} → ${gitCtx.baseBranch}`));
41
+ log.info(dim(`would git push -u ${gitCfg.remote} ${gitCtx.taskBranch} and open ${label} → ${gitCtx.baseBranch}`));
46
42
  return { ok: true, mergeRequestUrl: "(dry-run)" };
47
43
  }
48
44
  const remoteUrl = await gitRemoteUrl(opts.cwd, gitCfg.remote);
@@ -59,10 +55,18 @@ export async function finishTaskWithPullRequest(opts) {
59
55
  if (git.branch !== gitCtx.taskBranch) {
60
56
  log.warn(`Current branch is ${git.branch ?? "(detached)"}, not ${gitCtx.taskBranch}. Push may fail.`);
61
57
  }
62
- if (isExistingMergeRequestUrl(gitCtx.mergeRequestUrl) &&
63
- opts.forceNewMergeRequest !== true) {
64
- log.info(dim(`Merge request already recorded: ${gitCtx.mergeRequestUrl}`));
65
- return { ok: true, mergeRequestUrl: gitCtx.mergeRequestUrl, pushed: true };
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
+ }
66
70
  }
67
71
  const ahead = await gitCommitsAhead(opts.cwd, gitCtx.baseCommit, "HEAD");
68
72
  if (ahead === 0) {
@@ -77,17 +81,14 @@ export async function finishTaskWithPullRequest(opts) {
77
81
  log.error(`git push failed: ${msg}`);
78
82
  return { ok: false };
79
83
  }
80
- const pushedAt = new Date().toISOString();
81
84
  if (opts.skipPr === true) {
82
- await upsertGitContext(opts.taskFile, { ...gitCtx, pushedAt });
83
85
  log.info(dim("Merge request creation skipped (--no-pr)."));
84
86
  return { ok: true, pushed: true };
85
87
  }
86
88
  const cliOk = await probeMergeRequestCli(gitCfg.host);
87
89
  if (!cliOk) {
88
90
  const tool = gitCfg.host === "gitlab" ? "glab" : "gh";
89
- await upsertGitContext(opts.taskFile, { ...gitCtx, pushedAt });
90
- log.warn(`${tool} CLI not found — create the ${mergeRequestLabel(gitCfg.host)} manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`);
91
+ log.warn(`${tool} CLI not found — create the ${label} manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`);
91
92
  return { ok: true, pushed: true };
92
93
  }
93
94
  const config = await readConfig(opts.cwd);
@@ -114,18 +115,13 @@ export async function finishTaskWithPullRequest(opts) {
114
115
  title: prTitle,
115
116
  body: prBody,
116
117
  });
117
- log.ok(`${mergeRequestLabel(gitCfg.host)}: ${url}`);
118
- await upsertGitContext(opts.taskFile, {
119
- ...gitCtx,
120
- mergeRequestUrl: url,
121
- pushedAt,
122
- });
118
+ log.ok(`${label}: ${url}`);
119
+ log.info(dim("MR/PR URL is on the host — not written to TASK md (use task merge)."));
123
120
  return { ok: true, mergeRequestUrl: url, pushed: true };
124
121
  }
125
122
  catch (e) {
126
123
  const msg = e instanceof Error ? e.message : String(e);
127
124
  log.error(`Could not create merge request: ${msg}`);
128
- await upsertGitContext(opts.taskFile, { ...gitCtx, pushedAt });
129
125
  log.info(dim(`Branch was pushed. Create MR manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`));
130
126
  return { ok: false, pushed: true };
131
127
  }
@@ -2,9 +2,10 @@ import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "./git-config.js";
3
3
  import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitFetchRemote, gitGovernanceOnlyDirty, gitHeadCommit, gitMergeRemoteBranch, gitMergeInProgress, gitPullFastForwardOnly, gitRemoteBranchExists, gitSwitchToBranch, listUnmergedRelPaths, readGitInfo, } from "./git.js";
4
4
  import { dim, log } from "./logger.js";
5
- import { getMergeRequestStateForTask } from "./task-effective-status.js";
5
+ import { findMergeRequestByBranches } from "./pr-create.js";
6
6
  import { normalizeTaskRef } from "./resolve-task.js";
7
- import { archiveMergeRequestForReship, findTaskFile, loadActionableTasks, pickLatestShippedTask, readGitContext, readTaskFile, upsertGitContext, } from "./task.js";
7
+ import { getTaskMergeRequestLifecycle, resolveGitHostForCwd, } from "./task-effective-status.js";
8
+ import { findTaskFile, loadActionableTasks, pickLatestShippedTask, readGitContext, readTaskFile, upsertGitContext, } from "./task.js";
8
9
  export async function resolveReshipTarget(paths, cwd, taskRef) {
9
10
  const trimmed = taskRef?.trim();
10
11
  let taskId;
@@ -160,7 +161,7 @@ export async function integrateIntegrationIntoTaskBranch(opts) {
160
161
  export async function assertMergeRequestNotOpen(cwd, taskFile, allowOpenMr) {
161
162
  if (allowOpenMr)
162
163
  return true;
163
- const state = await getMergeRequestStateForTask(cwd, taskFile);
164
+ const state = await getTaskMergeRequestLifecycle(cwd, taskFile);
164
165
  if (state !== "open")
165
166
  return true;
166
167
  log.error("Current MR/PR is still open. Merge or close it first, or rerun with --allow-open-mr.");
@@ -172,8 +173,32 @@ export async function refreshGitContextBaseCommit(taskFile, gitCtx, cwd) {
172
173
  return gitCtx;
173
174
  return { ...gitCtx, baseCommit: head };
174
175
  }
175
- export async function prepareGitContextForReship(taskFile, gitCtx) {
176
- const archived = archiveMergeRequestForReship(gitCtx);
176
+ export async function prepareGitContextForReship(cwd, taskFile, gitCtx) {
177
+ const previous = [...(gitCtx.previousMergeRequestUrls ?? [])];
178
+ const host = await resolveGitHostForCwd(cwd);
179
+ if (host !== null) {
180
+ const open = await findMergeRequestByBranches({
181
+ cwd,
182
+ host,
183
+ headBranch: gitCtx.taskBranch,
184
+ baseBranch: gitCtx.baseBranch,
185
+ state: "open",
186
+ });
187
+ if (open !== null) {
188
+ previous.push(open.url);
189
+ }
190
+ }
191
+ const legacy = gitCtx.mergeRequestUrl?.trim();
192
+ if (legacy !== undefined && legacy.length > 0 && !previous.includes(legacy)) {
193
+ previous.push(legacy);
194
+ }
195
+ const archived = {
196
+ ...gitCtx,
197
+ previousMergeRequestUrls: previous.length > 0 ? previous : undefined,
198
+ mergeRequestUrl: undefined,
199
+ reshipCount: (gitCtx.reshipCount ?? 0) + 1,
200
+ lastReshipAt: new Date().toISOString(),
201
+ };
177
202
  await upsertGitContext(taskFile, archived);
178
203
  return archived;
179
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",