@goodtek/vibeops 2.3.0 → 2.4.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.4.0 - 2026-06-12
8
+
9
+ ### Changed
10
+
11
+ - **`vibeops task ship` is now state-aware.** It detects the TASK's PR/MR state and branches:
12
+ 1. **No PR** → push + open a new PR (first submit, Status → Shipped).
13
+ 2. **Open PR (unmerged)** → commit + push the **same** branch to update the existing PR (no new PR); CI re-runs. This replaces the manual `git add/commit/push` loop.
14
+ 3. **Merged PR** → start a **new** PR cycle (former `reship`), guarded by an interactive confirm (`This starts a NEW PR cycle after merge. Continue? (y/N)`) or `--new-cycle` in non-interactive mode.
15
+ - Every path prints a one-line summary: `Created PR #<n> → <url>`, `Updated existing PR #<n> (pushed <sha>) — CI re-running`, `Started new PR cycle → PR #<n>`, or `Nothing to ship (working tree clean, PR #<n> up to date)`.
16
+ - Commit messages are auto-scoped with the TASK id (`feat(task-001): …`). `ship` refuses when HEAD is not the TASK branch.
17
+ - PR/MR state resolution is unified in a single source (`resolveTaskMergeRequestLifecycle`).
18
+
19
+ ### Added
20
+
21
+ - **`vibeops task ship`** flags: `-m/--message <msg>` (TASK id auto-prefixed; LLM-drafted or prompted when omitted), `--new-cycle` (alias `--reship`), `--no-commit` (push committed history only), `--non-interactive`.
22
+
23
+ ### Deprecated
24
+
25
+ - **`vibeops task reship`** is now a thin alias for `vibeops task ship --new-cycle` and prints a deprecation notice.
26
+
7
27
  ## 2.3.0 - 2026-06-09
8
28
 
9
29
  ### Added
package/README.md CHANGED
@@ -14,8 +14,8 @@ VibeOps bootstraps an **agent-friendly repo**, starts numbered **TASK** files on
14
14
  | `vibeops init` | Core docs + agent packs (cursor / claude / codex) |
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
- | `vibeops task ship` | LLM summary, commit, push, open MR/PR (Status → **Shipped**) |
18
- | `vibeops task reship` | Shipped follow-up: move uncommitted work to task branch, commit, new MR/PR (Status stays Shipped) |
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
19
  | `vibeops task merge` | Merge TASK MR/PR into integration branch (default: squash) |
20
20
  | `vibeops task sync` | After merge: ff-only integration pull, delete task branches (no TASK md edits) |
21
21
  | `vibeops task release` | Release PR: integration → production (GitFlow) |
@@ -56,13 +56,24 @@ vibeops task add
56
56
  vibeops task ship
57
57
  vibeops task merge
58
58
  vibeops pull
59
- # Follow-up on same TASK (after merge):
60
- vibeops task reship TASK-NNN
59
+ # Same TASK, before merge — edit, then just re-run ship (updates the open PR):
60
+ vibeops task ship -m "address review"
61
+ # Same TASK, after merge — start a new PR cycle:
62
+ vibeops task ship --new-cycle
61
63
  vibeops task merge
62
64
  vibeops task sync
63
65
  vibeops task add
64
66
  ```
65
67
 
68
+ `ship` detects the TASK's PR state and does the right thing:
69
+
70
+ | State | `ship` does | Output |
71
+ |-------|-------------|--------|
72
+ | No PR yet | push + open a new PR | `Created PR #<n> → <url>` |
73
+ | PR open (unmerged) | commit + push the **same** branch (no new PR), CI re-runs | `Updated existing PR #<n> (pushed <sha>) — CI re-running` |
74
+ | PR merged | start a **new** PR cycle (needs confirm or `--new-cycle`) | `Started new PR cycle → PR #<n>` |
75
+ | No change | no-op | `Nothing to ship (working tree clean, PR #<n> up to date)` |
76
+
66
77
  Occasionally (GitFlow release to production):
67
78
 
68
79
  ```bash
@@ -73,7 +84,7 @@ Only one TASK **In Progress** at a time (`task add` blocks otherwise). **Shipped
73
84
 
74
85
  ## LLM (optional)
75
86
 
76
- For **`task add`** / **`task ship`** / **`task reship`** only (not for coding in the IDE):
87
+ For **`task add`** / **`task ship`** only (not for coding in the IDE). When `-m` is omitted, `ship` uses a connected provider to draft the commit subject:
77
88
 
78
89
  ```bash
79
90
  vibeops llm connect
@@ -99,8 +110,8 @@ pnpm install && pnpm build && pnpm smoke
99
110
  - **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--allow-no-remote`, `--cwd`
100
111
  - **`task add`**: `--dry-run`, `--non-interactive --idea "…"`
101
112
  - **`task del`**: `--dry-run`, `--force`, `--no-remote-delete`, `--no-close-mr`
102
- - **`task ship`**: `--dry-run`, `--no-pr`
103
- - **`task reship`**: `--dry-run`, `--no-pr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`, `--allow-open-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`
104
115
  - **`task merge`**: `--dry-run`, `--merge`, `--rebase`
105
116
  - **`task sync`**: `--dry-run`, `--no-remote-delete`, `--force`
106
117
  - **`task release`**: `--dry-run`, `--no-merge`, `--merge`, `--rebase`
@@ -111,8 +122,8 @@ pnpm install && pnpm build && pnpm smoke
111
122
 
112
123
  - **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`).
113
124
  - **`task add`**: `task/<slug>` from **integration** (e.g. `develop`).
114
- - **`task ship`**: commit implementation + ship metadata (Status **Shipped**) → push once → MR/PR (URL on host; `task merge` resolves by branch).
115
- - **`task reship`**: Shipped TASK follow-up — carries uncommitted work onto the task branch (creates it from integration when missing after sync), integrates `develop`, commit → push → **new** PR.
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`.
116
127
  - **`task merge`**: merge MR/PR into integration (CLI or host UI; TASK md unchanged).
117
128
  - **`task sync`**: integration ff-only pull → delete `task/*` branches (TASK md unchanged).
118
129
  - **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command).
package/dist/cli.js CHANGED
@@ -96,20 +96,37 @@ task
96
96
  });
97
97
  task
98
98
  .command("ship [taskRef]")
99
- .description("Submit TASK: LLM summary, commit, push, open MR/PR (Status → Shipped)")
99
+ .description("State-aware submit: new PR · update open PR · new PR cycle after merge (Status → Shipped)")
100
100
  .option("--dry-run", "Plan only")
101
+ .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")
104
+ .option("--no-commit", "Push already-committed changes only (skip staging/commit)")
101
105
  .option("--no-pr", "Push only; skip creating MR/PR")
106
+ .option("--non-interactive", "CI: never prompt")
107
+ .option("--no-integrate", "New PR cycle: skip merging integration into the task branch")
108
+ .option("--recreate-branch", "New PR cycle: recreate task branch from integration")
109
+ .option("--skip-llm", "New PR cycle: do not run LLM for Result / Test Result")
110
+ .option("--allow-open-mr", "Update the open MR/PR with new commits instead of a new cycle")
102
111
  .option("--cwd <path>", "Target directory")
103
112
  .action(async (taskRef, opts) => {
104
113
  await taskShipCommand(taskRef, {
105
114
  dryRun: Boolean(opts.dryRun),
106
- noPr: Boolean(opts.noPr),
115
+ message: opts.message,
116
+ newCycle: Boolean(opts.newCycle) || Boolean(opts.reship),
117
+ noCommit: opts.commit === false,
118
+ noPr: opts.pr === false,
119
+ nonInteractive: Boolean(opts.nonInteractive),
120
+ noIntegrate: opts.integrate === false,
121
+ recreateBranch: Boolean(opts.recreateBranch),
122
+ skipLlm: Boolean(opts.skipLlm),
123
+ allowOpenMr: Boolean(opts.allowOpenMr),
107
124
  cwd: opts.cwd,
108
125
  });
109
126
  });
110
127
  task
111
128
  .command("reship [taskRef]")
112
- .description("Follow-up on Shipped TASK: move uncommitted work to task branch, commit, new MR/PR")
129
+ .description("Deprecated alias for `task ship --new-cycle` (start a new PR cycle after merge)")
113
130
  .option("--dry-run", "Plan only")
114
131
  .option("--no-pr", "Push only; skip creating MR/PR")
115
132
  .option("--no-integrate", "Skip merging integration branch into task branch")
@@ -120,8 +137,8 @@ task
120
137
  .action(async (taskRef, opts) => {
121
138
  await taskReshipCommand(taskRef, {
122
139
  dryRun: Boolean(opts.dryRun),
123
- noPr: Boolean(opts.noPr),
124
- noIntegrate: Boolean(opts.noIntegrate),
140
+ noPr: opts.pr === false,
141
+ noIntegrate: opts.integrate === false,
125
142
  recreateBranch: Boolean(opts.recreateBranch),
126
143
  skipLlm: Boolean(opts.skipLlm),
127
144
  allowOpenMr: Boolean(opts.allowOpenMr),
@@ -1,175 +1,21 @@
1
- import { resolve } from "node:path";
2
- import { readText } from "../lib/filesystem.js";
3
- import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
4
- import { projectPaths } from "../lib/paths.js";
5
- import { relPath } from "../lib/task-context.js";
6
- import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, } from "../lib/task-git-commit.js";
7
- import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
8
- import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "../lib/task-ship-llm.js";
9
- import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "../lib/task-reship.js";
10
- import { hasNonEmptySection, parseTaskFilename, readGitContext, } from "../lib/task.js";
11
- import { listWorkingTreeRelPaths } from "../lib/git.js";
12
- async function commitReshipMetadata(opts) {
13
- if (opts.dryRun) {
14
- log.info(dim(" would commit reship metadata (Status stays Shipped)"));
15
- return;
16
- }
17
- const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "reship metadata"), false);
18
- if (!committed) {
19
- log.info(dim("No reship metadata commit (Git Context already on disk)."));
20
- }
21
- }
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
+ */
22
7
  export async function taskReshipCommand(taskRef, options = {}) {
23
- const cwd = resolve(options.cwd ?? process.cwd());
24
- const paths = projectPaths(cwd);
25
- const dryRun = options.dryRun === true;
26
- const target = await resolveReshipTarget(paths, cwd, taskRef);
27
- if (target === null) {
28
- process.exitCode = 1;
29
- return;
30
- }
31
- const { taskId, taskFile, meta, gitCtx: initialCtx } = target;
32
- const relFile = relPath(cwd, taskFile);
33
- log.info(bold(`vibeops task reship ${taskId}`));
34
- log.info(` ${dim("file")} ${relFile}`);
35
- log.info(` ${dim("branch")} ${initialCtx.taskBranch}`);
36
- log.info(` ${dim("status")} Shipped (unchanged)`);
37
- log.blank();
38
- const gitCfg = dryRun
39
- ? await loadGitConfigForReship(cwd).catch(() => null)
40
- : await loadGitConfigForReship(cwd);
41
- if (!dryRun && gitCfg === null) {
42
- process.exitCode = 1;
43
- return;
44
- }
45
- const integrationBranch = gitCfg?.integrationBranch ?? "develop";
46
- const remote = gitCfg?.remote ?? "origin";
47
- const updateOpenMr = options.allowOpenMr === true;
48
- if (!dryRun && !(await assertMergeRequestNotOpen(cwd, taskFile, updateOpenMr))) {
49
- process.exitCode = 1;
50
- return;
51
- }
52
- const branchOk = await ensureTaskBranchForReship({
53
- cwd,
54
- taskFile,
55
- gitCtx: initialCtx,
56
- integrationBranch,
57
- remote,
58
- recreateBranch: options.recreateBranch === true,
59
- dryRun,
60
- });
61
- if (!branchOk) {
62
- process.exitCode = 1;
63
- return;
64
- }
65
- if (options.noIntegrate !== true) {
66
- const integrated = await integrateIntegrationIntoTaskBranch({
67
- cwd,
68
- integrationBranch,
69
- remote,
70
- dryRun,
71
- });
72
- if (!integrated) {
73
- process.exitCode = 1;
74
- return;
75
- }
76
- }
77
- else {
78
- log.info(dim("Skipped integration merge (--no-integrate)."));
79
- }
80
- const body = await readText(taskFile);
81
- let resultOk = hasNonEmptySection(body, "Result");
82
- let testOk = hasNonEmptySection(body, "Test Result");
83
- if (!options.skipLlm && !dryRun) {
84
- log.step("LLM — follow-up Result / Test Result");
85
- const patch = await llmCompleteTaskReship({
86
- cwd,
87
- taskId,
88
- taskTitle: meta.title,
89
- taskBody: body,
90
- taskFileRel: relFile,
91
- });
92
- if (patch !== null) {
93
- await writeTaskResultSections(taskFile, patch.result, patch.testResult);
94
- log.ok(`Result / Test Result updated (${patch.provider})`);
95
- const memory = await applyTaskShipMemory(cwd, patch);
96
- for (const p of memory.updated)
97
- log.ok(`Updated ${p}`);
98
- for (const p of memory.skipped)
99
- log.skip(`Skipped ${p}`);
100
- }
101
- else {
102
- log.warn("LLM unavailable — ensure Result / Test Result describe this follow-up.");
103
- }
104
- const body2 = await readText(taskFile);
105
- resultOk = hasNonEmptySection(body2, "Result");
106
- testOk = hasNonEmptySection(body2, "Test Result");
107
- }
108
- else if (options.skipLlm) {
109
- log.info(dim("LLM skipped (--skip-llm)."));
110
- }
111
- log.info(bold("Sections"));
112
- log.info(` ${resultOk ? green("✓") : yellow("·")} Result`);
113
- log.info(` ${testOk ? green("✓") : yellow("·")} Test Result`);
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."));
114
10
  log.blank();
115
- if (dryRun) {
116
- log.info(bold("dry-run — would also:"));
117
- if (updateOpenMr) {
118
- log.info(" · feat commit, push, update open MR/PR (unless --no-pr)");
119
- }
120
- else {
121
- log.info(" · feat commit + reship metadata, then push once and open MR/PR (unless --no-pr)");
122
- }
123
- log.blank();
124
- log.info(`Next: ${cyan("vibeops task merge")}, then optional ${cyan("task sync")}`);
125
- return;
126
- }
127
- if (!resultOk || !testOk) {
128
- log.error("Fill Result and Test Result for this follow-up, then rerun.");
129
- process.exitCode = 1;
130
- return;
131
- }
132
- const dirtyBefore = await listWorkingTreeRelPaths(cwd);
133
- if (dirtyBefore.length === 0) {
134
- log.error("No changes to reship. Edit files (e.g. on develop), then rerun.");
135
- process.exitCode = 1;
136
- return;
137
- }
138
- const parts = parseTaskFilename(taskFile);
139
- await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
140
- let gitCtx = await readGitContext(taskFile);
141
- if (gitCtx === null) {
142
- log.error("Git Context missing after commit.");
143
- process.exitCode = 1;
144
- return;
145
- }
146
- if (updateOpenMr) {
147
- log.info(dim("Updating open MR/PR (--allow-open-mr)."));
148
- }
149
- else {
150
- gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
151
- gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
152
- await commitReshipMetadata({
153
- cwd,
154
- taskFile,
155
- taskId: parts.id,
156
- dryRun: false,
157
- });
158
- }
159
- const prResult = await finishTaskWithPullRequest({
160
- cwd,
161
- taskFile,
162
- skipPr: options.noPr === true,
163
- forceNewMergeRequest: !updateOpenMr,
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,
164
20
  });
165
- if (!prResult.ok) {
166
- log.error("Reship failed — fix push/MR, then rerun task reship.");
167
- process.exitCode = 1;
168
- return;
169
- }
170
- if (prResult.mergeRequestUrl) {
171
- log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
172
- }
173
- log.blank();
174
- log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
175
21
  }
@@ -1,13 +1,55 @@
1
1
  import { resolve } from "node:path";
2
2
  import { readText } from "../lib/filesystem.js";
3
+ import { requireGitConfig } from "../lib/git-config.js";
4
+ import { gitCommitsAhead, gitHeadCommit, gitPush, gitRemoteBranchExists, readGitInfo, } from "../lib/git.js";
5
+ import { askYesNo, isInteractiveSession } from "../lib/inquirer-helpers.js";
3
6
  import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
4
7
  import { projectPaths } from "../lib/paths.js";
5
- import { resolveTask, taskNotFoundMessage } from "../lib/resolve-task.js";
8
+ import { prNumberFromUrl } from "../lib/pr-create.js";
9
+ import { resolveShipTarget, taskNotFoundMessage } from "../lib/resolve-task.js";
6
10
  import { relPath } from "../lib/task-context.js";
7
- import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, } from "../lib/task-git-commit.js";
11
+ import { resolveShipCommitMessage } from "../lib/task-commit-msg.js";
12
+ import { commitDirtyWorkingTree, docsCommitMessageFor, } from "../lib/task-git-commit.js";
13
+ import { runPostMergeNewCycle } from "../lib/task-new-cycle.js";
8
14
  import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, writeTaskResultSections, } from "../lib/task-ship-llm.js";
15
+ import { detectShipPrState, prRefLabel, resolveShipAction, } from "../lib/task-ship-state.js";
9
16
  import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
10
17
  import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
18
+ function describePushFailure(message) {
19
+ const lower = message.toLowerCase();
20
+ if (lower.includes("protected") ||
21
+ lower.includes("pre-receive") ||
22
+ lower.includes("gh006") ||
23
+ lower.includes("hook declined")) {
24
+ return "Push rejected by branch protection. Adjust the rule on the host (or push via the MR), then rerun.";
25
+ }
26
+ if (lower.includes("non-fast-forward") ||
27
+ lower.includes("fetch first") ||
28
+ lower.includes("rejected") ||
29
+ lower.includes("behind")) {
30
+ return "Push rejected — the remote branch has commits you don't. Pull/rebase the task branch, then rerun.";
31
+ }
32
+ return `git push failed: ${message}`;
33
+ }
34
+ function warnPlaceholderSections(body) {
35
+ const resultOk = hasNonEmptySection(body, "Result");
36
+ const testOk = hasNonEmptySection(body, "Test Result");
37
+ if (!resultOk || !testOk) {
38
+ log.warn("Result / Test Result still look like placeholders (docs-before-ship). Update the TASK file before merge.");
39
+ }
40
+ }
41
+ async function ensureOnTaskBranch(cwd, gitCtx, dryRun) {
42
+ const git = await readGitInfo(cwd);
43
+ if (git.branch === gitCtx.taskBranch)
44
+ return true;
45
+ const where = git.branch ?? "(detached)";
46
+ if (dryRun) {
47
+ log.warn(`Current branch is ${where}, not ${gitCtx.taskBranch}.`);
48
+ return true;
49
+ }
50
+ log.error(`On ${where}, not ${gitCtx.taskBranch}. Switch with \`git switch ${gitCtx.taskBranch}\` and rerun.`);
51
+ return false;
52
+ }
11
53
  async function commitShipMetadata(opts) {
12
54
  if (opts.dryRun) {
13
55
  log.info(dim(" would set Status → Shipped"));
@@ -21,23 +63,10 @@ async function commitShipMetadata(opts) {
21
63
  log.warn("No file changes for ship metadata commit — check TASK Status on the remote branch.");
22
64
  }
23
65
  }
24
- export async function taskShipCommand(taskRef, options = {}) {
25
- const cwd = resolve(options.cwd ?? process.cwd());
26
- const paths = projectPaths(cwd);
27
- const dryRun = options.dryRun === true;
28
- const resolved = await resolveTask(paths, cwd, taskRef);
29
- if (resolved === null) {
30
- log.error(taskNotFoundMessage(taskRef));
31
- process.exitCode = 1;
32
- return;
33
- }
34
- const { taskId, taskFile } = resolved;
35
- const meta = await readTaskFile(taskFile);
36
- const body = await readText(taskFile);
37
- const relFile = relPath(cwd, taskFile);
38
- log.info(bold(`vibeops task ship ${taskId}`));
39
- log.info(` ${dim("file")} ${relFile}`);
40
- log.blank();
66
+ /** State 1: first submit — push the task branch and open a new PR/MR. */
67
+ async function runFirstShip(ctx) {
68
+ const { cwd, taskId, taskFile, meta, relFile, dryRun, nonInteractive, options } = ctx;
69
+ let body = ctx.body;
41
70
  let resultOk = hasNonEmptySection(body, "Result");
42
71
  let testOk = hasNonEmptySection(body, "Test Result");
43
72
  if (!resultOk || !testOk) {
@@ -68,9 +97,9 @@ export async function taskShipCommand(taskRef, options = {}) {
68
97
  await writeTaskResultSections(taskFile, fb.result, fb.testResult);
69
98
  }
70
99
  }
71
- const body2 = dryRun ? body : await readText(taskFile);
72
- resultOk = hasNonEmptySection(body2, "Result");
73
- testOk = hasNonEmptySection(body2, "Test Result");
100
+ body = dryRun ? body : await readText(taskFile);
101
+ resultOk = hasNonEmptySection(body, "Result");
102
+ testOk = hasNonEmptySection(body, "Test Result");
74
103
  }
75
104
  log.info(bold("Sections"));
76
105
  log.info(` ${resultOk ? green("✓") : yellow("·")} Result`);
@@ -86,15 +115,10 @@ export async function taskShipCommand(taskRef, options = {}) {
86
115
  if (!resultOk || !testOk) {
87
116
  log.info(" · LLM fill Result / Test Result + patch docs/project/*");
88
117
  }
89
- log.info(" · commit implementation + ship metadata (Status → Shipped)");
90
- log.info(" · push task branch once, open MR/PR (unless --no-pr)");
118
+ log.info(" · commit implementation (TASK-scoped message) + ship metadata (Status → Shipped)");
119
+ log.info(" · push task branch once, open a new MR/PR (unless --no-pr)");
91
120
  const partsDry = parseTaskFilename(taskFile);
92
- await commitShipMetadata({
93
- cwd,
94
- taskFile,
95
- taskId: partsDry.id,
96
- dryRun: true,
97
- });
121
+ await commitShipMetadata({ cwd, taskFile, taskId: partsDry.id, dryRun: true });
98
122
  await finishTaskWithPullRequest({
99
123
  cwd,
100
124
  taskFile,
@@ -103,33 +127,223 @@ export async function taskShipCommand(taskRef, options = {}) {
103
127
  });
104
128
  return;
105
129
  }
106
- const parts = parseTaskFilename(taskFile);
107
- await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
108
130
  const gitCtx = await readGitContext(taskFile);
109
- if (gitCtx !== null) {
110
- await commitShipMetadata({
131
+ if (gitCtx !== null && !(await ensureOnTaskBranch(cwd, gitCtx, dryRun))) {
132
+ process.exitCode = 1;
133
+ return;
134
+ }
135
+ const parts = parseTaskFilename(taskFile);
136
+ if (options.noCommit === true) {
137
+ const git = await readGitInfo(cwd);
138
+ if (git.dirty === true) {
139
+ log.warn("Uncommitted changes left in place (--no-commit); committing ship metadata only.");
140
+ }
141
+ }
142
+ else {
143
+ const message = await resolveShipCommitMessage({
111
144
  cwd,
112
- taskFile,
113
145
  taskId: parts.id,
114
- dryRun: false,
146
+ title: meta.title,
147
+ provided: options.message,
148
+ nonInteractive,
115
149
  });
116
- const prResult = await finishTaskWithPullRequest({
150
+ await commitDirtyWorkingTree(cwd, message, false);
151
+ }
152
+ if (gitCtx === null) {
153
+ log.info(dim("No Git Context — push/MR skipped."));
154
+ return;
155
+ }
156
+ await commitShipMetadata({ cwd, taskFile, taskId: parts.id, dryRun: false });
157
+ const prResult = await finishTaskWithPullRequest({
158
+ cwd,
159
+ taskFile,
160
+ skipPr: options.noPr === true,
161
+ });
162
+ if (!prResult.ok) {
163
+ log.error("TASK left In Progress — fix push/MR, then rerun task ship.");
164
+ process.exitCode = 1;
165
+ return;
166
+ }
167
+ log.blank();
168
+ if (prResult.mergeRequestUrl && prResult.mergeRequestUrl !== "(dry-run)") {
169
+ const ref = prRefLabel(prNumberFromUrl(prResult.mergeRequestUrl));
170
+ log.ok(`Created PR ${ref} → ${prResult.mergeRequestUrl}`);
171
+ }
172
+ else if (options.noPr === true) {
173
+ log.ok(`Pushed ${gitCtx.taskBranch} (no PR created — --no-pr).`);
174
+ }
175
+ else {
176
+ log.ok(`Pushed ${gitCtx.taskBranch} (create the MR/PR manually).`);
177
+ }
178
+ log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
179
+ }
180
+ /** State 2: an open PR exists — commit + push to update it (no new PR). */
181
+ async function runUpdateOpenPr(ctx) {
182
+ const { cwd, taskId, taskFile, meta, body, prCtx, dryRun, nonInteractive, options } = ctx;
183
+ const gitCtx = await readGitContext(taskFile);
184
+ if (gitCtx === null) {
185
+ log.error("No Git Context — cannot update the open PR.");
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ let gitCfg;
190
+ try {
191
+ gitCfg = await requireGitConfig(cwd);
192
+ }
193
+ catch (e) {
194
+ log.error(e instanceof Error ? e.message : String(e));
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+ const remote = gitCfg.remote;
199
+ if (!(await ensureOnTaskBranch(cwd, gitCtx, dryRun))) {
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+ warnPlaceholderSections(body);
204
+ if (dryRun) {
205
+ log.info(bold("dry-run — would:"));
206
+ if (options.noCommit === true) {
207
+ log.info(" · skip commit (--no-commit); push committed history only");
208
+ }
209
+ else {
210
+ log.info(" · commit working tree with a TASK-scoped message");
211
+ }
212
+ log.info(` · push ${gitCtx.taskBranch} → ${remote} (no new PR)`);
213
+ log.info(` · update existing PR ${prRefLabel(prCtx.number)} — CI re-runs`);
214
+ return;
215
+ }
216
+ if (meta.status === "in_progress") {
217
+ await updateInlineStatus(taskFile, "shipped");
218
+ log.ok("Status → Shipped");
219
+ await commitDirtyWorkingTree(cwd, docsCommitMessageFor(taskId, "mark shipped"), false);
220
+ }
221
+ if (options.noCommit === true) {
222
+ const git = await readGitInfo(cwd);
223
+ if (git.dirty === true) {
224
+ log.warn("Uncommitted changes left in place (--no-commit); pushing committed history only.");
225
+ }
226
+ }
227
+ else {
228
+ const message = await resolveShipCommitMessage({
117
229
  cwd,
118
- taskFile,
119
- skipPr: options.noPr === true,
230
+ taskId,
231
+ title: meta.title,
232
+ provided: options.message,
233
+ nonInteractive,
120
234
  });
121
- if (!prResult.ok) {
122
- log.error("TASK left In Progress — fix push/MR, then rerun task ship.");
235
+ await commitDirtyWorkingTree(cwd, message, false);
236
+ }
237
+ const remoteExists = await gitRemoteBranchExists(cwd, remote, gitCtx.taskBranch);
238
+ const ahead = remoteExists
239
+ ? await gitCommitsAhead(cwd, `${remote}/${gitCtx.taskBranch}`, "HEAD")
240
+ : 1;
241
+ const git = await readGitInfo(cwd);
242
+ const stillDirty = git.dirty === true;
243
+ if (ahead === 0 && !stillDirty) {
244
+ log.blank();
245
+ log.info(`Nothing to ship (working tree clean, PR ${prRefLabel(prCtx.number)} up to date).`);
246
+ return;
247
+ }
248
+ try {
249
+ await gitPush(cwd, remote, gitCtx.taskBranch, false);
250
+ log.ok(`Pushed ${gitCtx.taskBranch} → ${remote}`);
251
+ }
252
+ catch (e) {
253
+ const msg = e instanceof Error ? e.message : String(e);
254
+ log.error(describePushFailure(msg));
255
+ process.exitCode = 1;
256
+ return;
257
+ }
258
+ const sha = (await gitHeadCommit(cwd, true)) ?? "HEAD";
259
+ log.blank();
260
+ log.ok(`Updated existing PR ${prRefLabel(prCtx.number)} (pushed ${sha}) — CI re-running`);
261
+ if (prCtx.url)
262
+ log.info(` ${dim("MR/PR")} ${prCtx.url}`);
263
+ }
264
+ /** State 3: the previous PR is merged — start a new PR cycle (guarded). */
265
+ async function runNewCycleFromShip(ctx) {
266
+ const { taskId, prCtx, dryRun, nonInteractive, options } = ctx;
267
+ if (!dryRun && options.newCycle !== true && options.allowOpenMr !== true) {
268
+ if (nonInteractive) {
269
+ log.error(`PR ${prRefLabel(prCtx.number)} is merged. Starting a NEW PR cycle requires --new-cycle in non-interactive mode.`);
123
270
  process.exitCode = 1;
124
271
  return;
125
272
  }
126
- if (prResult.mergeRequestUrl) {
127
- log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
273
+ const ok = await askYesNo({
274
+ message: "This starts a NEW PR cycle after merge. Continue?",
275
+ nonInteractive: false,
276
+ defaultValue: false,
277
+ });
278
+ if (!ok) {
279
+ log.info(`Aborted. Use ${cyan("vibeops task add")} for unrelated work, or rerun with ${cyan("--new-cycle")}.`);
280
+ return;
128
281
  }
129
282
  }
130
- else {
131
- log.info(dim("No Git Context — push/MR skipped."));
283
+ await runPostMergeNewCycle(taskId, {
284
+ dryRun,
285
+ cwd: options.cwd,
286
+ noPr: options.noPr,
287
+ noIntegrate: options.noIntegrate,
288
+ recreateBranch: options.recreateBranch,
289
+ skipLlm: options.skipLlm,
290
+ allowOpenMr: options.allowOpenMr,
291
+ });
292
+ }
293
+ /** State/PR mismatch: Status is Shipped but no open/merged PR was found. */
294
+ function runMismatchHint(ctx) {
295
+ log.warn(`${ctx.taskId} is Shipped but no open or merged PR was found for its branch.`);
296
+ log.info("The PR was likely merged and the branch synced/deleted.");
297
+ log.info(`Next: start new work with ${cyan("vibeops task add")}, or run ${cyan("vibeops status")} to re-check.`);
298
+ }
299
+ export async function taskShipCommand(taskRef, options = {}) {
300
+ const cwd = resolve(options.cwd ?? process.cwd());
301
+ const paths = projectPaths(cwd);
302
+ const dryRun = options.dryRun === true;
303
+ const nonInteractive = options.nonInteractive === true || !isInteractiveSession();
304
+ const resolved = await resolveShipTarget(paths, cwd, taskRef);
305
+ if (resolved === null) {
306
+ log.error(taskNotFoundMessage(taskRef));
307
+ process.exitCode = 1;
308
+ return;
309
+ }
310
+ const { taskId, taskFile } = resolved;
311
+ const meta = await readTaskFile(taskFile);
312
+ const body = await readText(taskFile);
313
+ const relFile = relPath(cwd, taskFile);
314
+ const prCtx = await detectShipPrState(cwd, taskFile);
315
+ const action = resolveShipAction(prCtx.state, meta.status);
316
+ log.info(bold(`vibeops task ship ${taskId}`));
317
+ log.info(` ${dim("file")} ${relFile}`);
318
+ if (prCtx.state !== "none") {
319
+ log.info(` ${dim("PR")} ${prRefLabel(prCtx.number)} (${prCtx.state})`);
132
320
  }
133
321
  log.blank();
134
- log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
322
+ const ctx = {
323
+ cwd,
324
+ taskId,
325
+ taskFile,
326
+ meta,
327
+ body,
328
+ relFile,
329
+ prCtx,
330
+ dryRun,
331
+ nonInteractive,
332
+ options,
333
+ };
334
+ switch (action) {
335
+ case "update-open":
336
+ await runUpdateOpenPr(ctx);
337
+ return;
338
+ case "new-cycle":
339
+ await runNewCycleFromShip(ctx);
340
+ return;
341
+ case "mismatch":
342
+ runMismatchHint(ctx);
343
+ return;
344
+ case "first":
345
+ default:
346
+ await runFirstShip(ctx);
347
+ return;
348
+ }
135
349
  }
@@ -1,5 +1,9 @@
1
1
  import { checkbox, confirm, input, select } from "@inquirer/prompts";
2
2
  export const OTHER_LABEL = "Other";
3
+ /** True when both stdin and stdout are TTYs (safe to show interactive prompts). */
4
+ export function isInteractiveSession() {
5
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
6
+ }
3
7
  function isOther(value) {
4
8
  return value === OTHER_LABEL;
5
9
  }
@@ -157,8 +157,13 @@ export async function findMergeRequestByBranches(opts) {
157
157
  return null;
158
158
  }
159
159
  }
160
- function prRefFromUrl(url) {
160
+ /** Extract the numeric PR/MR id from a host URL (e.g. `#42`), or `null`. */
161
+ export function prNumberFromUrl(url) {
162
+ if (typeof url !== "string")
163
+ return null;
161
164
  const trimmed = url.trim();
165
+ if (trimmed.length === 0)
166
+ return null;
162
167
  const pullMatch = /\/pull\/(\d+)/i.exec(trimmed);
163
168
  if (pullMatch)
164
169
  return pullMatch[1];
@@ -167,7 +172,10 @@ function prRefFromUrl(url) {
167
172
  return mrMatch[1];
168
173
  if (/^\d+$/.test(trimmed))
169
174
  return trimmed;
170
- return trimmed;
175
+ return null;
176
+ }
177
+ function prRefFromUrl(url) {
178
+ return prNumberFromUrl(url) ?? url.trim();
171
179
  }
172
180
  export async function getMergeRequestState(cwd, host, url) {
173
181
  const ref = prRefFromUrl(url);
@@ -1,4 +1,4 @@
1
- import { findTaskFile, loadActionableTasks, pickActiveTask, pickInProgressTask, } from "./task.js";
1
+ import { findTaskFile, loadActionableTasks, pickActiveTask, pickInProgressTask, pickLatestShippedTask, } from "./task.js";
2
2
  export function normalizeTaskRef(ref) {
3
3
  const t = ref.trim();
4
4
  if (/^task-/i.test(t) && !/^TASK-/i.test(t)) {
@@ -26,6 +26,36 @@ export async function resolveTask(paths, cwd, taskRef) {
26
26
  }
27
27
  return null;
28
28
  }
29
+ /**
30
+ * Resolve the TASK that `ship` should act on. Unlike {@link resolveTask}, this
31
+ * also matches an already-Shipped TASK so state-aware ship (update open PR /
32
+ * new PR cycle) can re-run on it.
33
+ * Order: explicit ref → In Progress → active `task/*` branch → latest Shipped.
34
+ */
35
+ export async function resolveShipTarget(paths, cwd, taskRef) {
36
+ const trimmed = taskRef?.trim();
37
+ if (trimmed !== undefined && trimmed.length > 0) {
38
+ const taskId = normalizeTaskRef(trimmed);
39
+ const taskFile = await findTaskFile(paths.docsTasks, taskId);
40
+ if (taskFile === null)
41
+ return null;
42
+ return { taskId, taskFile };
43
+ }
44
+ const tasks = await loadActionableTasks(paths.docsTasks);
45
+ const inProgress = pickInProgressTask(tasks);
46
+ if (inProgress !== null) {
47
+ return { taskId: inProgress.id, taskFile: inProgress.filePath };
48
+ }
49
+ const onBranch = await pickActiveTask(paths.docsTasks, cwd);
50
+ if (onBranch !== null) {
51
+ return { taskId: onBranch.id, taskFile: onBranch.filePath };
52
+ }
53
+ const shipped = pickLatestShippedTask(tasks);
54
+ if (shipped !== null) {
55
+ return { taskId: shipped.id, taskFile: shipped.filePath };
56
+ }
57
+ return null;
58
+ }
29
59
  export function taskNotFoundMessage(taskRef) {
30
60
  if (taskRef?.trim()) {
31
61
  return `TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
@@ -0,0 +1,80 @@
1
+ import { readConfig } from "./config.js";
2
+ import { gitDiffNameOnly, listWorkingTreeRelPaths } from "./git.js";
3
+ import { anyLlmProviderAvailable, llmCompleteText } from "./llm-complete.js";
4
+ import { getLlmPreferenceFromConfig } from "./llm-preference.js";
5
+ import { askInput, isInteractiveSession } from "./inquirer-helpers.js";
6
+ import { dim, log } from "./logger.js";
7
+ import { featCommitMessageFor, taskScopedCommitMessage } from "./task-git-commit.js";
8
+ async function changedPathsForMessage(cwd) {
9
+ const working = await listWorkingTreeRelPaths(cwd);
10
+ if (working.length > 0)
11
+ return working;
12
+ return gitDiffNameOnly(cwd);
13
+ }
14
+ /** Ask the LLM for a one-line conventional-commit subject from the changed files. */
15
+ export async function generateCommitSubjectWithLlm(cwd, taskId, title) {
16
+ const files = await changedPathsForMessage(cwd);
17
+ const fileList = files.slice(0, 40).join("\n") || "(no file changes detected)";
18
+ const messages = [
19
+ {
20
+ role: "system",
21
+ content: "Write a single Conventional Commit subject line (under 72 chars) for the change. " +
22
+ "Reply with ONLY the subject line — no scope, no body, no quotes, no code fences. " +
23
+ "Pick the type from feat, fix, docs, refactor, test, chore.",
24
+ },
25
+ {
26
+ role: "user",
27
+ content: [
28
+ `TASK: ${taskId} — ${title}`,
29
+ "",
30
+ "Changed files:",
31
+ fileList,
32
+ ].join("\n"),
33
+ },
34
+ ];
35
+ try {
36
+ const config = await readConfig(cwd);
37
+ const preference = getLlmPreferenceFromConfig(config);
38
+ const { text } = await llmCompleteText(messages, { cwd, preference });
39
+ const line = text
40
+ .split("\n")
41
+ .map((l) => l.trim())
42
+ .find((l) => l.length > 0);
43
+ if (line === undefined)
44
+ return null;
45
+ return line.replace(/^["'`]+|["'`]+$/g, "").trim() || null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Resolve the commit subject for a ship, always TASK-id-scoped:
53
+ * 1. explicit `--message`, 2. LLM from diff (when a provider is connected),
54
+ * 3. interactive prompt, 4. TASK-title fallback.
55
+ */
56
+ export async function resolveShipCommitMessage(opts) {
57
+ const provided = opts.provided?.trim();
58
+ if (provided !== undefined && provided.length > 0) {
59
+ return taskScopedCommitMessage(opts.taskId, provided);
60
+ }
61
+ if (await anyLlmProviderAvailable(opts.cwd)) {
62
+ const subject = await generateCommitSubjectWithLlm(opts.cwd, opts.taskId, opts.title);
63
+ if (subject !== null && subject.length > 0) {
64
+ const msg = taskScopedCommitMessage(opts.taskId, subject);
65
+ log.info(dim(` commit message (LLM): ${msg}`));
66
+ return msg;
67
+ }
68
+ }
69
+ if (!opts.nonInteractive && isInteractiveSession()) {
70
+ const typed = await askInput({
71
+ message: `Commit message for ${opts.taskId}`,
72
+ nonInteractive: false,
73
+ default: opts.title,
74
+ });
75
+ if (typed.trim().length > 0) {
76
+ return taskScopedCommitMessage(opts.taskId, typed);
77
+ }
78
+ }
79
+ return featCommitMessageFor(opts.taskId, opts.title);
80
+ }
@@ -64,21 +64,29 @@ export async function resolveOpenTaskMergeRequest(cwd, taskFile) {
64
64
  return legacy;
65
65
  return null;
66
66
  }
67
- /** MR lifecycle for hints, sync, and status — prefers host branch lookup. */
68
- export async function getTaskMergeRequestLifecycle(cwd, taskFile) {
67
+ /**
68
+ * Single source of truth for a TASK's PR/MR lifecycle and URL.
69
+ * Resolution order: open branch lookup → merged branch lookup → legacy TASK URL.
70
+ * `ship` keys its state machine off the returned `state`.
71
+ */
72
+ export async function resolveTaskMergeRequestLifecycle(cwd, taskFile) {
69
73
  const ctx = await readTaskGitContext(taskFile);
70
74
  if (ctx === null)
71
- return "none";
75
+ return { state: "none", url: null, host: null };
72
76
  const open = await resolveFromBranches(cwd, ctx, "open");
73
77
  if (open !== null)
74
- return "open";
78
+ return { state: "open", url: open.url, host: open.host };
75
79
  const merged = await resolveFromBranches(cwd, ctx, "merged");
76
80
  if (merged !== null)
77
- return "merged";
81
+ return { state: "merged", url: merged.url, host: merged.host };
78
82
  const legacy = await resolveFromTaskFileUrl(cwd, ctx);
79
83
  if (legacy !== null)
80
- return legacy.state;
81
- return "none";
84
+ return { state: legacy.state, url: legacy.url, host: legacy.host };
85
+ return { state: "none", url: null, host: null };
86
+ }
87
+ /** MR lifecycle for hints, sync, and status — prefers host branch lookup. */
88
+ export async function getTaskMergeRequestLifecycle(cwd, taskFile) {
89
+ return (await resolveTaskMergeRequestLifecycle(cwd, taskFile)).state;
82
90
  }
83
91
  /** @deprecated Use {@link getTaskMergeRequestLifecycle} or {@link resolveOpenTaskMergeRequest}. */
84
92
  export async function getMergeRequestStateForTask(cwd, taskFile) {
@@ -7,6 +7,30 @@ export function featCommitMessageFor(taskId, title) {
7
7
  export function docsCommitMessageFor(taskId, suffix) {
8
8
  return `docs(${taskId.toLowerCase()}): ${suffix}`;
9
9
  }
10
+ const CONVENTIONAL_COMMIT_RE = /^([a-z]+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/is;
11
+ /**
12
+ * Normalize a raw commit message so it carries the TASK id as the conventional
13
+ * commit scope (git-safety rule: `feat(task-001): …`).
14
+ *
15
+ * - `"fix: handle null"` → `fix(task-001): handle null`
16
+ * - `"fix(login): handle null"` → `fix(task-001): handle null` (scope rewritten)
17
+ * - `"handle null"` → `feat(task-001): handle null` (defaultType)
18
+ * - already-scoped correct messages are returned unchanged.
19
+ */
20
+ export function taskScopedCommitMessage(taskId, rawMessage, defaultType = "feat") {
21
+ const scope = taskId.toLowerCase();
22
+ const msg = rawMessage.trim().replace(/\s+/g, " ");
23
+ if (msg.length === 0)
24
+ return `${defaultType}(${scope}): ${taskId}`;
25
+ const m = CONVENTIONAL_COMMIT_RE.exec(msg);
26
+ if (m) {
27
+ const type = m[1].toLowerCase();
28
+ const bang = m[3] ?? "";
29
+ const subject = m[4].trim();
30
+ return `${type}(${scope})${bang}: ${subject}`;
31
+ }
32
+ return `${defaultType}(${scope}): ${msg}`;
33
+ }
10
34
  /** Commit all committable dirty paths; returns whether a commit was created. */
11
35
  export async function commitDirtyWorkingTree(cwd, message, dryRun) {
12
36
  const git = await readGitInfo(cwd);
@@ -0,0 +1,186 @@
1
+ import { resolve } from "node:path";
2
+ import { readText } from "./filesystem.js";
3
+ import { listWorkingTreeRelPaths } from "./git.js";
4
+ import { bold, cyan, dim, green, log, yellow } from "./logger.js";
5
+ import { projectPaths } from "./paths.js";
6
+ import { prNumberFromUrl } from "./pr-create.js";
7
+ import { relPath } from "./task-context.js";
8
+ import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, } from "./task-git-commit.js";
9
+ import { finishTaskWithPullRequest } from "./task-push-pr.js";
10
+ import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "./task-ship-llm.js";
11
+ import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "./task-reship.js";
12
+ import { hasNonEmptySection, parseTaskFilename, readGitContext } from "./task.js";
13
+ import { prRefLabel } from "./task-ship-state.js";
14
+ async function commitReshipMetadata(opts) {
15
+ if (opts.dryRun) {
16
+ log.info(dim(" would commit new-cycle metadata (Status stays Shipped)"));
17
+ return;
18
+ }
19
+ const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "new PR cycle metadata"), false);
20
+ if (!committed) {
21
+ log.info(dim("No new-cycle metadata commit (Git Context already on disk)."));
22
+ }
23
+ }
24
+ /**
25
+ * Start a NEW PR cycle for an already-Shipped TASK whose previous PR was merged
26
+ * (the former `task reship` behavior). Carries uncommitted work onto the task
27
+ * branch, integrates the integration branch, and opens a fresh MR/PR.
28
+ */
29
+ export async function runPostMergeNewCycle(taskRef, options = {}) {
30
+ const cwd = resolve(options.cwd ?? process.cwd());
31
+ const paths = projectPaths(cwd);
32
+ const dryRun = options.dryRun === true;
33
+ const target = await resolveReshipTarget(paths, cwd, taskRef);
34
+ if (target === null) {
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+ const { taskId, taskFile, meta, gitCtx: initialCtx } = target;
39
+ const relFile = relPath(cwd, taskFile);
40
+ log.info(bold(`vibeops task ship ${taskId} — new PR cycle`));
41
+ log.info(` ${dim("file")} ${relFile}`);
42
+ log.info(` ${dim("branch")} ${initialCtx.taskBranch}`);
43
+ log.info(` ${dim("status")} Shipped (unchanged)`);
44
+ log.blank();
45
+ const gitCfg = dryRun
46
+ ? await loadGitConfigForReship(cwd).catch(() => null)
47
+ : await loadGitConfigForReship(cwd);
48
+ if (!dryRun && gitCfg === null) {
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ const integrationBranch = gitCfg?.integrationBranch ?? "develop";
53
+ const remote = gitCfg?.remote ?? "origin";
54
+ const updateOpenMr = options.allowOpenMr === true;
55
+ if (!dryRun && !(await assertMergeRequestNotOpen(cwd, taskFile, updateOpenMr))) {
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ const branchOk = await ensureTaskBranchForReship({
60
+ cwd,
61
+ taskFile,
62
+ gitCtx: initialCtx,
63
+ integrationBranch,
64
+ remote,
65
+ recreateBranch: options.recreateBranch === true,
66
+ dryRun,
67
+ });
68
+ if (!branchOk) {
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+ if (options.noIntegrate !== true) {
73
+ const integrated = await integrateIntegrationIntoTaskBranch({
74
+ cwd,
75
+ integrationBranch,
76
+ remote,
77
+ dryRun,
78
+ });
79
+ if (!integrated) {
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ }
84
+ else {
85
+ log.info(dim("Skipped integration merge (--no-integrate)."));
86
+ }
87
+ const body = await readText(taskFile);
88
+ let resultOk = hasNonEmptySection(body, "Result");
89
+ let testOk = hasNonEmptySection(body, "Test Result");
90
+ if (!options.skipLlm && !dryRun) {
91
+ log.step("LLM — follow-up Result / Test Result");
92
+ const patch = await llmCompleteTaskReship({
93
+ cwd,
94
+ taskId,
95
+ taskTitle: meta.title,
96
+ taskBody: body,
97
+ taskFileRel: relFile,
98
+ });
99
+ if (patch !== null) {
100
+ await writeTaskResultSections(taskFile, patch.result, patch.testResult);
101
+ log.ok(`Result / Test Result updated (${patch.provider})`);
102
+ const memory = await applyTaskShipMemory(cwd, patch);
103
+ for (const p of memory.updated)
104
+ log.ok(`Updated ${p}`);
105
+ for (const p of memory.skipped)
106
+ log.skip(`Skipped ${p}`);
107
+ }
108
+ else {
109
+ log.warn("LLM unavailable — ensure Result / Test Result describe this follow-up.");
110
+ }
111
+ const body2 = await readText(taskFile);
112
+ resultOk = hasNonEmptySection(body2, "Result");
113
+ testOk = hasNonEmptySection(body2, "Test Result");
114
+ }
115
+ else if (options.skipLlm) {
116
+ log.info(dim("LLM skipped (--skip-llm)."));
117
+ }
118
+ log.info(bold("Sections"));
119
+ log.info(` ${resultOk ? green("✓") : yellow("·")} Result`);
120
+ log.info(` ${testOk ? green("✓") : yellow("·")} Test Result`);
121
+ log.blank();
122
+ if (dryRun) {
123
+ log.info(bold("dry-run — would also:"));
124
+ if (updateOpenMr) {
125
+ log.info(" · feat commit, push, update open MR/PR (unless --no-pr)");
126
+ }
127
+ else {
128
+ log.info(" · feat commit + metadata, then push once and open a NEW MR/PR (unless --no-pr)");
129
+ }
130
+ log.blank();
131
+ log.info(`Next: ${cyan("vibeops task merge")}, then optional ${cyan("task sync")}`);
132
+ return;
133
+ }
134
+ if (!resultOk || !testOk) {
135
+ log.error("Fill Result and Test Result for this follow-up, then rerun.");
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ const dirtyBefore = await listWorkingTreeRelPaths(cwd);
140
+ if (dirtyBefore.length === 0) {
141
+ log.info(`Nothing to do — ${taskId} is merged with no new changes. Create a new TASK for new work (${cyan("vibeops task add")}).`);
142
+ return;
143
+ }
144
+ const parts = parseTaskFilename(taskFile);
145
+ await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
146
+ let gitCtx = await readGitContext(taskFile);
147
+ if (gitCtx === null) {
148
+ log.error("Git Context missing after commit.");
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ if (updateOpenMr) {
153
+ log.info(dim("Updating open MR/PR (--allow-open-mr)."));
154
+ }
155
+ else {
156
+ gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
157
+ gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
158
+ await commitReshipMetadata({
159
+ cwd,
160
+ taskFile,
161
+ taskId: parts.id,
162
+ dryRun: false,
163
+ });
164
+ }
165
+ const prResult = await finishTaskWithPullRequest({
166
+ cwd,
167
+ taskFile,
168
+ skipPr: options.noPr === true,
169
+ forceNewMergeRequest: !updateOpenMr,
170
+ });
171
+ if (!prResult.ok) {
172
+ log.error("New PR cycle failed — fix push/MR, then rerun `vibeops task ship --new-cycle`.");
173
+ process.exitCode = 1;
174
+ return;
175
+ }
176
+ log.blank();
177
+ const ref = prRefLabel(prNumberFromUrl(prResult.mergeRequestUrl));
178
+ if (prResult.mergeRequestUrl) {
179
+ log.ok(`Started new PR cycle → PR ${ref}`);
180
+ log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
181
+ }
182
+ else {
183
+ log.ok("Started new PR cycle (branch pushed; create the MR/PR manually).");
184
+ }
185
+ log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
186
+ }
@@ -0,0 +1,39 @@
1
+ import { prNumberFromUrl } from "./pr-create.js";
2
+ import { resolveTaskMergeRequestLifecycle } from "./task-effective-status.js";
3
+ /**
4
+ * Single entry point for `ship` to learn the current PR/MR state of a TASK.
5
+ * Wraps {@link resolveTaskMergeRequestLifecycle} and extracts the PR number.
6
+ */
7
+ export async function detectShipPrState(cwd, taskFile) {
8
+ const lifecycle = await resolveTaskMergeRequestLifecycle(cwd, taskFile);
9
+ const state = lifecycle.state === "open"
10
+ ? "open"
11
+ : lifecycle.state === "merged"
12
+ ? "merged"
13
+ : lifecycle.state === "closed"
14
+ ? "closed"
15
+ : "none";
16
+ return {
17
+ state,
18
+ url: lifecycle.url,
19
+ number: prNumberFromUrl(lifecycle.url),
20
+ };
21
+ }
22
+ /**
23
+ * Map (PR state, TASK status) → the `ship` action. Pure so it is unit-testable.
24
+ * - open PR → update the existing PR (state 2)
25
+ * - merged PR → start a new PR cycle (state 3, guarded)
26
+ * - no/closed PR + In Progress → first submit (state 1)
27
+ * - no/closed PR + Shipped → status/PR mismatch (resync hint)
28
+ */
29
+ export function resolveShipAction(prState, taskStatus) {
30
+ if (prState === "open")
31
+ return "update-open";
32
+ if (prState === "merged")
33
+ return "new-cycle";
34
+ return taskStatus === "shipped" ? "mismatch" : "first";
35
+ }
36
+ /** Format the PR reference for output (`#42` or `the PR`). */
37
+ export function prRefLabel(prNumber) {
38
+ return prNumber !== null ? `#${prNumber}` : "the PR";
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",
@@ -35,7 +35,8 @@
35
35
  "build": "tsc -p tsconfig.json",
36
36
  "typecheck": "tsc -p tsconfig.json --noEmit",
37
37
  "start": "node dist/cli.js",
38
- "smoke": "pnpm typecheck && pnpm build && node scripts/smoke.mjs",
38
+ "test": "pnpm build && node --test \"tests/*.test.mjs\"",
39
+ "smoke": "pnpm typecheck && pnpm build && node --test \"tests/*.test.mjs\" && node scripts/smoke.mjs",
39
40
  "clean": "rm -rf dist",
40
41
  "prepack": "pnpm clean && pnpm build",
41
42
  "publish:dry": "pnpm publish --dry-run --no-git-checks"
@@ -2,5 +2,5 @@
2
2
 
3
3
  - Work from `docs/tasks/TASK-NNN-*.md` only.
4
4
  - One TASK at a time.
5
- - Fill **Result** and **Test Result** before telling the human to run `vibeops task ship TASK-NNN` (or `task reship` for Shipped follow-up).
5
+ - Fill **Result** and **Test Result** before telling the human to run `vibeops task ship TASK-NNN`. `ship` is state-aware: re-running before merge updates the open PR; after merge use `vibeops task ship --new-cycle`.
6
6
  - Do not merge to integration or main unless the human runs `vibeops task merge` / `task sync` / `task release`.
@@ -8,4 +8,4 @@ alwaysApply: true
8
8
  - Use branch pattern `task/*` from `vibeops task add`.
9
9
  - Include TASK id in commits: `feat(task-nnn): …`.
10
10
  - No force-push to shared branches.
11
- - Run `vibeops task ship`, `task reship`, `task merge`, `task sync` when the human asks — do not merge locally in the IDE unless requested.
11
+ - Run `vibeops task ship`, `task merge`, `task sync` when the human asks — do not merge locally in the IDE unless requested. (`ship` is state-aware: it updates an open PR or, with `--new-cycle`, starts a new PR cycle after merge.)
@@ -5,7 +5,7 @@ alwaysApply: true
5
5
 
6
6
  # Docs before task ship
7
7
 
8
- Before `vibeops task ship` or `vibeops task reship`:
8
+ Before `vibeops task ship`:
9
9
 
10
10
  1. Fill **Result** and **Test Result** in the TASK file (paths, commands, outcomes — facts only).
11
11
  2. If architecture or decisions changed, note them in the TASK; `task ship` can update `docs/project/*` via LLM.
@@ -8,12 +8,14 @@
8
8
  vibeops task add
9
9
  # Plan / build in your agent (@docs/tasks/TASK-NNN-*.md)
10
10
 
11
- vibeops task ship
11
+ vibeops task ship # state-aware: new PR / update open PR / new cycle
12
12
  vibeops task merge # CLI or host UI
13
13
  vibeops task sync # optional — branch cleanup only
14
14
 
15
- # Same TASK follow-up (after merge):
16
- vibeops task reship TASK-NNN
15
+ # Same TASK, before merge — just re-run ship to update the open PR:
16
+ vibeops task ship -m "address review"
17
+ # Same TASK, after merge — start a new PR cycle:
18
+ vibeops task ship --new-cycle
17
19
  vibeops task merge
18
20
  ```
19
21
 
@@ -26,16 +28,17 @@ Only two values in `## Status`:
26
28
  | Status | Meaning |
27
29
  |--------|---------|
28
30
  | **In Progress** | Active slice (`task add`) |
29
- | **Shipped** | Submitted (`task ship` or `task reship`) |
31
+ | **Shipped** | Submitted (`task ship`) |
30
32
 
31
33
  Merge and sync do **not** change the TASK file.
32
34
 
33
35
  ```bash
34
- vibeops llm connect # optional — task add / ship / reship
36
+ vibeops llm connect # optional — task add / ship
35
37
  ```
36
38
 
37
39
  ## Rules
38
40
 
39
41
  - One **In Progress** TASK at a time; **Shipped** does not block `task add`.
40
- - Fill **Result** and **Test Result** before `vibeops task ship` (or before `task reship` for follow-ups).
41
- - Agents do not run `task merge`, `task sync`, or `task reship` unless the human asks.
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`.
44
+ - Agents do not run `task merge` or `task sync` unless the human asks.
@@ -8,6 +8,6 @@ description: Implement the current VibeOps TASK file in the repo.
8
8
  1. Read the full `docs/tasks/TASK-NNN-*.md` (Scope, Acceptance Criteria, Test Plan).
9
9
  2. Search the codebase before adding new patterns.
10
10
  3. Implement only within the TASK scope.
11
- 4. Do not run `vibeops task ship`, `reship`, `merge`, or `sync` — the human runs the CLI lifecycle.
11
+ 4. Do not run `vibeops task ship`, `merge`, or `sync` — the human runs the CLI lifecycle.
12
12
 
13
- When implementation is ready, tell the human to run `vibeops task ship TASK-NNN`. For a **Shipped** follow-up after merge, use `vibeops task reship TASK-NNN`.
13
+ When implementation is ready, tell the human to run `vibeops task ship TASK-NNN`. `ship` is state-aware: re-running it before merge updates the open PR; after merge it starts a new PR cycle with `vibeops task ship --new-cycle`.