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