@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.
@@ -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, { cwd });
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 runGit(cwd, ["checkout", ref]);
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 args = ["checkout", "-b", name];
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 `task done` / doc updates. */
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/state/",
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 (TASK files, project docs, logs, task state JSON).
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
+ }