@goodtek/vibeops 2.4.3 → 2.5.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,26 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.5.0 - 2026-07-19
8
+
9
+ ### Breaking
10
+
11
+ - **Removed `vibeops task reship`.** Use `vibeops task ship` (state-aware). After merge, confirm or pass `--new-cycle`. Hidden flag `ship --reship` remains as an alias for `--new-cycle`.
12
+
13
+ ### Changed
14
+
15
+ - **`vibeops init` remote setup:** ask GitHub / GitLab / Skip, then Create or Connect. Soft-gate when `gh`/`glab` is missing (install hints, optional `brew install` with consent, or manual URL).
16
+ - **`vibeops status`:** human output is a **Now / Next** card (focus, checklist, PR, next command); details (project, counts, LLM) move to a dim footer.
17
+ - Shared **host CLI install/login hints** for ship / merge / release when the tool is missing.
18
+
19
+ ## 2.4.4 - 2026-06-25
20
+
21
+ ### Fixed
22
+
23
+ - **`task sync`:** After merge verification passes, delete the local `task/*` branch with `-D`. Squash-merged TASK branches no longer require `--force` for cleanup.
24
+ - **`task merge` / `task release`:** Wait for MR/PR CI to finish before calling the host merge API. GitLab no longer gets an immediate `--auto-merge=false` merge while the pipeline is still running (405). Falls back to auto-merge when needed, then polls until `merged_at` is set.
25
+ - **`task release`:** Default merge strategy is now **merge commit** (integration → production). Use `--squash` to squash.
26
+
7
27
  ## 2.4.3 - 2026-06-25
8
28
 
9
29
  ### Fixed
package/README.md CHANGED
@@ -15,22 +15,23 @@ VibeOps bootstraps an **agent-friendly repo**, starts numbered **TASK** files on
15
15
  | `vibeops task add` | New `TASK-NNN` file + task branch |
16
16
  | `vibeops task del` | Cancel TASK before merge (md + branch + close open MR) |
17
17
  | `vibeops task ship` | State-aware submit: open a new PR · update the open PR · start a new PR cycle after merge (Status → **Shipped**) |
18
- | `vibeops task reship` | **Deprecated** alias for `task ship --new-cycle` |
19
18
  | `vibeops task merge` | Merge TASK MR/PR into integration branch (default: squash) |
20
19
  | `vibeops task sync` | After merge: ff-only integration pull, delete task branches (no TASK md edits) |
21
20
  | `vibeops task release` | Release PR: integration → production (GitFlow) |
22
21
  | `vibeops pull` | Fetch remote + update integration branch (e.g. develop) |
23
- | `vibeops status` | Briefing: TASK, Git, LLM, clients |
22
+ | `vibeops status` | Now / Next briefing: focus TASK, Git, what to run next |
24
23
  | `vibeops llm` | Connect LLM providers (`connect` · `status` · `use`) |
25
24
 
26
25
  ## Init
27
26
 
27
+ Interactive init asks which agents to install, Git branch policy, then **where the remote lives** (GitHub / GitLab / skip) and whether to **create** or **connect** a repository. If `gh`/`glab` is missing, init prints install hints (optional `brew install` with consent) or accepts a URL — it does not block the rest of VibeOps.
28
+
28
29
  ```bash
29
- # Interactive: pick agents (≥1), then Git
30
+ # Interactive: pick agents (≥1), then Git + remote host
30
31
  vibeops init
31
32
 
32
33
  # Non-interactive (GitFlow: develop + main, origin required unless CI)
33
- vibeops init --clients cursor,claude,codex --git --initial-commit --git-policy gitflow
34
+ vibeops init --clients cursor,claude,codex --git --initial-commit --git-policy gitflow --allow-no-remote
34
35
 
35
36
  # Re-init templates on an existing project
36
37
  vibeops init --clients cursor --yes
@@ -107,11 +108,10 @@ pnpm install && pnpm build && pnpm smoke
107
108
 
108
109
  ## Flags (common)
109
110
 
110
- - **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--allow-no-remote`, `--cwd`
111
+ - **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--git-host github|gitlab`, `--allow-no-remote`, `--cwd`
111
112
  - **`task add`**: `--dry-run`, `--non-interactive --idea "…"`
112
113
  - **`task del`**: `--dry-run`, `--force`, `--no-remote-delete`, `--no-close-mr`
113
- - **`task ship`**: `-m/--message`, `--new-cycle` (alias `--reship`), `--no-commit`, `--dry-run`, `--no-pr`, `--non-interactive`, `--allow-open-mr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`
114
- - **`task reship`** (deprecated): `--dry-run`, `--no-pr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`, `--allow-open-mr` → delegates to `task ship --new-cycle`
114
+ - **`task ship`**: `-m/--message`, `--new-cycle`, `--no-commit`, `--dry-run`, `--no-pr`, `--non-interactive`, `--allow-open-mr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`
115
115
  - **`task merge`**: `--dry-run`, `--merge`, `--rebase`
116
116
  - **`task sync`**: `--dry-run`, `--no-remote-delete`, `--force`
117
117
  - **`task release`**: `--dry-run`, `--no-merge`, `--merge`, `--rebase`
@@ -120,14 +120,14 @@ pnpm install && pnpm build && pnpm smoke
120
120
 
121
121
  ## Git
122
122
 
123
- - **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`).
123
+ - **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`). Interactive remote setup: ask GitHub/GitLab, create or connect; soft-gate when `gh`/`glab` is missing.
124
124
  - **`task add`**: `task/<slug>` from **integration** (e.g. `develop`).
125
- - **`task ship`** (state-aware): **no PR** → commit + ship metadata (Status **Shipped**) → push → open MR/PR; **open PR** → commit + push the same branch, CI re-runs, no new PR; **merged PR** → new PR cycle (carries uncommitted work onto the task branch, integrates `develop`, opens a **new** PR). Commit messages are TASK-id-scoped (`feat(task-001): …`). Refuses when HEAD is not the task branch.
126
- - **`task reship`** *(deprecated)*: alias for `task ship --new-cycle`.
125
+ - **`task ship`** (state-aware): **no PR** → commit + ship metadata (Status **Shipped**) → push → open MR/PR; **open PR** → commit + push the same branch, CI re-runs, no new PR; **merged PR** → new PR cycle (carries uncommitted work onto the task branch, integrates `develop`, opens a **new** PR). Use `--new-cycle` in non-interactive mode. Commit messages are TASK-id-scoped (`feat(task-001): …`). Refuses when HEAD is not the task branch.
127
126
  - **`task merge`**: merge MR/PR into integration (CLI or host UI; TASK md unchanged).
128
127
  - **`task sync`**: integration ff-only pull → delete `task/*` branches (TASK md unchanged).
129
128
  - **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command).
130
129
  - **`task release`**: integration → production PR + merge (skipped on trunk policy).
130
+ - **`status`**: Now / Next card — focus stage, checklist, PR, and the next command to run.
131
131
  - No force-push to shared branches.
132
132
 
133
133
  ## License
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
2
+ import { Command, Option } from "commander";
3
3
  import { pullCommand } from "./commands/pull.js";
4
4
  import { initCommand } from "./commands/init.js";
5
5
  import { llmConnectCommand, llmStatusCommand, llmUseCommand } from "./commands/llm.js";
@@ -9,7 +9,6 @@ import { taskDelCommand } from "./commands/task-del.js";
9
9
  import { taskMergeCommand } from "./commands/task-merge.js";
10
10
  import { taskReleaseCommand } from "./commands/task-release.js";
11
11
  import { taskShipCommand } from "./commands/task-ship.js";
12
- import { taskReshipCommand } from "./commands/task-reship.js";
13
12
  import { taskSyncCommand } from "./commands/task-sync.js";
14
13
  import { loadVibeopsEnv } from "./lib/env.js";
15
14
  import { VERSION } from "./version.js";
@@ -61,7 +60,7 @@ program
61
60
  });
62
61
  const task = program
63
62
  .command("task")
64
- .description("TASK lifecycle (add · del · ship · reship · merge · sync · release)");
63
+ .description("TASK lifecycle (add · del · ship · merge · sync · release)");
65
64
  task
66
65
  .command("add")
67
66
  .description("Create next TASK-NNN, task branch, In Progress")
@@ -99,8 +98,8 @@ task
99
98
  .description("State-aware submit: new PR · update open PR · new PR cycle after merge (Status → Shipped)")
100
99
  .option("--dry-run", "Plan only")
101
100
  .option("-m, --message <msg>", "Commit message (TASK id auto-prefixed; LLM/prompt when omitted)")
102
- .option("--new-cycle", "Allow a new PR cycle after merge without prompting (alias: --reship)")
103
- .option("--reship", "Alias for --new-cycle")
101
+ .option("--new-cycle", "Allow a new PR cycle after merge without prompting")
102
+ .addOption(new Option("--reship", "Alias for --new-cycle").hideHelp())
104
103
  .option("--no-commit", "Push already-committed changes only (skip staging/commit)")
105
104
  .option("--no-pr", "Push only; skip creating MR/PR")
106
105
  .option("--non-interactive", "CI: never prompt")
@@ -124,27 +123,6 @@ task
124
123
  cwd: opts.cwd,
125
124
  });
126
125
  });
127
- task
128
- .command("reship [taskRef]")
129
- .description("Deprecated alias for `task ship --new-cycle` (start a new PR cycle after merge)")
130
- .option("--dry-run", "Plan only")
131
- .option("--no-pr", "Push only; skip creating MR/PR")
132
- .option("--no-integrate", "Skip merging integration branch into task branch")
133
- .option("--recreate-branch", "Create task branch from integration instead of reusing local/remote ref")
134
- .option("--skip-llm", "Do not run LLM for Result / Test Result")
135
- .option("--allow-open-mr", "Update the open MR/PR with new commits (merge before reship for a new PR)")
136
- .option("--cwd <path>", "Target directory")
137
- .action(async (taskRef, opts) => {
138
- await taskReshipCommand(taskRef, {
139
- dryRun: Boolean(opts.dryRun),
140
- noPr: opts.pr === false,
141
- noIntegrate: opts.integrate === false,
142
- recreateBranch: Boolean(opts.recreateBranch),
143
- skipLlm: Boolean(opts.skipLlm),
144
- allowOpenMr: Boolean(opts.allowOpenMr),
145
- cwd: opts.cwd,
146
- });
147
- });
148
126
  task
149
127
  .command("merge [taskRef]")
150
128
  .description("Merge TASK MR/PR into integration branch (default: squash)")
@@ -165,7 +143,7 @@ task
165
143
  .description("After MR merge: integration pull and delete task branch (TASK md unchanged)")
166
144
  .option("--dry-run", "Plan only")
167
145
  .option("--no-remote-delete", "Keep the task branch on the remote")
168
- .option("--force", "Bypass MR merge checks and delete local task branch with -D if not fully merged")
146
+ .option("--force", "Bypass MR merge verification before branch cleanup")
169
147
  .option("--cwd <path>", "Target directory")
170
148
  .action(async (taskRef, opts) => {
171
149
  await taskSyncCommand(taskRef, {
@@ -177,10 +155,11 @@ task
177
155
  });
178
156
  task
179
157
  .command("release")
180
- .description("Open (and merge) release MR/PR: integration branch → production")
158
+ .description("Open (and merge) release MR/PR: integration branch → production (default: merge commit)")
181
159
  .option("--dry-run", "Plan only")
182
160
  .option("--no-merge", "Create release PR only")
183
- .option("--merge", "Merge commit (instead of squash)")
161
+ .option("--squash", "Squash merge (default is merge commit for release)")
162
+ .option("--merge", "Merge commit (default for release)")
184
163
  .option("--rebase", "Rebase merge")
185
164
  .option("--cwd <path>", "Target directory")
186
165
  .action(async (opts) => {
@@ -188,6 +167,7 @@ task
188
167
  dryRun: Boolean(opts.dryRun),
189
168
  noMerge: Boolean(opts.noMerge),
190
169
  merge: Boolean(opts.merge),
170
+ squash: Boolean(opts.squash),
191
171
  rebase: Boolean(opts.rebase),
192
172
  cwd: opts.cwd,
193
173
  });
@@ -108,30 +108,41 @@ async function resolveBranchPolicy(options, interactive) {
108
108
  }
109
109
  return { integrationBranch: "develop", productionBranch: "main" };
110
110
  }
111
- function printNextSteps(clients, git) {
111
+ function printNextSteps(clients, git, opts) {
112
112
  log.blank();
113
113
  log.info("Next steps:");
114
- log.info(" 1. Read AGENTS.md");
114
+ let n = 1;
115
+ log.info(` ${n++}. Read AGENTS.md`);
115
116
  if (clients.includes("cursor")) {
116
- log.info(" 2. Cursor: @docs/tasks/TASK-001-….md in Ask, then Agent (+ /plan-task, /implement-task)");
117
+ log.info(` ${n++}. Cursor: @docs/tasks/TASK-001-….md in Ask, then Agent (+ /plan-task, /implement-task)`);
117
118
  }
118
- if (clients.includes("claude")) {
119
- log.info(" 2. Claude Code: open TASK file; use /plan-task then /implement-task");
119
+ else if (clients.includes("claude")) {
120
+ log.info(` ${n++}. Claude Code: open TASK file; use /plan-task then /implement-task`);
120
121
  }
121
- if (clients.includes("codex")) {
122
- log.info(" 2. Codex: open TASK file; use $plan-task / $implement-task when needed");
122
+ else if (clients.includes("codex")) {
123
+ log.info(` ${n++}. Codex: open TASK file; use $plan-task / $implement-task when needed`);
123
124
  }
124
- if (!clients.includes("cursor") && !clients.includes("claude") && !clients.includes("codex")) {
125
- log.info(" 2. Open the current TASK file in your agent before coding");
125
+ else {
126
+ log.info(` ${n++}. Open the current TASK file in your agent before coding`);
127
+ }
128
+ log.info(` ${n++}. ${cyan("vibeops llm connect")} — LLM for task add / task ship`);
129
+ if (!opts.hasRemote) {
130
+ log.info(` ${n++}. Add a remote later: ${dim(`git remote add ${git.remote} <url>`)} (or re-run init)`);
131
+ }
132
+ else if (!opts.pushedBaseline) {
133
+ log.info(` ${n++}. Push branches to origin (first time only):`);
134
+ log.info(` ${dim(`git push -u ${git.remote} ${git.productionBranch}`)}`);
135
+ if (git.integrationBranch !== git.productionBranch) {
136
+ log.info(` ${dim(`git push -u ${git.remote} ${git.integrationBranch}`)}`);
137
+ }
126
138
  }
127
- log.info(` 3. ${cyan("vibeops llm connect")} — LLM for task add / task ship`);
128
- log.info(" 4. Push branches to origin (first time only):");
129
- log.info(` ${dim(`git push -u ${git.remote} ${git.productionBranch}`)}`);
130
- if (git.integrationBranch !== git.productionBranch) {
131
- log.info(` ${dim(`git push -u ${git.remote} ${git.integrationBranch}`)}`);
139
+ log.info(` ${n++}. ${cyan("vibeops task add")} — branches from ${git.integrationBranch} (pulls latest first)`);
140
+ if (opts.hasRemote) {
141
+ log.info(` ${n++}. ${cyan("vibeops task ship")} → merge → sync — TASK lifecycle on ${git.host}`);
142
+ }
143
+ else {
144
+ log.info(` ${n++}. ${cyan("vibeops task ship")} — after origin exists`);
132
145
  }
133
- log.info(` 5. ${cyan("vibeops task add")} — branches from ${git.integrationBranch} (pulls latest first)`);
134
- log.info(` 6. ${cyan("vibeops task ship")} → merge → sync — TASK lifecycle on ${git.host}`);
135
146
  }
136
147
  export async function initCommand(options = {}) {
137
148
  const dryRun = options.dryRun === true;
@@ -223,6 +234,7 @@ export async function initCommand(options = {}) {
223
234
  dryRun,
224
235
  nonInteractive: !interactive,
225
236
  allowMissing: options.allowNoRemote === true,
237
+ preferredHost: hostFromFlag ?? existingConfig?.git?.host,
226
238
  defaultBranch: gitConfig.productionBranch,
227
239
  });
228
240
  if (remote !== null) {
@@ -232,14 +244,17 @@ export async function initCommand(options = {}) {
232
244
  host: remote.host,
233
245
  };
234
246
  }
247
+ let pushedBaseline = hadRemoteBefore;
235
248
  // If we created/configured a new remote during init, push baseline branches so
236
249
  // `task add` can always pull latest integration before branching.
237
- if (!dryRun && options.allowNoRemote !== true && !hadRemoteBefore) {
250
+ if (!dryRun &&
251
+ options.allowNoRemote !== true &&
252
+ !hadRemoteBefore &&
253
+ remote !== null) {
238
254
  const remoteName = gitConfig.remote;
239
255
  log.blank();
240
256
  log.info("Remote bootstrap:");
241
257
  try {
242
- // Ensure local branches are up-to-date with origin if they already exist remotely.
243
258
  const prodRemoteExists = await gitRemoteBranchExists(projectRoot, remoteName, gitConfig.productionBranch);
244
259
  if (prodRemoteExists) {
245
260
  await gitCheckout(projectRoot, gitConfig.productionBranch);
@@ -258,6 +273,7 @@ export async function initCommand(options = {}) {
258
273
  await gitPush(projectRoot, remoteName, gitConfig.integrationBranch, true);
259
274
  log.ok(`pushed ${gitConfig.integrationBranch} → ${remoteName}`);
260
275
  }
276
+ pushedBaseline = true;
261
277
  }
262
278
  catch (e) {
263
279
  const msg = e instanceof Error ? e.message : String(e);
@@ -267,14 +283,20 @@ export async function initCommand(options = {}) {
267
283
  if (gitConfig.integrationBranch !== gitConfig.productionBranch) {
268
284
  log.info(dim(` git push -u ${remoteName} ${gitConfig.integrationBranch}`));
269
285
  }
286
+ pushedBaseline = false;
270
287
  }
271
288
  }
272
289
  if (!dryRun) {
273
290
  await writeConfig(projectRoot, buildConfig(name, clients, gitConfig, existingConfig));
274
- if (options.allowNoRemote === true && remote === null) {
275
- log.warn("No git remote configured (--allow-no-remote). task ship push/MR will fail until origin exists.");
291
+ if (remote === null) {
292
+ log.warn(options.allowNoRemote === true
293
+ ? "No git remote configured (--allow-no-remote). task ship push/MR will fail until origin exists."
294
+ : "No git remote configured. task ship push/MR will fail until origin exists.");
276
295
  }
277
- printNextSteps(clients, gitConfig);
296
+ printNextSteps(clients, gitConfig, {
297
+ hasRemote: remote !== null,
298
+ pushedBaseline,
299
+ });
278
300
  }
279
301
  }
280
302
  async function runGitSetup(projectRoot, plan, dryRun) {
@@ -7,7 +7,7 @@ import { buildLlmStatusReport } from "../lib/llm-status.js";
7
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
- import { computeNextHint, hintToText, listInProgressTasks, pickFocusTask, relPath, taskBranchExistsFor, } from "../lib/task-context.js";
10
+ import { computeNextHint, hintToLines, hintToText, listInProgressTasks, pickFocusTask, relPath, taskBranchExistsFor, } from "../lib/task-context.js";
11
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";
@@ -59,6 +59,8 @@ async function buildReport(cwd) {
59
59
  taskBranch,
60
60
  taskBranchExists: branchExists,
61
61
  mergeRequestUrl,
62
+ mergeRequestMerged,
63
+ needsSync,
62
64
  };
63
65
  }
64
66
  const hint = computeNextHint({
@@ -85,6 +87,8 @@ async function buildReport(cwd) {
85
87
  branch: git.branch,
86
88
  dirty: git.dirty,
87
89
  policy: config?.git ? formatGitPolicySummary(config.git) : null,
90
+ host: config?.git?.host ?? null,
91
+ remote: config?.git?.remote ?? null,
88
92
  },
89
93
  counts,
90
94
  inProgress: inProgress.map((t) => ({
@@ -94,96 +98,111 @@ async function buildReport(cwd) {
94
98
  })),
95
99
  focus: focusBlock,
96
100
  nextHint: hintToText(hint, cwd, focus),
101
+ nextLines: [...hintToLines(hint, cwd, focus)],
97
102
  llm,
98
103
  };
99
104
  }
100
- function printHuman(report) {
101
- log.blank();
102
- log.info(bold("─".repeat(56)));
103
- log.info(bold(" VibeOps · status"));
104
- log.info(bold("─".repeat(56)));
105
- log.info(` ${dim("CLI")} v${report.vibeopsVersion}`);
105
+ function filledLabel(ok) {
106
+ return ok ? green("ok") : yellow("empty");
107
+ }
108
+ function printNow(report) {
109
+ log.info(bold(" NOW"));
106
110
  if (!report.isVibeopsProject) {
107
- log.info(` ${dim("Project")} ${yellow("not initialized")} — run vibeops init`);
108
- log.info(bold("─".repeat(56)));
109
- log.blank();
111
+ log.info(` ${dim("Project")} ${yellow("not a VibeOps project")}`);
110
112
  return;
111
113
  }
112
- log.info(` ${dim("Project")} ${report.projectName ?? "(unnamed)"}`);
113
- if (report.clients.length > 0) {
114
- log.info(` ${dim("Clients")} ${report.clients.join(", ")}`);
115
- }
116
- if (report.counts) {
117
- const c = report.counts;
118
- log.info(` ${dim("Tasks")} ${c.total} total · ${cyan(String(c.in_progress))} in progress · ${c.shipped} shipped`);
119
- }
120
- if (report.inProgress.length > 1) {
121
- log.blank();
122
- log.info(bold(" In progress"));
123
- for (const t of report.inProgress) {
124
- log.info(` ${cyan(t.id)} — ${t.title}`);
125
- }
126
- }
127
- log.blank();
128
114
  if (report.focus) {
129
115
  const f = report.focus;
130
- log.info(` ${dim("Focus")} ${cyan(f.id)} — ${f.title || dim("(no title)")}`);
131
- log.info(` ${dim("Status")} ${f.status}`);
132
- log.info(` ${dim("File")} ${f.file}`);
133
- if (f.goalExcerpt)
134
- log.info(` ${dim("Goal")} ${f.goalExcerpt}`);
135
- log.info(` ${dim("Result")} ${f.resultFilled ? green("filled") : yellow("empty")} · Test Result ${f.testResultFilled ? green("filled") : yellow("empty")}`);
116
+ log.info(` ${dim("Focus")} ${cyan(f.id)} — ${f.title || dim("(no title)")}`);
117
+ log.info(` ${dim("Stage")} ${f.status}`);
118
+ log.info(` ${dim("Checklist")} Result ${filledLabel(f.resultFilled)} · Test Result ${filledLabel(f.testResultFilled)}`);
119
+ if (f.taskBranch) {
120
+ const parts = [];
121
+ if (f.onTaskBranch === true)
122
+ parts.push("on it");
123
+ else if (f.onTaskBranch === false)
124
+ parts.push("not checked out");
125
+ if (report.git.dirty === true)
126
+ parts.push("dirty");
127
+ else if (report.git.dirty === false)
128
+ parts.push("clean");
129
+ const suffix = parts.length > 0 ? ` (${parts.join(" · ")})` : "";
130
+ log.info(` ${dim("Branch")} ${f.taskBranch}${suffix}`);
131
+ }
132
+ else if (report.git.isRepo) {
133
+ log.info(` ${dim("Branch")} ${report.git.branch ?? "(detached)"}${report.git.dirty === true ? yellow(" · dirty") : ""}`);
134
+ }
135
+ if (f.needsSync) {
136
+ log.info(` ${dim("PR")} ${dim("(merged — sync pending)")}`);
137
+ }
138
+ else if (f.mergeRequestUrl) {
139
+ const tag = f.mergeRequestMerged ? dim(" · merged") : "";
140
+ log.info(` ${dim("PR")} ${f.mergeRequestUrl}${tag}`);
141
+ }
142
+ else {
143
+ log.info(` ${dim("PR")} ${dim("(none)")}`);
144
+ }
136
145
  }
137
146
  else {
138
- log.info(` ${dim("Focus")} ${dim("(none)")}`);
147
+ log.info(` ${dim("Focus")} ${dim("(none)")}`);
148
+ if (report.git.isRepo) {
149
+ log.info(` ${dim("Branch")} ${report.git.branch ?? "(detached)"}${report.git.dirty === true ? yellow(" · dirty") : ""}`);
150
+ }
139
151
  }
152
+ }
153
+ function printNext(report) {
140
154
  log.blank();
141
- log.info(bold(" Git"));
142
- if (!report.git.isRepo) {
143
- log.info(` ${yellow("not a git repository")}`);
155
+ log.info(bold(" NEXT"));
156
+ for (const line of report.nextLines) {
157
+ log.info(` ${cyan("→")} ${line}`);
144
158
  }
145
- else {
146
- log.info(` ${dim("HEAD")} ${report.git.branch ?? "(detached)"}`);
147
- if (report.git.dirty === true)
148
- log.info(` ${dim("Tree")} ${yellow("dirty")}`);
149
- else if (report.git.dirty === false)
150
- log.info(` ${dim("Tree")} ${green("clean")}`);
151
- if (report.git.policy) {
152
- log.info(` ${dim("Policy")} ${report.git.policy}`);
153
- }
154
- if (report.focus?.taskBranch) {
155
- const on = report.focus.onTaskBranch === true
156
- ? green("yes")
157
- : report.focus.onTaskBranch === false
158
- ? yellow("no")
159
- : dim("—");
160
- log.info(` ${dim("Task br.")} ${report.focus.taskBranch} · on branch: ${on}`);
161
- }
162
- if (report.focus?.mergeRequestUrl) {
163
- log.info(` ${dim("MR/PR")} ${report.focus.mergeRequestUrl}`);
164
- }
159
+ }
160
+ function printDetails(report) {
161
+ log.blank();
162
+ log.info(dim(" ──"));
163
+ if (!report.isVibeopsProject) {
164
+ log.info(` ${dim("CLI")} v${report.vibeopsVersion}`);
165
+ return;
166
+ }
167
+ const clients = report.clients.length > 0 ? report.clients.join(", ") : dim("(none)");
168
+ log.info(` ${dim("Project")} ${report.projectName ?? "(unnamed)"} · clients ${clients}`);
169
+ if (report.counts) {
170
+ const c = report.counts;
171
+ log.info(` ${dim("Tasks")} ${c.total} total · ${cyan(String(c.in_progress))} in progress · ${c.shipped} shipped`);
172
+ }
173
+ if (report.inProgress.length > 1) {
174
+ log.info(` ${dim("Active")} ${report.inProgress.map((t) => t.id).join(", ")}`);
175
+ }
176
+ const hostBits = [];
177
+ if (report.git.host)
178
+ hostBits.push(report.git.host);
179
+ if (report.git.remote)
180
+ hostBits.push(report.git.remote);
181
+ if (report.git.policy)
182
+ hostBits.push(report.git.policy);
183
+ if (hostBits.length > 0) {
184
+ log.info(` ${dim("Git")} ${hostBits.join(" · ")}`);
165
185
  }
166
186
  if (report.llm) {
167
- log.blank();
168
- log.info(bold(" LLM"));
169
- log.info(` ${dim("Preferred")} ${report.llm.preference}`);
170
187
  const active = report.llm.active !== null
171
- ? green(`${report.llm.active} (ok)`)
188
+ ? `${report.llm.active} (ok)`
172
189
  : report.llm.anyAvailable
173
- ? yellow("(preference not available)")
174
- : yellow("none");
175
- log.info(` ${dim("Active")} ${active}`);
176
- for (const p of report.llm.providers) {
177
- const summary = p.summary.length > 56 ? `${p.summary.slice(0, 53)}…` : p.summary;
178
- log.info(` ${dim(p.id.padEnd(14))} ${p.ok ? green("ok") : dim("—")} ${dim(summary)}`);
179
- }
180
- if (!report.llm.anyAvailable) {
181
- log.info(` ${dim("Setup")} ${cyan("vibeops llm connect")}`);
182
- }
190
+ ? "(preference not available)"
191
+ : "none";
192
+ log.info(` ${dim("LLM")} ${active} · prefer ${report.llm.preference}${report.llm.anyAvailable ? "" : ` · ${cyan("vibeops llm connect")}`}`);
183
193
  }
194
+ }
195
+ function printHuman(report) {
196
+ log.blank();
197
+ log.info(bold("─".repeat(44)));
198
+ log.info(bold(" VibeOps · status"));
199
+ log.info(bold("─".repeat(44)));
200
+ log.blank();
201
+ printNow(report);
202
+ printNext(report);
203
+ printDetails(report);
184
204
  log.blank();
185
- log.info(`${dim("Next")} ${report.nextHint}`);
186
- log.info(bold("─".repeat(56)));
205
+ log.info(bold("─".repeat(44)));
187
206
  log.blank();
188
207
  }
189
208
  export async function statusCommand(opts = {}) {
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
+ import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
3
4
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
4
5
  import { gitRemoteUrl } from "../lib/git.js";
5
6
  import { bold, cyan, dim, log } from "../lib/logger.js";
@@ -55,7 +56,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
55
56
  ? null
56
57
  : await resolveOpenTaskMergeRequest(cwd, target.taskFile);
57
58
  if (!dryRun && resolvedMr === null) {
58
- log.error(`No open MR/PR for ${ctx.taskBranch} → ${ctx.baseBranch}. Run task ship or task reship first.`);
59
+ log.error(`No open MR/PR for ${ctx.taskBranch} → ${ctx.baseBranch}. Run task ship first.`);
59
60
  process.exitCode = 1;
60
61
  return;
61
62
  }
@@ -96,13 +97,23 @@ export async function taskMergeCommand(taskRef, options = {}) {
96
97
  if (state === "open") {
97
98
  const cliOk = await probeMergeRequestCli(host);
98
99
  if (!cliOk) {
99
- const tool = host === "gitlab" ? "glab" : "gh";
100
- log.error(`${tool} CLI not found — merge in the host UI, then task sync.`);
100
+ log.error(formatHostCliMissingMessage(host, `merge the ${label}`));
101
+ for (const line of formatHostCliHint(host)) {
102
+ log.info(dim(` ${line}`));
103
+ }
104
+ log.info(dim("Or merge in the host UI, then task sync."));
101
105
  process.exitCode = 1;
102
106
  return;
103
107
  }
104
108
  try {
105
- await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method, immediate: true });
109
+ await mergeMergeRequest({
110
+ cwd,
111
+ host,
112
+ url: mergeRequestUrl,
113
+ method,
114
+ waitForCi: true,
115
+ immediate: true,
116
+ });
106
117
  const verified = await assertMergeRequestMerged({
107
118
  cwd,
108
119
  host,
@@ -132,7 +143,14 @@ export async function taskMergeCommand(taskRef, options = {}) {
132
143
  else {
133
144
  log.warn(`Could not read ${label} state — attempting merge anyway.`);
134
145
  try {
135
- await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method, immediate: true });
146
+ await mergeMergeRequest({
147
+ cwd,
148
+ host,
149
+ url: mergeRequestUrl,
150
+ method,
151
+ waitForCi: true,
152
+ immediate: true,
153
+ });
136
154
  const verified = await assertMergeRequestMerged({
137
155
  cwd,
138
156
  host,
@@ -1,16 +1,17 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
+ import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
3
4
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
4
5
  import { gitRemoteUrl } from "../lib/git.js";
5
6
  import { bold, dim, log } from "../lib/logger.js";
6
7
  import { createMergeRequest, getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
7
8
  import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
8
- function resolveMergeMethod(opts) {
9
+ function resolveReleaseMergeMethod(opts) {
9
10
  if (opts.rebase === true)
10
11
  return "rebase";
11
- if (opts.merge === true)
12
- return "merge";
13
- return "squash";
12
+ if (opts.squash === true)
13
+ return "squash";
14
+ return "merge";
14
15
  }
15
16
  export async function taskReleaseCommand(options = {}) {
16
17
  const cwd = resolve(options.cwd ?? process.cwd());
@@ -51,14 +52,16 @@ export async function taskReleaseCommand(options = {}) {
51
52
  log.info(bold("dry-run — would:"));
52
53
  log.info(` · ${label}: ${integrationBranch} → ${productionBranch}`);
53
54
  if (options.noMerge !== true) {
54
- log.info(` · merge ${label} (${resolveMergeMethod(options)})`);
55
+ log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}, wait for CI)`);
55
56
  }
56
57
  return;
57
58
  }
58
59
  const cliOk = await probeMergeRequestCli(host);
59
60
  if (!cliOk) {
60
- const tool = host === "gitlab" ? "glab" : "gh";
61
- log.error(`${tool} CLI not found — create the release ${label} manually.`);
61
+ log.error(formatHostCliMissingMessage(host, `create the release ${label}`));
62
+ for (const line of formatHostCliHint(host)) {
63
+ log.info(dim(` ${line}`));
64
+ }
62
65
  process.exitCode = 1;
63
66
  return;
64
67
  }
@@ -90,9 +93,16 @@ export async function taskReleaseCommand(options = {}) {
90
93
  log.ok(`Release ${label} already merged.`);
91
94
  return;
92
95
  }
93
- const method = resolveMergeMethod(options);
96
+ const method = resolveReleaseMergeMethod(options);
94
97
  try {
95
- await mergeMergeRequest({ cwd, host, url: releaseUrl, method, immediate: true });
98
+ await mergeMergeRequest({
99
+ cwd,
100
+ host,
101
+ url: releaseUrl,
102
+ method,
103
+ waitForCi: true,
104
+ immediate: true,
105
+ });
96
106
  const verified = await assertMergeRequestMerged({
97
107
  cwd,
98
108
  host,