@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,283 @@
1
+ import { resolve } from "node:path";
2
+ import { ensureCursorImplementPrompt } from "../lib/cursor-task-prompt.js";
3
+ import { readText } from "../lib/filesystem.js";
4
+ import { doneCommand } from "./done.js";
5
+ import { notionSyncCommand } from "./notion-sync.js";
6
+ import { startCommand } from "./start.js";
7
+ import { readConfig } from "../lib/config.js";
8
+ import { pathExists } from "../lib/filesystem.js";
9
+ import { popGuideHistory, pushGuideHistory, readGuideState, writeGuideState, } from "../lib/guide-state.js";
10
+ import { askSelect, yesNoSelect } from "../lib/inquirer-helpers.js";
11
+ import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
12
+ import { projectPaths } from "../lib/paths.js";
13
+ import { statusDisplay } from "../lib/task.js";
14
+ import { excerptDoneSummary, LAST_DONE_SUMMARY_REL, readLastDoneSummary, } from "../lib/mvp-done-summary.js";
15
+ import { followUpStepId, guideStepRank, inferGuideStep, successorStepId, } from "../lib/workflow-guide.js";
16
+ const MENU_NEXT_RUN = "Next — run suggested step";
17
+ const MENU_NEXT_ADVANCE = "Next — check / advance step";
18
+ const MENU_PREV = "Previous step";
19
+ const MENU_REFRESH = "Refresh (re-detect from repo)";
20
+ const MENU_QUIT = "Quit";
21
+ async function renderPanel(ctx) {
22
+ const { step, task, gitBranch, gitDirty } = ctx;
23
+ log.blank();
24
+ log.info(bold("─".repeat(56)));
25
+ log.info(bold(" VibeOps · what's next"));
26
+ log.info(bold("─".repeat(56)));
27
+ if (task) {
28
+ log.info(` ${dim("TASK")} ${cyan(task.id)} — ${task.title || dim("(no title)")}`);
29
+ log.info(` ${dim("Status")} ${statusDisplay(task.status)}`);
30
+ if (gitBranch) {
31
+ log.info(` ${dim("Git")} ${gitBranch}${gitDirty === true ? yellow(" · dirty") : gitDirty === false ? green(" · clean") : ""}`);
32
+ }
33
+ }
34
+ else if (ctx.mode === "post-mvp" && ctx.referenceTask) {
35
+ log.info(` ${dim("TASK")} ${dim("(continue — last:")} ${cyan(ctx.referenceTask.id)} ${dim("done)")}`);
36
+ }
37
+ else if (ctx.mode === "post-mvp") {
38
+ log.info(` ${dim("TASK")} ${dim("(MVP complete — plan next slice)")}`);
39
+ }
40
+ else {
41
+ log.info(` ${dim("TASK")} ${dim("(none — run vibeops plan)")}`);
42
+ }
43
+ log.blank();
44
+ log.info(bold(` Step: ${step.title}`));
45
+ for (const line of step.youDo)
46
+ log.info(` ${dim("·")} ${line}`);
47
+ if (step.vibeopsCommand) {
48
+ log.blank();
49
+ log.info(` ${dim("Command")} ${cyan(step.vibeopsCommand)}`);
50
+ }
51
+ if (step.shellHints.length > 0) {
52
+ log.blank();
53
+ log.info(dim(" Shell:"));
54
+ for (const h of step.shellHints)
55
+ log.info(` ${dim("$")} ${h}`);
56
+ }
57
+ log.info(bold("─".repeat(56)));
58
+ if (ctx.task && (step.id === "implement" || step.id === "commit")) {
59
+ const body = await readText(ctx.task.filePath);
60
+ const cursorPrompt = await ensureCursorImplementPrompt(ctx.cwd, ctx.config, ctx.task, body);
61
+ if (cursorPrompt !== null) {
62
+ log.blank();
63
+ log.info(bold(" Cursor"));
64
+ const missing = [];
65
+ if (cursorPrompt.fillResult)
66
+ missing.push("Result");
67
+ if (cursorPrompt.fillTestResult)
68
+ missing.push("Test Result");
69
+ log.info(` ${dim("·")} ${missing.join(" / ")} not filled — start a chat and @-mention:`);
70
+ log.info(` ${cyan(cursorPrompt.rel)}`);
71
+ log.info(` ${dim("·")} Or open that file and send its contents as the first message.`);
72
+ }
73
+ }
74
+ const prior = await readLastDoneSummary(ctx.cwd);
75
+ if (prior !== null &&
76
+ (step.id === "no-mvp" ||
77
+ step.id === "all-done" ||
78
+ step.id === "continue-iteration" ||
79
+ task?.status === "done")) {
80
+ log.blank();
81
+ log.info(bold(" Previous iteration"));
82
+ for (const line of excerptDoneSummary(prior, 14).split("\n")) {
83
+ log.info(` ${dim(line)}`);
84
+ }
85
+ log.info(` ${dim("full")} ${cyan(LAST_DONE_SUMMARY_REL)}`);
86
+ }
87
+ log.blank();
88
+ }
89
+ async function runStep(ctx, step, dryRun, opts) {
90
+ if (dryRun && step.runnable) {
91
+ log.info(bold("dry-run — would run:"));
92
+ log.info(` ${cyan(step.vibeopsCommand ?? step.title)}`);
93
+ return { ok: true, advancedTo: step.id };
94
+ }
95
+ switch (step.id) {
96
+ case "start":
97
+ case "checkout-task": {
98
+ const ref = ctx.task?.id ?? "mvp";
99
+ await startCommand(ref, { cwd: ctx.cwd, dryRun: false });
100
+ return { ok: process.exitCode !== 1, advancedTo: "implement" };
101
+ }
102
+ case "finish": {
103
+ const ref = ctx.task?.id ?? "mvp";
104
+ await doneCommand(ref, {
105
+ cwd: ctx.cwd,
106
+ dryRun: false,
107
+ mergeViaPr: opts.mergeViaPr,
108
+ allowDirty: opts.allowDirty,
109
+ });
110
+ return {
111
+ ok: process.exitCode !== 1,
112
+ advancedTo: ctx.mode === "mvp" ? "all-done" : undefined,
113
+ };
114
+ }
115
+ case "notion-sync":
116
+ await notionSyncCommand({ cwd: ctx.cwd, dryRun: false });
117
+ return { ok: process.exitCode !== 1, advancedTo: "all-done" };
118
+ default:
119
+ log.info(dim("Nothing to execute for this step."));
120
+ return { ok: true };
121
+ }
122
+ }
123
+ export async function nextCommand(options = {}) {
124
+ const cwd = resolve(options.cwd ?? process.cwd());
125
+ const paths = projectPaths(cwd);
126
+ if (!(await pathExists(paths.config))) {
127
+ log.error(`Not a VibeOps project. Run ${cyan("vibeops init")} first.`);
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ const config = await readConfig(paths.root);
132
+ let guideState = await readGuideState(paths.vibeopsDir);
133
+ /** Set only after "Previous step" — one-shot forced step for display. */
134
+ let forceStepOnce;
135
+ const loadContext = async (forceStepId) => {
136
+ const inferred = await inferGuideStep({ cwd, paths, config });
137
+ const taskKey = inferred.task?.id ?? inferred.referenceTask?.id ?? null;
138
+ const saved = forceStepOnce ??
139
+ (guideState?.taskId === taskKey &&
140
+ guideState.stepId &&
141
+ guideStepRank(guideState.stepId) > guideStepRank(inferred.step.id)
142
+ ? guideState.stepId
143
+ : undefined);
144
+ if (saved !== undefined && saved !== inferred.step.id) {
145
+ return inferGuideStep({ cwd, paths, config, forceStepId: saved });
146
+ }
147
+ return inferred;
148
+ };
149
+ const syncGuideStep = async (ctx) => {
150
+ if (forceStepOnce !== undefined)
151
+ return;
152
+ const taskKey = ctx.task?.id ?? ctx.referenceTask?.id ?? null;
153
+ if (guideState?.taskId === taskKey && guideState.stepId) {
154
+ if (guideStepRank(guideState.stepId) > guideStepRank(ctx.step.id))
155
+ return;
156
+ }
157
+ if (guideState?.stepId === ctx.step.id && guideState?.taskId === taskKey)
158
+ return;
159
+ guideState = pushGuideHistory(guideState, taskKey, ctx.step.id);
160
+ await writeGuideState(paths.vibeopsDir, guideState);
161
+ };
162
+ if (options.nonInteractive === true) {
163
+ const ctx = await loadContext(undefined);
164
+ await renderPanel(ctx);
165
+ if (options.execute === true && ctx.step.runnable) {
166
+ await runStep(ctx, ctx.step, options.dryRun === true, options);
167
+ if (ctx.task) {
168
+ await writeGuideState(paths.vibeopsDir, pushGuideHistory(guideState, ctx.task.id, ctx.step.id));
169
+ }
170
+ }
171
+ return;
172
+ }
173
+ if (!process.stdin.isTTY) {
174
+ const ctx = await loadContext(guideState?.stepId);
175
+ await renderPanel(ctx);
176
+ log.info(dim("Re-run in a terminal for the interactive menu."));
177
+ return;
178
+ }
179
+ for (;;) {
180
+ const ctx = await loadContext(forceStepOnce);
181
+ forceStepOnce = undefined;
182
+ await syncGuideStep(ctx);
183
+ await renderPanel(ctx);
184
+ const menuNext = ctx.step.runnable ? MENU_NEXT_RUN : MENU_NEXT_ADVANCE;
185
+ const picked = await askSelect({
186
+ message: "Choose",
187
+ nonInteractive: false,
188
+ choices: [menuNext, MENU_PREV, MENU_REFRESH, MENU_QUIT],
189
+ default: menuNext,
190
+ });
191
+ if (picked === MENU_QUIT) {
192
+ log.info(dim("Resume with: vibeops next"));
193
+ return;
194
+ }
195
+ if (picked === MENU_REFRESH) {
196
+ guideState = null;
197
+ continue;
198
+ }
199
+ if (picked === MENU_PREV) {
200
+ if (!guideState?.history.length) {
201
+ log.warn("No previous step in .vibeops/state/guide.json");
202
+ continue;
203
+ }
204
+ const popped = popGuideHistory(guideState);
205
+ guideState = popped.next;
206
+ if (popped.stepId)
207
+ forceStepOnce = popped.stepId;
208
+ continue;
209
+ }
210
+ const step = ctx.step;
211
+ if (!step.runnable) {
212
+ const body = ctx.task ? await readText(ctx.task.filePath) : "";
213
+ const fresh = await loadContext(undefined);
214
+ if (fresh.task?.id !== ctx.task?.id && ctx.task) {
215
+ log.warn(`Still on ${ctx.gitBranch ?? "task branch"} for ${ctx.task.id}. Run ${cyan(`vibeops done ${ctx.task.id}`)} (commit, merge, Notion) before starting ${fresh.task?.id ?? "another TASK"}.`);
216
+ continue;
217
+ }
218
+ if (fresh.step.runnable && fresh.step.id !== step.id) {
219
+ log.ok(`Step updated: ${bold(fresh.step.title)}`);
220
+ const go = await yesNoSelect({
221
+ message: `Run: ${fresh.step.vibeopsCommand ?? fresh.step.title}?`,
222
+ defaultValue: true,
223
+ });
224
+ if (!go)
225
+ continue;
226
+ const result = await runStep(fresh, fresh.step, options.dryRun === true, options);
227
+ if (!result.ok) {
228
+ log.warn("Step failed — fix the issue, then Refresh.");
229
+ continue;
230
+ }
231
+ const nextId = result.advancedTo ?? followUpStepId(fresh.step.id);
232
+ if (nextId !== null && nextId !== undefined) {
233
+ guideState = pushGuideHistory(guideState, fresh.task?.id ?? fresh.referenceTask?.id ?? null, nextId);
234
+ await writeGuideState(paths.vibeopsDir, guideState);
235
+ }
236
+ else {
237
+ guideState = null;
238
+ }
239
+ guideState = await readGuideState(paths.vibeopsDir);
240
+ log.ok("Done.");
241
+ log.blank();
242
+ continue;
243
+ }
244
+ const succ = successorStepId(step.id, { body, gitDirty: ctx.gitDirty });
245
+ if (succ !== null && succ !== step.id) {
246
+ const go = await yesNoSelect({
247
+ message: `Move to next step (${succ})?`,
248
+ defaultValue: true,
249
+ });
250
+ if (!go)
251
+ continue;
252
+ guideState = pushGuideHistory(guideState, ctx.task?.id ?? null, succ);
253
+ await writeGuideState(paths.vibeopsDir, guideState);
254
+ log.ok(`Advanced to ${succ}.`);
255
+ log.blank();
256
+ continue;
257
+ }
258
+ log.info(dim("This step is manual (Cursor / git). Fill Result and Test Result, then choose Next again or Refresh."));
259
+ continue;
260
+ }
261
+ const go = await yesNoSelect({
262
+ message: `Run: ${step.vibeopsCommand ?? step.title}?`,
263
+ defaultValue: true,
264
+ });
265
+ if (!go)
266
+ continue;
267
+ const result = await runStep(ctx, step, options.dryRun === true, options);
268
+ if (!result.ok) {
269
+ log.warn("Step failed — fix the issue, then Refresh.");
270
+ continue;
271
+ }
272
+ const nextId = result.advancedTo ?? followUpStepId(step.id);
273
+ if (nextId !== null && nextId !== undefined) {
274
+ await writeGuideState(paths.vibeopsDir, pushGuideHistory(guideState, ctx.task?.id ?? ctx.referenceTask?.id ?? null, nextId));
275
+ }
276
+ else {
277
+ guideState = null;
278
+ }
279
+ guideState = await readGuideState(paths.vibeopsDir);
280
+ log.ok(`Done. Refresh or Next for the following step.`);
281
+ log.blank();
282
+ }
283
+ }
@@ -1,16 +1,14 @@
1
1
  import { isAbsolute, join, relative, resolve } from "node:path";
2
2
  import { briefToMarkdown, findMissingRequired, gatherBrief, parseBriefFromMarkdown, parseIdea, } from "../lib/brief.js";
3
- import { askYesNo } from "../lib/inquirer-helpers.js";
4
3
  import { pathExists, readText, writeText } from "../lib/filesystem.js";
5
4
  import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
6
- import { applyPlannerMarkdownFlow, commitPlannerGit, pushPlannerGit } from "../lib/plan-apply-planner.js";
7
5
  import { gatherBriefViaLlm, pickPlanDialogueLocale, pickPlanLlmProvider } from "../lib/plan-llm-session.js";
8
6
  import { pickPlanExecutionModel } from "../lib/plan-llm-models.js";
9
- import { ensureGitForPlannerCommit, offerGithubInitBeforePush, runNotionSyncWithOptionalInit } from "../lib/plan-post-apply-setup.js";
10
- import { buildPlanPrompt } from "../lib/prompt-builder.js";
7
+ import { writeMvpArtifacts } from "../lib/mvp-artifacts.js";
8
+ import { LAST_DONE_SUMMARY_REL, readLastDoneSummary } from "../lib/mvp-done-summary.js";
9
+ import { MVP_BUILD_PROMPT_REL } from "../lib/mvp-constants.js";
11
10
  import { projectPaths } from "../lib/paths.js";
12
11
  const DEFAULT_BRIEF_REL = ".vibeops/brief/project-brief.md";
13
- const DEFAULT_PROMPT_REL = ".vibeops/generated/plan-prompt.md";
14
12
  function toAbsolute(root, candidate) {
15
13
  return isAbsolute(candidate) ? candidate : resolve(root, candidate);
16
14
  }
@@ -18,7 +16,7 @@ function relDisplay(root, abs) {
18
16
  const r = relative(root, abs);
19
17
  return r.length === 0 ? "." : r;
20
18
  }
21
- export async function planCommand(options) {
19
+ export async function planCommand(options = {}) {
22
20
  const cwd = resolve(options.cwd ?? process.cwd());
23
21
  const nonInteractive = options.nonInteractive === true;
24
22
  const paths = projectPaths(cwd);
@@ -29,7 +27,6 @@ export async function planCommand(options) {
29
27
  return;
30
28
  }
31
29
  const briefAbs = join(cwd, DEFAULT_BRIEF_REL);
32
- const promptAbs = typeof options.output === "string" ? toAbsolute(cwd, options.output) : join(cwd, DEFAULT_PROMPT_REL);
33
30
  log.info(bold(`vibeops plan`));
34
31
  log.info(dim(` cwd: ${cwd}`));
35
32
  if (options.from)
@@ -43,8 +40,6 @@ export async function planCommand(options) {
43
40
  log.info(dim(` mode: non-interactive`));
44
41
  log.blank();
45
42
  let bundle;
46
- let llmProviderForApply;
47
- let llmModelForApply;
48
43
  if (typeof options.from === "string" && options.from.length > 0) {
49
44
  bundle = await loadFromFile({
50
45
  cwd,
@@ -53,255 +48,69 @@ export async function planCommand(options) {
53
48
  idea: options.idea,
54
49
  });
55
50
  }
51
+ else if (nonInteractive) {
52
+ log.step("non-interactive: build ProjectBrief from flags + placeholders");
53
+ log.blank();
54
+ bundle = await gatherBrief({ cwd, idea: options.idea, nonInteractive: true });
55
+ }
56
56
  else {
57
- if (!nonInteractive && process.stdin.isTTY !== true) {
58
- log.error("vibeops plan requires a TTY. In CI/piped environments, pass --non-interactive or supply --from <brief.md>.");
57
+ if (process.stdin.isTTY !== true) {
58
+ log.error("vibeops plan requires a TTY. In CI, pass --non-interactive or --from <brief.md>.");
59
59
  process.exitCode = 1;
60
60
  return;
61
61
  }
62
- if (nonInteractive) {
63
- log.step("non-interactive: build the ProjectBrief from flag values + safe placeholders");
64
- log.blank();
65
- bundle = await gatherBrief({
66
- cwd,
67
- idea: options.idea,
68
- nonInteractive: true,
69
- });
70
- }
71
- else if (options.legacyWizard === true) {
72
- log.step("legacy wizard: build the ProjectBrief from 20 fixed questions (arrow keys · Space · Enter)");
73
- log.blank();
74
- bundle = await gatherBrief({
75
- cwd,
76
- idea: options.idea,
77
- nonInteractive: false,
78
- legacyWizard: true,
79
- });
80
- }
81
- else {
82
- log.step("LLM planning: verify provider, then interactive discovery + structured brief");
83
- log.blank();
84
- const interactiveLogin = process.stdin.isTTY === true;
85
- const provider = await pickPlanLlmProvider(cwd, options.provider, { interactiveLogin });
86
- if (provider === null) {
87
- return;
88
- }
89
- const forcedModel = typeof options.llmModel === "string" && options.llmModel.trim().length > 0
90
- ? options.llmModel.trim()
91
- : undefined;
92
- const pickedModel = forcedModel ??
93
- (await pickPlanExecutionModel({
94
- provider,
95
- cwd,
96
- interactive: interactiveLogin,
97
- }));
98
- log.blank();
99
- const planningLocale = await pickPlanDialogueLocale();
100
- try {
101
- bundle = await gatherBriefViaLlm({
102
- cwd,
103
- idea: options.idea,
104
- provider,
105
- allowCodexBrowserLogin: interactiveLogin,
106
- llmModelOverride: pickedModel,
107
- planningLocale,
108
- });
109
- llmProviderForApply = provider;
110
- llmModelForApply =
111
- typeof pickedModel === "string" && pickedModel.trim().length > 0 ? pickedModel.trim() : undefined;
112
- }
113
- catch (e) {
114
- const msg = e instanceof Error ? e.message : String(e);
115
- log.error(`LLM planning failed: ${msg}`);
116
- process.exitCode = 1;
117
- return;
118
- }
119
- }
120
- }
121
- const briefMd = briefToMarkdown(bundle.brief, bundle.meta);
122
- await writeText(briefAbs, briefMd);
123
- log.ok(`Wrote brief: ${relDisplay(cwd, briefAbs)}`);
124
- const promptMd = buildPlanPrompt({
125
- brief: bundle.brief,
126
- meta: bundle.meta,
127
- briefRelativePath: relDisplay(cwd, briefAbs),
128
- });
129
- await writeText(promptAbs, promptMd);
130
- log.ok(`Cursor planning prompt: ${relDisplay(cwd, promptAbs)}`);
131
- if (bundle.meta.assumptions.length > 0) {
62
+ log.step("LLM planning: interactive discovery → brief + MVP artifacts");
132
63
  log.blank();
133
- log.info(`${yellow("!")} ${bold("Assumptions")} (items the Planner Agent should reconfirm):`);
134
- for (const a of bundle.meta.assumptions)
135
- log.info(` · ${a}`);
136
- }
137
- const wantsApply = options.applyPlanner === true || options.applyPlannerDryRun === true;
138
- if (wantsApply) {
139
- const applyDryRun = options.applyPlannerDryRun === true;
140
- const exec = await resolvePlannerApplyExecution({
141
- cwd,
142
- nonInteractive,
143
- fromLlmProvider: llmProviderForApply,
144
- fromLlmModel: llmModelForApply,
145
- cliProvider: options.provider,
146
- cliModel: options.llmModel,
147
- });
148
- if (exec === null) {
64
+ const interactiveLogin = true;
65
+ const provider = await pickPlanLlmProvider(cwd, options.provider, { interactiveLogin });
66
+ if (provider === null)
149
67
  return;
150
- }
68
+ const forcedModel = typeof options.llmModel === "string" && options.llmModel.trim().length > 0
69
+ ? options.llmModel.trim()
70
+ : undefined;
71
+ const pickedModel = forcedModel ??
72
+ (await pickPlanExecutionModel({ provider, cwd, interactive: interactiveLogin }));
73
+ log.blank();
74
+ const planningLocale = await pickPlanDialogueLocale();
151
75
  try {
152
- const applyResult = await applyPlannerMarkdownFlow({
76
+ bundle = await gatherBriefViaLlm({
153
77
  cwd,
154
- planPromptMarkdown: promptMd,
155
- provider: exec.provider,
156
- model: exec.model,
157
- dryRun: applyDryRun,
78
+ idea: options.idea,
79
+ provider,
80
+ allowCodexBrowserLogin: interactiveLogin,
81
+ llmModelOverride: pickedModel,
82
+ planningLocale,
158
83
  });
159
- const applyInteractive = !nonInteractive && process.stdin.isTTY === true && !applyDryRun;
160
- const hasWrittenFiles = !applyDryRun && applyResult.writtenRelativePaths.length > 0;
161
- let doCommit = hasWrittenFiles && options.noGitCommit !== true;
162
- if (doCommit && applyInteractive) {
163
- log.blank();
164
- log.step("Next: commit, push, and Notion (confirm each step)");
165
- doCommit = await askYesNo({
166
- message: "[1] Create a git commit for the planner output (docs/project + docs/tasks)?",
167
- nonInteractive: false,
168
- defaultValue: true,
169
- });
170
- }
171
- let committedOk = false;
172
- if (doCommit) {
173
- const gitReady = await ensureGitForPlannerCommit({
174
- cwd,
175
- interactive: applyInteractive,
176
- });
177
- if (!gitReady) {
178
- doCommit = false;
179
- }
180
- }
181
- if (doCommit) {
182
- try {
183
- await commitPlannerGit(cwd, applyResult.writtenRelativePaths, "docs: planner output from vibeops plan (--apply-planner)");
184
- log.ok("Git commit created for planner docs/tasks.");
185
- committedOk = true;
186
- }
187
- catch (e) {
188
- const msg = e instanceof Error ? e.message : String(e);
189
- log.warn(`Git commit did not run (${msg}). Review changes and commit manually.`);
190
- }
191
- }
192
- let doPush = false;
193
- if (committedOk) {
194
- if (options.gitPush === true) {
195
- doPush = true;
196
- }
197
- else if (applyInteractive) {
198
- doPush = await askYesNo({
199
- message: "[2] Push this commit to the remote (git push)? (If Yes, you may be offered `vibeops github init` to connect or create a GitHub repo / set `origin`.)",
200
- nonInteractive: false,
201
- defaultValue: false,
202
- });
203
- }
204
- if (doPush) {
205
- await offerGithubInitBeforePush({ cwd, interactive: applyInteractive });
206
- try {
207
- await pushPlannerGit(cwd);
208
- log.ok("git push complete.");
209
- }
210
- catch (e) {
211
- const msg = e instanceof Error ? e.message : String(e);
212
- log.error(`git push failed: ${msg}`);
213
- process.exitCode = 1;
214
- }
215
- }
216
- }
217
- else if (options.gitPush === true && !committedOk) {
218
- log.warn("`--push` skipped (no successful commit: dry-run, --no-git-commit, you declined the commit prompt, commit error, or no files written).");
219
- }
220
- let doNotion = bundle.brief.useNotion === true && options.noNotionSync !== true && !applyDryRun;
221
- if (doNotion && applyInteractive) {
222
- doNotion = await askYesNo({
223
- message: "[3] Run Notion sync now (project + TASK metadata)?",
224
- nonInteractive: false,
225
- defaultValue: true,
226
- });
227
- }
228
- if (doNotion) {
229
- const hadErrBeforeNotion = process.exitCode === 1;
230
- await runNotionSyncWithOptionalInit({ cwd, interactive: applyInteractive });
231
- if (hadErrBeforeNotion) {
232
- process.exitCode = 1;
233
- }
234
- if (process.exitCode === 1 && !hadErrBeforeNotion) {
235
- log.warn("Notion sync failed — fix integration and run `vibeops notion sync`.");
236
- }
237
- }
238
- else if (bundle.brief.useNotion === true && applyDryRun) {
239
- log.info(dim("Notion sync skipped for --apply-dry-run."));
240
- }
241
- else if (bundle.brief.useNotion === true && options.noNotionSync === true) {
242
- log.info(dim("Notion sync skipped (--no-notion-sync)."));
243
- }
244
- else if (bundle.brief.useNotion === true && applyInteractive && !doNotion) {
245
- log.info(dim("Notion sync skipped (you chose No)."));
246
- }
247
84
  }
248
85
  catch (e) {
249
86
  const msg = e instanceof Error ? e.message : String(e);
250
- log.error(`Planner apply failed: ${msg}`);
87
+ log.error(`LLM planning failed: ${msg}`);
251
88
  process.exitCode = 1;
252
89
  return;
253
90
  }
254
91
  }
255
- log.blank();
256
- log.info(bold("Next steps:"));
257
- if (wantsApply && options.applyPlannerDryRun !== true) {
258
- log.info(` 1) Open this repo in Cursor and run ${cyan("vibeops task start TASK-NNN")} for each backlog item.`);
259
- log.info(` 2) The planning prompt is still at ${cyan(relDisplay(cwd, promptAbs))} if you need to re-run the planner.`);
260
- log.info(` 3) To revise the brief only, edit ${cyan(relDisplay(cwd, briefAbs))} then:`);
261
- log.info(` ${dim("vibeops plan --from " + DEFAULT_BRIEF_REL + " [--apply-planner …]")}`);
262
- }
263
- else if (wantsApply && options.applyPlannerDryRun === true) {
264
- log.info(` 1) Re-run without ${cyan("--apply-dry-run")} to write files, commit, and (if enabled) Notion sync.`);
265
- log.info(` 2) Or paste ${cyan(relDisplay(cwd, promptAbs))} into Cursor for a manual planner pass.`);
266
- }
267
- else {
268
- log.info(` 1) Open a new Cursor chat and paste the full contents of ${cyan(relDisplay(cwd, promptAbs))}.`);
269
- log.info(` 2) Or run again with ${cyan("--apply-planner")}: the CLI calls the same LLM and writes ${cyan("docs/project/*")} + ${cyan("docs/tasks/*")} from the prompt (brief alone does not create TASK files).`);
270
- log.info(` 3) Regenerate only the prompt from an edited brief:`);
271
- log.info(` ${dim("vibeops plan --from " + DEFAULT_BRIEF_REL + " [--apply-planner …]")}`);
272
- }
273
- }
274
- async function resolvePlannerApplyExecution(inputs) {
275
- if (inputs.fromLlmProvider !== undefined) {
276
- return { provider: inputs.fromLlmProvider, model: inputs.fromLlmModel };
277
- }
278
- const tty = process.stdin.isTTY === true;
279
- if (inputs.cliProvider !== undefined) {
280
- const interactiveLogin = tty && !inputs.nonInteractive;
281
- const provider = await pickPlanLlmProvider(inputs.cwd, inputs.cliProvider, { interactiveLogin });
282
- if (provider === null)
283
- return null;
284
- let model = inputs.cliModel?.trim();
285
- if (!model && tty && !inputs.nonInteractive) {
286
- const picked = await pickPlanExecutionModel({
287
- provider,
288
- cwd: inputs.cwd,
289
- interactive: true,
290
- });
291
- model = picked?.trim();
292
- }
293
- return { provider, model: model && model.length > 0 ? model : undefined };
92
+ await writeText(briefAbs, briefToMarkdown(bundle.brief, bundle.meta));
93
+ log.ok(`Wrote brief: ${relDisplay(cwd, briefAbs)}`);
94
+ const prior = await readLastDoneSummary(cwd);
95
+ if (prior !== null) {
96
+ log.info(dim(` prior iteration: ${LAST_DONE_SUMMARY_REL} → included in TASK + mvp-build.md`));
294
97
  }
295
- if (!tty || inputs.nonInteractive) {
296
- log.error("When using --apply-planner without an interactive LLM briefing in the same run, pass --provider openai | codex-oauth | cursor-agent (and optional --model / env defaults).");
297
- process.exitCode = 1;
298
- return null;
98
+ const mvp = await writeMvpArtifacts(cwd, bundle);
99
+ log.ok(`MVP TASK: ${relDisplay(cwd, mvp.taskFile)}`);
100
+ log.ok(`MVP build prompt: ${relDisplay(cwd, mvp.buildPromptFile)}`);
101
+ if (bundle.meta.assumptions.length > 0) {
102
+ log.blank();
103
+ log.info(`${yellow("!")} ${bold("Assumptions")} (reconfirm in Cursor if needed):`);
104
+ for (const a of bundle.meta.assumptions)
105
+ log.info(` · ${a}`);
299
106
  }
300
- const provider = await pickPlanLlmProvider(inputs.cwd, undefined, { interactiveLogin: true });
301
- if (provider === null)
302
- return null;
303
- const model = await pickPlanExecutionModel({ provider, cwd: inputs.cwd, interactive: true });
304
- return { provider, model };
107
+ log.blank();
108
+ log.info(bold("Next steps:"));
109
+ log.info(` 1) ${cyan("vibeops start")}`);
110
+ log.info(` 2) Drag ${cyan(MVP_BUILD_PROMPT_REL)} into a new Cursor chat`);
111
+ log.info(` 3) ${cyan("vibeops done")} when Result / Test Result are filled`);
112
+ log.info(` 4) ${cyan("vibeops next")} for step-by-step guidance`);
113
+ log.info(dim(`Re-plan from brief: vibeops plan --from ${DEFAULT_BRIEF_REL}`));
305
114
  }
306
115
  async function loadFromFile(inputs) {
307
116
  const { cwd, fromPath, nonInteractive, idea } = inputs;
@@ -323,21 +132,15 @@ async function loadFromFile(inputs) {
323
132
  }
324
133
  }
325
134
  const missing = findMissingRequired(parsed.brief);
326
- if (missing.length === 0) {
135
+ if (missing.length === 0)
327
136
  return parsed;
328
- }
329
137
  if (nonInteractive) {
330
- log.warn(`Missing required fields: ${missing.join(", ")} → filling with placeholders and recording them in Assumptions.`);
138
+ log.warn(`Missing fields: ${missing.join(", ")} — placeholders recorded in Assumptions.`);
331
139
  return parsed;
332
140
  }
333
- log.warn(`Missing required fields: ${missing.join(", ")} → asking only the missing questions.`);
141
+ log.warn(`Missing fields: ${missing.join(", ")} — asking only those questions.`);
334
142
  log.blank();
335
- const filled = await gatherBrief({
336
- cwd,
337
- idea,
338
- nonInteractive: false,
339
- seed: parsed.brief,
340
- });
143
+ const filled = await gatherBrief({ cwd, idea, nonInteractive: false, seed: parsed.brief });
341
144
  filled.meta.source = "from-file";
342
145
  return filled;
343
146
  }