@goodtek/vibeops 0.3.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 +184 -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/plan-llm-openai.js +15 -4
- package/dist/lib/plan-llm-session.js +3 -2
- 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,308 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { notionSyncCommand } from "../commands/notion-sync.js";
|
|
3
|
+
import { askYesNo } from "./inquirer-helpers.js";
|
|
4
|
+
import { readConfig } from "./config.js";
|
|
5
|
+
import { pathExists, readText, readTextOrNull, writeText } from "./filesystem.js";
|
|
6
|
+
import { detectDefaultBranch, gitAddPaths, gitCheckoutNewBranch, gitHeadCommit, gitMergeInProgress, gitRemoteUrl, listUnmergedRelPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, tryResolveGovernanceUnmerged, } from "./git.js";
|
|
7
|
+
import { bold, cyan, dim, log, yellow } from "./logger.js";
|
|
8
|
+
import { llmCompleteJson, resolveAvailableLlmProvider } from "./llm-complete.js";
|
|
9
|
+
import { NEXT_TASK_SUGGESTION_REL } from "./mvp-constants.js";
|
|
10
|
+
import { LAST_DONE_SUMMARY_REL, readLastDoneSummary, } from "./mvp-done-summary.js";
|
|
11
|
+
import { mergeTaskBranch } from "./task-merge.js";
|
|
12
|
+
import { parseTaskFilename, readSection } from "./task.js";
|
|
13
|
+
const BRIEF_REL = ".vibeops/brief/project-brief.md";
|
|
14
|
+
async function gitDiffStatSummary(cwd) {
|
|
15
|
+
try {
|
|
16
|
+
const stat = await runGit(cwd, ["diff", "--stat"]);
|
|
17
|
+
const body = stat.stdout.trim();
|
|
18
|
+
return body.length > 0 ? body : "(no staged/unstaged diff — check untracked files with git status)";
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return "(could not read git diff)";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function syncNotionAfterDone(opts) {
|
|
25
|
+
if (opts.noNotionSync === true) {
|
|
26
|
+
log.info(dim("Notion sync skipped (--no-notion-sync)."));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const config = await readConfig(opts.cwd);
|
|
30
|
+
if (config?.notion?.enabled !== true) {
|
|
31
|
+
log.info(dim("Notion sync skipped (not enabled in .vibeops.json)."));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
log.blank();
|
|
35
|
+
log.step(bold("Notion sync"));
|
|
36
|
+
if (opts.dryRun === true) {
|
|
37
|
+
log.info(dim(" dry-run: would run vibeops notion sync"));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const prevCode = process.exitCode;
|
|
41
|
+
await notionSyncCommand({ cwd: opts.cwd, dryRun: false });
|
|
42
|
+
if (process.exitCode === 1) {
|
|
43
|
+
log.warn("Notion sync failed — fix integration and run `vibeops notion sync`.");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
log.ok("Notion sync complete.");
|
|
47
|
+
}
|
|
48
|
+
if (prevCode === 1)
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
}
|
|
51
|
+
async function handleMergeInProgressDirtyTree(opts) {
|
|
52
|
+
const unmerged = await listUnmergedRelPaths(opts.cwd);
|
|
53
|
+
if (unmerged.length > 0) {
|
|
54
|
+
log.warn("Unmerged files from the task merge:");
|
|
55
|
+
for (const p of unmerged.slice(0, 12))
|
|
56
|
+
log.info(` ${dim("·")} ${p}`);
|
|
57
|
+
if (unmerged.length > 12)
|
|
58
|
+
log.info(` ${dim("·")} … and ${unmerged.length - 12} more`);
|
|
59
|
+
const resolved = await tryResolveGovernanceUnmerged(opts.cwd);
|
|
60
|
+
if (resolved.length > 0) {
|
|
61
|
+
log.ok(`Auto-resolved ${resolved.length} governance conflict(s) (kept incoming branch version).`);
|
|
62
|
+
}
|
|
63
|
+
const still = await listUnmergedRelPaths(opts.cwd);
|
|
64
|
+
if (still.length > 0) {
|
|
65
|
+
log.error("Remaining conflicts — resolve manually, then:");
|
|
66
|
+
for (const p of still)
|
|
67
|
+
log.info(` ${dim("·")} ${p}`);
|
|
68
|
+
log.info(dim(" git add <paths> && git commit (completes merge)"));
|
|
69
|
+
log.info(dim(" Or abort: git merge --quit"));
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const allPaths = await listWorkingTreeRelPaths(opts.cwd);
|
|
74
|
+
const { committable } = partitionPathsForAutoCommit(allPaths, {
|
|
75
|
+
unmerged: await listUnmergedRelPaths(opts.cwd),
|
|
76
|
+
});
|
|
77
|
+
if (committable.length > 0) {
|
|
78
|
+
await gitAddPaths(opts.cwd, committable);
|
|
79
|
+
log.info(dim("Staged remaining changes."));
|
|
80
|
+
}
|
|
81
|
+
log.warn("Complete the merge on this branch:");
|
|
82
|
+
log.info(dim(" git commit -m \"merge: complete TASK merge and post-done files\""));
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
async function cleanupDirtyTree(opts) {
|
|
86
|
+
const git = await readGitInfo(opts.cwd);
|
|
87
|
+
if (!git.isRepo || git.dirty !== true)
|
|
88
|
+
return true;
|
|
89
|
+
if (await gitMergeInProgress(opts.cwd)) {
|
|
90
|
+
log.blank();
|
|
91
|
+
log.warn(`${yellow("!")} Git merge still in progress — cannot create a cleanup branch yet.`);
|
|
92
|
+
return handleMergeInProgressDirtyTree(opts);
|
|
93
|
+
}
|
|
94
|
+
const unmerged = await listUnmergedRelPaths(opts.cwd);
|
|
95
|
+
const allPaths = await listWorkingTreeRelPaths(opts.cwd);
|
|
96
|
+
const { committable, excluded, unmerged: unmergedListed } = partitionPathsForAutoCommit(allPaths, { unmerged });
|
|
97
|
+
log.blank();
|
|
98
|
+
log.warn(`${yellow("!")} Working tree is still dirty after done.`);
|
|
99
|
+
const summary = await gitDiffStatSummary(opts.cwd);
|
|
100
|
+
log.info(dim("Changes:"));
|
|
101
|
+
log.info(dim(summary.split("\n").map((l) => ` ${l}`).join("\n")));
|
|
102
|
+
if (unmergedListed.length > 0) {
|
|
103
|
+
log.warn("Skipping unmerged paths until conflicts are resolved:");
|
|
104
|
+
for (const p of unmergedListed.slice(0, 8))
|
|
105
|
+
log.info(` ${dim("·")} ${p}`);
|
|
106
|
+
}
|
|
107
|
+
if (excluded.length > 0) {
|
|
108
|
+
log.blank();
|
|
109
|
+
log.warn("These paths are build/install artifacts — VibeOps will not commit them:");
|
|
110
|
+
for (const p of excluded.slice(0, 8))
|
|
111
|
+
log.info(` ${dim("·")} ${p}`);
|
|
112
|
+
if (excluded.length > 8)
|
|
113
|
+
log.info(` ${dim("·")} … and ${excluded.length - 8} more`);
|
|
114
|
+
log.info(dim(" Add node_modules/, .next/, etc. to .gitignore if they are not already ignored."));
|
|
115
|
+
}
|
|
116
|
+
if (committable.length === 0) {
|
|
117
|
+
log.warn("Nothing safe to auto-commit — fix .gitignore and remove tracked artifacts before the next TASK.");
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
log.blank();
|
|
121
|
+
log.info(dim("Would commit:"));
|
|
122
|
+
for (const p of committable.slice(0, 12))
|
|
123
|
+
log.info(` ${dim("·")} ${p}`);
|
|
124
|
+
if (committable.length > 12)
|
|
125
|
+
log.info(` ${dim("·")} … and ${committable.length - 12} more`);
|
|
126
|
+
let proceed = false;
|
|
127
|
+
if (opts.interactive === true) {
|
|
128
|
+
proceed = await askYesNo({
|
|
129
|
+
message: "Create a cleanup branch, commit the paths above (not node_modules/.next), merge, and delete the branch?",
|
|
130
|
+
nonInteractive: false,
|
|
131
|
+
defaultValue: true,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (!proceed) {
|
|
135
|
+
log.info(dim("Cleanup skipped — commit manually before starting the next TASK."));
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (opts.dryRun === true) {
|
|
139
|
+
log.info(dim(" dry-run: would create chore/vibeops-post-done-* branch, commit, merge"));
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const taskId = parseTaskFilename(opts.taskFile).id;
|
|
143
|
+
const baseBranch = (await detectDefaultBranch(opts.cwd)) ?? "main";
|
|
144
|
+
const baseCommit = (await gitHeadCommit(opts.cwd, false)) ?? "";
|
|
145
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
146
|
+
const cleanupBranch = `chore/vibeops-post-done-${stamp}`;
|
|
147
|
+
await gitCheckoutNewBranch(opts.cwd, cleanupBranch);
|
|
148
|
+
await gitAddPaths(opts.cwd, committable);
|
|
149
|
+
await runGit(opts.cwd, [
|
|
150
|
+
"commit",
|
|
151
|
+
"-q",
|
|
152
|
+
"-m",
|
|
153
|
+
`chore(post-done): cleanup uncommitted work after ${taskId}`,
|
|
154
|
+
]);
|
|
155
|
+
log.ok(`Committed on ${cleanupBranch}`);
|
|
156
|
+
const remote = (await gitRemoteUrl(opts.cwd, "origin")) ?? "";
|
|
157
|
+
if (remote.length === 0) {
|
|
158
|
+
log.warn("No origin remote — merge locally only.");
|
|
159
|
+
await runGit(opts.cwd, ["switch", baseBranch]);
|
|
160
|
+
await runGit(opts.cwd, ["merge", "--no-ff", cleanupBranch, "-m", `merge: post-mvp cleanup`]);
|
|
161
|
+
await runGit(opts.cwd, ["branch", "-d", cleanupBranch]);
|
|
162
|
+
return (await readGitInfo(opts.cwd)).dirty !== true;
|
|
163
|
+
}
|
|
164
|
+
const ctx = {
|
|
165
|
+
baseBranch,
|
|
166
|
+
baseCommit,
|
|
167
|
+
taskBranch: cleanupBranch,
|
|
168
|
+
startedAt: new Date().toISOString(),
|
|
169
|
+
};
|
|
170
|
+
const merged = await mergeTaskBranch({
|
|
171
|
+
cwd: opts.cwd,
|
|
172
|
+
taskId,
|
|
173
|
+
taskTitle: opts.taskTitle,
|
|
174
|
+
gitCtx: ctx,
|
|
175
|
+
mode: "direct",
|
|
176
|
+
});
|
|
177
|
+
if (!merged.ok) {
|
|
178
|
+
log.warn("Cleanup merge did not finish — resolve Git state manually.");
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const after = await readGitInfo(opts.cwd);
|
|
182
|
+
if (after.dirty === true) {
|
|
183
|
+
log.warn("Tree is still dirty after cleanup merge.");
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
log.ok("Working tree clean after post-MVP cleanup.");
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
function renderSuggestionMarkdown(s, priorDoneSummary) {
|
|
190
|
+
const priorBlock = priorDoneSummary !== null
|
|
191
|
+
? `## Previous iteration (from \`${LAST_DONE_SUMMARY_REL}\`)\n\n${priorDoneSummary.trim()}\n\n---\n\n`
|
|
192
|
+
: "";
|
|
193
|
+
return `# Next iteration (suggested)
|
|
194
|
+
|
|
195
|
+
${priorBlock}## Title
|
|
196
|
+
|
|
197
|
+
${s.title}
|
|
198
|
+
|
|
199
|
+
## One-line idea
|
|
200
|
+
|
|
201
|
+
${s.oneLineIdea}
|
|
202
|
+
|
|
203
|
+
## Goal
|
|
204
|
+
|
|
205
|
+
${s.goal}
|
|
206
|
+
|
|
207
|
+
## Suggested scope
|
|
208
|
+
|
|
209
|
+
${s.scope.map((x) => `- ${x}`).join("\n")}
|
|
210
|
+
|
|
211
|
+
## Rationale
|
|
212
|
+
|
|
213
|
+
${s.rationale}
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
Run planning again when ready:
|
|
218
|
+
|
|
219
|
+
\`\`\`bash
|
|
220
|
+
vibeops plan --idea "${s.oneLineIdea.replace(/"/g, '\\"')}"
|
|
221
|
+
\`\`\`
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
224
|
+
async function suggestNextTask(opts) {
|
|
225
|
+
const git = await readGitInfo(opts.cwd);
|
|
226
|
+
if (git.dirty === true) {
|
|
227
|
+
log.info(dim("Next-task suggestion skipped until the working tree is clean."));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const briefPath = join(opts.cwd, BRIEF_REL);
|
|
231
|
+
const brief = (await readTextOrNull(briefPath)) ??
|
|
232
|
+
"(no project brief — run vibeops plan or add .vibeops/brief/project-brief.md)";
|
|
233
|
+
const priorDone = await readLastDoneSummary(opts.cwd);
|
|
234
|
+
const result = readSection(await readText(opts.taskFile), "Result");
|
|
235
|
+
const provider = await resolveAvailableLlmProvider(opts.cwd);
|
|
236
|
+
if (provider === null) {
|
|
237
|
+
log.info(dim(`Next-task suggestion skipped (no LLM). Connect Codex OAuth, Cursor Agent, or OPENAI_API_KEY.`));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
log.blank();
|
|
241
|
+
log.step(bold("Next iteration (LLM suggestion)"));
|
|
242
|
+
const providerLabel = provider === "codex-oauth"
|
|
243
|
+
? "Codex OAuth"
|
|
244
|
+
: provider === "cursor-agent"
|
|
245
|
+
? "Cursor Agent CLI"
|
|
246
|
+
: "OpenAI API";
|
|
247
|
+
log.info(dim(` using ${providerLabel}`));
|
|
248
|
+
if (opts.dryRun === true) {
|
|
249
|
+
log.info(dim(` dry-run: would write ${NEXT_TASK_SUGGESTION_REL}`));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const { text: raw } = await llmCompleteJson([
|
|
254
|
+
{
|
|
255
|
+
role: "system",
|
|
256
|
+
content: "You suggest the next small product iteration after an MVP shipped. Reply with JSON only: title, oneLineIdea, goal, scope (string array, 3-6 items), rationale.",
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
role: "user",
|
|
260
|
+
content: [
|
|
261
|
+
`Project brief:\n${brief.slice(0, 8000)}`,
|
|
262
|
+
priorDone
|
|
263
|
+
? `\nLast done summary (${LAST_DONE_SUMMARY_REL}):\n${priorDone.slice(0, 12000)}`
|
|
264
|
+
: "",
|
|
265
|
+
`\nMVP Result (TASK file):\n${result.slice(0, 6000)}`,
|
|
266
|
+
"\nSuggest the next TASK (post-MVP), not a full replan.",
|
|
267
|
+
].join("\n"),
|
|
268
|
+
},
|
|
269
|
+
], { cwd: opts.cwd, provider });
|
|
270
|
+
const parsed = JSON.parse(raw);
|
|
271
|
+
const suggestion = {
|
|
272
|
+
title: String(parsed.title ?? "Next iteration"),
|
|
273
|
+
oneLineIdea: String(parsed.oneLineIdea ?? parsed.title ?? "Next feature slice"),
|
|
274
|
+
goal: String(parsed.goal ?? ""),
|
|
275
|
+
scope: Array.isArray(parsed.scope)
|
|
276
|
+
? parsed.scope.map((x) => String(x))
|
|
277
|
+
: ["(define scope in vibeops plan)"],
|
|
278
|
+
rationale: String(parsed.rationale ?? ""),
|
|
279
|
+
};
|
|
280
|
+
const outPath = join(opts.cwd, NEXT_TASK_SUGGESTION_REL);
|
|
281
|
+
await writeText(outPath, renderSuggestionMarkdown(suggestion, priorDone));
|
|
282
|
+
log.ok(`Wrote ${cyan(NEXT_TASK_SUGGESTION_REL)}`);
|
|
283
|
+
log.info(` ${dim("title")} ${suggestion.title}`);
|
|
284
|
+
log.info(` ${dim("idea")} ${suggestion.oneLineIdea}`);
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
288
|
+
log.warn(`Next-task suggestion failed (${msg}).`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Notion sync, dirty-tree cleanup, and optional next-task LLM suggestion after `vibeops done`. */
|
|
292
|
+
export async function runDoneFollowUp(opts) {
|
|
293
|
+
await syncNotionAfterDone(opts);
|
|
294
|
+
const clean = await cleanupDirtyTree(opts);
|
|
295
|
+
if (clean) {
|
|
296
|
+
await suggestNextTask(opts);
|
|
297
|
+
}
|
|
298
|
+
log.blank();
|
|
299
|
+
log.info(bold("Next"));
|
|
300
|
+
log.info(` · ${cyan("vibeops status")}`);
|
|
301
|
+
if (await pathExists(join(opts.cwd, LAST_DONE_SUMMARY_REL))) {
|
|
302
|
+
log.info(` · ${cyan(LAST_DONE_SUMMARY_REL)} — what shipped last time`);
|
|
303
|
+
}
|
|
304
|
+
if (await pathExists(join(opts.cwd, NEXT_TASK_SUGGESTION_REL))) {
|
|
305
|
+
log.info(` · ${cyan(NEXT_TASK_SUGGESTION_REL)} — suggested follow-up`);
|
|
306
|
+
}
|
|
307
|
+
log.info(` · ${cyan("vibeops plan --idea \"…\"")} — start the next iteration when ready`);
|
|
308
|
+
}
|
package/dist/lib/git.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { dim, log } from "./logger.js";
|
|
3
4
|
const exec = promisify(execFile);
|
|
5
|
+
/** Avoid `maxBuffer` errors on large `git commit` / `git add` output. */
|
|
6
|
+
const DEFAULT_GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
4
7
|
async function tryGit(cwd, args) {
|
|
5
8
|
try {
|
|
6
9
|
const { stdout } = await exec("git", args, { cwd });
|
|
@@ -10,8 +13,11 @@ async function tryGit(cwd, args) {
|
|
|
10
13
|
return null;
|
|
11
14
|
}
|
|
12
15
|
}
|
|
13
|
-
export async function runGit(cwd, args) {
|
|
14
|
-
const { stdout, stderr } = await exec("git", args, {
|
|
16
|
+
export async function runGit(cwd, args, options = {}) {
|
|
17
|
+
const { stdout, stderr } = await exec("git", args, {
|
|
18
|
+
cwd,
|
|
19
|
+
maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
|
|
20
|
+
});
|
|
15
21
|
return { stdout, stderr };
|
|
16
22
|
}
|
|
17
23
|
export async function isGitRepository(cwd) {
|
|
@@ -83,14 +89,90 @@ export async function gitCreateBranch(cwd, name, startPoint) {
|
|
|
83
89
|
args.push(startPoint);
|
|
84
90
|
await runGit(cwd, args);
|
|
85
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* When only governance / VibeOps paths are dirty, stash them so `git switch` can proceed.
|
|
94
|
+
* Callers that switch branches should restore with {@link restoreGovernanceStashAfterSwitch}.
|
|
95
|
+
*/
|
|
96
|
+
export async function stashGovernanceIfBlocking(cwd) {
|
|
97
|
+
const git = await readGitInfo(cwd);
|
|
98
|
+
if (git.dirty !== true)
|
|
99
|
+
return false;
|
|
100
|
+
const gov = await gitGovernanceOnlyDirty(cwd);
|
|
101
|
+
if (!gov.onlyGovernance || gov.allPaths.length === 0)
|
|
102
|
+
return false;
|
|
103
|
+
const entries = parsePorcelain(await gitStatusPorcelain(cwd));
|
|
104
|
+
const tracked = [];
|
|
105
|
+
const untracked = [];
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
if (!isGovernanceDocumentationPath(e.path))
|
|
108
|
+
continue;
|
|
109
|
+
if (e.untracked)
|
|
110
|
+
untracked.push(e.path);
|
|
111
|
+
else
|
|
112
|
+
tracked.push(e.path);
|
|
113
|
+
}
|
|
114
|
+
let didStash = false;
|
|
115
|
+
if (tracked.length > 0) {
|
|
116
|
+
await runGit(cwd, [
|
|
117
|
+
"stash",
|
|
118
|
+
"push",
|
|
119
|
+
"-m",
|
|
120
|
+
"vibeops: governance before branch switch",
|
|
121
|
+
"--",
|
|
122
|
+
...tracked,
|
|
123
|
+
]);
|
|
124
|
+
didStash = true;
|
|
125
|
+
}
|
|
126
|
+
if (untracked.length > 0) {
|
|
127
|
+
try {
|
|
128
|
+
await runGit(cwd, [
|
|
129
|
+
"stash",
|
|
130
|
+
"push",
|
|
131
|
+
"-u",
|
|
132
|
+
"-m",
|
|
133
|
+
"vibeops: governance untracked before branch switch",
|
|
134
|
+
"--",
|
|
135
|
+
...untracked,
|
|
136
|
+
]);
|
|
137
|
+
didStash = true;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Untracked generated files (e.g. cursor-implement-*.md) may not stash; switch can still proceed.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return didStash;
|
|
144
|
+
}
|
|
145
|
+
/** Re-apply governance paths stashed for a branch switch (e.g. new `docs/tasks/*.md`). */
|
|
146
|
+
export async function restoreGovernanceStashAfterSwitch(cwd, didStash) {
|
|
147
|
+
if (!didStash)
|
|
148
|
+
return;
|
|
149
|
+
try {
|
|
150
|
+
await runGit(cwd, ["stash", "pop"]);
|
|
151
|
+
log.info(dim("Restored stashed governance paths on the task branch."));
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
155
|
+
log.warn(`Could not restore stashed governance files (${msg}). Run ${dim("git stash pop")} manually.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
86
158
|
export async function gitCheckout(cwd, ref) {
|
|
87
|
-
await
|
|
159
|
+
const stashed = await stashGovernanceIfBlocking(cwd);
|
|
160
|
+
if (stashed) {
|
|
161
|
+
log.info(dim("Stashed governance-only changes (.vibeops/, docs/) before branch switch."));
|
|
162
|
+
}
|
|
163
|
+
await runGit(cwd, ["switch", ref]);
|
|
164
|
+
await restoreGovernanceStashAfterSwitch(cwd, stashed);
|
|
88
165
|
}
|
|
89
166
|
export async function gitCheckoutNewBranch(cwd, name, startPoint) {
|
|
90
|
-
const
|
|
167
|
+
const stashed = await stashGovernanceIfBlocking(cwd);
|
|
168
|
+
if (stashed) {
|
|
169
|
+
log.info(dim("Stashed governance-only changes (.vibeops/, docs/) before branch switch."));
|
|
170
|
+
}
|
|
171
|
+
const args = ["switch", "-c", name];
|
|
91
172
|
if (typeof startPoint === "string" && startPoint.length > 0)
|
|
92
173
|
args.push(startPoint);
|
|
93
174
|
await runGit(cwd, args);
|
|
175
|
+
await restoreGovernanceStashAfterSwitch(cwd, stashed);
|
|
94
176
|
}
|
|
95
177
|
export async function gitDeleteBranch(cwd, name, opts = {}) {
|
|
96
178
|
const flag = opts.force === true ? "-D" : "-d";
|
|
@@ -167,12 +249,12 @@ function parsePorcelain(lines) {
|
|
|
167
249
|
}
|
|
168
250
|
return out;
|
|
169
251
|
}
|
|
170
|
-
/** Paths under these prefixes may stay uncommitted across `
|
|
252
|
+
/** Paths under these prefixes may stay uncommitted across `start` / `done` / doc updates. */
|
|
171
253
|
const GOVERNANCE_DOC_REL_PREFIXES = [
|
|
172
254
|
"docs/tasks/",
|
|
173
255
|
"docs/project/",
|
|
174
256
|
"docs/logs/",
|
|
175
|
-
".vibeops/
|
|
257
|
+
".vibeops/",
|
|
176
258
|
];
|
|
177
259
|
function normalizeRepoRelPath(p) {
|
|
178
260
|
return p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
@@ -185,9 +267,109 @@ export function isGovernanceDocumentationPath(repoRelativePath) {
|
|
|
185
267
|
}
|
|
186
268
|
return false;
|
|
187
269
|
}
|
|
270
|
+
/** Build artifacts / deps — never auto-committed by `vibeops done` cleanup. */
|
|
271
|
+
const AUTO_COMMIT_EXCLUDED_PREFIXES = [
|
|
272
|
+
"node_modules/",
|
|
273
|
+
".next/",
|
|
274
|
+
".pnpm-store/",
|
|
275
|
+
"dist/",
|
|
276
|
+
"build/",
|
|
277
|
+
".turbo/",
|
|
278
|
+
"coverage/",
|
|
279
|
+
".vercel/",
|
|
280
|
+
".output/",
|
|
281
|
+
".cache/",
|
|
282
|
+
];
|
|
283
|
+
const AUTO_COMMIT_EXCLUDED_EXACT = new Set([
|
|
284
|
+
"node_modules",
|
|
285
|
+
".next",
|
|
286
|
+
"dist",
|
|
287
|
+
"build",
|
|
288
|
+
]);
|
|
289
|
+
export function isAutoCommitExcludedPath(repoRelativePath) {
|
|
290
|
+
const n = normalizeRepoRelPath(repoRelativePath);
|
|
291
|
+
if (AUTO_COMMIT_EXCLUDED_EXACT.has(n))
|
|
292
|
+
return true;
|
|
293
|
+
for (const prefix of AUTO_COMMIT_EXCLUDED_PREFIXES) {
|
|
294
|
+
if (n.startsWith(prefix))
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
export async function gitMergeInProgress(cwd) {
|
|
300
|
+
return (await tryGit(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== null;
|
|
301
|
+
}
|
|
302
|
+
/** Conflict paths from an in-progress merge (`git diff --diff-filter=U`). */
|
|
303
|
+
export async function listUnmergedRelPaths(cwd) {
|
|
304
|
+
const res = await tryGit(cwd, ["diff", "--name-only", "--diff-filter=U"]);
|
|
305
|
+
if (res === null)
|
|
306
|
+
return [];
|
|
307
|
+
return res.stdout
|
|
308
|
+
.split("\n")
|
|
309
|
+
.map((p) => p.trim())
|
|
310
|
+
.filter((p) => p.length > 0);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* During a merge, take the incoming branch version for governance / VibeOps paths
|
|
314
|
+
* (typical after `vibeops done` merge conflicts on `.vibeops/generated/*`).
|
|
315
|
+
*/
|
|
316
|
+
export async function tryResolveGovernanceUnmerged(cwd) {
|
|
317
|
+
const unmerged = await listUnmergedRelPaths(cwd);
|
|
318
|
+
const resolved = [];
|
|
319
|
+
for (const p of unmerged) {
|
|
320
|
+
if (!isGovernanceDocumentationPath(p))
|
|
321
|
+
continue;
|
|
322
|
+
try {
|
|
323
|
+
await runGit(cwd, ["checkout", "--theirs", "--", p]);
|
|
324
|
+
await runGit(cwd, ["add", "--", p]);
|
|
325
|
+
resolved.push(p);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// skip paths that cannot be auto-resolved
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return resolved;
|
|
332
|
+
}
|
|
333
|
+
export async function listWorkingTreeRelPaths(cwd) {
|
|
334
|
+
const entries = parsePorcelain(await gitStatusPorcelain(cwd));
|
|
335
|
+
const all = new Set();
|
|
336
|
+
for (const e of entries) {
|
|
337
|
+
all.add(e.path);
|
|
338
|
+
if (typeof e.origPath === "string" && e.origPath.length > 0) {
|
|
339
|
+
all.add(e.origPath);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return [...all];
|
|
343
|
+
}
|
|
344
|
+
export function partitionPathsForAutoCommit(paths, opts = {}) {
|
|
345
|
+
const unmergedSet = new Set(opts.unmerged ?? []);
|
|
346
|
+
const committable = [];
|
|
347
|
+
const excluded = [];
|
|
348
|
+
const unmerged = [];
|
|
349
|
+
for (const p of paths) {
|
|
350
|
+
if (unmergedSet.has(p)) {
|
|
351
|
+
unmerged.push(p);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (isAutoCommitExcludedPath(p))
|
|
355
|
+
excluded.push(p);
|
|
356
|
+
else
|
|
357
|
+
committable.push(p);
|
|
358
|
+
}
|
|
359
|
+
return { committable, excluded, unmerged };
|
|
360
|
+
}
|
|
361
|
+
export async function gitAddPaths(cwd, paths) {
|
|
362
|
+
if (paths.length === 0)
|
|
363
|
+
return;
|
|
364
|
+
const chunkSize = 80;
|
|
365
|
+
for (let i = 0; i < paths.length; i += chunkSize) {
|
|
366
|
+
const slice = paths.slice(i, i + chunkSize);
|
|
367
|
+
await runGit(cwd, ["add", "--", ...slice]);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
188
370
|
/**
|
|
189
371
|
* When the index/worktree is dirty, classify whether every changed path is
|
|
190
|
-
* limited to governance docs
|
|
372
|
+
* limited to governance docs and VibeOps metadata (`.vibeops/**`, docs/tasks, etc.).
|
|
191
373
|
* Used by `vibeops task start` so `task done --finalize` on main does not
|
|
192
374
|
* block the next TASK unless application code is also dirty.
|
|
193
375
|
*/
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { pathExists, readTextOrNull, writeText } from "./filesystem.js";
|
|
4
|
+
export const GUIDE_STATE_SCHEMA_VERSION = 1;
|
|
5
|
+
export function guideStatePath(vibeopsDir) {
|
|
6
|
+
return join(vibeopsDir, "state", "guide.json");
|
|
7
|
+
}
|
|
8
|
+
export async function readGuideState(vibeopsDir) {
|
|
9
|
+
const path = guideStatePath(vibeopsDir);
|
|
10
|
+
const raw = await readTextOrNull(path);
|
|
11
|
+
if (raw === null || raw.trim().length === 0)
|
|
12
|
+
return null;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (parsed.schemaVersion !== GUIDE_STATE_SCHEMA_VERSION)
|
|
16
|
+
return null;
|
|
17
|
+
if (typeof parsed.stepId !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
return {
|
|
20
|
+
schemaVersion: GUIDE_STATE_SCHEMA_VERSION,
|
|
21
|
+
taskId: typeof parsed.taskId === "string" ? parsed.taskId : null,
|
|
22
|
+
stepId: parsed.stepId,
|
|
23
|
+
history: Array.isArray(parsed.history)
|
|
24
|
+
? parsed.history.filter((x) => typeof x === "string")
|
|
25
|
+
: [],
|
|
26
|
+
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function writeGuideState(vibeopsDir, state) {
|
|
34
|
+
const dir = join(vibeopsDir, "state");
|
|
35
|
+
if (!(await pathExists(dir))) {
|
|
36
|
+
await mkdir(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
await writeText(guideStatePath(vibeopsDir), `${JSON.stringify(state, null, 2)}\n`);
|
|
39
|
+
}
|
|
40
|
+
export function pushGuideHistory(prev, taskId, stepId) {
|
|
41
|
+
const history = [...(prev?.history ?? [])];
|
|
42
|
+
if (prev?.stepId && prev.stepId !== stepId) {
|
|
43
|
+
history.push(prev.stepId);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
schemaVersion: GUIDE_STATE_SCHEMA_VERSION,
|
|
47
|
+
taskId,
|
|
48
|
+
stepId,
|
|
49
|
+
history,
|
|
50
|
+
updatedAt: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function popGuideHistory(state) {
|
|
54
|
+
if (state.history.length === 0) {
|
|
55
|
+
return { next: null, stepId: null };
|
|
56
|
+
}
|
|
57
|
+
const history = [...state.history];
|
|
58
|
+
const stepId = history.pop();
|
|
59
|
+
return {
|
|
60
|
+
stepId,
|
|
61
|
+
next: {
|
|
62
|
+
schemaVersion: GUIDE_STATE_SCHEMA_VERSION,
|
|
63
|
+
taskId: state.taskId,
|
|
64
|
+
stepId,
|
|
65
|
+
history,
|
|
66
|
+
updatedAt: new Date().toISOString(),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { probeCodexOAuthFile } from "./plan-codex-auth.js";
|
|
2
|
+
import { codexOAuthTextCompletion } from "./plan-codex-responses.js";
|
|
3
|
+
import { cursorAgentPrint, extractAgentAssistantText } from "./plan-llm-cursor-agent.js";
|
|
4
|
+
import { probeCursorAgentCli, probeOpenAiApiKey } from "./plan-llm-detect.js";
|
|
5
|
+
import { extractJsonObject, openAiChatCompletionJson, openAiChatCompletionText, } from "./plan-llm-openai.js";
|
|
6
|
+
/** Non-interactive: Codex OAuth → Cursor Agent → OpenAI API key (same sources as `vibeops plan`). */
|
|
7
|
+
export async function resolveAvailableLlmProvider(cwd) {
|
|
8
|
+
const codex = await probeCodexOAuthFile();
|
|
9
|
+
if (codex.ok)
|
|
10
|
+
return "codex-oauth";
|
|
11
|
+
const cursor = await probeCursorAgentCli(cwd);
|
|
12
|
+
if (cursor.ok)
|
|
13
|
+
return "cursor-agent";
|
|
14
|
+
const openai = await probeOpenAiApiKey();
|
|
15
|
+
if (openai.ok)
|
|
16
|
+
return "openai";
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
function formatMessagesForCursor(messages) {
|
|
20
|
+
return messages
|
|
21
|
+
.map((m) => `### ${m.role}\n\n${m.content}`)
|
|
22
|
+
.join("\n\n---\n\n");
|
|
23
|
+
}
|
|
24
|
+
export async function llmCompleteText(messages, opts) {
|
|
25
|
+
const provider = opts.provider ?? (await resolveAvailableLlmProvider(opts.cwd));
|
|
26
|
+
if (provider === null) {
|
|
27
|
+
throw new Error("No LLM provider available. Use Codex OAuth (~/.codex/auth.json), Cursor Agent CLI (`agent login`), or OPENAI_API_KEY.");
|
|
28
|
+
}
|
|
29
|
+
if (provider === "openai") {
|
|
30
|
+
const text = await openAiChatCompletionText({ messages, model: opts.model });
|
|
31
|
+
return { text, provider };
|
|
32
|
+
}
|
|
33
|
+
if (provider === "codex-oauth") {
|
|
34
|
+
const text = await codexOAuthTextCompletion(messages, { model: opts.model });
|
|
35
|
+
return { text, provider };
|
|
36
|
+
}
|
|
37
|
+
const raw = await cursorAgentPrint({
|
|
38
|
+
cwd: opts.cwd,
|
|
39
|
+
prompt: formatMessagesForCursor(messages),
|
|
40
|
+
model: opts.model,
|
|
41
|
+
});
|
|
42
|
+
return { text: extractAgentAssistantText(raw), provider: "cursor-agent" };
|
|
43
|
+
}
|
|
44
|
+
export async function llmCompleteJson(messages, opts) {
|
|
45
|
+
const provider = opts.provider ?? (await resolveAvailableLlmProvider(opts.cwd));
|
|
46
|
+
if (provider === null) {
|
|
47
|
+
throw new Error("No LLM provider available. Use Codex OAuth (~/.codex/auth.json), Cursor Agent CLI (`agent login`), or OPENAI_API_KEY.");
|
|
48
|
+
}
|
|
49
|
+
if (provider === "openai") {
|
|
50
|
+
const text = await openAiChatCompletionJson({ messages, model: opts.model });
|
|
51
|
+
return { text, provider };
|
|
52
|
+
}
|
|
53
|
+
const jsonMessages = [
|
|
54
|
+
...messages,
|
|
55
|
+
{
|
|
56
|
+
role: "user",
|
|
57
|
+
content: "Reply with a single JSON object only. No markdown code fences or extra prose.",
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
const { text: raw } = await llmCompleteText(jsonMessages, { ...opts, provider });
|
|
61
|
+
return { text: extractJsonObject(raw), provider };
|
|
62
|
+
}
|