@goodtek/vibeops 2.0.0 → 2.1.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,13 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Changed
8
+
9
+ - **TASK md status:** only **In Progress** → **Shipped** (`task ship`). Merge (CLI/UI) and **`task sync`** do not edit the TASK file.
10
+ - **`task sync`**: Git cleanup only (integration pull, delete `task/*` branches).
11
+ - **`task add`**: blocks only another **In Progress** TASK (not **Shipped**).
12
+ - Legacy statuses (Review, Done, Merged, Planned, …) normalize to **In Progress** or **Shipped** when read.
13
+
7
14
  ## 2.0.0 - 2026-06-03
8
15
 
9
16
  ### Breaking
@@ -18,7 +25,7 @@ All notable changes to VibeOps are documented here.
18
25
 
19
26
  ### Changed
20
27
 
21
- - **`task ship`** replaces `task done`: Done status and `doneAt` are set on **`task sync`** on the integration branch, not at ship time.
28
+ - **`task ship`** replaces `task done`: Review on the task branch at ship; **Done** is derived from merged MR (see Unreleased — sync no longer writes TASK md).
22
29
  - **`status`** next hints: ship → merge → sync.
23
30
  - Templates and Cursor rules reference ship / merge / sync (rule file `03-docs-before-ship.mdc`).
24
31
 
package/README.md CHANGED
@@ -13,9 +13,9 @@ 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 ship` | LLM summary, commit, push, open MR/PR (Status → Review) |
16
+ | `vibeops task ship` | LLM summary, commit, push, open MR/PR (Status → **Shipped**) |
17
17
  | `vibeops task merge` | Merge TASK MR/PR into integration branch (default: squash) |
18
- | `vibeops task sync` | After merge: update integration branch, mark Done, delete task branch |
18
+ | `vibeops task sync` | After merge: ff-only integration pull, delete task branches (no TASK md edits) |
19
19
  | `vibeops task release` | Release PR: integration → production (GitFlow) |
20
20
  | `vibeops status` | Briefing: TASK, Git, LLM, clients |
21
21
  | `vibeops llm` | Connect LLM providers (`connect` · `status` · `use`) |
@@ -62,7 +62,7 @@ Occasionally (GitFlow release to production):
62
62
  vibeops task release
63
63
  ```
64
64
 
65
- If a TASK is **In Progress** or **Review**, `task add` exits with a guide.
65
+ Only one TASK **In Progress** at a time (`task add` blocks otherwise). **Shipped** slices do not block the next add; merge on the host or with `task merge`, then optional `task sync`.
66
66
 
67
67
  ## LLM (optional)
68
68
 
@@ -101,9 +101,9 @@ pnpm install && pnpm build && pnpm smoke
101
101
 
102
102
  - **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`).
103
103
  - **`task add`**: `task/<slug>` from **integration** (e.g. `develop`).
104
- - **`task ship`**: commit → push → MR/PR → Status **Review** on task branch.
105
- - **`task merge`**: merge MR/PR into integration (CLI default: squash).
106
- - **`task sync`**: integration ff-only pull → Status **Done** → delete `task/*` branches.
104
+ - **`task ship`**: commit → push → MR/PR → Status **Shipped** on task branch.
105
+ - **`task merge`**: merge MR/PR into integration (CLI or host UI; TASK md unchanged).
106
+ - **`task sync`**: integration ff-only pull → delete `task/*` branches (TASK md unchanged).
107
107
  - **`task release`**: integration → production PR + merge (skipped on trunk policy).
108
108
  - No force-push to shared branches.
109
109
 
@@ -8,7 +8,8 @@ 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 { countTasks, hasNonEmptySection, isOnTaskBranch, loadActionableTasks, readGitContext, statusDisplay } from "../lib/task.js";
11
+ import { isMergeRequestMerged, taskNeedsSync } from "../lib/task-effective-status.js";
12
+ import { countTasks, hasNonEmptySection, isOnTaskBranch, loadActionableTasks, readGitContext, statusDisplay, } from "../lib/task.js";
12
13
  import { summarizeGoal } from "../lib/task-summary.js";
13
14
  async function buildReport(cwd) {
14
15
  const paths = projectPaths(cwd);
@@ -32,6 +33,8 @@ async function buildReport(cwd) {
32
33
  let branchExists = null;
33
34
  let goalExcerpt = "";
34
35
  let mergeRequestUrl = null;
36
+ let mergeRequestMerged = false;
37
+ let needsSync = false;
35
38
  if (focus !== null) {
36
39
  const body = await readText(focus.filePath);
37
40
  goalExcerpt = summarizeGoal(body);
@@ -42,6 +45,8 @@ async function buildReport(cwd) {
42
45
  mergeRequestUrl = ctx?.mergeRequestUrl ?? null;
43
46
  onTaskBranch = isOnTaskBranch(git.branch, ctx);
44
47
  branchExists = await taskBranchExistsFor(cwd, focus);
48
+ mergeRequestMerged = await isMergeRequestMerged(cwd, focus);
49
+ needsSync = await taskNeedsSync(cwd, focus);
45
50
  focusBlock = {
46
51
  id: focus.id,
47
52
  title: focus.title,
@@ -63,6 +68,8 @@ async function buildReport(cwd) {
63
68
  testFilled,
64
69
  onTaskBranch,
65
70
  hasMergeRequest: typeof mergeRequestUrl === "string" && mergeRequestUrl.length > 0,
71
+ mergeRequestMerged,
72
+ needsSync,
66
73
  });
67
74
  const llm = isVibeopsProject ? await buildLlmStatusReport(cwd) : null;
68
75
  return {
@@ -105,7 +112,7 @@ function printHuman(report) {
105
112
  }
106
113
  if (report.counts) {
107
114
  const c = report.counts;
108
- log.info(` ${dim("Tasks")} ${c.total} total · ${cyan(String(c.in_progress))} in progress · ${c.done} done`);
115
+ log.info(` ${dim("Tasks")} ${c.total} total · ${cyan(String(c.in_progress))} in progress · ${c.shipped} shipped`);
109
116
  }
110
117
  if (report.inProgress.length > 1) {
111
118
  log.blank();
@@ -44,7 +44,7 @@ export async function taskAddCommand(opts = {}) {
44
44
  }
45
45
  log.info(` ${dim("file")} ${relPath(root, blocking.filePath)}`);
46
46
  log.blank();
47
- log.info(`Finish it first: ${cyan(`vibeops task sync ${blocking.id}`)} (after ship + merge)`);
47
+ log.info(`Finish it first: ${cyan(`vibeops task ship ${blocking.id}`)}.`);
48
48
  log.info(`Then run ${cyan("vibeops task add")} again.`);
49
49
  process.exitCode = 1;
50
50
  return;
@@ -11,30 +11,30 @@ import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
11
11
  import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
12
12
  async function commitShipMetadataAndPush(opts) {
13
13
  if (opts.dryRun) {
14
- log.info(dim(" would set Status → Review"));
14
+ log.info(dim(" would set Status → Shipped"));
15
15
  log.info(dim(" would commit ship metadata"));
16
16
  if (opts.push !== null) {
17
- log.info(dim(` would git push ${opts.push.remote} ${opts.push.branch} (review metadata)`));
17
+ log.info(dim(` would git push ${opts.push.remote} ${opts.push.branch} (ship metadata)`));
18
18
  }
19
19
  return true;
20
20
  }
21
- await updateInlineStatus(opts.taskFile, "review");
22
- log.ok("Status → Review");
23
- const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "ready for review"), false);
21
+ await updateInlineStatus(opts.taskFile, "shipped");
22
+ log.ok("Status → Shipped");
23
+ const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "mark shipped"), false);
24
24
  if (!committed) {
25
- log.warn("No file changes for review metadata commit — check TASK Status on the remote branch.");
25
+ log.warn("No file changes for ship metadata commit — check TASK Status on the remote branch.");
26
26
  return true;
27
27
  }
28
28
  if (opts.push === null)
29
29
  return true;
30
30
  try {
31
31
  await pushBranch(opts.cwd, opts.push.remote, opts.push.branch, false);
32
- log.ok(`Pushed review metadata → ${opts.push.remote}/${opts.push.branch}`);
32
+ log.ok(`Pushed ship metadata → ${opts.push.remote}/${opts.push.branch}`);
33
33
  return true;
34
34
  }
35
35
  catch (e) {
36
36
  const msg = e instanceof Error ? e.message : String(e);
37
- log.error(`Review metadata push failed: ${msg}`);
37
+ log.error(`Ship metadata push failed: ${msg}`);
38
38
  log.info(dim(`Fix auth/remote, then: git push ${opts.push.remote} ${opts.push.branch}`));
39
39
  return false;
40
40
  }
@@ -105,7 +105,7 @@ export async function taskShipCommand(taskRef, options = {}) {
105
105
  log.info(" · LLM fill Result / Test Result + patch docs/project/*");
106
106
  }
107
107
  log.info(" · commit implementation, push task branch, open MR/PR (unless --no-pr)");
108
- log.info(" · Status → Review; commit metadata; push again");
108
+ log.info(" · Status → Shipped; commit metadata; push again");
109
109
  await finishTaskWithPullRequest({
110
110
  cwd,
111
111
  taskFile,
@@ -166,7 +166,7 @@ export async function taskShipCommand(taskRef, options = {}) {
166
166
  push: pushTarget,
167
167
  });
168
168
  if (!shipped) {
169
- log.error("Review metadata push failed — fix manually, then run task merge.");
169
+ log.error("Ship metadata push failed — fix manually, then merge the MR.");
170
170
  process.exitCode = 1;
171
171
  return;
172
172
  }
@@ -3,13 +3,13 @@ import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
3
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
4
4
  import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitFetchRemote, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitSwitchToBranch, readGitInfo, } from "../lib/git.js";
5
5
  import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
6
- import { getMergeRequestState, probeMergeRequestCli } from "../lib/pr-create.js";
6
+ 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 { commitDirtyWorkingTree, docsCommitMessageFor, pushBranch, } from "../lib/task-git-commit.js";
10
+ import { getMergeRequestStateForTask, isMergeRequestMerged, } from "../lib/task-effective-status.js";
11
11
  import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
12
- import { markGitContextDone, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
12
+ import { readGitContext, 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
  }
@@ -52,15 +52,16 @@ export async function taskSyncCommand(taskRef, options = {}) {
52
52
  log.info(` ${dim("task branch")} ${cyan(taskBranch)}`);
53
53
  log.info(` ${dim("integration")} ${integrationBranch}`);
54
54
  log.info(` ${dim("file")} ${relPath(cwd, taskFile)}`);
55
+ log.info(` ${dim("note")} ${dim("Git cleanup only — TASK md stays Shipped")}`);
55
56
  log.blank();
56
57
  const meta = await readTaskFile(taskFile);
57
58
  if (ctx?.mergeRequestUrl && !dryRun) {
58
- const remoteUrl = await gitRemoteUrl(cwd, remote);
59
- const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
60
- ? detectGitHost(remoteUrl)
61
- : gitCfg.host;
62
- if (await probeMergeRequestCli(host)) {
63
- const state = await getMergeRequestState(cwd, host, ctx.mergeRequestUrl);
59
+ const state = await getMergeRequestStateForTask(cwd, taskFile);
60
+ if (state !== null) {
61
+ const remoteUrl = await gitRemoteUrl(cwd, remote);
62
+ const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
63
+ ? detectGitHost(remoteUrl)
64
+ : gitCfg.host;
64
65
  const label = mergeRequestLabel(host);
65
66
  if (state === "open") {
66
67
  log.warn(`${yellow(label)} still open — merge with task merge or the host UI before sync.`);
@@ -69,22 +70,23 @@ export async function taskSyncCommand(taskRef, options = {}) {
69
70
  log.ok(`${label} merged.`);
70
71
  }
71
72
  }
73
+ else if (await probeMergeRequestCli(gitCfg.host)) {
74
+ log.warn("Could not read MR state — continuing with branch cleanup.");
75
+ }
72
76
  }
73
- else if (meta.status === "review") {
74
- log.warn(`${yellow("Status")} is Review — merge the MR first, then rerun task sync.`);
77
+ else if (meta.status === "shipped" && !(await isMergeRequestMerged(cwd, meta))) {
78
+ log.warn(`${yellow("MR")} not merged yet — merge on the host or \`task merge\`, then rerun task sync.`);
75
79
  }
76
80
  if (dryRun) {
77
81
  log.info(bold("dry-run — would:"));
78
82
  log.info(` · git fetch ${remote} --prune`);
79
83
  log.info(` · git switch ${integrationBranch}`);
80
84
  log.info(` · git pull --ff-only ${remote} ${integrationBranch}`);
81
- if (meta.status !== "done") {
82
- log.info(" · Status → Done on integration branch; commit; push");
83
- }
84
85
  log.info(` · git branch ${options.force === true ? "-D" : "-d"} ${taskBranch}`);
85
86
  if (options.noRemoteDelete !== true) {
86
87
  log.info(` · git push ${remote} --delete ${taskBranch} (if exists)`);
87
88
  }
89
+ log.info(dim(" · no edits to docs/tasks/*.md"));
88
90
  log.blank();
89
91
  log.info(`Next: ${cyan("vibeops task add")}`);
90
92
  return;
@@ -128,23 +130,6 @@ export async function taskSyncCommand(taskRef, options = {}) {
128
130
  else {
129
131
  log.warn(`No ${remote}/${integrationBranch} — skipped pull.`);
130
132
  }
131
- const metaOnIntegration = await readTaskFile(taskFile);
132
- if (metaOnIntegration.status !== "done") {
133
- await updateInlineStatus(taskFile, "done");
134
- await markGitContextDone(taskFile);
135
- log.ok("Status → Done");
136
- const committed = await commitDirtyWorkingTree(cwd, docsCommitMessageFor(taskId, "mark done"), false);
137
- if (committed) {
138
- try {
139
- await pushBranch(cwd, remote, integrationBranch, false);
140
- log.ok(`Pushed done metadata → ${remote}/${integrationBranch}`);
141
- }
142
- catch (e) {
143
- const msg = e instanceof Error ? e.message : String(e);
144
- log.warn(`Could not push done metadata: ${msg}`);
145
- }
146
- }
147
- }
148
133
  if (await gitBranchExists(cwd, taskBranch)) {
149
134
  try {
150
135
  await gitDeleteBranch(cwd, taskBranch, { force: options.force === true });
@@ -21,7 +21,7 @@ export async function resolveTask(paths, cwd, taskRef) {
21
21
  return { taskId: inProgress.id, taskFile: inProgress.filePath };
22
22
  }
23
23
  const onBranch = await pickActiveTask(paths.docsTasks, cwd);
24
- if (onBranch !== null && onBranch.status !== "done") {
24
+ if (onBranch !== null && onBranch.status === "in_progress") {
25
25
  return { taskId: onBranch.id, taskFile: onBranch.filePath };
26
26
  }
27
27
  return null;
@@ -40,7 +40,7 @@ function systemPrompt() {
40
40
  }
41
41
 
42
42
  markdown must include sections: Status, Goal, Scope, Out of Scope, Acceptance Criteria, Test Plan, Git Context, Result, Test Result.
43
- Status body: ${statusDisplay("planned")}
43
+ Status body: ${statusDisplay("in_progress")}
44
44
  Result and Test Result: (not yet)
45
45
  Git Context: (populated by vibeops task add)
46
46
  Use the exact TASK id given in the user message for the # heading.`;
@@ -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,12 @@ 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
+ return "task-add";
43
52
  }
44
53
  if (!input.resultFilled || !input.testFilled) {
45
54
  return input.onTaskBranch === true ? "cursor-implement" : "cursor-plan";
@@ -56,12 +65,12 @@ export function hintToText(hint, cwd, task) {
56
65
  return task ? `Run \`vibeops task ship ${task.id}\`.` : "Run `vibeops task ship`.";
57
66
  case "task-merge":
58
67
  return task
59
- ? `Run \`vibeops task merge ${task.id}\`, then task sync.`
60
- : "Run `vibeops task merge` after the MR is ready.";
68
+ ? `Merge the MR (UI or \`vibeops task merge ${task.id}\`), then optional task sync.`
69
+ : "Merge the MR on the host or with `vibeops task merge`.";
61
70
  case "task-sync":
62
71
  return task
63
- ? `MR merged? Run \`vibeops task sync ${task.id}\`, then task add.`
64
- : "Run `vibeops task sync` after merging the MR.";
72
+ ? `Run \`vibeops task sync ${task.id}\` to delete task branches, then task add.`
73
+ : "Run `vibeops task sync` to delete task branches after merge.";
65
74
  case "cursor-plan":
66
75
  return task
67
76
  ? `In Cursor Ask: @${relPath(cwd, task.filePath)} — refine Scope / AC.`
@@ -81,3 +90,4 @@ export async function taskBranchExistsFor(cwd, task) {
81
90
  return false;
82
91
  return gitBranchExists(cwd, ctx.taskBranch);
83
92
  }
93
+ 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
  }
@@ -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,7 @@ 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
95
  provider,
96
96
  };
97
97
  }
@@ -147,7 +147,7 @@ export async function fallbackResultSections(cwd, taskId, taskFile) {
147
147
  const body = await readText(taskFile);
148
148
  const goal = readSection(body, "Goal").slice(0, 200);
149
149
  const result = [
150
- `Shipped ${taskId} for review.`,
150
+ `Shipped ${taskId}.`,
151
151
  goal.length > 0 ? `Goal: ${goal}` : "",
152
152
  names.length > 0 ? `Paths: ${names.slice(0, 15).join(", ")}` : "",
153
153
  ]
@@ -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) {
package/dist/lib/task.js CHANGED
@@ -3,33 +3,31 @@ import { basename, extname, join } from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import { isDirectory, readText, writeText } from "./filesystem.js";
5
5
  import { readGitInfo } from "./git.js";
6
- const KNOWN_STATUSES = new Set([
7
- "planned",
8
- "in_progress",
6
+ const SHIPPED_ALIASES = new Set([
9
7
  "review",
10
- "blocked",
8
+ "merged",
11
9
  "done",
10
+ "shipped",
11
+ "ready_for_review",
12
12
  ]);
13
13
  export function normalizeStatus(value) {
14
14
  if (typeof value === "string") {
15
15
  const s = value.toLowerCase().replace(/\s+/g, "_");
16
- if (KNOWN_STATUSES.has(s))
17
- return s;
16
+ if (s === "in_progress" || s === "inprogress")
17
+ return "in_progress";
18
+ if (SHIPPED_ALIASES.has(s))
19
+ return "shipped";
20
+ if (s === "planned" || s === "blocked")
21
+ return "in_progress";
18
22
  }
19
- return "planned";
23
+ return "in_progress";
20
24
  }
21
25
  export function statusDisplay(status) {
22
26
  switch (status) {
23
- case "planned":
24
- return "Planned";
25
27
  case "in_progress":
26
28
  return "In Progress";
27
- case "review":
28
- return "Review";
29
- case "blocked":
30
- return "Blocked";
31
- case "done":
32
- return "Done";
29
+ case "shipped":
30
+ return "Shipped";
33
31
  }
34
32
  }
35
33
  function extractIdFromFilename(file) {
@@ -89,7 +87,7 @@ export async function readTaskFile(filePath) {
89
87
  const title = titleFromFm ?? extractTitleFromBody(body);
90
88
  const status = data["status"] !== undefined
91
89
  ? normalizeStatus(data["status"])
92
- : (extractInlineStatus(body) ?? "planned");
90
+ : (extractInlineStatus(body) ?? "in_progress");
93
91
  const mvpPhaseFromFm = typeof data["mvpPhase"] === "string" ? data["mvpPhase"] : undefined;
94
92
  const mvpPhase = mvpPhaseFromFm ?? extractInlineMvpPhase(body);
95
93
  const priority = typeof data["priority"] === "string" ? data["priority"] : undefined;
@@ -117,11 +115,8 @@ export async function scanTasks(tasksDir) {
117
115
  export function countTasks(tasks) {
118
116
  const counts = {
119
117
  total: tasks.length,
120
- planned: 0,
121
118
  in_progress: 0,
122
- review: 0,
123
- blocked: 0,
124
- done: 0,
119
+ shipped: 0,
125
120
  };
126
121
  for (const t of tasks)
127
122
  counts[t.status]++;
@@ -131,14 +126,7 @@ export function pickInProgressTask(tasks) {
131
126
  return tasks.find((t) => t.status === "in_progress") ?? null;
132
127
  }
133
128
  export function pickNextTask(tasks) {
134
- const inProgress = pickInProgressTask(tasks);
135
- if (inProgress)
136
- return inProgress;
137
- const review = tasks.find((t) => t.status === "review");
138
- if (review)
139
- return review;
140
- const planned = tasks.filter((t) => t.status === "planned");
141
- return planned[0] ?? null;
129
+ return pickInProgressTask(tasks);
142
130
  }
143
131
  const TEMPLATE_TASK_ID = "TASK-000";
144
132
  export function filterActionableTasks(tasks) {
@@ -148,19 +136,12 @@ function taskSortKey(id) {
148
136
  const m = /^TASK-(\d+)$/i.exec(id.trim());
149
137
  return m ? Number.parseInt(m[1], 10) : 0;
150
138
  }
151
- /** Latest finished TASK by numeric id (TASK-mvp sorts after numbered tasks). */
152
- export function pickLatestDoneTask(tasks) {
153
- const done = filterActionableTasks(tasks).filter((t) => t.status === "done");
154
- if (done.length === 0)
139
+ /** Latest **Shipped** TASK (for merge / sync targeting). */
140
+ export function pickLatestShippedTask(tasks) {
141
+ const shipped = filterActionableTasks(tasks).filter((t) => t.status === "shipped");
142
+ if (shipped.length === 0)
155
143
  return null;
156
- return [...done].sort((a, b) => taskSortKey(b.id) - taskSortKey(a.id))[0];
157
- }
158
- /** Latest TASK in Review (awaiting merge / sync). */
159
- export function pickLatestReviewTask(tasks) {
160
- const review = filterActionableTasks(tasks).filter((t) => t.status === "review");
161
- if (review.length === 0)
162
- return null;
163
- return [...review].sort((a, b) => taskSortKey(b.id) - taskSortKey(a.id))[0];
144
+ return [...shipped].sort((a, b) => taskSortKey(b.id) - taskSortKey(a.id))[0];
164
145
  }
165
146
  export async function loadActionableTasks(tasksDir) {
166
147
  return filterActionableTasks(await scanTasks(tasksDir));
@@ -185,7 +166,7 @@ export async function pickActiveTask(tasksDir, cwd) {
185
166
  return t;
186
167
  }
187
168
  }
188
- return pickNextTask(actionable);
169
+ return pickNextTask(actionable.filter((t) => t.status === "in_progress"));
189
170
  }
190
171
  export async function findTaskFile(tasksDir, taskId) {
191
172
  const all = await scanTasks(tasksDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "author": "VibeOps contributors",
@@ -1,6 +1,6 @@
1
1
  # Current state
2
2
 
3
- > Facts only. Updated by `vibeops task ship` / humans; stage changes on `task sync`.
3
+ > Facts only. Updated by `vibeops task ship` / humans after merge (not by `task sync`).
4
4
 
5
5
  ## Stage
6
6