@goodtek/vibeops 1.1.1 → 2.0.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 +32 -0
- package/README.md +28 -12
- package/dist/bootstrap/installer.js +1 -1
- package/dist/bootstrap/manifest.js +0 -10
- 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 +1 -0
- 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} +74 -36
- package/dist/commands/task-sync.js +183 -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 +1 -1
- package/dist/lib/task-context.js +22 -7
- package/dist/lib/task-git-commit.js +32 -0
- package/dist/lib/task-lifecycle-target.js +41 -0
- package/dist/lib/task-scaffold.js +1 -1
- package/dist/lib/{task-done-llm.js → task-ship-llm.js} +9 -6
- package/dist/lib/task.js +7 -0
- package/package.json +11 -13
- 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/.cursor/rules/02-git-safety.mdc +0 -12
- package/templates/AGENTS.md +0 -38
- package/templates/clients/cursor/.cursor/rules/03-docs-on-done.mdc +0 -13
|
@@ -1,37 +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 {
|
|
8
|
+
import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, pushBranch, } from "../lib/task-git-commit.js";
|
|
9
|
+
import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, writeTaskResultSections, } from "../lib/task-ship-llm.js";
|
|
10
10
|
import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (committable.length === 0)
|
|
21
|
-
return false;
|
|
22
|
-
if (excluded.length > 0) {
|
|
23
|
-
log.warn(`Skipping ${excluded.length} build artifact path(s) from auto-commit.`);
|
|
11
|
+
import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
12
|
+
async function commitShipMetadataAndPush(opts) {
|
|
13
|
+
if (opts.dryRun) {
|
|
14
|
+
log.info(dim(" would set Status → Review"));
|
|
15
|
+
log.info(dim(" would commit ship metadata"));
|
|
16
|
+
if (opts.push !== null) {
|
|
17
|
+
log.info(dim(` would git push ${opts.push.remote} ${opts.push.branch} (review metadata)`));
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
24
20
|
}
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
await updateInlineStatus(opts.taskFile, "review");
|
|
22
|
+
log.ok("Status → Review");
|
|
23
|
+
const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "ready for review"), false);
|
|
24
|
+
if (!committed) {
|
|
25
|
+
log.warn("No file changes for review metadata commit — check TASK Status on the remote branch.");
|
|
27
26
|
return true;
|
|
28
27
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
if (opts.push === null)
|
|
29
|
+
return true;
|
|
30
|
+
try {
|
|
31
|
+
await pushBranch(opts.cwd, opts.push.remote, opts.push.branch, false);
|
|
32
|
+
log.ok(`Pushed review metadata → ${opts.push.remote}/${opts.push.branch}`);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
37
|
+
log.error(`Review metadata push failed: ${msg}`);
|
|
38
|
+
log.info(dim(`Fix auth/remote, then: git push ${opts.push.remote} ${opts.push.branch}`));
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
33
41
|
}
|
|
34
|
-
export async function
|
|
42
|
+
export async function taskShipCommand(taskRef, options = {}) {
|
|
35
43
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
36
44
|
const paths = projectPaths(cwd);
|
|
37
45
|
const dryRun = options.dryRun === true;
|
|
@@ -45,7 +53,7 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
45
53
|
const meta = await readTaskFile(taskFile);
|
|
46
54
|
const body = await readText(taskFile);
|
|
47
55
|
const relFile = relPath(cwd, taskFile);
|
|
48
|
-
log.info(bold(`vibeops task
|
|
56
|
+
log.info(bold(`vibeops task ship ${taskId}`));
|
|
49
57
|
log.info(` ${dim("file")} ${relFile}`);
|
|
50
58
|
log.blank();
|
|
51
59
|
let resultOk = hasNonEmptySection(body, "Result");
|
|
@@ -56,7 +64,7 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
56
64
|
log.info(dim(" would call LLM and patch docs/project/*"));
|
|
57
65
|
}
|
|
58
66
|
else {
|
|
59
|
-
const patch = await
|
|
67
|
+
const patch = await llmCompleteTaskShip({
|
|
60
68
|
cwd,
|
|
61
69
|
taskId,
|
|
62
70
|
taskTitle: meta.title,
|
|
@@ -66,7 +74,7 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
66
74
|
if (patch !== null) {
|
|
67
75
|
await writeTaskResultSections(taskFile, patch.result, patch.testResult);
|
|
68
76
|
log.ok(`Result / Test Result updated (${patch.provider})`);
|
|
69
|
-
const memory = await
|
|
77
|
+
const memory = await applyTaskShipMemory(cwd, patch);
|
|
70
78
|
for (const p of memory.updated)
|
|
71
79
|
log.ok(`Updated ${p}`);
|
|
72
80
|
for (const p of memory.skipped)
|
|
@@ -96,19 +104,29 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
96
104
|
if (!resultOk || !testOk) {
|
|
97
105
|
log.info(" · LLM fill Result / Test Result + patch docs/project/*");
|
|
98
106
|
}
|
|
99
|
-
log.info(" · commit
|
|
100
|
-
log.info(" · Status →
|
|
107
|
+
log.info(" · commit implementation, push task branch, open MR/PR (unless --no-pr)");
|
|
108
|
+
log.info(" · Status → Review; commit metadata; push again");
|
|
101
109
|
await finishTaskWithPullRequest({
|
|
102
110
|
cwd,
|
|
103
111
|
taskFile,
|
|
104
112
|
dryRun: true,
|
|
105
113
|
skipPr: options.noPr === true,
|
|
106
114
|
});
|
|
115
|
+
const gitCtxDry = await readGitContext(taskFile);
|
|
116
|
+
const gitCfgDry = gitCtxDry !== null ? await requireGitConfig(cwd).catch(() => null) : null;
|
|
117
|
+
await commitShipMetadataAndPush({
|
|
118
|
+
cwd,
|
|
119
|
+
taskFile,
|
|
120
|
+
taskId,
|
|
121
|
+
dryRun: true,
|
|
122
|
+
push: gitCtxDry !== null && gitCfgDry !== null
|
|
123
|
+
? { remote: gitCfgDry.remote, branch: gitCtxDry.taskBranch }
|
|
124
|
+
: null,
|
|
125
|
+
});
|
|
107
126
|
return;
|
|
108
127
|
}
|
|
109
128
|
const parts = parseTaskFilename(taskFile);
|
|
110
|
-
|
|
111
|
-
await tryCommitDirty(cwd, commitMsg, false);
|
|
129
|
+
await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
|
|
112
130
|
const gitCtx = await readGitContext(taskFile);
|
|
113
131
|
if (gitCtx !== null) {
|
|
114
132
|
const prResult = await finishTaskWithPullRequest({
|
|
@@ -117,21 +135,41 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
117
135
|
skipPr: options.noPr === true,
|
|
118
136
|
});
|
|
119
137
|
if (!prResult.ok) {
|
|
120
|
-
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.");
|
|
121
139
|
process.exitCode = 1;
|
|
122
140
|
return;
|
|
123
141
|
}
|
|
124
142
|
if (prResult.mergeRequestUrl) {
|
|
125
143
|
log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
|
|
126
|
-
log.info(dim("Merge on GitHub/GitLab — CI deploys after merge."));
|
|
127
144
|
}
|
|
128
145
|
}
|
|
129
146
|
else {
|
|
130
147
|
log.info(dim("No Git Context — push/MR skipped."));
|
|
131
148
|
}
|
|
132
|
-
await
|
|
133
|
-
|
|
134
|
-
|
|
149
|
+
const gitCtxAfter = await readGitContext(taskFile);
|
|
150
|
+
let pushTarget = null;
|
|
151
|
+
if (gitCtxAfter !== null) {
|
|
152
|
+
try {
|
|
153
|
+
const gitCfg = await requireGitConfig(cwd);
|
|
154
|
+
pushTarget = { remote: gitCfg.remote, branch: gitCtxAfter.taskBranch };
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
if (e instanceof Error)
|
|
158
|
+
log.warn(e.message);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const shipped = await commitShipMetadataAndPush({
|
|
162
|
+
cwd,
|
|
163
|
+
taskFile,
|
|
164
|
+
taskId: parts.id,
|
|
165
|
+
dryRun: false,
|
|
166
|
+
push: pushTarget,
|
|
167
|
+
});
|
|
168
|
+
if (!shipped) {
|
|
169
|
+
log.error("Review metadata push failed — fix manually, then run task merge.");
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
135
173
|
log.blank();
|
|
136
|
-
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")}`);
|
|
137
175
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
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 { getMergeRequestState, 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 { commitDirtyWorkingTree, docsCommitMessageFor, pushBranch, } from "../lib/task-git-commit.js";
|
|
11
|
+
import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
|
|
12
|
+
import { markGitContextDone, readGitContext, readTaskFile, updateInlineStatus, } 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.blank();
|
|
56
|
+
const meta = await readTaskFile(taskFile);
|
|
57
|
+
if (ctx?.mergeRequestUrl && !dryRun) {
|
|
58
|
+
const remoteUrl = await gitRemoteUrl(cwd, remote);
|
|
59
|
+
const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
|
|
60
|
+
? detectGitHost(remoteUrl)
|
|
61
|
+
: gitCfg.host;
|
|
62
|
+
if (await probeMergeRequestCli(host)) {
|
|
63
|
+
const state = await getMergeRequestState(cwd, host, ctx.mergeRequestUrl);
|
|
64
|
+
const label = mergeRequestLabel(host);
|
|
65
|
+
if (state === "open") {
|
|
66
|
+
log.warn(`${yellow(label)} still open — merge with task merge or the host UI before sync.`);
|
|
67
|
+
}
|
|
68
|
+
else if (state === "merged") {
|
|
69
|
+
log.ok(`${label} merged.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (meta.status === "review") {
|
|
74
|
+
log.warn(`${yellow("Status")} is Review — merge the MR first, then rerun task sync.`);
|
|
75
|
+
}
|
|
76
|
+
if (dryRun) {
|
|
77
|
+
log.info(bold("dry-run — would:"));
|
|
78
|
+
log.info(` · git fetch ${remote} --prune`);
|
|
79
|
+
log.info(` · git switch ${integrationBranch}`);
|
|
80
|
+
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
|
+
log.info(` · git branch ${options.force === true ? "-D" : "-d"} ${taskBranch}`);
|
|
85
|
+
if (options.noRemoteDelete !== true) {
|
|
86
|
+
log.info(` · git push ${remote} --delete ${taskBranch} (if exists)`);
|
|
87
|
+
}
|
|
88
|
+
log.blank();
|
|
89
|
+
log.info(`Next: ${cyan("vibeops task add")}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const git = await readGitInfo(cwd);
|
|
93
|
+
if (!git.isRepo) {
|
|
94
|
+
log.error("Not a git repository.");
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (git.branch === taskBranch) {
|
|
99
|
+
log.info(dim(`On ${taskBranch} — switching to ${integrationBranch} first.`));
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await gitFetchRemote(cwd, remote);
|
|
103
|
+
log.ok(`Fetched ${remote} (--prune)`);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
107
|
+
log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
|
|
108
|
+
}
|
|
109
|
+
const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
|
|
110
|
+
if (!switched) {
|
|
111
|
+
log.error(`Integration branch "${integrationBranch}" not found locally or on ${remote}.`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
log.ok(`On ${integrationBranch}`);
|
|
116
|
+
if (await gitRemoteBranchExists(cwd, remote, integrationBranch)) {
|
|
117
|
+
try {
|
|
118
|
+
await gitPullFastForwardOnly(cwd, remote, integrationBranch);
|
|
119
|
+
log.ok(`Up to date with ${remote}/${integrationBranch} (--ff-only)`);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
123
|
+
log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Resolve locally, then rerun.`);
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
log.warn(`No ${remote}/${integrationBranch} — skipped pull.`);
|
|
130
|
+
}
|
|
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
|
+
if (await gitBranchExists(cwd, taskBranch)) {
|
|
149
|
+
try {
|
|
150
|
+
await gitDeleteBranch(cwd, taskBranch, { force: options.force === true });
|
|
151
|
+
log.ok(`Deleted local branch ${taskBranch}`);
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
155
|
+
log.error(`Could not delete local ${taskBranch}: ${msg}`);
|
|
156
|
+
log.info(dim("Merge the MR into the integration branch first, or rerun with --force (discards unmerged branch)."));
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
log.info(dim(`Local branch ${taskBranch} not found — skipped.`));
|
|
163
|
+
}
|
|
164
|
+
if (options.noRemoteDelete !== true && (await gitRemoteBranchExists(cwd, remote, taskBranch))) {
|
|
165
|
+
try {
|
|
166
|
+
await gitDeleteRemoteBranch(cwd, remote, taskBranch);
|
|
167
|
+
log.ok(`Deleted ${remote}/${taskBranch}`);
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
171
|
+
log.warn(`Could not delete remote ${taskBranch}: ${msg}`);
|
|
172
|
+
log.info(dim(`Delete manually: git push ${remote} --delete ${taskBranch}`));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else if (options.noRemoteDelete === true) {
|
|
176
|
+
log.info(dim("Remote branch delete skipped (--no-remote-delete)."));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
log.info(dim(`No ${remote}/${taskBranch} — skipped remote delete.`));
|
|
180
|
+
}
|
|
181
|
+
log.blank();
|
|
182
|
+
log.info(`Next: ${cyan("vibeops task add")}`);
|
|
183
|
+
}
|
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
|
@@ -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-context.js
CHANGED
|
@@ -2,12 +2,15 @@ import { relative } from "node:path";
|
|
|
2
2
|
import { gitBranchExists, readGitInfo } from "./git.js";
|
|
3
3
|
import { readGitContext } from "./task.js";
|
|
4
4
|
import { isOnTaskBranch, loadActionableTasks, pickActiveTask, pickInProgressTask, } from "./task.js";
|
|
5
|
-
/** TASK that blocks `task add` — in progress or open on current task branch. */
|
|
5
|
+
/** TASK that blocks `task add` — in progress, in review, or open on current task branch. */
|
|
6
6
|
export async function findBlockingTask(paths, cwd) {
|
|
7
7
|
const tasks = await loadActionableTasks(paths.docsTasks);
|
|
8
8
|
const inProgress = pickInProgressTask(tasks);
|
|
9
9
|
if (inProgress !== null)
|
|
10
10
|
return inProgress;
|
|
11
|
+
const review = tasks.find((t) => t.status === "review");
|
|
12
|
+
if (review !== undefined)
|
|
13
|
+
return review;
|
|
11
14
|
const git = await readGitInfo(cwd);
|
|
12
15
|
if (git.isRepo && typeof git.branch === "string" && git.branch.startsWith("task/")) {
|
|
13
16
|
for (const t of tasks) {
|
|
@@ -32,12 +35,16 @@ export function computeNextHint(input) {
|
|
|
32
35
|
return "init";
|
|
33
36
|
if (input.focus === null)
|
|
34
37
|
return "task-add";
|
|
35
|
-
if (input.focus.status === "done")
|
|
36
|
-
return "task-add";
|
|
38
|
+
if (input.focus.status === "done") {
|
|
39
|
+
return input.onTaskBranch === true ? "task-sync" : "task-add";
|
|
40
|
+
}
|
|
41
|
+
if (input.focus.status === "review") {
|
|
42
|
+
return input.hasMergeRequest ? "task-merge" : "task-ship";
|
|
43
|
+
}
|
|
37
44
|
if (!input.resultFilled || !input.testFilled) {
|
|
38
45
|
return input.onTaskBranch === true ? "cursor-implement" : "cursor-plan";
|
|
39
46
|
}
|
|
40
|
-
return "task-
|
|
47
|
+
return input.hasMergeRequest ? "task-merge" : "task-ship";
|
|
41
48
|
}
|
|
42
49
|
export function hintToText(hint, cwd, task) {
|
|
43
50
|
switch (hint) {
|
|
@@ -45,15 +52,23 @@ export function hintToText(hint, cwd, task) {
|
|
|
45
52
|
return "Run `vibeops init` in this directory.";
|
|
46
53
|
case "task-add":
|
|
47
54
|
return "Run `vibeops task add` to start the next slice.";
|
|
48
|
-
case "task-
|
|
49
|
-
return task ? `Run \`vibeops task
|
|
55
|
+
case "task-ship":
|
|
56
|
+
return task ? `Run \`vibeops task ship ${task.id}\`.` : "Run `vibeops task ship`.";
|
|
57
|
+
case "task-merge":
|
|
58
|
+
return task
|
|
59
|
+
? `Run \`vibeops task merge ${task.id}\`, then task sync.`
|
|
60
|
+
: "Run `vibeops task merge` after the MR is ready.";
|
|
61
|
+
case "task-sync":
|
|
62
|
+
return task
|
|
63
|
+
? `MR merged? Run \`vibeops task sync ${task.id}\`, then task add.`
|
|
64
|
+
: "Run `vibeops task sync` after merging the MR.";
|
|
50
65
|
case "cursor-plan":
|
|
51
66
|
return task
|
|
52
67
|
? `In Cursor Ask: @${relPath(cwd, task.filePath)} — refine Scope / AC.`
|
|
53
68
|
: "Open the TASK file in Cursor Ask.";
|
|
54
69
|
case "cursor-implement":
|
|
55
70
|
return task
|
|
56
|
-
? `In Cursor Agent: @${relPath(cwd, task.filePath)} — implement, then task
|
|
71
|
+
? `In Cursor Agent: @${relPath(cwd, task.filePath)} — implement, then task ship.`
|
|
57
72
|
: "Implement per the TASK file in Cursor.";
|
|
58
73
|
}
|
|
59
74
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readGitInfo } from "./git.js";
|
|
2
|
+
import { isOnTaskBranch, loadActionableTasks, pickLatestReviewTask, readGitContext, } from "./task.js";
|
|
3
|
+
import { resolveTask } from "./resolve-task.js";
|
|
4
|
+
/** Resolve TASK + task branch for ship / merge / sync. */
|
|
5
|
+
export async function resolveLifecycleTarget(paths, cwd, taskRef) {
|
|
6
|
+
const trimmed = taskRef?.trim();
|
|
7
|
+
if (trimmed !== undefined && trimmed.length > 0) {
|
|
8
|
+
const resolved = await resolveTask(paths, cwd, trimmed);
|
|
9
|
+
if (resolved === null)
|
|
10
|
+
return null;
|
|
11
|
+
const ctx = await readGitContext(resolved.taskFile);
|
|
12
|
+
if (ctx === null)
|
|
13
|
+
return null;
|
|
14
|
+
return {
|
|
15
|
+
taskId: resolved.taskId,
|
|
16
|
+
taskBranch: ctx.taskBranch,
|
|
17
|
+
taskFile: resolved.taskFile,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const git = await readGitInfo(cwd);
|
|
21
|
+
if (git.isRepo && typeof git.branch === "string" && git.branch.startsWith("task/")) {
|
|
22
|
+
const tasks = await loadActionableTasks(paths.docsTasks);
|
|
23
|
+
for (const t of tasks) {
|
|
24
|
+
const ctx = await readGitContext(t.filePath);
|
|
25
|
+
if (isOnTaskBranch(git.branch, ctx)) {
|
|
26
|
+
return { taskId: t.id, taskBranch: git.branch, taskFile: t.filePath };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const review = pickLatestReviewTask(await loadActionableTasks(paths.docsTasks));
|
|
31
|
+
if (review === null)
|
|
32
|
+
return null;
|
|
33
|
+
const ctx = await readGitContext(review.filePath);
|
|
34
|
+
if (ctx === null)
|
|
35
|
+
return null;
|
|
36
|
+
return {
|
|
37
|
+
taskId: review.id,
|
|
38
|
+
taskBranch: ctx.taskBranch,
|
|
39
|
+
taskFile: review.filePath,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -45,7 +45,7 @@ ${draft.idea.trim()}
|
|
|
45
45
|
## Acceptance Criteria
|
|
46
46
|
|
|
47
47
|
1. Goal and Scope are met and verifiable via Test Plan.
|
|
48
|
-
2. Result and Test Result are filled before \`vibeops task
|
|
48
|
+
2. Result and Test Result are filled before \`vibeops task ship\`.
|
|
49
49
|
|
|
50
50
|
## Test Plan
|
|
51
51
|
|
|
@@ -17,7 +17,7 @@ function localDateFilename() {
|
|
|
17
17
|
async function readOptional(path) {
|
|
18
18
|
return readTextOrNull(path);
|
|
19
19
|
}
|
|
20
|
-
export async function
|
|
20
|
+
export async function llmCompleteTaskShip(params) {
|
|
21
21
|
const git = await readGitInfo(params.cwd);
|
|
22
22
|
let diffSummary = "";
|
|
23
23
|
if (git.isRepo) {
|
|
@@ -31,7 +31,7 @@ export async function llmCompleteTaskDone(params) {
|
|
|
31
31
|
const messages = [
|
|
32
32
|
{
|
|
33
33
|
role: "system",
|
|
34
|
-
content: `You
|
|
34
|
+
content: `You ship a VibeOps TASK for review. 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}
|
|
94
|
+
: `${params.taskId} shipped for review.`,
|
|
95
95
|
provider,
|
|
96
96
|
};
|
|
97
97
|
}
|
|
@@ -99,7 +99,7 @@ Be factual. Do not invent files not in the diff.`,
|
|
|
99
99
|
return null;
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
-
export async function
|
|
102
|
+
export async function applyTaskShipMemory(cwd, patch) {
|
|
103
103
|
const paths = projectPaths(cwd);
|
|
104
104
|
const updated = [];
|
|
105
105
|
const skipped = [];
|
|
@@ -142,12 +142,15 @@ export async function writeTaskResultSections(taskFile, result, testResult) {
|
|
|
142
142
|
await updateTaskSection(taskFile, "Result", result);
|
|
143
143
|
await updateTaskSection(taskFile, "Test Result", testResult);
|
|
144
144
|
}
|
|
145
|
-
/** Minimal fallback when LLM unavailable — git-only sections from diff. */
|
|
146
145
|
export async function fallbackResultSections(cwd, taskId, taskFile) {
|
|
147
146
|
const names = await gitDiffNameOnly(cwd);
|
|
148
147
|
const body = await readText(taskFile);
|
|
149
148
|
const goal = readSection(body, "Goal").slice(0, 200);
|
|
150
|
-
const result = [
|
|
149
|
+
const result = [
|
|
150
|
+
`Shipped ${taskId} for review.`,
|
|
151
|
+
goal.length > 0 ? `Goal: ${goal}` : "",
|
|
152
|
+
names.length > 0 ? `Paths: ${names.slice(0, 15).join(", ")}` : "",
|
|
153
|
+
]
|
|
151
154
|
.filter((l) => l.length > 0)
|
|
152
155
|
.join("\n");
|
|
153
156
|
const testResult = names.length > 0 ? `Changed: ${names.join(", ")}` : "Manual verification required.";
|