@goodtek/vibeops 2.4.4 → 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,18 @@ 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
+
7
19
  ## 2.4.4 - 2026-06-25
8
20
 
9
21
  ### 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)")
@@ -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,8 +97,11 @@ 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
  }
@@ -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, dim, log } from "../lib/logger.js";
@@ -57,8 +58,10 @@ export async function taskReleaseCommand(options = {}) {
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
  }
@@ -0,0 +1,115 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ /** Run a CLI with inherited stdio (e.g. `gh auth login`). */
5
+ export function runInteractiveCli(bin, args) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(bin, [...args], { stdio: "inherit" });
8
+ child.on("error", reject);
9
+ child.on("close", (code) => resolve(code ?? 1));
10
+ });
11
+ }
12
+ export function hostCliTool(host) {
13
+ return host === "gitlab" ? "glab" : "gh";
14
+ }
15
+ export async function commandExists(bin) {
16
+ try {
17
+ await execFileAsync(bin, ["--version"], { maxBuffer: 1024 });
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export async function probeGhAuth() {
25
+ try {
26
+ await execFileAsync("gh", ["auth", "status"], { maxBuffer: 1024 * 1024 });
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ export async function probeGlabAuth() {
34
+ try {
35
+ await execFileAsync("glab", ["auth", "status"], { maxBuffer: 1024 * 1024 });
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ export async function probeHostCliAuth(host) {
43
+ const tool = hostCliTool(host);
44
+ if (!(await commandExists(tool)))
45
+ return "missing";
46
+ const ok = host === "gitlab" ? await probeGlabAuth() : await probeGhAuth();
47
+ return ok ? "ok" : "unauthenticated";
48
+ }
49
+ /** Install / login lines shared by init and ship/merge. */
50
+ export function formatHostCliHint(host) {
51
+ if (host === "gitlab") {
52
+ return [
53
+ `Install glab: https://gitlab.com/gitlab-org/cli#installation`,
54
+ ` brew install glab`,
55
+ `Then: glab auth login`,
56
+ ];
57
+ }
58
+ return [
59
+ `Install gh: https://cli.github.com/`,
60
+ ` brew install gh`,
61
+ `Then: gh auth login`,
62
+ ];
63
+ }
64
+ export function formatHostCliMissingMessage(host, purpose) {
65
+ const tool = hostCliTool(host);
66
+ return `${tool} CLI is required to ${purpose}.`;
67
+ }
68
+ export async function probeBrew() {
69
+ return commandExists("brew");
70
+ }
71
+ export async function brewInstall(pkg) {
72
+ await execFileAsync("brew", ["install", pkg], {
73
+ maxBuffer: 16 * 1024 * 1024,
74
+ });
75
+ }
76
+ /** Parse `owner/repo`, HTTPS, or SSH GitHub/GitLab URLs. */
77
+ export function parseOwnerRepo(input) {
78
+ const raw = input.trim();
79
+ if (raw.length === 0)
80
+ return null;
81
+ const slug = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/;
82
+ const fromSlug = slug.exec(raw);
83
+ if (fromSlug && !raw.includes(":") && !raw.includes("@")) {
84
+ return { owner: fromSlug[1], repo: fromSlug[2] };
85
+ }
86
+ const https = /^https?:\/\/(?:www\.)?(?:github\.com|gitlab\.com)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(raw);
87
+ if (https) {
88
+ return { owner: https[1], repo: https[2].replace(/\.git$/i, "") };
89
+ }
90
+ const ssh = /^(?:git@|ssh:\/\/git@)(?:github\.com|gitlab\.com)[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(raw);
91
+ if (ssh) {
92
+ return { owner: ssh[1], repo: ssh[2].replace(/\.git$/i, "") };
93
+ }
94
+ return null;
95
+ }
96
+ export function remoteUrlForHost(host, owner, repo) {
97
+ if (host === "gitlab") {
98
+ return `https://gitlab.com/${owner}/${repo}.git`;
99
+ }
100
+ return `https://github.com/${owner}/${repo}.git`;
101
+ }
102
+ export async function repoExistsOnHost(host, owner, repo) {
103
+ const slug = `${owner}/${repo}`;
104
+ try {
105
+ if (host === "gitlab") {
106
+ await execFileAsync("glab", ["repo", "view", slug], { maxBuffer: 1024 * 1024 });
107
+ return true;
108
+ }
109
+ await execFileAsync("gh", ["repo", "view", slug], { maxBuffer: 1024 * 1024 });
110
+ return true;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
@@ -1,32 +1,14 @@
1
+ import { basename } from "node:path";
1
2
  import { execFile } from "node:child_process";
2
3
  import { promisify } from "node:util";
3
- import { askInput } from "./inquirer-helpers.js";
4
+ import { brewInstall, formatHostCliHint, formatHostCliMissingMessage, hostCliTool, parseOwnerRepo, probeBrew, probeHostCliAuth, remoteUrlForHost, repoExistsOnHost, runInteractiveCli, } from "./git-host-cli.js";
4
5
  import { detectGitHost } from "./git-host.js";
5
6
  import { gitRemoteUrl, runGit } from "./git.js";
6
- import { dim, log } from "./logger.js";
7
+ import { askInput, askSelect, askYesNo } from "./inquirer-helpers.js";
8
+ import { dim, log, yellow } from "./logger.js";
7
9
  const execFileAsync = promisify(execFile);
8
- async function probeGh() {
9
- try {
10
- await execFileAsync("gh", ["auth", "status"], { maxBuffer: 1024 * 1024 });
11
- return true;
12
- }
13
- catch {
14
- return false;
15
- }
16
- }
17
- async function probeGlab() {
18
- try {
19
- await execFileAsync("glab", ["auth", "status"], { maxBuffer: 1024 * 1024 });
20
- return true;
21
- }
22
- catch {
23
- return false;
24
- }
25
- }
26
10
  async function createGithubRepo(cwd, name) {
27
- const { stdout } = await execFileAsync("gh",
28
- // gh requires an explicit visibility flag when non-interactive.
29
- ["repo", "create", name, "--source=.", "--remote=origin", "--push", "--private"], { cwd, maxBuffer: 8 * 1024 * 1024 });
11
+ const { stdout } = await execFileAsync("gh", ["repo", "create", name, "--source=.", "--remote=origin", "--push", "--private"], { cwd, maxBuffer: 8 * 1024 * 1024 });
30
12
  const match = stdout.match(/https:\/\/github\.com\/[^\s]+/);
31
13
  if (match)
32
14
  return match[0].replace(/\.git$/, "") + ".git";
@@ -42,8 +24,135 @@ async function createGitlabRepo(cwd, name, defaultBranch) {
42
24
  return url;
43
25
  throw new Error("glab repo create finished but origin URL could not be read.");
44
26
  }
27
+ async function addRemoteUrl(cwd, remote, url) {
28
+ const host = detectGitHost(url);
29
+ if (!host) {
30
+ throw new Error("Only GitHub and GitLab HTTPS/SSH URLs are supported.");
31
+ }
32
+ await runGit(cwd, ["remote", "add", remote, url.trim()]);
33
+ return { remote, url: url.trim(), host };
34
+ }
35
+ async function resolveConnectUrl(host, input) {
36
+ const trimmed = input.trim();
37
+ const parsed = parseOwnerRepo(trimmed);
38
+ if (parsed) {
39
+ if (trimmed.startsWith("http") || trimmed.startsWith("git@") || trimmed.startsWith("ssh://")) {
40
+ return trimmed.endsWith(".git") ? trimmed : `${trimmed.replace(/\/$/, "")}.git`;
41
+ }
42
+ return remoteUrlForHost(host, parsed.owner, parsed.repo);
43
+ }
44
+ if (detectGitHost(trimmed)) {
45
+ return trimmed.endsWith(".git") ? trimmed : `${trimmed.replace(/\/$/, "")}.git`;
46
+ }
47
+ throw new Error(`Could not parse remote: ${trimmed}`);
48
+ }
49
+ async function repairMissingCli(host) {
50
+ const tool = hostCliTool(host);
51
+ const status = await probeHostCliAuth(host);
52
+ log.warn(status === "missing"
53
+ ? formatHostCliMissingMessage(host, "create a remote repository")
54
+ : `${tool} is installed but not logged in.`);
55
+ for (const line of formatHostCliHint(host)) {
56
+ log.info(dim(` ${line}`));
57
+ }
58
+ const brewOk = status === "missing" ? await probeBrew() : false;
59
+ const choices = [
60
+ "I installed / logged in — retry",
61
+ ...(brewOk ? [`Run brew install ${tool}`] : []),
62
+ "Enter remote URL manually",
63
+ "Skip remote for now",
64
+ ];
65
+ // Loop so brew install / retry can re-prompt.
66
+ for (;;) {
67
+ const pick = await askSelect({
68
+ message: "How do you want to continue?",
69
+ nonInteractive: false,
70
+ choices,
71
+ });
72
+ if (pick === "I installed / logged in — retry") {
73
+ const again = await probeHostCliAuth(host);
74
+ if (again === "ok")
75
+ return "retry";
76
+ log.warn(again === "missing"
77
+ ? `${tool} still not found on PATH.`
78
+ : `${tool} still not authenticated. Run \`${tool} auth login\`.`);
79
+ continue;
80
+ }
81
+ if (pick.startsWith("Run brew install")) {
82
+ log.info(dim(`Running: brew install ${tool}`));
83
+ try {
84
+ await brewInstall(tool);
85
+ log.ok(`Installed ${tool}`);
86
+ log.info(dim(`Next: ${tool} auth login`));
87
+ const loginNow = await askYesNo({
88
+ message: `Run \`${tool} auth login\` now?`,
89
+ nonInteractive: false,
90
+ defaultValue: true,
91
+ });
92
+ if (loginNow) {
93
+ try {
94
+ await runInteractiveCli(tool, ["auth", "login"]);
95
+ }
96
+ catch {
97
+ /* user may cancel */
98
+ }
99
+ }
100
+ }
101
+ catch (e) {
102
+ const msg = e instanceof Error ? e.message : String(e);
103
+ log.warn(`brew install failed: ${msg}`);
104
+ }
105
+ continue;
106
+ }
107
+ if (pick === "Enter remote URL manually")
108
+ return "manual";
109
+ return "skip";
110
+ }
111
+ }
112
+ async function connectExisting(cwd, remote, host) {
113
+ const input = await askInput({
114
+ message: "Repository URL or owner/repo",
115
+ nonInteractive: false,
116
+ required: true,
117
+ });
118
+ const url = await resolveConnectUrl(host, input);
119
+ const parsed = parseOwnerRepo(url) ?? parseOwnerRepo(input);
120
+ if (parsed && (await probeHostCliAuth(host)) === "ok") {
121
+ const exists = await repoExistsOnHost(host, parsed.owner, parsed.repo);
122
+ if (!exists) {
123
+ log.warn(yellow(`Could not verify ${parsed.owner}/${parsed.repo} on ${host} (missing or private). Continuing with remote add.`));
124
+ }
125
+ }
126
+ return addRemoteUrl(cwd, remote, url);
127
+ }
128
+ async function createOnHost(cwd, remote, host, defaultBranch) {
129
+ const folder = basename(cwd);
130
+ const label = host === "gitlab" ? "GitLab project path (group/project)" : "GitHub repository (owner/repo or name)";
131
+ const name = await askInput({
132
+ message: label,
133
+ nonInteractive: false,
134
+ required: true,
135
+ default: folder,
136
+ });
137
+ log.info(dim(`Creating ${host} repository via ${hostCliTool(host)}…`));
138
+ if (host === "gitlab") {
139
+ const url = await createGitlabRepo(cwd, name, defaultBranch);
140
+ // glab may name remote differently; ensure our remote name.
141
+ const current = await gitRemoteUrl(cwd, remote);
142
+ if (!current) {
143
+ const origin = await gitRemoteUrl(cwd, "origin");
144
+ if (origin && remote !== "origin") {
145
+ await runGit(cwd, ["remote", "rename", "origin", remote]);
146
+ }
147
+ }
148
+ return { remote, url, host };
149
+ }
150
+ const url = await createGithubRepo(cwd, name);
151
+ return { remote, url, host };
152
+ }
45
153
  /**
46
- * Ensures `origin` (or named remote) exists. Interactive path can create via gh/glab or manual URL.
154
+ * Ensures `origin` (or named remote) exists.
155
+ * Interactive: ask host → create/connect; soft-gate when CLI missing.
47
156
  */
48
157
  export async function ensureOriginRemote(opts) {
49
158
  const remote = opts.remoteName ?? "origin";
@@ -59,8 +168,9 @@ export async function ensureOriginRemote(opts) {
59
168
  return { remote, url: existing, host };
60
169
  }
61
170
  if (opts.dryRun) {
62
- log.info(dim(`would configure git remote ${remote}`));
63
- return { remote, url: "(dry-run)", host: "github" };
171
+ const host = opts.preferredHost ?? "github";
172
+ log.info(dim(`would configure git remote ${remote} (${host})`));
173
+ return { remote, url: "(dry-run)", host };
64
174
  }
65
175
  if (opts.allowMissing) {
66
176
  return null;
@@ -69,40 +179,48 @@ export async function ensureOriginRemote(opts) {
69
179
  throw new Error(`Git remote "${remote}" is not configured. Add it with: git remote add ${remote} <url>`);
70
180
  }
71
181
  log.blank();
72
- log.info("Remote origin is required for push and merge requests.");
73
- const ghOk = await probeGh();
74
- const glabOk = await probeGlab();
75
- if (ghOk) {
76
- const name = await askInput({
77
- message: "GitHub repository name (owner/repo or name for gh to create)",
78
- nonInteractive: false,
79
- required: true,
80
- });
81
- log.info(dim("Creating GitHub repository via gh…"));
82
- const url = await createGithubRepo(opts.cwd, name);
83
- return { remote, url, host: "github" };
84
- }
85
- if (glabOk) {
86
- const name = await askInput({
87
- message: "GitLab project path (group/project)",
88
- nonInteractive: false,
89
- required: true,
90
- });
91
- log.info(dim("Creating GitLab project via glab…"));
92
- const defaultBranch = typeof opts.defaultBranch === "string" && opts.defaultBranch.length > 0
93
- ? opts.defaultBranch
94
- : "main";
95
- const url = await createGitlabRepo(opts.cwd, name, defaultBranch);
96
- return { remote, url, host: "gitlab" };
182
+ log.info("Remote setup — used later for pull requests / merge requests.");
183
+ const hostPick = await askSelect({
184
+ message: "Where will this project's remote live?",
185
+ nonInteractive: false,
186
+ choices: ["GitHub", "GitLab", "Skip for now (local only)"],
187
+ default: opts.preferredHost === "gitlab"
188
+ ? "GitLab"
189
+ : opts.preferredHost === "github"
190
+ ? "GitHub"
191
+ : undefined,
192
+ });
193
+ if (hostPick.startsWith("Skip")) {
194
+ log.warn("No git remote configured. task ship push/MR will fail until origin exists.");
195
+ log.info(dim(` Later: git remote add ${remote} <url>`));
196
+ return null;
97
197
  }
98
- const url = await askInput({
99
- message: `Remote URL for ${remote} (https://github.com/… or https://gitlab.com/…)`,
198
+ const host = hostPick === "GitLab" ? "gitlab" : "github";
199
+ const mode = await askSelect({
200
+ message: "Set up the remote",
100
201
  nonInteractive: false,
101
- required: true,
202
+ choices: ["Create a new repository", "Connect an existing repository"],
102
203
  });
103
- await runGit(opts.cwd, ["remote", "add", remote, url.trim()]);
104
- const host = detectGitHost(url);
105
- if (!host)
106
- throw new Error("Only GitHub and GitLab HTTPS/SSH URLs are supported.");
107
- return { remote, url: url.trim(), host };
204
+ if (mode.startsWith("Connect")) {
205
+ return connectExisting(opts.cwd, remote, host);
206
+ }
207
+ // Create path — requires authenticated CLI (soft gate).
208
+ for (;;) {
209
+ const auth = await probeHostCliAuth(host);
210
+ if (auth === "ok") {
211
+ const defaultBranch = typeof opts.defaultBranch === "string" && opts.defaultBranch.length > 0
212
+ ? opts.defaultBranch
213
+ : "main";
214
+ return createOnHost(opts.cwd, remote, host, defaultBranch);
215
+ }
216
+ const action = await repairMissingCli(host);
217
+ if (action === "retry")
218
+ continue;
219
+ if (action === "manual") {
220
+ return connectExisting(opts.cwd, remote, host);
221
+ }
222
+ log.warn("No git remote configured. task ship push/MR will fail until origin exists.");
223
+ log.info(dim(` Later: git remote add ${remote} <url>`));
224
+ return null;
225
+ }
108
226
  }
@@ -49,7 +49,7 @@ export function computeNextHint(input) {
49
49
  if (input.hasMergeRequest && !input.mergeRequestMerged)
50
50
  return "task-merge";
51
51
  if (input.hasLocalChanges)
52
- return "task-reship";
52
+ return "task-ship-followup";
53
53
  return "task-add";
54
54
  }
55
55
  if (!input.resultFilled || !input.testFilled) {
@@ -57,36 +57,48 @@ export function computeNextHint(input) {
57
57
  }
58
58
  return input.hasMergeRequest ? "task-merge" : "task-ship";
59
59
  }
60
- export function hintToText(hint, cwd, task) {
60
+ /** Multi-line NEXT block lines (without the leading arrow prefix). */
61
+ export function hintToLines(hint, cwd, task) {
61
62
  switch (hint) {
62
63
  case "init":
63
- return "Run `vibeops init` in this directory.";
64
+ return ["vibeops init"];
64
65
  case "task-add":
65
- return "Run `vibeops task add` to start the next slice.";
66
+ return ["vibeops task add"];
66
67
  case "task-ship":
67
- return task ? `Run \`vibeops task ship ${task.id}\`.` : "Run `vibeops task ship`.";
68
- case "task-reship":
68
+ return [task ? `vibeops task ship ${task.id}` : "vibeops task ship"];
69
+ case "task-ship-followup":
69
70
  return task
70
- ? `Follow-up on ${task.id}? Edit files (develop is fine), then \`vibeops task reship ${task.id}\`.`
71
- : "Run `vibeops task reship TASK-NNN` for a Shipped follow-up.";
71
+ ? [
72
+ `Follow up on ${task.id} (after merge: confirm or --new-cycle)`,
73
+ `vibeops task ship ${task.id}`,
74
+ ]
75
+ : ["vibeops task ship --new-cycle"];
72
76
  case "task-merge":
73
77
  return task
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`.";
78
+ ? [`vibeops task merge ${task.id}`, "(or merge in the host UI)"]
79
+ : ["vibeops task merge"];
76
80
  case "task-sync":
77
81
  return task
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.";
82
+ ? [`vibeops task sync ${task.id}`, "then: vibeops task add"]
83
+ : ["vibeops task sync"];
80
84
  case "cursor-plan":
81
85
  return task
82
- ? `In Cursor Ask: @${relPath(cwd, task.filePath)} — refine Scope / AC.`
83
- : "Open the TASK file in Cursor Ask.";
86
+ ? [
87
+ `Cursor Ask: @${relPath(cwd, task.filePath)} — refine Scope / AC`,
88
+ ]
89
+ : ["Open the TASK file in Cursor Ask"];
84
90
  case "cursor-implement":
85
91
  return task
86
- ? `In Cursor Agent: @${relPath(cwd, task.filePath)} — implement, then task ship.`
87
- : "Implement per the TASK file in Cursor.";
92
+ ? [
93
+ `Cursor Agent: @${relPath(cwd, task.filePath)} — implement`,
94
+ `then: vibeops task ship ${task.id}`,
95
+ ]
96
+ : ["Implement per the TASK file, then vibeops task ship"];
88
97
  }
89
98
  }
99
+ export function hintToText(hint, cwd, task) {
100
+ return hintToLines(hint, cwd, task).join(" · ");
101
+ }
90
102
  export async function listInProgressTasks(paths) {
91
103
  return (await loadActionableTasks(paths.docsTasks)).filter((t) => t.status === "in_progress");
92
104
  }
@@ -1,3 +1,4 @@
1
+ import { formatHostCliHint, formatHostCliMissingMessage } from "./git-host-cli.js";
1
2
  import { readText } from "./filesystem.js";
2
3
  import { readConfig } from "./config.js";
3
4
  import { detectGitHost, mergeRequestLabel } from "./git-host.js";
@@ -94,8 +95,11 @@ export async function finishTaskWithPullRequest(opts) {
94
95
  }
95
96
  const cliOk = await probeMergeRequestCli(gitCfg.host);
96
97
  if (!cliOk) {
97
- const tool = gitCfg.host === "gitlab" ? "glab" : "gh";
98
- log.warn(`${tool} CLI not found — create the ${label} manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`);
98
+ log.warn(formatHostCliMissingMessage(gitCfg.host, `create the ${label}`));
99
+ for (const line of formatHostCliHint(gitCfg.host)) {
100
+ log.info(dim(` ${line}`));
101
+ }
102
+ log.info(dim(`Create the ${label} manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`));
99
103
  return { ok: true, pushed: true };
100
104
  }
101
105
  const config = await readConfig(opts.cwd);
@@ -43,7 +43,7 @@ export function reshipNotFoundMessage(taskRef) {
43
43
  if (taskRef?.trim()) {
44
44
  return `Shipped TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
45
45
  }
46
- return "No Shipped TASK found. Pass `vibeops task reship TASK-NNN`.";
46
+ return "No Shipped TASK found. Pass `vibeops task ship TASK-NNN` (use --new-cycle after merge).";
47
47
  }
48
48
  async function switchToTaskBranch(cwd, taskBranch) {
49
49
  try {
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "2.4.4",
3
+ "version": "2.5.0",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
+ "packageManager": "pnpm@9.15.9",
6
7
  "author": "VibeOps contributors",
7
8
  "license": "MIT",
8
9
  "repository": {
@@ -29,6 +30,17 @@
29
30
  "engines": {
30
31
  "node": ">=20"
31
32
  },
33
+ "scripts": {
34
+ "dev": "tsx src/cli.ts",
35
+ "build": "tsc -p tsconfig.json",
36
+ "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "start": "node dist/cli.js",
38
+ "test": "pnpm build && node --test \"tests/*.test.mjs\"",
39
+ "smoke": "pnpm typecheck && pnpm build && node --test \"tests/*.test.mjs\" && node scripts/smoke.mjs",
40
+ "clean": "rm -rf dist",
41
+ "prepack": "pnpm clean && pnpm build",
42
+ "publish:dry": "pnpm publish --dry-run --no-git-checks"
43
+ },
32
44
  "keywords": [
33
45
  "cursor",
34
46
  "ai",
@@ -47,15 +59,5 @@
47
59
  "@types/node": "^20.14.0",
48
60
  "tsx": "^4.19.0",
49
61
  "typescript": "^5.5.4"
50
- },
51
- "scripts": {
52
- "dev": "tsx src/cli.ts",
53
- "build": "tsc -p tsconfig.json",
54
- "typecheck": "tsc -p tsconfig.json --noEmit",
55
- "start": "node dist/cli.js",
56
- "test": "pnpm build && node --test \"tests/*.test.mjs\"",
57
- "smoke": "pnpm typecheck && pnpm build && node --test \"tests/*.test.mjs\" && node scripts/smoke.mjs",
58
- "clean": "rm -rf dist",
59
- "publish:dry": "pnpm publish --dry-run --no-git-checks"
60
62
  }
61
- }
63
+ }
@@ -40,5 +40,5 @@ vibeops llm connect # optional — task add / ship
40
40
 
41
41
  - One **In Progress** TASK at a time; **Shipped** does not block `task add`.
42
42
  - Fill **Result** and **Test Result** before `vibeops task ship` (warned, not blocked, when updating an open PR).
43
- - `ship` is state-aware: re-run it to update an open PR; after merge it needs `--new-cycle` (or a confirm) to start a new PR cycle. `task reship` still works as a deprecated alias for `task ship --new-cycle`.
43
+ - `ship` is state-aware: re-run it to update an open PR; after merge it needs `--new-cycle` (or a confirm) to start a new PR cycle.
44
44
  - Agents do not run `task merge` or `task sync` unless the human asks.
@@ -5,5 +5,5 @@
5
5
  ## D-001 · VibeOps workflow
6
6
 
7
7
  - **Date:** {{CREATED_AT}}
8
- - **Decision:** TASK-driven development with `vibeops task add` / `task ship` / `task reship` / `merge` / `sync` and Cursor for plan/build. TASK status: **In Progress** → **Shipped** only.
8
+ - **Decision:** TASK-driven development with `vibeops task add` / `task ship` / `merge` / `sync` and Cursor for plan/build. TASK status: **In Progress** → **Shipped** only.
9
9
  - **Why:** Git `docs/tasks` beats chat as source of truth.
@@ -1,21 +0,0 @@
1
- import { dim, log, yellow } from "../lib/logger.js";
2
- import { taskShipCommand } from "./task-ship.js";
3
- /**
4
- * @deprecated `reship` is now `vibeops task ship --new-cycle`.
5
- * Kept as a thin alias for backward compatibility.
6
- */
7
- export async function taskReshipCommand(taskRef, options = {}) {
8
- log.warn(`${yellow("reship is now `ship` (deprecated alias)")} — delegating to \`vibeops task ship --new-cycle\`.`);
9
- log.info(dim("Run `vibeops task ship` directly; it detects first submit / open-PR update / new PR cycle."));
10
- log.blank();
11
- await taskShipCommand(taskRef, {
12
- dryRun: options.dryRun,
13
- cwd: options.cwd,
14
- noPr: options.noPr,
15
- newCycle: true,
16
- noIntegrate: options.noIntegrate,
17
- recreateBranch: options.recreateBranch,
18
- skipLlm: options.skipLlm,
19
- allowOpenMr: options.allowOpenMr,
20
- });
21
- }