@goodtek/vibeops 0.4.0 → 0.7.22
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 +177 -0
- package/README.md +34 -54
- package/dist/cli.js +156 -214
- package/dist/commands/done.js +160 -0
- package/dist/commands/next.js +283 -0
- package/dist/commands/plan.js +53 -250
- package/dist/commands/rollback.js +126 -0
- package/dist/commands/start.js +129 -0
- package/dist/commands/task-add.js +200 -0
- package/dist/commands/task-status.js +122 -0
- package/dist/lib/brief.js +1 -1
- package/dist/lib/cursor-task-prompt.js +60 -0
- package/dist/lib/done-follow-up.js +308 -0
- package/dist/lib/git.js +189 -7
- package/dist/lib/guide-state.js +69 -0
- package/dist/lib/llm-complete.js +62 -0
- package/dist/lib/mvp-artifacts.js +204 -0
- package/dist/lib/mvp-constants.js +15 -0
- package/dist/lib/mvp-diff-summary.js +43 -0
- package/dist/lib/mvp-done-summary.js +335 -0
- package/dist/lib/resolve-command-task.js +40 -0
- package/dist/lib/slug.js +11 -0
- package/dist/lib/task-add-build-prompt.js +39 -0
- package/dist/lib/task-add-cursor.js +81 -0
- package/dist/lib/task-add-llm.js +268 -0
- package/dist/lib/task-merge.js +220 -0
- package/dist/lib/task-scaffold.js +56 -74
- package/dist/lib/task-status-brief.js +132 -0
- package/dist/lib/task.js +89 -37
- package/dist/lib/workflow-guide.js +485 -0
- package/dist/status/collector.js +2 -2
- package/dist/status/format.js +6 -2
- package/package.json +2 -1
- package/templates/docs/tasks/TASK-000-template.md +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { cursorAgentPrint, extractAgentAssistantText } from "./plan-llm-cursor-agent.js";
|
|
3
|
+
import { parsePlannerFenceFiles, writePlannerFiles } from "./plan-apply-planner.js";
|
|
4
|
+
import { probeCursorAgentCli } from "./plan-llm-detect.js";
|
|
5
|
+
import { REQUIRED_TASK_SECTIONS } from "./task-generator.js";
|
|
6
|
+
import { bold, log } from "./logger.js";
|
|
7
|
+
const SINGLE_TASK_SYSTEM = `You are the VibeOps Planner Agent. Create exactly ONE TASK markdown file.
|
|
8
|
+
|
|
9
|
+
Rules:
|
|
10
|
+
- Output normal markdown with exactly one fenced code block.
|
|
11
|
+
- First line inside the block MUST be: <!-- file: docs/tasks/TASK-NNN-slug.md --> (use the path given in the user message).
|
|
12
|
+
- Fill every section listed in the template. Status must be "planned". Git Context and Result sections use placeholders only.
|
|
13
|
+
- Do not create application code. Do not edit files outside docs/tasks/.
|
|
14
|
+
- Acceptance Criteria must be numbered and testable.`;
|
|
15
|
+
export async function generateTaskFileViaCursor(input) {
|
|
16
|
+
const relPath = `docs/tasks/${input.taskId}-${input.slug}.md`;
|
|
17
|
+
const phase = input.phase?.trim() || "MVP 1";
|
|
18
|
+
if (input.dryRun === true) {
|
|
19
|
+
return { ok: true, writtenPath: relPath };
|
|
20
|
+
}
|
|
21
|
+
const probe = await probeCursorAgentCli(input.cwd);
|
|
22
|
+
if (!probe.ok) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
reason: probe.reason ?? "Cursor Agent CLI is not ready. Run `agent login` or use quick skeleton mode.",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const userPrompt = `${SINGLE_TASK_SYSTEM}
|
|
29
|
+
|
|
30
|
+
Create this file:
|
|
31
|
+
|
|
32
|
+
<!-- file: ${relPath} -->
|
|
33
|
+
|
|
34
|
+
Template sections (all required):
|
|
35
|
+
${REQUIRED_TASK_SECTIONS.map((s) => `- ${s}`).join("\n")}
|
|
36
|
+
|
|
37
|
+
TASK id: ${input.taskId}
|
|
38
|
+
Title: ${input.title}
|
|
39
|
+
MVP Phase: ${phase}
|
|
40
|
+
|
|
41
|
+
User description:
|
|
42
|
+
${input.idea}
|
|
43
|
+
|
|
44
|
+
Write the complete TASK markdown body now.`;
|
|
45
|
+
log.step(bold(`Cursor Agent is writing ${relPath}…`));
|
|
46
|
+
try {
|
|
47
|
+
const raw = await cursorAgentPrint({
|
|
48
|
+
cwd: input.cwd,
|
|
49
|
+
prompt: userPrompt,
|
|
50
|
+
model: input.model,
|
|
51
|
+
});
|
|
52
|
+
const text = extractAgentAssistantText(raw);
|
|
53
|
+
const parsed = parsePlannerFenceFiles(text);
|
|
54
|
+
const match = parsed.find((f) => f.relativePath === relPath) ??
|
|
55
|
+
parsed.find((f) => f.relativePath.endsWith(`${input.taskId}-${input.slug}.md`)) ??
|
|
56
|
+
parsed[0];
|
|
57
|
+
if (!match) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
reason: "Cursor output did not include a valid <!-- file: docs/tasks/... --> block.",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (match.relativePath !== relPath) {
|
|
64
|
+
log.warn(`Model used path ${match.relativePath}; writing to planned ${relPath}.`);
|
|
65
|
+
}
|
|
66
|
+
const written = await writePlannerFiles(input.cwd, [
|
|
67
|
+
{ relativePath: relPath, content: match.content },
|
|
68
|
+
]);
|
|
69
|
+
if (written.length === 0) {
|
|
70
|
+
return { ok: false, reason: "Failed to write TASK file." };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, writtenPath: relPath };
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
76
|
+
return { ok: false, reason: msg };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function plannedTaskRelPath(taskId, slug) {
|
|
80
|
+
return join("docs", "tasks", `${taskId}-${slug}.md`);
|
|
81
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { join, relative } from "node:path";
|
|
2
|
+
import { readTextOrNull } from "./filesystem.js";
|
|
3
|
+
import { llmCompleteJson } from "./llm-complete.js";
|
|
4
|
+
import { bold, dim, log } from "./logger.js";
|
|
5
|
+
import { askInput, askSelect, yesNoSelect } from "./inquirer-helpers.js";
|
|
6
|
+
import { pickPlanLlmProvider } from "./plan-llm-session.js";
|
|
7
|
+
import { extractJsonObject } from "./plan-llm-openai.js";
|
|
8
|
+
import { projectPaths } from "./paths.js";
|
|
9
|
+
import { slugify } from "./slug.js";
|
|
10
|
+
import { buildTaskBuildPrompt } from "./task-add-build-prompt.js";
|
|
11
|
+
import { statusDisplay } from "./task.js";
|
|
12
|
+
const MAX_PLAN_TURNS = 36;
|
|
13
|
+
const REQUIRED_SECTIONS = [
|
|
14
|
+
"Status",
|
|
15
|
+
"MVP Phase",
|
|
16
|
+
"Goal",
|
|
17
|
+
"Background",
|
|
18
|
+
"Scope",
|
|
19
|
+
"Out of Scope",
|
|
20
|
+
"Acceptance Criteria",
|
|
21
|
+
"Files to Inspect First",
|
|
22
|
+
"Expected Files to Change",
|
|
23
|
+
"Risks",
|
|
24
|
+
"Test Plan",
|
|
25
|
+
"Rollback Plan",
|
|
26
|
+
"Implementation Plan",
|
|
27
|
+
"Result",
|
|
28
|
+
"Test Result",
|
|
29
|
+
];
|
|
30
|
+
function parseTaskPlanTurn(jsonText) {
|
|
31
|
+
const raw = JSON.parse(jsonText);
|
|
32
|
+
const turn = raw.turn;
|
|
33
|
+
if (turn === "done") {
|
|
34
|
+
const taskMarkdown = typeof raw.taskMarkdown === "string" ? raw.taskMarkdown.trim() : "";
|
|
35
|
+
const buildPromptMarkdown = typeof raw.buildPromptMarkdown === "string" ? raw.buildPromptMarkdown.trim() : "";
|
|
36
|
+
if (taskMarkdown.length === 0) {
|
|
37
|
+
throw new Error('Invalid JSON: "done" needs non-empty "taskMarkdown".');
|
|
38
|
+
}
|
|
39
|
+
if (buildPromptMarkdown.length === 0) {
|
|
40
|
+
throw new Error('Invalid JSON: "done" needs non-empty "buildPromptMarkdown".');
|
|
41
|
+
}
|
|
42
|
+
return { turn: "done", taskMarkdown, buildPromptMarkdown };
|
|
43
|
+
}
|
|
44
|
+
if (turn === "question") {
|
|
45
|
+
const message = typeof raw.message === "string" ? raw.message.trim() : "";
|
|
46
|
+
if (message.length === 0)
|
|
47
|
+
throw new Error('Invalid JSON: "question" needs "message".');
|
|
48
|
+
const kindRaw = typeof raw.inputKind === "string" ? raw.inputKind : "text";
|
|
49
|
+
const inputKind = kindRaw === "select" || kindRaw === "confirm" ? kindRaw : "text";
|
|
50
|
+
const options = Array.isArray(raw.options)
|
|
51
|
+
? raw.options.filter((x) => typeof x === "string")
|
|
52
|
+
: undefined;
|
|
53
|
+
return { turn: "question", message, inputKind, options };
|
|
54
|
+
}
|
|
55
|
+
throw new Error('LLM JSON must use turn "question" or "done".');
|
|
56
|
+
}
|
|
57
|
+
async function readSnippet(absPath, maxChars) {
|
|
58
|
+
const text = await readTextOrNull(absPath);
|
|
59
|
+
if (text === null)
|
|
60
|
+
return null;
|
|
61
|
+
const t = text.trim();
|
|
62
|
+
return t.length <= maxChars ? t : `${t.slice(0, maxChars)}\n…`;
|
|
63
|
+
}
|
|
64
|
+
export async function loadTaskAddProjectContext(cwd) {
|
|
65
|
+
const paths = projectPaths(cwd);
|
|
66
|
+
const chunks = [];
|
|
67
|
+
const candidates = [
|
|
68
|
+
join(paths.docsProject, "05-current-state.md"),
|
|
69
|
+
join(paths.docsProject, "03-current-state.md"),
|
|
70
|
+
join(paths.docsProject, "02-mvp-scope.md"),
|
|
71
|
+
join(paths.docsProject, "03-architecture.md"),
|
|
72
|
+
join(paths.docsProject, "04-tech-stack.md"),
|
|
73
|
+
join(paths.docsProject, "06-decisions.md"),
|
|
74
|
+
];
|
|
75
|
+
for (const p of candidates) {
|
|
76
|
+
const snip = await readSnippet(p, 3500);
|
|
77
|
+
if (snip) {
|
|
78
|
+
chunks.push(`### ${relative(cwd, p)}\n\n${snip}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return chunks.length > 0 ? chunks.join("\n\n") : "(no docs/project context files found)";
|
|
82
|
+
}
|
|
83
|
+
function quickTaskSystemPrompt() {
|
|
84
|
+
return `You are a VibeOps TASK author. Respond with JSON only (no markdown fence).
|
|
85
|
+
|
|
86
|
+
Schema:
|
|
87
|
+
{
|
|
88
|
+
"title": "short title without TASK- id prefix",
|
|
89
|
+
"slug": "kebab-case-slug-max-60-chars-no-task-prefix",
|
|
90
|
+
"markdown": "full TASK markdown file"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
The markdown file must:
|
|
94
|
+
- Start with # TASK-NNN: title (use the exact TASK id provided)
|
|
95
|
+
- Include every section: ${REQUIRED_SECTIONS.join(", ")}
|
|
96
|
+
- Status body: ${statusDisplay("planned")}
|
|
97
|
+
- Result and Test Result: (not yet)
|
|
98
|
+
- Keep Scope and Acceptance Criteria minimal but valid — the human will edit heavily in Cursor
|
|
99
|
+
- Do not invent TASK numbers other than the one given`;
|
|
100
|
+
}
|
|
101
|
+
function taskPlanSystemPrompt(taskId, projectName) {
|
|
102
|
+
return `You are a VibeOps TASK planner. Interview the user, then produce a complete TASK and a Cursor build prompt.
|
|
103
|
+
|
|
104
|
+
Respond with JSON only (no markdown fence).
|
|
105
|
+
|
|
106
|
+
Turn "question":
|
|
107
|
+
{
|
|
108
|
+
"turn": "question",
|
|
109
|
+
"message": "…",
|
|
110
|
+
"inputKind": "text" | "select" | "confirm",
|
|
111
|
+
"options": ["…"] // required for select; optional for confirm (Yes/No implied)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
Turn "done":
|
|
115
|
+
{
|
|
116
|
+
"turn": "done",
|
|
117
|
+
"taskMarkdown": "full TASK file for ${taskId}",
|
|
118
|
+
"buildPromptMarkdown": "markdown for Cursor — implement ${taskId} per the TASK"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
taskMarkdown rules:
|
|
122
|
+
- # ${taskId}: … as first line
|
|
123
|
+
- All sections: ${REQUIRED_SECTIONS.join(", ")}
|
|
124
|
+
- Status: ${statusDisplay("planned")}; Result / Test Result: (not yet)
|
|
125
|
+
- Acceptance Criteria numbered and verifiable
|
|
126
|
+
- Align with docs/project; put out-of-MVP items in Out of Scope
|
|
127
|
+
|
|
128
|
+
buildPromptMarkdown rules:
|
|
129
|
+
- Standalone implementer brief for Cursor (like mvp-build.md)
|
|
130
|
+
- Reference the TASK path; list read-first docs; definition of done
|
|
131
|
+
- Project: ${projectName}
|
|
132
|
+
|
|
133
|
+
Ask enough questions to nail Scope, Acceptance Criteria, and Test Plan. Then finish with "done".`;
|
|
134
|
+
}
|
|
135
|
+
function parseQuickTaskJson(jsonText) {
|
|
136
|
+
const raw = JSON.parse(jsonText);
|
|
137
|
+
const title = typeof raw.title === "string" ? raw.title.trim() : "";
|
|
138
|
+
const slug = typeof raw.slug === "string" ? slugify(raw.slug, "task") : "";
|
|
139
|
+
const markdown = typeof raw.markdown === "string" ? raw.markdown.trim() : "";
|
|
140
|
+
if (title.length === 0)
|
|
141
|
+
throw new Error('LLM JSON missing "title".');
|
|
142
|
+
if (markdown.length === 0)
|
|
143
|
+
throw new Error('LLM JSON missing "markdown".');
|
|
144
|
+
return { title, slug: slug.length > 0 ? slug : slugify(title), markdown };
|
|
145
|
+
}
|
|
146
|
+
export function normalizeTaskMarkdownHeader(markdown, taskId, title) {
|
|
147
|
+
const lines = markdown.split("\n");
|
|
148
|
+
const header = `# ${taskId}: ${title}`;
|
|
149
|
+
if (lines.length > 0 && /^#\s+/.test(lines[0])) {
|
|
150
|
+
lines[0] = header;
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
return `${header}\n\n${markdown}`;
|
|
154
|
+
}
|
|
155
|
+
export function ensureTaskSections(markdown) {
|
|
156
|
+
let body = markdown;
|
|
157
|
+
for (const section of REQUIRED_SECTIONS) {
|
|
158
|
+
const re = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "im");
|
|
159
|
+
if (!re.test(body)) {
|
|
160
|
+
body = `${body.trimEnd()}\n\n## ${section}\n\n(not yet)\n`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return body;
|
|
164
|
+
}
|
|
165
|
+
async function collectTaskPlanAnswer(q) {
|
|
166
|
+
if (q.inputKind === "confirm") {
|
|
167
|
+
const yes = await yesNoSelect({ message: q.message, defaultValue: true });
|
|
168
|
+
return yes ? "Yes" : "No";
|
|
169
|
+
}
|
|
170
|
+
if (q.inputKind === "select" && q.options && q.options.length > 0) {
|
|
171
|
+
return await askSelect({
|
|
172
|
+
message: q.message,
|
|
173
|
+
nonInteractive: false,
|
|
174
|
+
choices: q.options,
|
|
175
|
+
default: q.options[0],
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return await askInput({ message: q.message, nonInteractive: false, required: true });
|
|
179
|
+
}
|
|
180
|
+
export async function llmGenerateQuickTask(params) {
|
|
181
|
+
const provider = await pickPlanLlmProvider(params.cwd, undefined, { interactiveLogin: false });
|
|
182
|
+
if (provider === null)
|
|
183
|
+
return null;
|
|
184
|
+
const context = await loadTaskAddProjectContext(params.cwd);
|
|
185
|
+
const latest = params.latestDone !== undefined && params.latestDone !== null
|
|
186
|
+
? `Latest done TASK: ${params.latestDone.id} — ${params.latestDone.title}`
|
|
187
|
+
: "";
|
|
188
|
+
const messages = [
|
|
189
|
+
{ role: "system", content: quickTaskSystemPrompt() },
|
|
190
|
+
{
|
|
191
|
+
role: "user",
|
|
192
|
+
content: [
|
|
193
|
+
`TASK id: ${params.taskId}`,
|
|
194
|
+
`User intent: ${params.idea}`,
|
|
195
|
+
latest,
|
|
196
|
+
"",
|
|
197
|
+
"Project context:",
|
|
198
|
+
context,
|
|
199
|
+
]
|
|
200
|
+
.filter((l) => l.length > 0)
|
|
201
|
+
.join("\n"),
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
const { text: raw, provider: used } = await llmCompleteJson(messages, { cwd: params.cwd, provider });
|
|
205
|
+
const parsed = parseQuickTaskJson(extractJsonObject(raw));
|
|
206
|
+
const title = parsed.title.replace(/^TASK-\d+\s*[·:\-]\s*/i, "").trim() || parsed.title;
|
|
207
|
+
const markdown = ensureTaskSections(normalizeTaskMarkdownHeader(parsed.markdown, params.taskId, title));
|
|
208
|
+
return {
|
|
209
|
+
title,
|
|
210
|
+
slug: parsed.slug,
|
|
211
|
+
markdown,
|
|
212
|
+
provider: used,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export async function gatherTaskPlanViaLlm(params) {
|
|
216
|
+
const provider = await pickPlanLlmProvider(params.cwd, undefined, { interactiveLogin: true });
|
|
217
|
+
if (provider === null)
|
|
218
|
+
return null;
|
|
219
|
+
const context = await loadTaskAddProjectContext(params.cwd);
|
|
220
|
+
const messages = [
|
|
221
|
+
{ role: "system", content: taskPlanSystemPrompt(params.taskId, params.projectName) },
|
|
222
|
+
{
|
|
223
|
+
role: "user",
|
|
224
|
+
content: [
|
|
225
|
+
`TASK id: ${params.taskId}`,
|
|
226
|
+
params.seedIdea ? `Initial idea: ${params.seedIdea}` : "Ask what the user wants to build.",
|
|
227
|
+
"",
|
|
228
|
+
"Project context:",
|
|
229
|
+
context,
|
|
230
|
+
"",
|
|
231
|
+
"Begin with your first question JSON.",
|
|
232
|
+
].join("\n"),
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
let lastQ = "";
|
|
236
|
+
for (let step = 0; step < MAX_PLAN_TURNS; step++) {
|
|
237
|
+
const { text: raw, provider: used } = await llmCompleteJson(messages, {
|
|
238
|
+
cwd: params.cwd,
|
|
239
|
+
provider,
|
|
240
|
+
});
|
|
241
|
+
const parsed = parseTaskPlanTurn(extractJsonObject(raw));
|
|
242
|
+
if (parsed.turn === "done") {
|
|
243
|
+
const titleMatch = /^#\s+TASK-\d+:\s*(.+)$/m.exec(parsed.taskMarkdown);
|
|
244
|
+
const title = titleMatch?.[1]?.trim() ?? params.taskId;
|
|
245
|
+
const markdown = ensureTaskSections(normalizeTaskMarkdownHeader(parsed.taskMarkdown, params.taskId, title));
|
|
246
|
+
return {
|
|
247
|
+
markdown,
|
|
248
|
+
buildPromptMarkdown: parsed.buildPromptMarkdown,
|
|
249
|
+
provider: used,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
log.blank();
|
|
253
|
+
log.info(dim(`Planning ${params.taskId} — question ${step + 1}`));
|
|
254
|
+
log.info(bold(parsed.message));
|
|
255
|
+
log.blank();
|
|
256
|
+
const answer = await collectTaskPlanAnswer(parsed);
|
|
257
|
+
lastQ = parsed.message;
|
|
258
|
+
messages.push({ role: "assistant", content: raw });
|
|
259
|
+
messages.push({
|
|
260
|
+
role: "user",
|
|
261
|
+
content: `Previous Q: ${lastQ}\nUser answer: ${answer}\n\nContinue with next JSON (question or done).`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
throw new Error(`Task planning stopped after ${MAX_PLAN_TURNS} turns — try a shorter scope or run again.`);
|
|
265
|
+
}
|
|
266
|
+
export function defaultBuildPromptForTask(projectName, taskId, taskRelativePath) {
|
|
267
|
+
return buildTaskBuildPrompt({ projectName, taskId, taskRelativePath });
|
|
268
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { ghAuthStatus, runGh } from "./github-cli.js";
|
|
2
|
+
import { gitBranchExists, gitCheckout, gitCommitsAhead, gitDeleteBranch, gitGovernanceOnlyDirty, gitRemoteUrl, readGitInfo, runGit, } from "./git.js";
|
|
3
|
+
import { bold, cyan, dim, log } from "./logger.js";
|
|
4
|
+
function mergeCommitMessage(taskId, taskTitle, taskBranch) {
|
|
5
|
+
const slug = taskTitle.replace(/^TASK-\d+\s*[·:\-]\s*/i, "").trim() || taskId;
|
|
6
|
+
return `merge(${taskId.toLowerCase()}): ${slug} (${taskBranch})`;
|
|
7
|
+
}
|
|
8
|
+
function prTitle(taskId, taskTitle) {
|
|
9
|
+
const slug = taskTitle.replace(/^TASK-\d+\s*[·:\-]\s*/i, "").trim() || taskId;
|
|
10
|
+
return `feat(${taskId.toLowerCase()}): ${slug}`;
|
|
11
|
+
}
|
|
12
|
+
async function gitPush(cwd, remote, ref, setUpstream) {
|
|
13
|
+
const args = ["push"];
|
|
14
|
+
if (setUpstream)
|
|
15
|
+
args.push("-u");
|
|
16
|
+
args.push(remote, ref);
|
|
17
|
+
await runGit(cwd, args);
|
|
18
|
+
}
|
|
19
|
+
async function findOpenPrNumber(baseBranch, headBranch) {
|
|
20
|
+
const res = await runGh([
|
|
21
|
+
"pr",
|
|
22
|
+
"list",
|
|
23
|
+
"--head",
|
|
24
|
+
headBranch,
|
|
25
|
+
"--base",
|
|
26
|
+
baseBranch,
|
|
27
|
+
"--state",
|
|
28
|
+
"open",
|
|
29
|
+
"--json",
|
|
30
|
+
"number,url",
|
|
31
|
+
"--limit",
|
|
32
|
+
"1",
|
|
33
|
+
]);
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
const rows = JSON.parse(res.stdout);
|
|
38
|
+
const row = rows[0];
|
|
39
|
+
if (row && typeof row.number === "number") {
|
|
40
|
+
return { number: row.number, url: row.url };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export async function mergeTaskBranch(opts) {
|
|
49
|
+
const mode = opts.mode ?? "direct";
|
|
50
|
+
const remote = opts.remote ?? "origin";
|
|
51
|
+
const { cwd, gitCtx, taskId, taskTitle } = opts;
|
|
52
|
+
const { baseBranch, taskBranch } = gitCtx;
|
|
53
|
+
if (baseBranch === taskBranch) {
|
|
54
|
+
log.error("Task branch matches base branch; nothing to merge.");
|
|
55
|
+
return { ok: false, mode };
|
|
56
|
+
}
|
|
57
|
+
const git = await readGitInfo(cwd);
|
|
58
|
+
if (!git.isRepo) {
|
|
59
|
+
log.error("Not a git repository.");
|
|
60
|
+
return { ok: false, mode };
|
|
61
|
+
}
|
|
62
|
+
const remoteUrl = await gitRemoteUrl(cwd, remote);
|
|
63
|
+
if (!remoteUrl) {
|
|
64
|
+
log.error(`No git remote "${remote}". Run ${cyan("vibeops github init")} or add the remote first.`);
|
|
65
|
+
return { ok: false, mode };
|
|
66
|
+
}
|
|
67
|
+
const branchExists = await gitBranchExists(cwd, taskBranch);
|
|
68
|
+
if (!branchExists) {
|
|
69
|
+
log.error(`Local task branch not found: ${taskBranch}`);
|
|
70
|
+
return { ok: false, mode };
|
|
71
|
+
}
|
|
72
|
+
const ahead = await gitCommitsAhead(cwd, baseBranch, taskBranch);
|
|
73
|
+
if (ahead === 0) {
|
|
74
|
+
log.warn(`No commits on ${taskBranch} ahead of ${baseBranch}. Merge may be a no-op.`);
|
|
75
|
+
}
|
|
76
|
+
const planLines = [
|
|
77
|
+
`push ${remote} ${taskBranch}`,
|
|
78
|
+
mode === "direct"
|
|
79
|
+
? `checkout ${baseBranch} · pull · merge --no-ff ${taskBranch} · push ${remote} ${baseBranch}`
|
|
80
|
+
: `gh pr create (if needed) · gh pr merge --merge --delete-branch`,
|
|
81
|
+
`delete local branch ${taskBranch}`,
|
|
82
|
+
mode === "direct" ? `delete remote branch ${remote}/${taskBranch}` : "(remote branch removed by gh if supported)",
|
|
83
|
+
];
|
|
84
|
+
if (opts.dryRun === true) {
|
|
85
|
+
log.info(bold("dry-run — would perform:"));
|
|
86
|
+
for (const line of planLines) {
|
|
87
|
+
log.info(` · ${line}`);
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, mode };
|
|
90
|
+
}
|
|
91
|
+
log.step(bold(`Merging ${cyan(taskBranch)} → ${cyan(baseBranch)} (${mode})`));
|
|
92
|
+
try {
|
|
93
|
+
log.step(`Pushing ${taskBranch} to ${remote}…`);
|
|
94
|
+
await gitPush(cwd, remote, taskBranch, true);
|
|
95
|
+
log.ok(`pushed ${taskBranch}`);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
99
|
+
log.error(`git push failed: ${msg}`);
|
|
100
|
+
return { ok: false, mode };
|
|
101
|
+
}
|
|
102
|
+
if (mode === "pr") {
|
|
103
|
+
const auth = await ghAuthStatus();
|
|
104
|
+
if (!auth.authenticated) {
|
|
105
|
+
log.error(`gh is not authenticated. Run ${cyan("gh auth login")} or use direct merge (default).`);
|
|
106
|
+
return { ok: false, mode };
|
|
107
|
+
}
|
|
108
|
+
let pr = await findOpenPrNumber(baseBranch, taskBranch);
|
|
109
|
+
if (!pr) {
|
|
110
|
+
log.step("Opening pull request…");
|
|
111
|
+
const create = await runGh([
|
|
112
|
+
"pr",
|
|
113
|
+
"create",
|
|
114
|
+
"--base",
|
|
115
|
+
baseBranch,
|
|
116
|
+
"--head",
|
|
117
|
+
taskBranch,
|
|
118
|
+
"--title",
|
|
119
|
+
prTitle(taskId, taskTitle),
|
|
120
|
+
"--body",
|
|
121
|
+
`Automated via vibeops next for ${taskId}.`,
|
|
122
|
+
]);
|
|
123
|
+
if (!create.ok) {
|
|
124
|
+
log.error(`gh pr create failed: ${create.stderr.trim() || create.stdout.trim()}`);
|
|
125
|
+
return { ok: false, mode };
|
|
126
|
+
}
|
|
127
|
+
const url = create.stdout.trim();
|
|
128
|
+
log.ok(`PR created${url.length > 0 ? `: ${url}` : ""}`);
|
|
129
|
+
pr = await findOpenPrNumber(baseBranch, taskBranch);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
log.ok(`Using existing PR #${pr.number}`);
|
|
133
|
+
}
|
|
134
|
+
if (!pr) {
|
|
135
|
+
log.error("Could not find PR number after create.");
|
|
136
|
+
return { ok: false, mode };
|
|
137
|
+
}
|
|
138
|
+
log.step(`Merging PR #${pr.number}…`);
|
|
139
|
+
const merged = await runGh([
|
|
140
|
+
"pr",
|
|
141
|
+
"merge",
|
|
142
|
+
String(pr.number),
|
|
143
|
+
"--merge",
|
|
144
|
+
"--delete-branch",
|
|
145
|
+
]);
|
|
146
|
+
if (!merged.ok) {
|
|
147
|
+
log.error(`gh pr merge failed: ${merged.stderr.trim() || merged.stdout.trim()}`);
|
|
148
|
+
return { ok: false, mode };
|
|
149
|
+
}
|
|
150
|
+
log.ok(`PR #${pr.number} merged`);
|
|
151
|
+
try {
|
|
152
|
+
await gitCheckout(cwd, baseBranch);
|
|
153
|
+
await runGit(cwd, ["pull", remote, baseBranch]);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
log.warn(`Run manually: git switch ${baseBranch} && git pull ${remote} ${baseBranch}`);
|
|
157
|
+
}
|
|
158
|
+
if (await gitBranchExists(cwd, taskBranch)) {
|
|
159
|
+
try {
|
|
160
|
+
await gitDeleteBranch(cwd, taskBranch, { force: true });
|
|
161
|
+
log.ok(`deleted local branch ${taskBranch}`);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
log.warn(`Could not delete local branch ${taskBranch}.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, mode, prUrl: pr.url };
|
|
168
|
+
}
|
|
169
|
+
// direct merge
|
|
170
|
+
if (git.dirty === true && opts.allowDirty !== true) {
|
|
171
|
+
const gov = await gitGovernanceOnlyDirty(cwd);
|
|
172
|
+
if (!gov.onlyGovernance) {
|
|
173
|
+
log.error("Working tree is dirty. Commit or stash before merging, or rerun with --allow-dirty.");
|
|
174
|
+
return { ok: false, mode };
|
|
175
|
+
}
|
|
176
|
+
log.warn("Dirty tree has only governance docs — proceeding.");
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await runGit(cwd, ["fetch", remote]);
|
|
180
|
+
await gitCheckout(cwd, baseBranch);
|
|
181
|
+
await runGit(cwd, ["pull", remote, baseBranch]);
|
|
182
|
+
log.step(`Merging ${taskBranch} into ${baseBranch}…`);
|
|
183
|
+
await runGit(cwd, [
|
|
184
|
+
"merge",
|
|
185
|
+
"--no-ff",
|
|
186
|
+
taskBranch,
|
|
187
|
+
"-m",
|
|
188
|
+
mergeCommitMessage(taskId, taskTitle, taskBranch),
|
|
189
|
+
]);
|
|
190
|
+
await gitPush(cwd, remote, baseBranch, false);
|
|
191
|
+
log.ok(`merged and pushed ${baseBranch}`);
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
195
|
+
log.error(`merge failed: ${msg}`);
|
|
196
|
+
log.info(dim(` Resolve conflicts on ${baseBranch}, then commit and push manually.`));
|
|
197
|
+
return { ok: false, mode };
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await gitDeleteBranch(cwd, taskBranch, { force: false });
|
|
201
|
+
log.ok(`deleted local branch ${taskBranch}`);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
try {
|
|
205
|
+
await gitDeleteBranch(cwd, taskBranch, { force: true });
|
|
206
|
+
log.ok(`deleted local branch ${taskBranch} (force)`);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
log.warn(`Could not delete local branch ${taskBranch}.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
await runGit(cwd, ["push", remote, "--delete", taskBranch]);
|
|
214
|
+
log.ok(`deleted remote branch ${remote}/${taskBranch}`);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
log.warn(dim(`Remote branch may already be gone. To remove: git push ${remote} --delete ${taskBranch}`));
|
|
218
|
+
}
|
|
219
|
+
return { ok: true, mode };
|
|
220
|
+
}
|