@goodtek/vibeops 2.2.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +22 -9
- package/dist/cli.js +43 -6
- package/dist/commands/task-del.js +104 -0
- package/dist/commands/task-reship.js +17 -171
- package/dist/commands/task-ship.js +260 -46
- package/dist/lib/filesystem.js +4 -1
- package/dist/lib/inquirer-helpers.js +4 -0
- package/dist/lib/pr-create.js +29 -2
- package/dist/lib/resolve-task.js +31 -1
- package/dist/lib/task-commit-msg.js +80 -0
- package/dist/lib/task-del.js +193 -0
- package/dist/lib/task-effective-status.js +15 -7
- package/dist/lib/task-git-commit.js +24 -0
- package/dist/lib/task-new-cycle.js +186 -0
- package/dist/lib/task-ship-state.js +39 -0
- package/package.json +3 -2
- package/templates/clients/claude/CLAUDE.md +1 -1
- package/templates/clients/cursor/.cursor/rules/02-git-safety.mdc +1 -1
- package/templates/clients/cursor/.cursor/rules/03-docs-before-ship.mdc +1 -1
- package/templates/core/AGENTS.md +10 -7
- package/templates/skills-shared/implement-task/SKILL.md +2 -2
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { deleteFile } from "./filesystem.js";
|
|
3
|
+
import { GitConfigError, requireGitConfig } from "./git-config.js";
|
|
4
|
+
import { mergeRequestLabel } from "./git-host.js";
|
|
5
|
+
import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists, gitSwitchToBranch, readGitInfo, } from "./git.js";
|
|
6
|
+
import { dim, log } from "./logger.js";
|
|
7
|
+
import { closeMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
|
|
8
|
+
import { normalizeTaskRef } from "./resolve-task.js";
|
|
9
|
+
import { getTaskMergeRequestLifecycle, resolveGitHostForCwd } from "./task-effective-status.js";
|
|
10
|
+
import { findTaskFile, loadActionableTasks, pickInProgressTask, readGitContext, readTaskFile, } from "./task.js";
|
|
11
|
+
export async function resolveDelTarget(paths, cwd, taskRef) {
|
|
12
|
+
const trimmed = taskRef?.trim();
|
|
13
|
+
let taskId;
|
|
14
|
+
let taskFile;
|
|
15
|
+
if (trimmed !== undefined && trimmed.length > 0) {
|
|
16
|
+
taskId = normalizeTaskRef(trimmed);
|
|
17
|
+
taskFile = await findTaskFile(paths.docsTasks, taskId);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const inProgress = pickInProgressTask(await loadActionableTasks(paths.docsTasks));
|
|
21
|
+
if (inProgress === null) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
taskId = inProgress.id;
|
|
25
|
+
taskFile = inProgress.filePath;
|
|
26
|
+
}
|
|
27
|
+
if (taskFile === null) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const meta = await readTaskFile(taskFile);
|
|
31
|
+
const ctx = await readGitContext(taskFile);
|
|
32
|
+
if (ctx === null) {
|
|
33
|
+
log.error(`No Git Context on ${taskId}. Cannot delete.`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return { taskId, taskFile, meta, taskBranch: ctx.taskBranch };
|
|
37
|
+
}
|
|
38
|
+
export function taskDelNotFoundMessage(taskRef) {
|
|
39
|
+
if (taskRef?.trim()) {
|
|
40
|
+
return `TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
|
|
41
|
+
}
|
|
42
|
+
return "No In Progress TASK found. Pass `vibeops task del TASK-NNN`.";
|
|
43
|
+
}
|
|
44
|
+
export async function assertTaskDeletable(cwd, target) {
|
|
45
|
+
const lifecycle = await getTaskMergeRequestLifecycle(cwd, target.taskFile);
|
|
46
|
+
if (lifecycle === "merged") {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
reason: `${target.taskId} is merged into integration — task del is not allowed. ` +
|
|
50
|
+
"Revert on develop if needed, or keep the TASK file as history.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
export async function assertWorkingTreeForDel(cwd, allowDirty) {
|
|
56
|
+
const git = await readGitInfo(cwd);
|
|
57
|
+
if (git.dirty !== true)
|
|
58
|
+
return true;
|
|
59
|
+
const gov = await gitGovernanceOnlyDirty(cwd);
|
|
60
|
+
if (gov.onlyGovernance)
|
|
61
|
+
return true;
|
|
62
|
+
if (allowDirty) {
|
|
63
|
+
log.warn("Uncommitted changes remain in the working tree after delete.");
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
log.error("Working tree is dirty. Commit, stash, restore, or rerun with --force to discard uncommitted work.");
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
function isProtectedBranch(name, integration, production) {
|
|
70
|
+
return name === integration || name === production || name === "main" || name === "master";
|
|
71
|
+
}
|
|
72
|
+
export async function deleteTaskBranches(opts) {
|
|
73
|
+
const cwd = resolve(opts.cwd);
|
|
74
|
+
if (opts.dryRun) {
|
|
75
|
+
if (await gitBranchExists(cwd, opts.taskBranch)) {
|
|
76
|
+
log.info(dim(` would git branch -D ${opts.taskBranch}`));
|
|
77
|
+
}
|
|
78
|
+
if (opts.noRemoteDelete !== true &&
|
|
79
|
+
(await gitRemoteBranchExists(cwd, opts.remote, opts.taskBranch))) {
|
|
80
|
+
log.info(dim(` would git push ${opts.remote} --delete ${opts.taskBranch}`));
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
const git = await readGitInfo(cwd);
|
|
85
|
+
if (git.branch === opts.taskBranch) {
|
|
86
|
+
const switched = await gitSwitchToBranch(cwd, opts.integrationBranch, opts.remote);
|
|
87
|
+
if (!switched) {
|
|
88
|
+
log.error(`Integration branch "${opts.integrationBranch}" not found.`);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
log.ok(`On ${opts.integrationBranch}`);
|
|
92
|
+
}
|
|
93
|
+
if (await gitBranchExists(cwd, opts.taskBranch)) {
|
|
94
|
+
try {
|
|
95
|
+
await gitDeleteBranch(cwd, opts.taskBranch, { force: true });
|
|
96
|
+
log.ok(`Deleted local branch ${opts.taskBranch}`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
100
|
+
log.error(`Could not delete local ${opts.taskBranch}: ${msg}`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
log.info(dim(`Local branch ${opts.taskBranch} not found — skipped.`));
|
|
106
|
+
}
|
|
107
|
+
if (opts.noRemoteDelete !== true &&
|
|
108
|
+
(await gitRemoteBranchExists(cwd, opts.remote, opts.taskBranch))) {
|
|
109
|
+
try {
|
|
110
|
+
await gitDeleteRemoteBranch(cwd, opts.remote, opts.taskBranch);
|
|
111
|
+
log.ok(`Deleted ${opts.remote}/${opts.taskBranch}`);
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
115
|
+
log.warn(`Could not delete remote ${opts.taskBranch}: ${msg}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (opts.noRemoteDelete === true) {
|
|
119
|
+
log.info(dim("Remote branch delete skipped (--no-remote-delete)."));
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
export async function closeOpenTaskMergeRequestIfNeeded(opts) {
|
|
124
|
+
if (opts.skipClose) {
|
|
125
|
+
log.info(dim("Open MR/PR close skipped (--no-close-mr)."));
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
const lifecycle = await getTaskMergeRequestLifecycle(opts.cwd, opts.taskFile);
|
|
129
|
+
if (lifecycle !== "open")
|
|
130
|
+
return true;
|
|
131
|
+
const host = await resolveGitHostForCwd(opts.cwd);
|
|
132
|
+
if (host === null) {
|
|
133
|
+
log.warn("Could not resolve Git host — close the MR/PR manually.");
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
const open = await findMergeRequestByBranches({
|
|
137
|
+
cwd: opts.cwd,
|
|
138
|
+
host,
|
|
139
|
+
headBranch: opts.taskBranch,
|
|
140
|
+
baseBranch: opts.baseBranch,
|
|
141
|
+
state: "open",
|
|
142
|
+
});
|
|
143
|
+
if (open === null)
|
|
144
|
+
return true;
|
|
145
|
+
const label = mergeRequestLabel(host);
|
|
146
|
+
if (!(await probeMergeRequestCli(host))) {
|
|
147
|
+
log.warn(`Open ${label}: ${open.url} — close it manually before deleting the branch.`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await closeMergeRequest({ cwd: opts.cwd, host, url: open.url, dryRun: opts.dryRun });
|
|
152
|
+
if (!opts.dryRun)
|
|
153
|
+
log.ok(`Closed ${label}`);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
157
|
+
log.warn(`Could not close ${label}: ${msg}`);
|
|
158
|
+
log.info(dim(`Close manually: ${open.url}`));
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
export async function deleteTaskMarkdown(taskFile, dryRun) {
|
|
163
|
+
if (dryRun) {
|
|
164
|
+
log.info(dim(` would delete ${taskFile}`));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
await deleteFile(taskFile);
|
|
168
|
+
log.ok(`Deleted TASK file`);
|
|
169
|
+
}
|
|
170
|
+
export async function loadGitConfigForDel(cwd) {
|
|
171
|
+
try {
|
|
172
|
+
const cfg = await requireGitConfig(cwd);
|
|
173
|
+
return {
|
|
174
|
+
remote: cfg.remote,
|
|
175
|
+
integrationBranch: cfg.integrationBranch,
|
|
176
|
+
productionBranch: cfg.productionBranch,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
if (e instanceof GitConfigError) {
|
|
181
|
+
log.error(e.message);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function validateTaskBranchForDel(taskBranch, integrationBranch, productionBranch) {
|
|
188
|
+
if (isProtectedBranch(taskBranch, integrationBranch, productionBranch)) {
|
|
189
|
+
log.error(`Refusing to delete protected branch: ${taskBranch}`);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
@@ -64,21 +64,29 @@ export async function resolveOpenTaskMergeRequest(cwd, taskFile) {
|
|
|
64
64
|
return legacy;
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Single source of truth for a TASK's PR/MR lifecycle and URL.
|
|
69
|
+
* Resolution order: open branch lookup → merged branch lookup → legacy TASK URL.
|
|
70
|
+
* `ship` keys its state machine off the returned `state`.
|
|
71
|
+
*/
|
|
72
|
+
export async function resolveTaskMergeRequestLifecycle(cwd, taskFile) {
|
|
69
73
|
const ctx = await readTaskGitContext(taskFile);
|
|
70
74
|
if (ctx === null)
|
|
71
|
-
return "none";
|
|
75
|
+
return { state: "none", url: null, host: null };
|
|
72
76
|
const open = await resolveFromBranches(cwd, ctx, "open");
|
|
73
77
|
if (open !== null)
|
|
74
|
-
return "open";
|
|
78
|
+
return { state: "open", url: open.url, host: open.host };
|
|
75
79
|
const merged = await resolveFromBranches(cwd, ctx, "merged");
|
|
76
80
|
if (merged !== null)
|
|
77
|
-
return "merged";
|
|
81
|
+
return { state: "merged", url: merged.url, host: merged.host };
|
|
78
82
|
const legacy = await resolveFromTaskFileUrl(cwd, ctx);
|
|
79
83
|
if (legacy !== null)
|
|
80
|
-
return legacy.state;
|
|
81
|
-
return "none";
|
|
84
|
+
return { state: legacy.state, url: legacy.url, host: legacy.host };
|
|
85
|
+
return { state: "none", url: null, host: null };
|
|
86
|
+
}
|
|
87
|
+
/** MR lifecycle for hints, sync, and status — prefers host branch lookup. */
|
|
88
|
+
export async function getTaskMergeRequestLifecycle(cwd, taskFile) {
|
|
89
|
+
return (await resolveTaskMergeRequestLifecycle(cwd, taskFile)).state;
|
|
82
90
|
}
|
|
83
91
|
/** @deprecated Use {@link getTaskMergeRequestLifecycle} or {@link resolveOpenTaskMergeRequest}. */
|
|
84
92
|
export async function getMergeRequestStateForTask(cwd, taskFile) {
|
|
@@ -7,6 +7,30 @@ export function featCommitMessageFor(taskId, title) {
|
|
|
7
7
|
export function docsCommitMessageFor(taskId, suffix) {
|
|
8
8
|
return `docs(${taskId.toLowerCase()}): ${suffix}`;
|
|
9
9
|
}
|
|
10
|
+
const CONVENTIONAL_COMMIT_RE = /^([a-z]+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/is;
|
|
11
|
+
/**
|
|
12
|
+
* Normalize a raw commit message so it carries the TASK id as the conventional
|
|
13
|
+
* commit scope (git-safety rule: `feat(task-001): …`).
|
|
14
|
+
*
|
|
15
|
+
* - `"fix: handle null"` → `fix(task-001): handle null`
|
|
16
|
+
* - `"fix(login): handle null"` → `fix(task-001): handle null` (scope rewritten)
|
|
17
|
+
* - `"handle null"` → `feat(task-001): handle null` (defaultType)
|
|
18
|
+
* - already-scoped correct messages are returned unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export function taskScopedCommitMessage(taskId, rawMessage, defaultType = "feat") {
|
|
21
|
+
const scope = taskId.toLowerCase();
|
|
22
|
+
const msg = rawMessage.trim().replace(/\s+/g, " ");
|
|
23
|
+
if (msg.length === 0)
|
|
24
|
+
return `${defaultType}(${scope}): ${taskId}`;
|
|
25
|
+
const m = CONVENTIONAL_COMMIT_RE.exec(msg);
|
|
26
|
+
if (m) {
|
|
27
|
+
const type = m[1].toLowerCase();
|
|
28
|
+
const bang = m[3] ?? "";
|
|
29
|
+
const subject = m[4].trim();
|
|
30
|
+
return `${type}(${scope})${bang}: ${subject}`;
|
|
31
|
+
}
|
|
32
|
+
return `${defaultType}(${scope}): ${msg}`;
|
|
33
|
+
}
|
|
10
34
|
/** Commit all committable dirty paths; returns whether a commit was created. */
|
|
11
35
|
export async function commitDirtyWorkingTree(cwd, message, dryRun) {
|
|
12
36
|
const git = await readGitInfo(cwd);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { readText } from "./filesystem.js";
|
|
3
|
+
import { listWorkingTreeRelPaths } from "./git.js";
|
|
4
|
+
import { bold, cyan, dim, green, log, yellow } from "./logger.js";
|
|
5
|
+
import { projectPaths } from "./paths.js";
|
|
6
|
+
import { prNumberFromUrl } from "./pr-create.js";
|
|
7
|
+
import { relPath } from "./task-context.js";
|
|
8
|
+
import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, } from "./task-git-commit.js";
|
|
9
|
+
import { finishTaskWithPullRequest } from "./task-push-pr.js";
|
|
10
|
+
import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "./task-ship-llm.js";
|
|
11
|
+
import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "./task-reship.js";
|
|
12
|
+
import { hasNonEmptySection, parseTaskFilename, readGitContext } from "./task.js";
|
|
13
|
+
import { prRefLabel } from "./task-ship-state.js";
|
|
14
|
+
async function commitReshipMetadata(opts) {
|
|
15
|
+
if (opts.dryRun) {
|
|
16
|
+
log.info(dim(" would commit new-cycle metadata (Status stays Shipped)"));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "new PR cycle metadata"), false);
|
|
20
|
+
if (!committed) {
|
|
21
|
+
log.info(dim("No new-cycle metadata commit (Git Context already on disk)."));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Start a NEW PR cycle for an already-Shipped TASK whose previous PR was merged
|
|
26
|
+
* (the former `task reship` behavior). Carries uncommitted work onto the task
|
|
27
|
+
* branch, integrates the integration branch, and opens a fresh MR/PR.
|
|
28
|
+
*/
|
|
29
|
+
export async function runPostMergeNewCycle(taskRef, options = {}) {
|
|
30
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
31
|
+
const paths = projectPaths(cwd);
|
|
32
|
+
const dryRun = options.dryRun === true;
|
|
33
|
+
const target = await resolveReshipTarget(paths, cwd, taskRef);
|
|
34
|
+
if (target === null) {
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const { taskId, taskFile, meta, gitCtx: initialCtx } = target;
|
|
39
|
+
const relFile = relPath(cwd, taskFile);
|
|
40
|
+
log.info(bold(`vibeops task ship ${taskId} — new PR cycle`));
|
|
41
|
+
log.info(` ${dim("file")} ${relFile}`);
|
|
42
|
+
log.info(` ${dim("branch")} ${initialCtx.taskBranch}`);
|
|
43
|
+
log.info(` ${dim("status")} Shipped (unchanged)`);
|
|
44
|
+
log.blank();
|
|
45
|
+
const gitCfg = dryRun
|
|
46
|
+
? await loadGitConfigForReship(cwd).catch(() => null)
|
|
47
|
+
: await loadGitConfigForReship(cwd);
|
|
48
|
+
if (!dryRun && gitCfg === null) {
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const integrationBranch = gitCfg?.integrationBranch ?? "develop";
|
|
53
|
+
const remote = gitCfg?.remote ?? "origin";
|
|
54
|
+
const updateOpenMr = options.allowOpenMr === true;
|
|
55
|
+
if (!dryRun && !(await assertMergeRequestNotOpen(cwd, taskFile, updateOpenMr))) {
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const branchOk = await ensureTaskBranchForReship({
|
|
60
|
+
cwd,
|
|
61
|
+
taskFile,
|
|
62
|
+
gitCtx: initialCtx,
|
|
63
|
+
integrationBranch,
|
|
64
|
+
remote,
|
|
65
|
+
recreateBranch: options.recreateBranch === true,
|
|
66
|
+
dryRun,
|
|
67
|
+
});
|
|
68
|
+
if (!branchOk) {
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (options.noIntegrate !== true) {
|
|
73
|
+
const integrated = await integrateIntegrationIntoTaskBranch({
|
|
74
|
+
cwd,
|
|
75
|
+
integrationBranch,
|
|
76
|
+
remote,
|
|
77
|
+
dryRun,
|
|
78
|
+
});
|
|
79
|
+
if (!integrated) {
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
log.info(dim("Skipped integration merge (--no-integrate)."));
|
|
86
|
+
}
|
|
87
|
+
const body = await readText(taskFile);
|
|
88
|
+
let resultOk = hasNonEmptySection(body, "Result");
|
|
89
|
+
let testOk = hasNonEmptySection(body, "Test Result");
|
|
90
|
+
if (!options.skipLlm && !dryRun) {
|
|
91
|
+
log.step("LLM — follow-up Result / Test Result");
|
|
92
|
+
const patch = await llmCompleteTaskReship({
|
|
93
|
+
cwd,
|
|
94
|
+
taskId,
|
|
95
|
+
taskTitle: meta.title,
|
|
96
|
+
taskBody: body,
|
|
97
|
+
taskFileRel: relFile,
|
|
98
|
+
});
|
|
99
|
+
if (patch !== null) {
|
|
100
|
+
await writeTaskResultSections(taskFile, patch.result, patch.testResult);
|
|
101
|
+
log.ok(`Result / Test Result updated (${patch.provider})`);
|
|
102
|
+
const memory = await applyTaskShipMemory(cwd, patch);
|
|
103
|
+
for (const p of memory.updated)
|
|
104
|
+
log.ok(`Updated ${p}`);
|
|
105
|
+
for (const p of memory.skipped)
|
|
106
|
+
log.skip(`Skipped ${p}`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
log.warn("LLM unavailable — ensure Result / Test Result describe this follow-up.");
|
|
110
|
+
}
|
|
111
|
+
const body2 = await readText(taskFile);
|
|
112
|
+
resultOk = hasNonEmptySection(body2, "Result");
|
|
113
|
+
testOk = hasNonEmptySection(body2, "Test Result");
|
|
114
|
+
}
|
|
115
|
+
else if (options.skipLlm) {
|
|
116
|
+
log.info(dim("LLM skipped (--skip-llm)."));
|
|
117
|
+
}
|
|
118
|
+
log.info(bold("Sections"));
|
|
119
|
+
log.info(` ${resultOk ? green("✓") : yellow("·")} Result`);
|
|
120
|
+
log.info(` ${testOk ? green("✓") : yellow("·")} Test Result`);
|
|
121
|
+
log.blank();
|
|
122
|
+
if (dryRun) {
|
|
123
|
+
log.info(bold("dry-run — would also:"));
|
|
124
|
+
if (updateOpenMr) {
|
|
125
|
+
log.info(" · feat commit, push, update open MR/PR (unless --no-pr)");
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
log.info(" · feat commit + metadata, then push once and open a NEW MR/PR (unless --no-pr)");
|
|
129
|
+
}
|
|
130
|
+
log.blank();
|
|
131
|
+
log.info(`Next: ${cyan("vibeops task merge")}, then optional ${cyan("task sync")}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!resultOk || !testOk) {
|
|
135
|
+
log.error("Fill Result and Test Result for this follow-up, then rerun.");
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const dirtyBefore = await listWorkingTreeRelPaths(cwd);
|
|
140
|
+
if (dirtyBefore.length === 0) {
|
|
141
|
+
log.info(`Nothing to do — ${taskId} is merged with no new changes. Create a new TASK for new work (${cyan("vibeops task add")}).`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const parts = parseTaskFilename(taskFile);
|
|
145
|
+
await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
|
|
146
|
+
let gitCtx = await readGitContext(taskFile);
|
|
147
|
+
if (gitCtx === null) {
|
|
148
|
+
log.error("Git Context missing after commit.");
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (updateOpenMr) {
|
|
153
|
+
log.info(dim("Updating open MR/PR (--allow-open-mr)."));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
|
|
157
|
+
gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
|
|
158
|
+
await commitReshipMetadata({
|
|
159
|
+
cwd,
|
|
160
|
+
taskFile,
|
|
161
|
+
taskId: parts.id,
|
|
162
|
+
dryRun: false,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const prResult = await finishTaskWithPullRequest({
|
|
166
|
+
cwd,
|
|
167
|
+
taskFile,
|
|
168
|
+
skipPr: options.noPr === true,
|
|
169
|
+
forceNewMergeRequest: !updateOpenMr,
|
|
170
|
+
});
|
|
171
|
+
if (!prResult.ok) {
|
|
172
|
+
log.error("New PR cycle failed — fix push/MR, then rerun `vibeops task ship --new-cycle`.");
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
log.blank();
|
|
177
|
+
const ref = prRefLabel(prNumberFromUrl(prResult.mergeRequestUrl));
|
|
178
|
+
if (prResult.mergeRequestUrl) {
|
|
179
|
+
log.ok(`Started new PR cycle → PR ${ref}`);
|
|
180
|
+
log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
log.ok("Started new PR cycle (branch pushed; create the MR/PR manually).");
|
|
184
|
+
}
|
|
185
|
+
log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
|
|
186
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { prNumberFromUrl } from "./pr-create.js";
|
|
2
|
+
import { resolveTaskMergeRequestLifecycle } from "./task-effective-status.js";
|
|
3
|
+
/**
|
|
4
|
+
* Single entry point for `ship` to learn the current PR/MR state of a TASK.
|
|
5
|
+
* Wraps {@link resolveTaskMergeRequestLifecycle} and extracts the PR number.
|
|
6
|
+
*/
|
|
7
|
+
export async function detectShipPrState(cwd, taskFile) {
|
|
8
|
+
const lifecycle = await resolveTaskMergeRequestLifecycle(cwd, taskFile);
|
|
9
|
+
const state = lifecycle.state === "open"
|
|
10
|
+
? "open"
|
|
11
|
+
: lifecycle.state === "merged"
|
|
12
|
+
? "merged"
|
|
13
|
+
: lifecycle.state === "closed"
|
|
14
|
+
? "closed"
|
|
15
|
+
: "none";
|
|
16
|
+
return {
|
|
17
|
+
state,
|
|
18
|
+
url: lifecycle.url,
|
|
19
|
+
number: prNumberFromUrl(lifecycle.url),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Map (PR state, TASK status) → the `ship` action. Pure so it is unit-testable.
|
|
24
|
+
* - open PR → update the existing PR (state 2)
|
|
25
|
+
* - merged PR → start a new PR cycle (state 3, guarded)
|
|
26
|
+
* - no/closed PR + In Progress → first submit (state 1)
|
|
27
|
+
* - no/closed PR + Shipped → status/PR mismatch (resync hint)
|
|
28
|
+
*/
|
|
29
|
+
export function resolveShipAction(prState, taskStatus) {
|
|
30
|
+
if (prState === "open")
|
|
31
|
+
return "update-open";
|
|
32
|
+
if (prState === "merged")
|
|
33
|
+
return "new-cycle";
|
|
34
|
+
return taskStatus === "shipped" ? "mismatch" : "first";
|
|
35
|
+
}
|
|
36
|
+
/** Format the PR reference for output (`#42` or `the PR`). */
|
|
37
|
+
export function prRefLabel(prNumber) {
|
|
38
|
+
return prNumber !== null ? `#${prNumber}` : "the PR";
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodtek/vibeops",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@9.15.9",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"build": "tsc -p tsconfig.json",
|
|
36
36
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
37
37
|
"start": "node dist/cli.js",
|
|
38
|
-
"
|
|
38
|
+
"test": "pnpm build && node --test \"tests/*.test.mjs\"",
|
|
39
|
+
"smoke": "pnpm typecheck && pnpm build && node --test \"tests/*.test.mjs\" && node scripts/smoke.mjs",
|
|
39
40
|
"clean": "rm -rf dist",
|
|
40
41
|
"prepack": "pnpm clean && pnpm build",
|
|
41
42
|
"publish:dry": "pnpm publish --dry-run --no-git-checks"
|
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
|
|
3
3
|
- Work from `docs/tasks/TASK-NNN-*.md` only.
|
|
4
4
|
- One TASK at a time.
|
|
5
|
-
- Fill **Result** and **Test Result** before telling the human to run `vibeops task ship TASK-NNN`
|
|
5
|
+
- Fill **Result** and **Test Result** before telling the human to run `vibeops task ship TASK-NNN`. `ship` is state-aware: re-running before merge updates the open PR; after merge use `vibeops task ship --new-cycle`.
|
|
6
6
|
- Do not merge to integration or main unless the human runs `vibeops task merge` / `task sync` / `task release`.
|
|
@@ -8,4 +8,4 @@ alwaysApply: true
|
|
|
8
8
|
- Use branch pattern `task/*` from `vibeops task add`.
|
|
9
9
|
- Include TASK id in commits: `feat(task-nnn): …`.
|
|
10
10
|
- No force-push to shared branches.
|
|
11
|
-
- Run `vibeops task ship`, `task
|
|
11
|
+
- Run `vibeops task ship`, `task merge`, `task sync` when the human asks — do not merge locally in the IDE unless requested. (`ship` is state-aware: it updates an open PR or, with `--new-cycle`, starts a new PR cycle after merge.)
|
|
@@ -5,7 +5,7 @@ alwaysApply: true
|
|
|
5
5
|
|
|
6
6
|
# Docs before task ship
|
|
7
7
|
|
|
8
|
-
Before `vibeops task ship
|
|
8
|
+
Before `vibeops task ship`:
|
|
9
9
|
|
|
10
10
|
1. Fill **Result** and **Test Result** in the TASK file (paths, commands, outcomes — facts only).
|
|
11
11
|
2. If architecture or decisions changed, note them in the TASK; `task ship` can update `docs/project/*` via LLM.
|
package/templates/core/AGENTS.md
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
vibeops task add
|
|
9
9
|
# Plan / build in your agent (@docs/tasks/TASK-NNN-*.md)
|
|
10
10
|
|
|
11
|
-
vibeops task ship
|
|
11
|
+
vibeops task ship # state-aware: new PR / update open PR / new cycle
|
|
12
12
|
vibeops task merge # CLI or host UI
|
|
13
13
|
vibeops task sync # optional — branch cleanup only
|
|
14
14
|
|
|
15
|
-
# Same TASK
|
|
16
|
-
vibeops task
|
|
15
|
+
# Same TASK, before merge — just re-run ship to update the open PR:
|
|
16
|
+
vibeops task ship -m "address review"
|
|
17
|
+
# Same TASK, after merge — start a new PR cycle:
|
|
18
|
+
vibeops task ship --new-cycle
|
|
17
19
|
vibeops task merge
|
|
18
20
|
```
|
|
19
21
|
|
|
@@ -26,16 +28,17 @@ Only two values in `## Status`:
|
|
|
26
28
|
| Status | Meaning |
|
|
27
29
|
|--------|---------|
|
|
28
30
|
| **In Progress** | Active slice (`task add`) |
|
|
29
|
-
| **Shipped** | Submitted (`task ship`
|
|
31
|
+
| **Shipped** | Submitted (`task ship`) |
|
|
30
32
|
|
|
31
33
|
Merge and sync do **not** change the TASK file.
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
|
-
vibeops llm connect # optional — task add / ship
|
|
36
|
+
vibeops llm connect # optional — task add / ship
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
## Rules
|
|
38
40
|
|
|
39
41
|
- One **In Progress** TASK at a time; **Shipped** does not block `task add`.
|
|
40
|
-
- Fill **Result** and **Test Result** before `vibeops task ship` (
|
|
41
|
-
-
|
|
42
|
+
- Fill **Result** and **Test Result** before `vibeops task ship` (warned, not blocked, when updating an open PR).
|
|
43
|
+
- `ship` is state-aware: re-run it to update an open PR; after merge it needs `--new-cycle` (or a confirm) to start a new PR cycle. `task reship` still works as a deprecated alias for `task ship --new-cycle`.
|
|
44
|
+
- Agents do not run `task merge` or `task sync` unless the human asks.
|
|
@@ -8,6 +8,6 @@ description: Implement the current VibeOps TASK file in the repo.
|
|
|
8
8
|
1. Read the full `docs/tasks/TASK-NNN-*.md` (Scope, Acceptance Criteria, Test Plan).
|
|
9
9
|
2. Search the codebase before adding new patterns.
|
|
10
10
|
3. Implement only within the TASK scope.
|
|
11
|
-
4. Do not run `vibeops task ship`, `
|
|
11
|
+
4. Do not run `vibeops task ship`, `merge`, or `sync` — the human runs the CLI lifecycle.
|
|
12
12
|
|
|
13
|
-
When implementation is ready, tell the human to run `vibeops task ship TASK-NNN`.
|
|
13
|
+
When implementation is ready, tell the human to run `vibeops task ship TASK-NNN`. `ship` is state-aware: re-running it before merge updates the open PR; after merge it starts a new PR cycle with `vibeops task ship --new-cycle`.
|