@goodtek/vibeops 2.0.0 → 2.1.1

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.
@@ -1,20 +1,18 @@
1
1
  import { relative } from "node:path";
2
2
  import { gitBranchExists, readGitInfo } from "./git.js";
3
+ import { isMergeRequestMerged, taskNeedsSync } from "./task-effective-status.js";
3
4
  import { readGitContext } from "./task.js";
4
5
  import { isOnTaskBranch, loadActionableTasks, pickActiveTask, pickInProgressTask, } from "./task.js";
5
- /** TASK that blocks `task add` — in progress, in review, or open on current task branch. */
6
+ /** Only **In Progress** blocks the next `task add`. */
6
7
  export async function findBlockingTask(paths, cwd) {
7
8
  const tasks = await loadActionableTasks(paths.docsTasks);
8
9
  const inProgress = pickInProgressTask(tasks);
9
10
  if (inProgress !== null)
10
11
  return inProgress;
11
- const review = tasks.find((t) => t.status === "review");
12
- if (review !== undefined)
13
- return review;
14
12
  const git = await readGitInfo(cwd);
15
13
  if (git.isRepo && typeof git.branch === "string" && git.branch.startsWith("task/")) {
16
14
  for (const t of tasks) {
17
- if (t.status === "done")
15
+ if (t.status !== "in_progress")
18
16
  continue;
19
17
  const ctx = await readGitContext(t.filePath);
20
18
  if (isOnTaskBranch(git.branch, ctx))
@@ -24,7 +22,17 @@ export async function findBlockingTask(paths, cwd) {
24
22
  return null;
25
23
  }
26
24
  export async function pickFocusTask(paths, cwd) {
27
- return pickActiveTask(paths.docsTasks, cwd);
25
+ const active = await pickActiveTask(paths.docsTasks, cwd);
26
+ if (active !== null)
27
+ return active;
28
+ const shipped = (await loadActionableTasks(paths.docsTasks))
29
+ .filter((t) => t.status === "shipped")
30
+ .sort((a, b) => {
31
+ const na = Number.parseInt(/^TASK-(\d+)$/i.exec(a.id)?.[1] ?? "0", 10);
32
+ const nb = Number.parseInt(/^TASK-(\d+)$/i.exec(b.id)?.[1] ?? "0", 10);
33
+ return nb - na;
34
+ });
35
+ return shipped[0] ?? null;
28
36
  }
29
37
  export function relPath(cwd, filePath) {
30
38
  const r = relative(cwd, filePath).replace(/\\/g, "/");
@@ -35,11 +43,14 @@ export function computeNextHint(input) {
35
43
  return "init";
36
44
  if (input.focus === null)
37
45
  return "task-add";
38
- if (input.focus.status === "done") {
39
- return input.onTaskBranch === true ? "task-sync" : "task-add";
40
- }
41
- if (input.focus.status === "review") {
42
- return input.hasMergeRequest ? "task-merge" : "task-ship";
46
+ if (input.focus.status === "shipped") {
47
+ if (input.needsSync)
48
+ return "task-sync";
49
+ if (input.hasMergeRequest && !input.mergeRequestMerged)
50
+ return "task-merge";
51
+ if (input.hasLocalChanges && input.onTaskBranch === true)
52
+ return "task-reship";
53
+ return "task-add";
43
54
  }
44
55
  if (!input.resultFilled || !input.testFilled) {
45
56
  return input.onTaskBranch === true ? "cursor-implement" : "cursor-plan";
@@ -54,14 +65,18 @@ export function hintToText(hint, cwd, task) {
54
65
  return "Run `vibeops task add` to start the next slice.";
55
66
  case "task-ship":
56
67
  return task ? `Run \`vibeops task ship ${task.id}\`.` : "Run `vibeops task ship`.";
68
+ case "task-reship":
69
+ return task
70
+ ? `Follow-up on ${task.id}? Edit on task branch, then \`vibeops task reship ${task.id}\`.`
71
+ : "Run `vibeops task reship TASK-NNN` for a Shipped follow-up.";
57
72
  case "task-merge":
58
73
  return task
59
- ? `Run \`vibeops task merge ${task.id}\`, then task sync.`
60
- : "Run `vibeops task merge` after the MR is ready.";
74
+ ? `Merge the MR (UI or \`vibeops task merge ${task.id}\`), then optional task sync.`
75
+ : "Merge the MR on the host or with `vibeops task merge`.";
61
76
  case "task-sync":
62
77
  return task
63
- ? `MR merged? Run \`vibeops task sync ${task.id}\`, then task add.`
64
- : "Run `vibeops task sync` after merging the MR.";
78
+ ? `Run \`vibeops task sync ${task.id}\` to delete task branches, then task add.`
79
+ : "Run `vibeops task sync` to delete task branches after merge.";
65
80
  case "cursor-plan":
66
81
  return task
67
82
  ? `In Cursor Ask: @${relPath(cwd, task.filePath)} — refine Scope / AC.`
@@ -81,3 +96,4 @@ export async function taskBranchExistsFor(cwd, task) {
81
96
  return false;
82
97
  return gitBranchExists(cwd, ctx.taskBranch);
83
98
  }
99
+ export { isMergeRequestMerged, taskNeedsSync };
@@ -0,0 +1,62 @@
1
+ import { GitConfigError, requireGitConfig } from "./git-config.js";
2
+ import { detectGitHost } from "./git-host.js";
3
+ import { gitBranchExists, gitRemoteBranchExists, gitRemoteUrl, } from "./git.js";
4
+ import { getMergeRequestState, probeMergeRequestCli } from "./pr-create.js";
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;
13
+ try {
14
+ const gitCfg = await requireGitConfig(cwd);
15
+ const remoteUrl = await gitRemoteUrl(cwd, gitCfg.remote);
16
+ host =
17
+ remoteUrl !== null && detectGitHost(remoteUrl) !== null
18
+ ? detectGitHost(remoteUrl)
19
+ : gitCfg.host;
20
+ }
21
+ catch (e) {
22
+ if (e instanceof GitConfigError)
23
+ return null;
24
+ throw e;
25
+ }
26
+ if (!(await probeMergeRequestCli(host)))
27
+ return null;
28
+ return getMergeRequestState(cwd, host, url);
29
+ }
30
+ export async function isMergeRequestMerged(cwd, task) {
31
+ if (task.status !== "shipped")
32
+ return false;
33
+ const state = await getMergeRequestStateForTask(cwd, task.filePath);
34
+ return state === "merged";
35
+ }
36
+ /**
37
+ * After ship: optional `task sync` when MR is merged (or no MR) but `task/*` still exists.
38
+ */
39
+ export async function taskNeedsSync(cwd, task) {
40
+ if (task.status !== "shipped")
41
+ return false;
42
+ const ctx = await readGitContext(task.filePath);
43
+ if (ctx === null)
44
+ 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
+ }
51
+ if (await gitBranchExists(cwd, ctx.taskBranch))
52
+ return true;
53
+ try {
54
+ const gitCfg = await requireGitConfig(cwd);
55
+ return gitRemoteBranchExists(cwd, gitCfg.remote, ctx.taskBranch);
56
+ }
57
+ catch (e) {
58
+ if (e instanceof GitConfigError)
59
+ return false;
60
+ throw e;
61
+ }
62
+ }
@@ -1,5 +1,5 @@
1
1
  import { readGitInfo } from "./git.js";
2
- import { isOnTaskBranch, loadActionableTasks, pickLatestReviewTask, readGitContext, } from "./task.js";
2
+ import { isOnTaskBranch, loadActionableTasks, pickLatestShippedTask, readGitContext, } from "./task.js";
3
3
  import { resolveTask } from "./resolve-task.js";
4
4
  /** Resolve TASK + task branch for ship / merge / sync. */
5
5
  export async function resolveLifecycleTarget(paths, cwd, taskRef) {
@@ -27,15 +27,15 @@ export async function resolveLifecycleTarget(paths, cwd, taskRef) {
27
27
  }
28
28
  }
29
29
  }
30
- const review = pickLatestReviewTask(await loadActionableTasks(paths.docsTasks));
31
- if (review === null)
30
+ const shipped = pickLatestShippedTask(await loadActionableTasks(paths.docsTasks));
31
+ if (shipped === null)
32
32
  return null;
33
- const ctx = await readGitContext(review.filePath);
33
+ const ctx = await readGitContext(shipped.filePath);
34
34
  if (ctx === null)
35
35
  return null;
36
36
  return {
37
- taskId: review.id,
37
+ taskId: shipped.id,
38
38
  taskBranch: ctx.taskBranch,
39
- taskFile: review.filePath,
39
+ taskFile: shipped.filePath,
40
40
  };
41
41
  }
@@ -59,7 +59,8 @@ export async function finishTaskWithPullRequest(opts) {
59
59
  if (git.branch !== gitCtx.taskBranch) {
60
60
  log.warn(`Current branch is ${git.branch ?? "(detached)"}, not ${gitCtx.taskBranch}. Push may fail.`);
61
61
  }
62
- if (isExistingMergeRequestUrl(gitCtx.mergeRequestUrl)) {
62
+ if (isExistingMergeRequestUrl(gitCtx.mergeRequestUrl) &&
63
+ opts.forceNewMergeRequest !== true) {
63
64
  log.info(dim(`Merge request already recorded: ${gitCtx.mergeRequestUrl}`));
64
65
  return { ok: true, mergeRequestUrl: gitCtx.mergeRequestUrl, pushed: true };
65
66
  }
@@ -0,0 +1,192 @@
1
+ import { resolve } from "node:path";
2
+ import { GitConfigError, requireGitConfig } from "./git-config.js";
3
+ import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitFetchRemote, gitGovernanceOnlyDirty, gitHeadCommit, gitMergeRemoteBranch, gitMergeInProgress, gitPullFastForwardOnly, gitRemoteBranchExists, gitSwitchToBranch, listUnmergedRelPaths, readGitInfo, } from "./git.js";
4
+ import { dim, log } from "./logger.js";
5
+ import { getMergeRequestStateForTask } from "./task-effective-status.js";
6
+ import { normalizeTaskRef } from "./resolve-task.js";
7
+ import { archiveMergeRequestForReship, findTaskFile, loadActionableTasks, pickLatestShippedTask, readGitContext, readTaskFile, upsertGitContext, } from "./task.js";
8
+ export async function resolveReshipTarget(paths, cwd, taskRef) {
9
+ const trimmed = taskRef?.trim();
10
+ let taskId;
11
+ let taskFile;
12
+ if (trimmed !== undefined && trimmed.length > 0) {
13
+ taskId = normalizeTaskRef(trimmed);
14
+ taskFile = await findTaskFile(paths.docsTasks, taskId);
15
+ }
16
+ else {
17
+ const shipped = pickLatestShippedTask(await loadActionableTasks(paths.docsTasks));
18
+ if (shipped === null) {
19
+ log.error(reshipNotFoundMessage(undefined));
20
+ return null;
21
+ }
22
+ taskId = shipped.id;
23
+ taskFile = shipped.filePath;
24
+ }
25
+ if (taskFile === null) {
26
+ log.error(reshipNotFoundMessage(taskRef));
27
+ return null;
28
+ }
29
+ const meta = await readTaskFile(taskFile);
30
+ if (meta.status !== "shipped") {
31
+ log.error(`${taskId} is ${meta.status === "in_progress" ? "In Progress" : meta.status} — use \`vibeops task ship\` for first submit.`);
32
+ return null;
33
+ }
34
+ const gitCtx = await readGitContext(taskFile);
35
+ if (gitCtx === null) {
36
+ log.error(`No Git Context on ${taskId}. Cannot reship.`);
37
+ return null;
38
+ }
39
+ return { taskId, taskFile, meta, gitCtx };
40
+ }
41
+ export function reshipNotFoundMessage(taskRef) {
42
+ if (taskRef?.trim()) {
43
+ return `Shipped TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
44
+ }
45
+ return "No Shipped TASK found. Pass `vibeops task reship TASK-NNN`.";
46
+ }
47
+ /** Checkout or recreate the task branch without changing TASK Status. */
48
+ export async function ensureTaskBranchForReship(opts) {
49
+ const cwd = resolve(opts.cwd);
50
+ const { taskBranch } = opts.gitCtx;
51
+ const git = await readGitInfo(cwd);
52
+ if (!git.isRepo) {
53
+ log.error("Not a git repository.");
54
+ return false;
55
+ }
56
+ if (git.dirty === true && !opts.allowDirty) {
57
+ const gov = await gitGovernanceOnlyDirty(cwd);
58
+ if (!gov.onlyGovernance) {
59
+ log.error("Working tree is dirty. Commit or stash, or rerun with --allow-dirty.");
60
+ return false;
61
+ }
62
+ log.warn("Only governance paths are dirty — proceeding.");
63
+ }
64
+ const localExists = await gitBranchExists(cwd, taskBranch);
65
+ const remoteExists = await gitRemoteBranchExists(cwd, opts.remote, taskBranch);
66
+ if (opts.dryRun) {
67
+ if (localExists) {
68
+ log.info(dim(` would git switch ${taskBranch}`));
69
+ }
70
+ else if (opts.recreateBranch) {
71
+ log.info(dim(` would recreate ${taskBranch} from ${opts.integrationBranch}`));
72
+ }
73
+ else {
74
+ log.error(`Local branch ${taskBranch} missing. Rerun with --recreate-branch.`);
75
+ return false;
76
+ }
77
+ return true;
78
+ }
79
+ if (localExists) {
80
+ await gitCheckout(cwd, taskBranch);
81
+ log.ok(`On ${taskBranch}`);
82
+ return true;
83
+ }
84
+ if (!opts.recreateBranch) {
85
+ if (remoteExists) {
86
+ log.error(`Local ${taskBranch} missing. Fetch/checkout the branch or rerun with --recreate-branch.`);
87
+ }
88
+ else {
89
+ log.error(`Branch ${taskBranch} not found locally or on ${opts.remote}. Use --recreate-branch.`);
90
+ }
91
+ return false;
92
+ }
93
+ const ok = await gitSwitchToBranch(cwd, opts.integrationBranch, opts.remote);
94
+ if (!ok) {
95
+ log.error(`Integration branch "${opts.integrationBranch}" not found.`);
96
+ return false;
97
+ }
98
+ if (await gitRemoteBranchExists(cwd, opts.remote, opts.integrationBranch)) {
99
+ try {
100
+ await gitPullFastForwardOnly(cwd, opts.remote, opts.integrationBranch);
101
+ }
102
+ catch (e) {
103
+ const msg = e instanceof Error ? e.message : String(e);
104
+ log.error(`Could not update ${opts.integrationBranch}: ${msg}`);
105
+ return false;
106
+ }
107
+ }
108
+ const head = await gitHeadCommit(cwd);
109
+ if (head === null || head.length === 0) {
110
+ log.error("Integration branch has no commits.");
111
+ return false;
112
+ }
113
+ await gitCheckoutNewBranch(cwd, taskBranch, opts.integrationBranch);
114
+ await upsertGitContext(opts.taskFile, {
115
+ ...opts.gitCtx,
116
+ baseBranch: opts.integrationBranch,
117
+ baseCommit: head,
118
+ });
119
+ log.ok(`Recreated ${taskBranch} from ${opts.integrationBranch}`);
120
+ return true;
121
+ }
122
+ /** Merge latest integration into the current task branch. */
123
+ export async function integrateIntegrationIntoTaskBranch(opts) {
124
+ const cwd = resolve(opts.cwd);
125
+ if (opts.dryRun) {
126
+ log.info(dim(` would git fetch ${opts.remote} && git merge ${opts.remote}/${opts.integrationBranch}`));
127
+ return true;
128
+ }
129
+ try {
130
+ await gitFetchRemote(cwd, opts.remote);
131
+ }
132
+ catch (e) {
133
+ const msg = e instanceof Error ? e.message : String(e);
134
+ log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
135
+ }
136
+ if (!(await gitRemoteBranchExists(cwd, opts.remote, opts.integrationBranch))) {
137
+ log.warn(`No ${opts.remote}/${opts.integrationBranch} — skipped integrate.`);
138
+ return true;
139
+ }
140
+ try {
141
+ await gitMergeRemoteBranch(cwd, opts.remote, opts.integrationBranch);
142
+ log.ok(`Merged ${opts.remote}/${opts.integrationBranch} into task branch`);
143
+ return true;
144
+ }
145
+ catch (e) {
146
+ const msg = e instanceof Error ? e.message : String(e);
147
+ if (await gitMergeInProgress(cwd)) {
148
+ const conflicts = await listUnmergedRelPaths(cwd);
149
+ log.error(`Merge conflict with ${opts.integrationBranch}. Resolve, commit, then rerun.`);
150
+ if (conflicts.length > 0) {
151
+ log.info(dim(`Conflicts: ${conflicts.slice(0, 8).join(", ")}`));
152
+ }
153
+ }
154
+ else {
155
+ log.error(`Could not merge ${opts.integrationBranch}: ${msg}`);
156
+ }
157
+ return false;
158
+ }
159
+ }
160
+ export async function assertMergeRequestNotOpen(cwd, taskFile, allowOpenMr) {
161
+ if (allowOpenMr)
162
+ return true;
163
+ const state = await getMergeRequestStateForTask(cwd, taskFile);
164
+ if (state !== "open")
165
+ return true;
166
+ log.error("Current MR/PR is still open. Merge or close it first, or rerun with --allow-open-mr.");
167
+ return false;
168
+ }
169
+ export async function refreshGitContextBaseCommit(taskFile, gitCtx, cwd) {
170
+ const head = await gitHeadCommit(cwd);
171
+ if (head === null || head.length === 0)
172
+ return gitCtx;
173
+ return { ...gitCtx, baseCommit: head };
174
+ }
175
+ export async function prepareGitContextForReship(taskFile, gitCtx) {
176
+ const archived = archiveMergeRequestForReship(gitCtx);
177
+ await upsertGitContext(taskFile, archived);
178
+ return archived;
179
+ }
180
+ export async function loadGitConfigForReship(cwd) {
181
+ try {
182
+ const cfg = await requireGitConfig(cwd);
183
+ return { remote: cfg.remote, integrationBranch: cfg.integrationBranch };
184
+ }
185
+ catch (e) {
186
+ if (e instanceof GitConfigError) {
187
+ log.error(e.message);
188
+ return null;
189
+ }
190
+ throw e;
191
+ }
192
+ }
@@ -28,7 +28,7 @@ export function buildMinimalTaskMarkdown(draft) {
28
28
 
29
29
  ## Status
30
30
 
31
- ${statusDisplay("planned")}
31
+ ${statusDisplay("in_progress")}
32
32
 
33
33
  ## Goal
34
34
 
@@ -31,7 +31,7 @@ export async function llmCompleteTaskShip(params) {
31
31
  const messages = [
32
32
  {
33
33
  role: "system",
34
- content: `You ship a VibeOps TASK for review. Reply with JSON only:
34
+ content: `You ship a VibeOps TASK (Status → Shipped). Reply with JSON only:
35
35
  {
36
36
  "result": "markdown for TASK Result section — facts, paths, commands",
37
37
  "testResult": "markdown for Test Result — commands and outcomes",
@@ -91,7 +91,88 @@ Be factual. Do not invent files not in the diff.`,
91
91
  : null,
92
92
  logLine: typeof parsed.logLine === "string" && parsed.logLine.trim().length > 0
93
93
  ? parsed.logLine.trim()
94
- : `${params.taskId} shipped for review.`,
94
+ : `${params.taskId} shipped.`,
95
+ provider,
96
+ };
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ export async function llmCompleteTaskReship(params) {
103
+ const git = await readGitInfo(params.cwd);
104
+ let diffSummary = "";
105
+ if (git.isRepo) {
106
+ const names = await gitDiffNameOnly(params.cwd);
107
+ diffSummary = names.slice(0, 40).join("\n") || "(no diff vs HEAD index yet)";
108
+ }
109
+ const paths = projectPaths(params.cwd);
110
+ const currentState = (await readOptional(join(paths.root, PROJECT_MEMORY_FILES.currentState))) ?? "";
111
+ const existingResult = readSection(params.taskBody, "Result");
112
+ const existingTest = readSection(params.taskBody, "Test Result");
113
+ const messages = [
114
+ {
115
+ role: "system",
116
+ content: `You reship a follow-up on an already Shipped VibeOps TASK. Reply with JSON only:
117
+ {
118
+ "result": "full Result section — preserve prior facts and add a ## Follow-up subsection for this pass",
119
+ "testResult": "full Test Result for this follow-up — commands and outcomes",
120
+ "currentStateMarkdown": "full replacement for docs/project/05-current-state.md OR null to skip",
121
+ "decisionsAppend": "markdown bullets to append under ## Decisions OR null",
122
+ "architectureUpdate": "full replacement for 03-architecture.md OR null if unchanged",
123
+ "logLine": "one line for docs/logs/YYYY-MM-DD.md"
124
+ }
125
+ Be factual. Do not invent files not in the diff.`,
126
+ },
127
+ {
128
+ role: "user",
129
+ content: [
130
+ `TASK follow-up reship: ${params.taskId} — ${params.taskTitle}`,
131
+ `File: ${params.taskFileRel}`,
132
+ "",
133
+ "Existing Result:",
134
+ existingResult || "(empty)",
135
+ "",
136
+ "Existing Test Result:",
137
+ existingTest || "(empty)",
138
+ "",
139
+ "Changed paths (working tree / HEAD):",
140
+ diffSummary,
141
+ "",
142
+ "Current project state excerpt:",
143
+ currentState.slice(0, 2000),
144
+ ].join("\n"),
145
+ },
146
+ ];
147
+ try {
148
+ const config = await readConfig(params.cwd);
149
+ const preference = getLlmPreferenceFromConfig(config);
150
+ const { text: raw, provider } = await llmCompleteJson(messages, {
151
+ cwd: params.cwd,
152
+ preference,
153
+ });
154
+ const parsed = JSON.parse(extractJsonObject(raw));
155
+ return {
156
+ result: typeof parsed.result === "string" && parsed.result.trim().length > 0
157
+ ? parsed.result.trim()
158
+ : `${existingResult}\n\n## Follow-up\n\nReship ${params.taskId}.`,
159
+ testResult: typeof parsed.testResult === "string" && parsed.testResult.trim().length > 0
160
+ ? parsed.testResult.trim()
161
+ : existingTest || "Follow-up verification required.",
162
+ currentStateMarkdown: typeof parsed.currentStateMarkdown === "string" &&
163
+ parsed.currentStateMarkdown.trim().length > 0
164
+ ? parsed.currentStateMarkdown.trim()
165
+ : null,
166
+ decisionsAppend: typeof parsed.decisionsAppend === "string" && parsed.decisionsAppend.trim().length > 0
167
+ ? parsed.decisionsAppend.trim()
168
+ : null,
169
+ architectureUpdate: typeof parsed.architectureUpdate === "string" &&
170
+ parsed.architectureUpdate.trim().length > 0
171
+ ? parsed.architectureUpdate.trim()
172
+ : null,
173
+ logLine: typeof parsed.logLine === "string" && parsed.logLine.trim().length > 0
174
+ ? parsed.logLine.trim()
175
+ : `${params.taskId} reship follow-up.`,
95
176
  provider,
96
177
  };
97
178
  }
@@ -147,7 +228,7 @@ export async function fallbackResultSections(cwd, taskId, taskFile) {
147
228
  const body = await readText(taskFile);
148
229
  const goal = readSection(body, "Goal").slice(0, 200);
149
230
  const result = [
150
- `Shipped ${taskId} for review.`,
231
+ `Shipped ${taskId}.`,
151
232
  goal.length > 0 ? `Goal: ${goal}` : "",
152
233
  names.length > 0 ? `Paths: ${names.slice(0, 15).join(", ")}` : "",
153
234
  ]
@@ -81,7 +81,7 @@ export async function startTaskBranch(opts) {
81
81
  log.info(` ${dim("task branch")} ${cyan(taskBranch)}`);
82
82
  if (branchExists) {
83
83
  await gitCheckout(cwd, taskBranch);
84
- if (meta.status === "planned") {
84
+ if (meta.status !== "shipped") {
85
85
  await updateInlineStatus(taskFile, "in_progress");
86
86
  }
87
87
  if (existingCtx === null) {