@goodtek/vibeops 1.1.2 → 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 +31 -0
- package/README.md +28 -12
- package/dist/bootstrap/installer.js +1 -1
- package/dist/cli.js +58 -8
- package/dist/commands/init.js +3 -3
- package/dist/commands/llm.js +3 -3
- package/dist/commands/status.js +10 -2
- package/dist/commands/task-add.js +2 -2
- package/dist/commands/task-merge.js +134 -0
- package/dist/commands/task-release.js +103 -0
- package/dist/commands/{task-done.js → task-ship.js} +28 -63
- package/dist/commands/task-sync.js +168 -0
- package/dist/lib/git.js +7 -0
- package/dist/lib/paths.js +1 -1
- package/dist/lib/pr-create.js +63 -0
- package/dist/lib/resolve-task.js +2 -2
- package/dist/lib/task-add-llm.js +1 -1
- package/dist/lib/task-context.js +33 -8
- package/dist/lib/task-effective-status.js +62 -0
- package/dist/lib/task-git-commit.js +32 -0
- package/dist/lib/task-lifecycle-target.js +41 -0
- package/dist/lib/task-scaffold.js +2 -2
- package/dist/lib/{task-done-llm.js → task-ship-llm.js} +9 -6
- package/dist/lib/task-start.js +1 -1
- package/dist/lib/task.js +22 -34
- package/package.json +1 -1
- package/templates/clients/claude/CLAUDE.md +5 -27
- package/templates/clients/cursor/.cursor/rules/02-git-safety.mdc +5 -5
- package/templates/clients/cursor/.cursor/rules/03-docs-before-ship.mdc +13 -0
- package/templates/core/AGENTS.md +16 -34
- package/templates/core/docs/logs/README.md +1 -1
- package/templates/core/docs/project/03-architecture.md +1 -1
- package/templates/core/docs/project/05-current-state.md +1 -1
- package/templates/core/docs/project/06-decisions.md +1 -1
- package/templates/skills-shared/implement-task/SKILL.md +7 -16
- package/templates/clients/cursor/.cursor/rules/03-docs-on-done.mdc +0 -13
|
@@ -1,76 +1,45 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { readText } from "../lib/filesystem.js";
|
|
3
|
+
import { requireGitConfig } from "../lib/git-config.js";
|
|
3
4
|
import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
4
5
|
import { projectPaths } from "../lib/paths.js";
|
|
5
6
|
import { resolveTask, taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
6
|
-
import { applyTaskDoneMemory, fallbackResultSections, llmCompleteTaskDone, writeTaskResultSections, } from "../lib/task-done-llm.js";
|
|
7
7
|
import { relPath } from "../lib/task-context.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { hasNonEmptySection, markGitContextDone, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
8
|
+
import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, pushBranch, } from "../lib/task-git-commit.js";
|
|
9
|
+
import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, writeTaskResultSections, } from "../lib/task-ship-llm.js";
|
|
11
10
|
import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return `feat(${taskId.toLowerCase()}): ${slug}`;
|
|
15
|
-
}
|
|
16
|
-
function closureCommitMessageFor(taskId) {
|
|
17
|
-
return `docs(${taskId.toLowerCase()}): close task metadata`;
|
|
18
|
-
}
|
|
19
|
-
async function tryCommitDirty(cwd, message, dryRun) {
|
|
20
|
-
const git = await readGitInfo(cwd);
|
|
21
|
-
if (!git.isRepo || git.dirty !== true)
|
|
22
|
-
return false;
|
|
23
|
-
const { committable, excluded } = partitionPathsForAutoCommit(await listWorkingTreeRelPaths(cwd), { unmerged: [] });
|
|
24
|
-
if (committable.length === 0)
|
|
25
|
-
return false;
|
|
26
|
-
if (excluded.length > 0) {
|
|
27
|
-
log.warn(`Skipping ${excluded.length} build artifact path(s) from auto-commit.`);
|
|
28
|
-
}
|
|
29
|
-
if (dryRun) {
|
|
30
|
-
log.info(dim(` would commit ${committable.length} path(s)`));
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
33
|
-
await gitAddPaths(cwd, committable);
|
|
34
|
-
await runGit(cwd, ["commit", "-q", "-m", message]);
|
|
35
|
-
log.ok(`Committed: ${message}`);
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* After push/MR: write Status Done + Git Context closure, commit, push so the remote
|
|
40
|
-
* branch matches local TASK metadata (canonical two-phase close).
|
|
41
|
-
*/
|
|
42
|
-
async function commitClosureMetadataAndPush(opts) {
|
|
11
|
+
import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
12
|
+
async function commitShipMetadataAndPush(opts) {
|
|
43
13
|
if (opts.dryRun) {
|
|
44
|
-
log.info(dim(" would set Status →
|
|
45
|
-
log.info(dim(" would commit
|
|
14
|
+
log.info(dim(" would set Status → Shipped"));
|
|
15
|
+
log.info(dim(" would commit ship metadata"));
|
|
46
16
|
if (opts.push !== null) {
|
|
47
|
-
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)`));
|
|
48
18
|
}
|
|
49
19
|
return true;
|
|
50
20
|
}
|
|
51
|
-
await updateInlineStatus(opts.taskFile, "
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const committed = await tryCommitDirty(opts.cwd, closureCommitMessageFor(opts.taskId), false);
|
|
21
|
+
await updateInlineStatus(opts.taskFile, "shipped");
|
|
22
|
+
log.ok("Status → Shipped");
|
|
23
|
+
const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "mark shipped"), false);
|
|
55
24
|
if (!committed) {
|
|
56
|
-
log.warn("No file changes for
|
|
25
|
+
log.warn("No file changes for ship metadata commit — check TASK Status on the remote branch.");
|
|
57
26
|
return true;
|
|
58
27
|
}
|
|
59
28
|
if (opts.push === null)
|
|
60
29
|
return true;
|
|
61
30
|
try {
|
|
62
|
-
await
|
|
63
|
-
log.ok(`Pushed
|
|
31
|
+
await pushBranch(opts.cwd, opts.push.remote, opts.push.branch, false);
|
|
32
|
+
log.ok(`Pushed ship metadata → ${opts.push.remote}/${opts.push.branch}`);
|
|
64
33
|
return true;
|
|
65
34
|
}
|
|
66
35
|
catch (e) {
|
|
67
36
|
const msg = e instanceof Error ? e.message : String(e);
|
|
68
|
-
log.error(`
|
|
37
|
+
log.error(`Ship metadata push failed: ${msg}`);
|
|
69
38
|
log.info(dim(`Fix auth/remote, then: git push ${opts.push.remote} ${opts.push.branch}`));
|
|
70
39
|
return false;
|
|
71
40
|
}
|
|
72
41
|
}
|
|
73
|
-
export async function
|
|
42
|
+
export async function taskShipCommand(taskRef, options = {}) {
|
|
74
43
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
75
44
|
const paths = projectPaths(cwd);
|
|
76
45
|
const dryRun = options.dryRun === true;
|
|
@@ -84,7 +53,7 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
84
53
|
const meta = await readTaskFile(taskFile);
|
|
85
54
|
const body = await readText(taskFile);
|
|
86
55
|
const relFile = relPath(cwd, taskFile);
|
|
87
|
-
log.info(bold(`vibeops task
|
|
56
|
+
log.info(bold(`vibeops task ship ${taskId}`));
|
|
88
57
|
log.info(` ${dim("file")} ${relFile}`);
|
|
89
58
|
log.blank();
|
|
90
59
|
let resultOk = hasNonEmptySection(body, "Result");
|
|
@@ -95,7 +64,7 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
95
64
|
log.info(dim(" would call LLM and patch docs/project/*"));
|
|
96
65
|
}
|
|
97
66
|
else {
|
|
98
|
-
const patch = await
|
|
67
|
+
const patch = await llmCompleteTaskShip({
|
|
99
68
|
cwd,
|
|
100
69
|
taskId,
|
|
101
70
|
taskTitle: meta.title,
|
|
@@ -105,7 +74,7 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
105
74
|
if (patch !== null) {
|
|
106
75
|
await writeTaskResultSections(taskFile, patch.result, patch.testResult);
|
|
107
76
|
log.ok(`Result / Test Result updated (${patch.provider})`);
|
|
108
|
-
const memory = await
|
|
77
|
+
const memory = await applyTaskShipMemory(cwd, patch);
|
|
109
78
|
for (const p of memory.updated)
|
|
110
79
|
log.ok(`Updated ${p}`);
|
|
111
80
|
for (const p of memory.skipped)
|
|
@@ -136,7 +105,7 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
136
105
|
log.info(" · LLM fill Result / Test Result + patch docs/project/*");
|
|
137
106
|
}
|
|
138
107
|
log.info(" · commit implementation, push task branch, open MR/PR (unless --no-pr)");
|
|
139
|
-
log.info(" · Status →
|
|
108
|
+
log.info(" · Status → Shipped; commit metadata; push again");
|
|
140
109
|
await finishTaskWithPullRequest({
|
|
141
110
|
cwd,
|
|
142
111
|
taskFile,
|
|
@@ -144,10 +113,8 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
144
113
|
skipPr: options.noPr === true,
|
|
145
114
|
});
|
|
146
115
|
const gitCtxDry = await readGitContext(taskFile);
|
|
147
|
-
const gitCfgDry = gitCtxDry !== null
|
|
148
|
-
|
|
149
|
-
: null;
|
|
150
|
-
await commitClosureMetadataAndPush({
|
|
116
|
+
const gitCfgDry = gitCtxDry !== null ? await requireGitConfig(cwd).catch(() => null) : null;
|
|
117
|
+
await commitShipMetadataAndPush({
|
|
151
118
|
cwd,
|
|
152
119
|
taskFile,
|
|
153
120
|
taskId,
|
|
@@ -159,8 +126,7 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
159
126
|
return;
|
|
160
127
|
}
|
|
161
128
|
const parts = parseTaskFilename(taskFile);
|
|
162
|
-
|
|
163
|
-
await tryCommitDirty(cwd, commitMsg, false);
|
|
129
|
+
await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
|
|
164
130
|
const gitCtx = await readGitContext(taskFile);
|
|
165
131
|
if (gitCtx !== null) {
|
|
166
132
|
const prResult = await finishTaskWithPullRequest({
|
|
@@ -169,13 +135,12 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
169
135
|
skipPr: options.noPr === true,
|
|
170
136
|
});
|
|
171
137
|
if (!prResult.ok) {
|
|
172
|
-
log.error("TASK left In Progress — fix push/MR, then rerun task
|
|
138
|
+
log.error("TASK left In Progress — fix push/MR, then rerun task ship.");
|
|
173
139
|
process.exitCode = 1;
|
|
174
140
|
return;
|
|
175
141
|
}
|
|
176
142
|
if (prResult.mergeRequestUrl) {
|
|
177
143
|
log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
|
|
178
|
-
log.info(dim("Merge on GitHub/GitLab — CI deploys after merge."));
|
|
179
144
|
}
|
|
180
145
|
}
|
|
181
146
|
else {
|
|
@@ -193,18 +158,18 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
193
158
|
log.warn(e.message);
|
|
194
159
|
}
|
|
195
160
|
}
|
|
196
|
-
const
|
|
161
|
+
const shipped = await commitShipMetadataAndPush({
|
|
197
162
|
cwd,
|
|
198
163
|
taskFile,
|
|
199
164
|
taskId: parts.id,
|
|
200
165
|
dryRun: false,
|
|
201
166
|
push: pushTarget,
|
|
202
167
|
});
|
|
203
|
-
if (!
|
|
204
|
-
log.error("
|
|
168
|
+
if (!shipped) {
|
|
169
|
+
log.error("Ship metadata push failed — fix manually, then merge the MR.");
|
|
205
170
|
process.exitCode = 1;
|
|
206
171
|
return;
|
|
207
172
|
}
|
|
208
173
|
log.blank();
|
|
209
|
-
log.info(`Next: ${cyan("vibeops task
|
|
174
|
+
log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
|
|
210
175
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
3
|
+
import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
|
|
4
|
+
import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitFetchRemote, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitSwitchToBranch, readGitInfo, } from "../lib/git.js";
|
|
5
|
+
import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
|
|
6
|
+
import { probeMergeRequestCli } from "../lib/pr-create.js";
|
|
7
|
+
import { projectPaths } from "../lib/paths.js";
|
|
8
|
+
import { taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
9
|
+
import { relPath } from "../lib/task-context.js";
|
|
10
|
+
import { getMergeRequestStateForTask, isMergeRequestMerged, } from "../lib/task-effective-status.js";
|
|
11
|
+
import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
|
|
12
|
+
import { readGitContext, readTaskFile } from "../lib/task.js";
|
|
13
|
+
function isProtectedBranch(name, integration, production) {
|
|
14
|
+
return name === integration || name === production || name === "main" || name === "master";
|
|
15
|
+
}
|
|
16
|
+
export async function taskSyncCommand(taskRef, options = {}) {
|
|
17
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
18
|
+
const dryRun = options.dryRun === true;
|
|
19
|
+
const paths = projectPaths(cwd);
|
|
20
|
+
let gitCfg;
|
|
21
|
+
try {
|
|
22
|
+
gitCfg = await requireGitConfig(cwd);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof GitConfigError) {
|
|
26
|
+
log.error(e.message);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
const target = await resolveLifecycleTarget(paths, cwd, taskRef);
|
|
33
|
+
if (target === null) {
|
|
34
|
+
if (taskRef?.trim()) {
|
|
35
|
+
log.error(taskNotFoundMessage(taskRef));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
log.error("No TASK to sync. Pass TASK-NNN, checkout a task/* branch, or run task ship first.");
|
|
39
|
+
}
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const { integrationBranch, productionBranch, remote } = gitCfg;
|
|
44
|
+
const { taskId, taskBranch, taskFile } = target;
|
|
45
|
+
const ctx = await readGitContext(taskFile);
|
|
46
|
+
if (isProtectedBranch(taskBranch, integrationBranch, productionBranch)) {
|
|
47
|
+
log.error(`Refusing to delete protected branch: ${taskBranch}`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
log.info(bold(`vibeops task sync ${taskId}`));
|
|
52
|
+
log.info(` ${dim("task branch")} ${cyan(taskBranch)}`);
|
|
53
|
+
log.info(` ${dim("integration")} ${integrationBranch}`);
|
|
54
|
+
log.info(` ${dim("file")} ${relPath(cwd, taskFile)}`);
|
|
55
|
+
log.info(` ${dim("note")} ${dim("Git cleanup only — TASK md stays Shipped")}`);
|
|
56
|
+
log.blank();
|
|
57
|
+
const meta = await readTaskFile(taskFile);
|
|
58
|
+
if (ctx?.mergeRequestUrl && !dryRun) {
|
|
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;
|
|
65
|
+
const label = mergeRequestLabel(host);
|
|
66
|
+
if (state === "open") {
|
|
67
|
+
log.warn(`${yellow(label)} still open — merge with task merge or the host UI before sync.`);
|
|
68
|
+
}
|
|
69
|
+
else if (state === "merged") {
|
|
70
|
+
log.ok(`${label} merged.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (await probeMergeRequestCli(gitCfg.host)) {
|
|
74
|
+
log.warn("Could not read MR state — continuing with branch cleanup.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
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.`);
|
|
79
|
+
}
|
|
80
|
+
if (dryRun) {
|
|
81
|
+
log.info(bold("dry-run — would:"));
|
|
82
|
+
log.info(` · git fetch ${remote} --prune`);
|
|
83
|
+
log.info(` · git switch ${integrationBranch}`);
|
|
84
|
+
log.info(` · git pull --ff-only ${remote} ${integrationBranch}`);
|
|
85
|
+
log.info(` · git branch ${options.force === true ? "-D" : "-d"} ${taskBranch}`);
|
|
86
|
+
if (options.noRemoteDelete !== true) {
|
|
87
|
+
log.info(` · git push ${remote} --delete ${taskBranch} (if exists)`);
|
|
88
|
+
}
|
|
89
|
+
log.info(dim(" · no edits to docs/tasks/*.md"));
|
|
90
|
+
log.blank();
|
|
91
|
+
log.info(`Next: ${cyan("vibeops task add")}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const git = await readGitInfo(cwd);
|
|
95
|
+
if (!git.isRepo) {
|
|
96
|
+
log.error("Not a git repository.");
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (git.branch === taskBranch) {
|
|
101
|
+
log.info(dim(`On ${taskBranch} — switching to ${integrationBranch} first.`));
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await gitFetchRemote(cwd, remote);
|
|
105
|
+
log.ok(`Fetched ${remote} (--prune)`);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
109
|
+
log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
|
|
110
|
+
}
|
|
111
|
+
const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
|
|
112
|
+
if (!switched) {
|
|
113
|
+
log.error(`Integration branch "${integrationBranch}" not found locally or on ${remote}.`);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
log.ok(`On ${integrationBranch}`);
|
|
118
|
+
if (await gitRemoteBranchExists(cwd, remote, integrationBranch)) {
|
|
119
|
+
try {
|
|
120
|
+
await gitPullFastForwardOnly(cwd, remote, integrationBranch);
|
|
121
|
+
log.ok(`Up to date with ${remote}/${integrationBranch} (--ff-only)`);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
125
|
+
log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Resolve locally, then rerun.`);
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
log.warn(`No ${remote}/${integrationBranch} — skipped pull.`);
|
|
132
|
+
}
|
|
133
|
+
if (await gitBranchExists(cwd, taskBranch)) {
|
|
134
|
+
try {
|
|
135
|
+
await gitDeleteBranch(cwd, taskBranch, { force: options.force === true });
|
|
136
|
+
log.ok(`Deleted local branch ${taskBranch}`);
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
140
|
+
log.error(`Could not delete local ${taskBranch}: ${msg}`);
|
|
141
|
+
log.info(dim("Merge the MR into the integration branch first, or rerun with --force (discards unmerged branch)."));
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
log.info(dim(`Local branch ${taskBranch} not found — skipped.`));
|
|
148
|
+
}
|
|
149
|
+
if (options.noRemoteDelete !== true && (await gitRemoteBranchExists(cwd, remote, taskBranch))) {
|
|
150
|
+
try {
|
|
151
|
+
await gitDeleteRemoteBranch(cwd, remote, taskBranch);
|
|
152
|
+
log.ok(`Deleted ${remote}/${taskBranch}`);
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
156
|
+
log.warn(`Could not delete remote ${taskBranch}: ${msg}`);
|
|
157
|
+
log.info(dim(`Delete manually: git push ${remote} --delete ${taskBranch}`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (options.noRemoteDelete === true) {
|
|
161
|
+
log.info(dim("Remote branch delete skipped (--no-remote-delete)."));
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
log.info(dim(`No ${remote}/${taskBranch} — skipped remote delete.`));
|
|
165
|
+
}
|
|
166
|
+
log.blank();
|
|
167
|
+
log.info(`Next: ${cyan("vibeops task add")}`);
|
|
168
|
+
}
|
package/dist/lib/git.js
CHANGED
|
@@ -495,6 +495,13 @@ export async function gitFetch(cwd, remote, ref) {
|
|
|
495
495
|
args.push(ref);
|
|
496
496
|
await runGit(cwd, args);
|
|
497
497
|
}
|
|
498
|
+
/** Fetch all refs from `remote` and prune deleted remote-tracking branches. */
|
|
499
|
+
export async function gitFetchRemote(cwd, remote) {
|
|
500
|
+
await runGit(cwd, ["fetch", remote, "--prune"]);
|
|
501
|
+
}
|
|
502
|
+
export async function gitDeleteRemoteBranch(cwd, remote, branch) {
|
|
503
|
+
await runGit(cwd, ["push", remote, "--delete", branch]);
|
|
504
|
+
}
|
|
498
505
|
export async function gitPullFastForwardOnly(cwd, remote, branch) {
|
|
499
506
|
await runGit(cwd, ["pull", "--ff-only", remote, branch]);
|
|
500
507
|
}
|
package/dist/lib/paths.js
CHANGED
|
@@ -22,7 +22,7 @@ export function projectPaths(root) {
|
|
|
22
22
|
gitignore: join(abs, ".gitignore"),
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
-
/** Project memory files updated by `vibeops task
|
|
25
|
+
/** Project memory files updated by `vibeops task ship`. */
|
|
26
26
|
export const PROJECT_MEMORY_FILES = {
|
|
27
27
|
currentState: "docs/project/05-current-state.md",
|
|
28
28
|
architecture: "docs/project/03-architecture.md",
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -64,3 +64,66 @@ export async function probeMergeRequestCli(host) {
|
|
|
64
64
|
return false;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
function prRefFromUrl(url) {
|
|
68
|
+
const trimmed = url.trim();
|
|
69
|
+
const pullMatch = /\/pull\/(\d+)/i.exec(trimmed);
|
|
70
|
+
if (pullMatch)
|
|
71
|
+
return pullMatch[1];
|
|
72
|
+
const mrMatch = /\/merge_requests\/(\d+)/i.exec(trimmed);
|
|
73
|
+
if (mrMatch)
|
|
74
|
+
return mrMatch[1];
|
|
75
|
+
if (/^\d+$/.test(trimmed))
|
|
76
|
+
return trimmed;
|
|
77
|
+
return trimmed;
|
|
78
|
+
}
|
|
79
|
+
export async function getMergeRequestState(cwd, host, url) {
|
|
80
|
+
const ref = prRefFromUrl(url);
|
|
81
|
+
try {
|
|
82
|
+
if (host === "gitlab") {
|
|
83
|
+
const { stdout } = await execFileAsync("glab", ["mr", "view", ref, "--output", "json", "state"], { cwd, maxBuffer: 1024 * 1024 });
|
|
84
|
+
const state = stdout.trim().toLowerCase();
|
|
85
|
+
if (state === "merged")
|
|
86
|
+
return "merged";
|
|
87
|
+
if (state === "opened" || state === "open")
|
|
88
|
+
return "open";
|
|
89
|
+
if (state === "closed")
|
|
90
|
+
return "closed";
|
|
91
|
+
return "unknown";
|
|
92
|
+
}
|
|
93
|
+
const { stdout } = await execFileAsync("gh", ["pr", "view", ref, "--json", "state", "-q", ".state"], { cwd, maxBuffer: 1024 * 1024 });
|
|
94
|
+
const state = stdout.trim().toUpperCase();
|
|
95
|
+
if (state === "MERGED")
|
|
96
|
+
return "merged";
|
|
97
|
+
if (state === "OPEN")
|
|
98
|
+
return "open";
|
|
99
|
+
if (state === "CLOSED")
|
|
100
|
+
return "closed";
|
|
101
|
+
return "unknown";
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return "unknown";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export async function mergeMergeRequest(opts) {
|
|
108
|
+
const ref = prRefFromUrl(opts.url);
|
|
109
|
+
const method = opts.method ?? "squash";
|
|
110
|
+
const label = mergeRequestLabel(opts.host);
|
|
111
|
+
if (opts.dryRun === true) {
|
|
112
|
+
if (opts.host === "gitlab") {
|
|
113
|
+
log.info(`would ${label} merge ${ref} (glab mr merge)`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
log.info(`would gh pr merge ${ref} --${method}`);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (opts.host === "gitlab") {
|
|
121
|
+
await execFileAsync("glab", ["mr", "merge", ref], {
|
|
122
|
+
cwd: opts.cwd,
|
|
123
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const args = ["pr", "merge", ref, `--${method}`];
|
|
128
|
+
await execFileAsync("gh", args, { cwd: opts.cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
129
|
+
}
|
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;
|
|
@@ -30,5 +30,5 @@ export function taskNotFoundMessage(taskRef) {
|
|
|
30
30
|
if (taskRef?.trim()) {
|
|
31
31
|
return `TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
|
|
32
32
|
}
|
|
33
|
-
return "No open TASK found. Run `vibeops task add` or pass `vibeops task
|
|
33
|
+
return "No open TASK found. Run `vibeops task add` or pass `vibeops task ship TASK-NNN`.";
|
|
34
34
|
}
|
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,8 +1,9 @@
|
|
|
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);
|
|
@@ -11,7 +12,7 @@ export async function findBlockingTask(paths, cwd) {
|
|
|
11
12
|
const git = await readGitInfo(cwd);
|
|
12
13
|
if (git.isRepo && typeof git.branch === "string" && git.branch.startsWith("task/")) {
|
|
13
14
|
for (const t of tasks) {
|
|
14
|
-
if (t.status
|
|
15
|
+
if (t.status !== "in_progress")
|
|
15
16
|
continue;
|
|
16
17
|
const ctx = await readGitContext(t.filePath);
|
|
17
18
|
if (isOnTaskBranch(git.branch, ctx))
|
|
@@ -21,7 +22,17 @@ export async function findBlockingTask(paths, cwd) {
|
|
|
21
22
|
return null;
|
|
22
23
|
}
|
|
23
24
|
export async function pickFocusTask(paths, cwd) {
|
|
24
|
-
|
|
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;
|
|
25
36
|
}
|
|
26
37
|
export function relPath(cwd, filePath) {
|
|
27
38
|
const r = relative(cwd, filePath).replace(/\\/g, "/");
|
|
@@ -32,12 +43,17 @@ export function computeNextHint(input) {
|
|
|
32
43
|
return "init";
|
|
33
44
|
if (input.focus === null)
|
|
34
45
|
return "task-add";
|
|
35
|
-
if (input.focus.status === "
|
|
46
|
+
if (input.focus.status === "shipped") {
|
|
47
|
+
if (input.needsSync)
|
|
48
|
+
return "task-sync";
|
|
49
|
+
if (input.hasMergeRequest && !input.mergeRequestMerged)
|
|
50
|
+
return "task-merge";
|
|
36
51
|
return "task-add";
|
|
52
|
+
}
|
|
37
53
|
if (!input.resultFilled || !input.testFilled) {
|
|
38
54
|
return input.onTaskBranch === true ? "cursor-implement" : "cursor-plan";
|
|
39
55
|
}
|
|
40
|
-
return "task-
|
|
56
|
+
return input.hasMergeRequest ? "task-merge" : "task-ship";
|
|
41
57
|
}
|
|
42
58
|
export function hintToText(hint, cwd, task) {
|
|
43
59
|
switch (hint) {
|
|
@@ -45,15 +61,23 @@ export function hintToText(hint, cwd, task) {
|
|
|
45
61
|
return "Run `vibeops init` in this directory.";
|
|
46
62
|
case "task-add":
|
|
47
63
|
return "Run `vibeops task add` to start the next slice.";
|
|
48
|
-
case "task-
|
|
49
|
-
return task ? `Run \`vibeops task
|
|
64
|
+
case "task-ship":
|
|
65
|
+
return task ? `Run \`vibeops task ship ${task.id}\`.` : "Run `vibeops task ship`.";
|
|
66
|
+
case "task-merge":
|
|
67
|
+
return task
|
|
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`.";
|
|
70
|
+
case "task-sync":
|
|
71
|
+
return task
|
|
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.";
|
|
50
74
|
case "cursor-plan":
|
|
51
75
|
return task
|
|
52
76
|
? `In Cursor Ask: @${relPath(cwd, task.filePath)} — refine Scope / AC.`
|
|
53
77
|
: "Open the TASK file in Cursor Ask.";
|
|
54
78
|
case "cursor-implement":
|
|
55
79
|
return task
|
|
56
|
-
? `In Cursor Agent: @${relPath(cwd, task.filePath)} — implement, then task
|
|
80
|
+
? `In Cursor Agent: @${relPath(cwd, task.filePath)} — implement, then task ship.`
|
|
57
81
|
: "Implement per the TASK file in Cursor.";
|
|
58
82
|
}
|
|
59
83
|
}
|
|
@@ -66,3 +90,4 @@ export async function taskBranchExistsFor(cwd, task) {
|
|
|
66
90
|
return false;
|
|
67
91
|
return gitBranchExists(cwd, ctx.taskBranch);
|
|
68
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { dim, log } from "./logger.js";
|
|
2
|
+
import { gitAddPaths, gitPush, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "./git.js";
|
|
3
|
+
export function featCommitMessageFor(taskId, title) {
|
|
4
|
+
const slug = title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim() || taskId;
|
|
5
|
+
return `feat(${taskId.toLowerCase()}): ${slug}`;
|
|
6
|
+
}
|
|
7
|
+
export function docsCommitMessageFor(taskId, suffix) {
|
|
8
|
+
return `docs(${taskId.toLowerCase()}): ${suffix}`;
|
|
9
|
+
}
|
|
10
|
+
/** Commit all committable dirty paths; returns whether a commit was created. */
|
|
11
|
+
export async function commitDirtyWorkingTree(cwd, message, dryRun) {
|
|
12
|
+
const git = await readGitInfo(cwd);
|
|
13
|
+
if (!git.isRepo || git.dirty !== true)
|
|
14
|
+
return false;
|
|
15
|
+
const { committable, excluded } = partitionPathsForAutoCommit(await listWorkingTreeRelPaths(cwd), { unmerged: [] });
|
|
16
|
+
if (committable.length === 0)
|
|
17
|
+
return false;
|
|
18
|
+
if (excluded.length > 0) {
|
|
19
|
+
log.warn(`Skipping ${excluded.length} build artifact path(s) from auto-commit.`);
|
|
20
|
+
}
|
|
21
|
+
if (dryRun) {
|
|
22
|
+
log.info(dim(` would commit ${committable.length} path(s): ${message}`));
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
await gitAddPaths(cwd, committable);
|
|
26
|
+
await runGit(cwd, ["commit", "-q", "-m", message]);
|
|
27
|
+
log.ok(`Committed: ${message}`);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
export async function pushBranch(cwd, remote, branch, setUpstream) {
|
|
31
|
+
await gitPush(cwd, remote, branch, setUpstream);
|
|
32
|
+
}
|