@goodtek/vibeops 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,681 @@
1
+ import { basename } from "node:path";
2
+ import { probeCodexOAuthFile } from "./plan-codex-auth.js";
3
+ import { runCodexPkceOAuthLogin } from "./plan-codex-pkce.js";
4
+ import { codexOAuthPlanCompletion } from "./plan-codex-responses.js";
5
+ import { PROJECT_BRIEF_SCHEMA_VERSION } from "../types/brief.js";
6
+ import { askCheckbox, askInput, askSelect, OTHER_LABEL, yesNoSelect } from "./inquirer-helpers.js";
7
+ import { bold, cyan, dim, log } from "./logger.js";
8
+ import { cursorAgentPrint, extractAgentAssistantText } from "./plan-llm-cursor-agent.js";
9
+ import { probeCursorAgentCli, probeOpenAiApiKey } from "./plan-llm-detect.js";
10
+ import { extractJsonObject, openAiChatCompletionJson, parsePlanLlmTurn } from "./plan-llm-openai.js";
11
+ import { normalizeLlmProjectBrief } from "./plan-llm-normalize.js";
12
+ import { VERSION } from "../version.js";
13
+ const MAX_LLM_TURNS = 48;
14
+ const PLAN_JSON_PROTOCOL_CORE = `You are VibeOps' planning assistant. Learn about a software project through a short dialogue, infer the next best single question from the user's last answer, then produce a structured ProjectBrief.
15
+
16
+ You MUST reply with a single JSON object only (no markdown fences). Allowed "turn" values: "question", "confirm", "done".
17
+
18
+ 1) While discovering:
19
+ {"turn":"question","message":"<markdown allowed>","questionType":"single"|"multi"|"text","options":["..."]}
20
+ - **One primary ask per turn** (see conversation rules below).
21
+ - For "single" or "multi", include "options" (3–12 short labels) as **suggested answers only**. The terminal may show a picker (arrow keys / Space) but the user can always choose Other or type a custom reply.
22
+ - For "text", omit "options" or use an empty array.
23
+
24
+ 2) When information is enough for a solid ProjectBrief and task breakdown, **before** "done", emit exactly one approval step:
25
+ {"turn":"confirm","readableSummary":"<markdown>","plannerNote":"optional string"}
26
+ - readableSummary must be easy to scan (short headings or bullets): working title, one-line idea, primary users, MVP slice, key constraints or stack if known, main risks or unknowns. Use the planning language from the session rules.
27
+ - You may use earlier "question" turns for lightweight check-ins, but you **must** still emit "confirm" with a consolidated summary before the user can finalize.
28
+ - Never put "done" in the same JSON object as "confirm".
29
+
30
+ 3) Final structured brief — **only** after the user has confirmed in the terminal (you will receive a user message starting with "[VibeOps session] User CONFIRMED"):
31
+ {"turn":"done","projectBrief":{ ... },"plannerAssumptions":["optional strings"]}
32
+ - Until you receive that exact confirmation tag, you **must not** output "turn":"done". If you are not ready for approval yet, keep using "question". If ready for approval, use "confirm" first.
33
+
34
+ The final "projectBrief" MUST include every key below (best effort; prefer "Not sure" options over empty arrays when using predefined lists):
35
+
36
+ projectName (string), oneLineIdea (string), projectType (string), targetUsers (string[]), coreProblem (string),
37
+ mvpFeatures (string[]), outOfScope (string[]), frontend (string), backend (string), database (string), dbLayer (string),
38
+ packageManager (string), deploymentTargets (string[]), authRequirements (string[]), integrations (string[]),
39
+ useNotion (boolean), useGitWorkflow (boolean), agentWorkflowLevel (string), risks (string[]), successCriteria (string)
40
+
41
+ Conversation rules:
42
+ - **One step per reply:** one JSON "question" object → one user answer. No batched "1) … 2) …" lists or unrelated combined asks.
43
+ - **Infer the next question** from the latest user answer and the thread; stay conversational (calm "20 questions" pacing).
44
+ - Start from product intent (what / who / why) before deep stack; do not open with database unless the user already did or product context is clear.
45
+ - If the user is vague, offer gentle reframes or tiny examples in "message" (do not invent facts about their project).
46
+
47
+ - If a user message starts with "[VibeOps session] User chose to STOP further discovery", output **only** {"turn":"confirm","readableSummary":"..."} (no more "question" turns) using what the thread already contains; state gaps honestly.
48
+
49
+ After a "confirm" turn, if the user message says they did **not** confirm, continue with "question" turns (or another "confirm" when ready). Do not output "done" until you receive the CONFIRMED tag.`;
50
+ function buildPlanSystemPrompt(localeInstruction) {
51
+ return `${PLAN_JSON_PROTOCOL_CORE}
52
+
53
+ Planning language (user-chose in the terminal before this session):
54
+ ${localeInstruction}
55
+
56
+ Follow that language for all natural-language content inside your JSON ("message", each "options" label, "readableSummary", "plannerNote", "plannerAssumptions"). JSON keys remain in English.`;
57
+ }
58
+ const PLAN_DIALOGUE_LOCALE_PRESETS = [
59
+ {
60
+ label: "English",
61
+ tag: "en",
62
+ instruction: "Use English for every user-visible string inside JSON outputs (message, options, readableSummary, plannerNote, plannerAssumptions).",
63
+ },
64
+ {
65
+ label: "Korean (한국어)",
66
+ tag: "ko",
67
+ instruction: "Use Korean for every user-visible string inside JSON outputs (message, options, readableSummary, plannerNote, plannerAssumptions).",
68
+ },
69
+ {
70
+ label: "Japanese (日本語)",
71
+ tag: "ja",
72
+ instruction: "Use Japanese for every user-visible string inside JSON outputs (message, options, readableSummary, plannerNote, plannerAssumptions).",
73
+ },
74
+ {
75
+ label: "Spanish (Español)",
76
+ tag: "es",
77
+ instruction: "Use Spanish for every user-visible string inside JSON outputs (message, options, readableSummary, plannerNote, plannerAssumptions).",
78
+ },
79
+ {
80
+ label: "Chinese (中文)",
81
+ tag: "zh",
82
+ instruction: "Use Chinese for every user-visible string inside JSON outputs (message, options, readableSummary, plannerNote, plannerAssumptions).",
83
+ },
84
+ ];
85
+ /**
86
+ * First interactive step for LLM plan: pick dialogue language (arrow keys + Other).
87
+ */
88
+ export async function pickPlanDialogueLocale() {
89
+ const labels = [...PLAN_DIALOGUE_LOCALE_PRESETS.map((p) => p.label), OTHER_LABEL];
90
+ const picked = await askSelect({
91
+ message: "Choose planning dialogue language (↑/↓ · Enter)",
92
+ nonInteractive: false,
93
+ choices: labels,
94
+ default: PLAN_DIALOGUE_LOCALE_PRESETS[0].label,
95
+ });
96
+ if (picked === OTHER_LABEL) {
97
+ const raw = await askInput({
98
+ message: 'Language for planning (e.g. "French", "pt-BR", "Deutsch")',
99
+ nonInteractive: false,
100
+ required: true,
101
+ });
102
+ const name = raw.trim();
103
+ return {
104
+ tag: "custom",
105
+ instruction: `Use ${name} for every user-visible string inside JSON outputs (message, options, readableSummary, plannerNote, plannerAssumptions).`,
106
+ };
107
+ }
108
+ const hit = PLAN_DIALOGUE_LOCALE_PRESETS.find((p) => p.label === picked);
109
+ if (hit)
110
+ return { tag: hit.tag, instruction: hit.instruction };
111
+ return { tag: PLAN_DIALOGUE_LOCALE_PRESETS[0].tag, instruction: PLAN_DIALOGUE_LOCALE_PRESETS[0].instruction };
112
+ }
113
+ /** Last select/checkbox row — not sent to the LLM as the user's answer. */
114
+ const PLAN_GO_BACK_LABEL = "« Go back one step »";
115
+ /** User stops Q&A and requests draft summary from current thread. */
116
+ const PLAN_WRAP_UP_LABEL = "« Wrap up — draft summary now »";
117
+ const PLAN_ASSUMED_MAX_QUESTIONS = 14;
118
+ const PLAN_PROGRESS_BAR_WIDTH = 18;
119
+ function planningProgressLine(displayStep1Based) {
120
+ const denom = PLAN_ASSUMED_MAX_QUESTIONS;
121
+ const p = Math.min(1, displayStep1Based / denom);
122
+ const filled = Math.round(p * PLAN_PROGRESS_BAR_WIDTH);
123
+ const bar = "█".repeat(filled) + "░".repeat(PLAN_PROGRESS_BAR_WIDTH - filled);
124
+ const remaining = Math.max(0, denom - displayStep1Based);
125
+ const etaMin = Math.max(1, Math.ceil(remaining * 0.45));
126
+ return `[${bar}] Q${displayStep1Based}/${denom}+ · ~${etaMin} min left (rough guess)`;
127
+ }
128
+ function withOtherOption(options) {
129
+ const list = [...options];
130
+ if (!list.some((o) => o === OTHER_LABEL))
131
+ list.push(OTHER_LABEL);
132
+ return list;
133
+ }
134
+ function transcriptFromCursorParts(parts) {
135
+ return parts.map((p) => `${p.role}: ${p.content}`).join("\n") + "\n";
136
+ }
137
+ /** Pops pending assistant + last user reply (one planning step back). OpenAI array includes system at index 0. */
138
+ function planningGoBackOpenAi(messages) {
139
+ if (messages.length < 4)
140
+ return false;
141
+ if (messages.at(-1)?.role !== "assistant")
142
+ return false;
143
+ messages.pop();
144
+ if (messages.at(-1)?.role !== "user")
145
+ return false;
146
+ messages.pop();
147
+ return true;
148
+ }
149
+ function planningGoBackCursor(parts) {
150
+ if (parts.length < 3)
151
+ return false;
152
+ if (parts.at(-1)?.role !== "assistant")
153
+ return false;
154
+ parts.pop();
155
+ if (parts.at(-1)?.role !== "user")
156
+ return false;
157
+ parts.pop();
158
+ return true;
159
+ }
160
+ /** User message prefix — must stay in sync with PLAN_JSON_PROTOCOL_CORE. */
161
+ const PLAN_SESSION_WRAP_UP_USER = "[VibeOps session] User chose to STOP further discovery questions and move on with what is known so far. Output exactly one JSON object: {\"turn\":\"confirm\",\"readableSummary\":\"...\"} only (no \"question\"). readableSummary must reflect the full conversation to date; explicitly note gaps or unknowns in the summary or plannerNote. Use the planning language rules.";
162
+ function logQuestionHeader(q, displayStep1Based) {
163
+ log.blank();
164
+ log.info(dim(planningProgressLine(displayStep1Based)));
165
+ log.info(bold(q.message));
166
+ log.blank();
167
+ }
168
+ async function collectAnswerBody(q, canGoBack, canWrapUp) {
169
+ const rawOpts = q.options ?? [];
170
+ if (q.questionType === "text" || rawOpts.length === 0) {
171
+ if (canGoBack) {
172
+ log.info(dim('To go back one step, type exactly: back (then Enter).'));
173
+ }
174
+ if (canWrapUp) {
175
+ log.info(dim("To stop questions and draft a summary now, type: wrap (or enough)."));
176
+ }
177
+ const t = await askInput({ message: "Your answer", nonInteractive: false });
178
+ const w = t.trim().toLowerCase();
179
+ if (canGoBack && w === "back")
180
+ return { kind: "goBack" };
181
+ if (canWrapUp && (w === "wrap" || w === "enough"))
182
+ return { kind: "wrapUp" };
183
+ return { kind: "answer", text: t };
184
+ }
185
+ if (q.questionType === "multi") {
186
+ log.info(dim("Suggested answers — Space toggles; Other for custom text."));
187
+ const choices = [...withOtherOption([...rawOpts])];
188
+ if (canWrapUp)
189
+ choices.push(PLAN_WRAP_UP_LABEL);
190
+ if (canGoBack)
191
+ choices.push(PLAN_GO_BACK_LABEL);
192
+ const picked = await askCheckbox({
193
+ message: "Choose (↑/↓ · Space · Enter)",
194
+ nonInteractive: false,
195
+ choices,
196
+ default: [],
197
+ });
198
+ if (picked.some((p) => p === PLAN_WRAP_UP_LABEL))
199
+ return { kind: "wrapUp" };
200
+ if (picked.some((p) => p === PLAN_GO_BACK_LABEL))
201
+ return { kind: "goBack" };
202
+ if (picked.length > 0)
203
+ return { kind: "answer", text: picked.join("; ") };
204
+ const free = await askInput({
205
+ message: "Nothing selected — type your answer",
206
+ nonInteractive: false,
207
+ });
208
+ return { kind: "answer", text: free };
209
+ }
210
+ const choices = [...rawOpts, OTHER_LABEL];
211
+ if (canWrapUp)
212
+ choices.push(PLAN_WRAP_UP_LABEL);
213
+ if (canGoBack)
214
+ choices.push(PLAN_GO_BACK_LABEL);
215
+ const choice = await askSelect({
216
+ message: "Choose (↑/↓ · Enter), Other, wrap-up, or go back",
217
+ nonInteractive: false,
218
+ choices,
219
+ default: rawOpts[0],
220
+ });
221
+ if (choice === PLAN_WRAP_UP_LABEL)
222
+ return { kind: "wrapUp" };
223
+ if (choice === PLAN_GO_BACK_LABEL)
224
+ return { kind: "goBack" };
225
+ if (choice === OTHER_LABEL) {
226
+ const free = await askInput({ message: "Your answer (free text)", nonInteractive: false });
227
+ return { kind: "answer", text: free };
228
+ }
229
+ return { kind: "answer", text: choice };
230
+ }
231
+ async function runQuestionWithOptionalBack(params) {
232
+ const { parsed, chatMode, openAiMessages, cursorParts } = params;
233
+ const canGoBackNow = () => chatMode
234
+ ? openAiMessages.length >= 4 && openAiMessages.at(-1)?.role === "assistant"
235
+ : cursorParts.length >= 3 && cursorParts.at(-1)?.role === "assistant";
236
+ const canWrapNow = () => chatMode
237
+ ? openAiMessages.length >= 3 && openAiMessages.at(-1)?.role === "assistant"
238
+ : cursorParts.length >= 2 && cursorParts.at(-1)?.role === "assistant";
239
+ for (;;) {
240
+ logQuestionHeader(parsed, params.displayStep1Based);
241
+ const out = await collectAnswerBody(parsed, canGoBackNow(), canWrapNow());
242
+ if (out.kind === "goBack") {
243
+ const ok = chatMode ? planningGoBackOpenAi(openAiMessages) : planningGoBackCursor(cursorParts);
244
+ if (!ok)
245
+ log.warn("Nothing further to go back to.");
246
+ else {
247
+ params.onBacked();
248
+ log.skip("Went back one step — re-answer when ready.");
249
+ }
250
+ continue;
251
+ }
252
+ if (out.kind === "wrapUp")
253
+ return { kind: "wrapUp" };
254
+ return { kind: "answer", text: out.text };
255
+ }
256
+ }
257
+ function buildUserPayload(params) {
258
+ const lines = [];
259
+ if (params.planningLocale) {
260
+ lines.push(`Planning dialogue language tag: ${params.planningLocale.tag}`);
261
+ lines.push(`Assistant natural-language outputs must follow: ${params.planningLocale.instruction}`);
262
+ }
263
+ lines.push(`Project directory name: ${basename(params.cwd)}`);
264
+ lines.push(`Full path: ${params.cwd}`);
265
+ if (typeof params.idea === "string" && params.idea.trim().length > 0) {
266
+ lines.push(`Initial idea from user: ${params.idea.trim()}`);
267
+ }
268
+ if (params.lastQuestion && params.lastAnswer) {
269
+ lines.push("");
270
+ lines.push("Previous assistant question was answered as:");
271
+ lines.push(`Q: ${params.lastQuestion}`);
272
+ lines.push(`A: ${params.lastAnswer}`);
273
+ }
274
+ lines.push("");
275
+ lines.push("Produce your next JSON response following the protocol.");
276
+ return lines.join("\n");
277
+ }
278
+ async function completeOneTurnOpenAi(messages, model) {
279
+ const raw = await openAiChatCompletionJson({ messages, model });
280
+ return extractJsonObject(raw);
281
+ }
282
+ async function completeOneTurnCursor(params) {
283
+ const prompt = `${params.system}\n\n--- Conversation ---\n${params.transcript}\n\n---\nReply with ONE JSON object only (no markdown), following the protocol.`;
284
+ const out = await cursorAgentPrint({ cwd: params.cwd, prompt, model: params.model });
285
+ const text = extractAgentAssistantText(out);
286
+ return extractJsonObject(text);
287
+ }
288
+ export function printPlanProviderSetupHelp() {
289
+ log.blank();
290
+ log.info(bold("No LLM provider is ready for `vibeops plan`."));
291
+ log.blank();
292
+ log.info(bold("1) OpenAI (platform API key)"));
293
+ log.info(" Usage-based billing on the OpenAI API (not the same as ChatGPT-only login).");
294
+ log.info(` · Create a key: ${cyan("https://platform.openai.com/api-keys")}`);
295
+ log.info(` · ${dim("export OPENAI_API_KEY=\"sk-...\"")}`);
296
+ log.info(` · Optional model override: ${dim("export VIBEOPS_OPENAI_MODEL=gpt-4o")}`);
297
+ log.info(` · Interactive ${cyan("vibeops plan")} lists models from ${dim("GET /v1/models")} for you to pick.`);
298
+ log.blank();
299
+ log.info(bold("2) Codex (ChatGPT OAuth)"));
300
+ log.info(` · Interactive ${cyan("vibeops plan")} can open the browser and save tokens to ${dim("~/.codex/auth.json")}.`);
301
+ log.info(` · Or run ${cyan("codex login")} (official CLI).`);
302
+ log.info(` · Optional: ${dim("export VIBEOPS_CODEX_MODEL=...")} ${dim("export VIBEOPS_CODEX_BASE_URL=...")} ${dim("export VIBEOPS_CODEX_CLIENT_VERSION=...")} or ${cyan("vibeops plan --model <id>")}`);
303
+ log.info(` · Interactive plan lists models from ${dim("GET …/backend-api/codex/models")} when signed in.`);
304
+ log.blank();
305
+ log.info(bold("3) Cursor Agent CLI (same account as Cursor IDE)"));
306
+ log.info(" VibeOps does not install this for you: use Cursor’s installer for your OS, then put `agent` on PATH.");
307
+ log.info(` · Install / update: ${cyan("https://cursor.com/docs/cli")}`);
308
+ log.info(` · Run ${cyan("agent login")} then verify with ${cyan("agent status")}`);
309
+ log.info(` · Override binary name: ${dim("export VIBEOPS_CURSOR_AGENT_BIN=/path/to/agent")}`);
310
+ log.info(` · Interactive plan runs ${cyan("agent models")} and lets you pick before planning.`);
311
+ log.blank();
312
+ }
313
+ function printCursorAgentSetupHelp(cursor) {
314
+ const cmd = cursor.command;
315
+ const reason = cursor.reason ?? "";
316
+ const missingOnPath = /not found on PATH/i.test(reason);
317
+ log.blank();
318
+ if (missingOnPath) {
319
+ log.info(bold("Cursor Agent CLI — install"));
320
+ log.info(` ${cyan(cmd)} was not found on PATH. VibeOps does not run an installer for you (brew vs npm vs a manual bundle depends on your machine and policy).`);
321
+ log.info(` · Follow Cursor’s steps: ${cyan("https://cursor.com/docs/cli")}`);
322
+ log.info(` · Then open a new terminal and check: ${cyan(`${cmd} --version`)} and ${cyan(`${cmd} status`)}`);
323
+ }
324
+ else if (/not authenticated|not logged|login required|unauthorized/i.test(reason)) {
325
+ log.info(bold("Cursor Agent CLI — sign in"));
326
+ log.info(` · Run ${cyan(`${cmd} login`)} then ${cyan(`${cmd} status`)}`);
327
+ }
328
+ else {
329
+ log.info(bold("Cursor Agent CLI — fix the error below"));
330
+ if (reason.length > 0)
331
+ log.info(dim(` ${reason}`));
332
+ log.info(` · When resolved, verify with ${cyan(`${cmd} status`)}`);
333
+ }
334
+ log.info(` · Different binary name: ${dim(`export VIBEOPS_CURSOR_AGENT_BIN=/path/to/${cmd}`)}`);
335
+ log.blank();
336
+ }
337
+ function providerChoiceLabel(c) {
338
+ return c.ok ? `${c.label} ✓` : `${c.label} (needs setup)`;
339
+ }
340
+ export async function pickPlanLlmProvider(cwd, preferred, opts) {
341
+ const interactiveLogin = opts?.interactiveLogin === true;
342
+ async function loadProbes() {
343
+ return {
344
+ openai: await probeOpenAiApiKey(),
345
+ codex: await probeCodexOAuthFile(),
346
+ cursor: await probeCursorAgentCli(cwd),
347
+ };
348
+ }
349
+ let { openai, codex, cursor } = await loadProbes();
350
+ if (interactiveLogin && preferred === "codex-oauth" && !codex.ok) {
351
+ log.step("Codex (ChatGPT) OAuth — opening sign-in…");
352
+ try {
353
+ await runCodexPkceOAuthLogin();
354
+ }
355
+ catch (e) {
356
+ const msg = e instanceof Error ? e.message : String(e);
357
+ log.error(`Codex OAuth login failed: ${msg}`);
358
+ process.exitCode = 1;
359
+ return null;
360
+ }
361
+ ({ codex } = await loadProbes());
362
+ if (!codex.ok) {
363
+ log.error("Codex auth is still not available after login.");
364
+ process.exitCode = 1;
365
+ return null;
366
+ }
367
+ }
368
+ const buildCandidates = (o, c, u) => [
369
+ { id: "openai", label: "OpenAI (platform API key)", ok: o.ok },
370
+ { id: "codex-oauth", label: "Codex (ChatGPT OAuth — ~/.codex/auth.json)", ok: c.ok },
371
+ { id: "cursor-agent", label: "Cursor Agent CLI", ok: u.ok },
372
+ ];
373
+ let candidates = buildCandidates(openai, codex, cursor);
374
+ let okList = candidates.filter((c) => c.ok);
375
+ if (okList.length === 0 && interactiveLogin) {
376
+ const connect = await askSelect({
377
+ message: "No LLM provider detected. How do you want to connect?",
378
+ nonInteractive: false,
379
+ choices: [
380
+ "ChatGPT / Codex OAuth (open browser sign-in)",
381
+ "Re-check environment (I exported OPENAI_API_KEY or logged in with Cursor Agent in this shell)",
382
+ "Show setup instructions and exit",
383
+ ],
384
+ default: "ChatGPT / Codex OAuth (open browser sign-in)",
385
+ });
386
+ if (connect === "ChatGPT / Codex OAuth (open browser sign-in)") {
387
+ try {
388
+ await runCodexPkceOAuthLogin();
389
+ ({ openai, codex, cursor } = await loadProbes());
390
+ candidates = buildCandidates(openai, codex, cursor);
391
+ okList = candidates.filter((c) => c.ok);
392
+ }
393
+ catch (e) {
394
+ const msg = e instanceof Error ? e.message : String(e);
395
+ log.error(`Codex OAuth login failed: ${msg}`);
396
+ }
397
+ }
398
+ else if (connect === "Re-check environment (I exported OPENAI_API_KEY or logged in with Cursor Agent in this shell)") {
399
+ ({ openai, codex, cursor } = await loadProbes());
400
+ candidates = buildCandidates(openai, codex, cursor);
401
+ okList = candidates.filter((c) => c.ok);
402
+ }
403
+ }
404
+ if (okList.length === 0) {
405
+ printPlanProviderSetupHelp();
406
+ if (openai.reason)
407
+ log.info(dim(`OpenAI: ${openai.reason}`));
408
+ if (!codex.ok && codex.reason)
409
+ log.info(dim(`Codex OAuth: ${codex.reason}`));
410
+ if (cursor.reason)
411
+ log.info(dim(`Cursor Agent: ${cursor.reason}`));
412
+ process.exitCode = 1;
413
+ return null;
414
+ }
415
+ if (preferred !== undefined) {
416
+ const hit = candidates.find((c) => c.id === preferred);
417
+ if (hit?.ok === true)
418
+ return preferred;
419
+ if (hit !== undefined) {
420
+ log.warn(`--provider ${preferred} unavailable (${explainMiss(preferred, openai, codex, cursor)})`);
421
+ }
422
+ }
423
+ if (!interactiveLogin) {
424
+ if (okList.length === 1)
425
+ return okList[0].id;
426
+ return okList[0]?.id ?? null;
427
+ }
428
+ const choiceLabels = candidates.map(providerChoiceLabel);
429
+ const firstReady = candidates.find((c) => c.ok);
430
+ const defaultLabel = firstReady ? providerChoiceLabel(firstReady) : choiceLabels[0];
431
+ const pickedLabel = await askSelect({
432
+ message: "Choose LLM provider for planning (↑/↓ · Enter)",
433
+ nonInteractive: false,
434
+ choices: choiceLabels,
435
+ default: defaultLabel,
436
+ });
437
+ const picked = candidates.find((c) => providerChoiceLabel(c) === pickedLabel);
438
+ const pickedId = picked?.id ?? firstReady?.id;
439
+ if (!pickedId) {
440
+ process.exitCode = 1;
441
+ return null;
442
+ }
443
+ if (pickedId === "openai") {
444
+ if (!openai.ok) {
445
+ log.error("OpenAI is not configured: set OPENAI_API_KEY (platform API key), then run again.");
446
+ if (openai.reason)
447
+ log.info(dim(`OpenAI: ${openai.reason}`));
448
+ process.exitCode = 1;
449
+ return null;
450
+ }
451
+ return pickedId;
452
+ }
453
+ if (pickedId === "codex-oauth") {
454
+ if (!codex.ok) {
455
+ log.step("Codex (ChatGPT) OAuth — opening sign-in…");
456
+ try {
457
+ await runCodexPkceOAuthLogin();
458
+ }
459
+ catch (e) {
460
+ const msg = e instanceof Error ? e.message : String(e);
461
+ log.error(`Codex OAuth login failed: ${msg}`);
462
+ process.exitCode = 1;
463
+ return null;
464
+ }
465
+ ({ codex } = await loadProbes());
466
+ if (!codex.ok) {
467
+ log.error("Codex auth is still not available after login.");
468
+ process.exitCode = 1;
469
+ return null;
470
+ }
471
+ }
472
+ return pickedId;
473
+ }
474
+ if (!cursor.ok) {
475
+ log.error("Cursor Agent CLI is not ready for planning.");
476
+ printCursorAgentSetupHelp(cursor);
477
+ process.exitCode = 1;
478
+ return null;
479
+ }
480
+ return "cursor-agent";
481
+ }
482
+ function explainMiss(id, openai, codex, cursor) {
483
+ if (id === "openai")
484
+ return openai.reason ?? "unknown";
485
+ if (id === "codex-oauth")
486
+ return codex.reason ?? "unknown";
487
+ return cursor.reason ?? "unknown";
488
+ }
489
+ function usesChatMessageTranscript(provider) {
490
+ return provider === "openai" || provider === "codex-oauth";
491
+ }
492
+ export async function gatherBriefViaLlm(params) {
493
+ if (params.provider === "codex-oauth" && params.allowCodexBrowserLogin === true) {
494
+ let probe = await probeCodexOAuthFile();
495
+ if (!probe.ok) {
496
+ log.step("Codex OAuth required — starting browser sign-in…");
497
+ await runCodexPkceOAuthLogin();
498
+ probe = await probeCodexOAuthFile();
499
+ if (!probe.ok) {
500
+ throw new Error("Codex OAuth is not available after sign-in.");
501
+ }
502
+ }
503
+ }
504
+ const source = params.provider === "openai"
505
+ ? "llm-openai"
506
+ : params.provider === "codex-oauth"
507
+ ? "llm-codex-oauth"
508
+ : "llm-cursor-agent";
509
+ const systemContent = buildPlanSystemPrompt(params.planningLocale.instruction);
510
+ const user0 = buildUserPayload({
511
+ cwd: params.cwd,
512
+ idea: params.idea,
513
+ planningLocale: params.planningLocale,
514
+ });
515
+ const cursorParts = [{ role: "user", content: user0 }];
516
+ let transcript = transcriptFromCursorParts(cursorParts);
517
+ const openAiMessages = [
518
+ { role: "system", content: systemContent },
519
+ { role: "user", content: user0 },
520
+ ];
521
+ const chatMode = usesChatMessageTranscript(params.provider);
522
+ const modelRaw = params.llmModelOverride?.trim();
523
+ const model = modelRaw && modelRaw.length > 0 ? modelRaw : undefined;
524
+ let summaryConfirmed = false;
525
+ /** Count of answered planning questions (used for progress + go-back). */
526
+ let completedPlanningSteps = 0;
527
+ for (let i = 0; i < MAX_LLM_TURNS; i++) {
528
+ let jsonSlice;
529
+ if (params.provider === "openai") {
530
+ jsonSlice = await completeOneTurnOpenAi(openAiMessages, model);
531
+ }
532
+ else if (params.provider === "codex-oauth") {
533
+ jsonSlice = await codexOAuthPlanCompletion(openAiMessages, model !== undefined ? { model } : undefined);
534
+ }
535
+ else {
536
+ jsonSlice = await completeOneTurnCursor({
537
+ cwd: params.cwd,
538
+ system: systemContent,
539
+ transcript,
540
+ model,
541
+ });
542
+ }
543
+ let parsed;
544
+ try {
545
+ parsed = parsePlanLlmTurn(jsonSlice);
546
+ }
547
+ catch (e) {
548
+ const hint = e instanceof Error ? e.message : String(e);
549
+ const repair = `Your previous reply was not valid for this protocol (${hint}). Output ONE JSON object only.`;
550
+ if (chatMode) {
551
+ openAiMessages.push({ role: "assistant", content: jsonSlice.slice(0, 4000) });
552
+ openAiMessages.push({ role: "user", content: repair });
553
+ }
554
+ else {
555
+ cursorParts.push({ role: "assistant", content: jsonSlice.slice(0, 2000) });
556
+ cursorParts.push({ role: "user", content: repair });
557
+ transcript = transcriptFromCursorParts(cursorParts);
558
+ }
559
+ continue;
560
+ }
561
+ if (parsed.turn === "done") {
562
+ if (!summaryConfirmed) {
563
+ const repair = 'Protocol: do not output {"turn":"done"} until the user has confirmed a {"turn":"confirm"} summary in the terminal. First output {"turn":"confirm","readableSummary":"..."} when you have enough context. Output ONE JSON object only.';
564
+ if (chatMode) {
565
+ openAiMessages.push({ role: "assistant", content: jsonSlice.slice(0, 4000) });
566
+ openAiMessages.push({ role: "user", content: repair });
567
+ }
568
+ else {
569
+ cursorParts.push({ role: "assistant", content: jsonSlice.slice(0, 2000) });
570
+ cursorParts.push({ role: "user", content: repair });
571
+ transcript = transcriptFromCursorParts(cursorParts);
572
+ }
573
+ continue;
574
+ }
575
+ const norm = normalizeLlmProjectBrief(parsed.projectBrief);
576
+ const assumptions = [...norm.assumptions, ...(parsed.plannerAssumptions ?? [])];
577
+ const meta = {
578
+ vibeopsVersion: VERSION,
579
+ generatedAt: new Date().toISOString(),
580
+ source,
581
+ schemaVersion: PROJECT_BRIEF_SCHEMA_VERSION,
582
+ assumptions,
583
+ };
584
+ return { brief: norm.brief, meta };
585
+ }
586
+ if (parsed.turn === "confirm") {
587
+ if (summaryConfirmed) {
588
+ const repair = '[VibeOps session] User already confirmed. Reply with ONE JSON object only: {"turn":"done","projectBrief":{...},"plannerAssumptions":[...]}. No "confirm" or "question".';
589
+ if (chatMode) {
590
+ openAiMessages.push({ role: "assistant", content: jsonSlice.slice(0, 4000) });
591
+ openAiMessages.push({ role: "user", content: repair });
592
+ }
593
+ else {
594
+ cursorParts.push({ role: "assistant", content: jsonSlice.slice(0, 2000) });
595
+ cursorParts.push({ role: "user", content: repair });
596
+ transcript = transcriptFromCursorParts(cursorParts);
597
+ }
598
+ continue;
599
+ }
600
+ log.blank();
601
+ log.step("Review summary before writing the ProjectBrief");
602
+ log.raw(parsed.readableSummary.endsWith("\n") ? parsed.readableSummary : `${parsed.readableSummary}\n`);
603
+ log.blank();
604
+ const ok = await yesNoSelect({
605
+ message: "Does this summary match what you want to build?",
606
+ defaultValue: true,
607
+ });
608
+ if (chatMode) {
609
+ openAiMessages.push({ role: "assistant", content: jsonSlice });
610
+ }
611
+ else {
612
+ cursorParts.push({ role: "assistant", content: jsonSlice });
613
+ transcript = transcriptFromCursorParts(cursorParts);
614
+ }
615
+ if (ok) {
616
+ summaryConfirmed = true;
617
+ const msg = '[VibeOps session] User CONFIRMED this summary. Reply with ONE JSON object only: {"turn":"done","projectBrief":{...all required keys...},"plannerAssumptions":[...]}. Align projectBrief with the confirmed summary and the full conversation. Do NOT output "question" or "confirm".';
618
+ if (chatMode) {
619
+ openAiMessages.push({ role: "user", content: msg });
620
+ }
621
+ else {
622
+ cursorParts.push({ role: "user", content: msg });
623
+ transcript = transcriptFromCursorParts(cursorParts);
624
+ }
625
+ continue;
626
+ }
627
+ const fb = await askInput({
628
+ message: "What should we change or add?",
629
+ nonInteractive: false,
630
+ });
631
+ const msg = `[VibeOps session] User did NOT confirm the summary. Feedback:\n${fb}\nContinue with one {"turn":"question",...} per reply until ready, then {"turn":"confirm",...} again before any "done".`;
632
+ if (chatMode) {
633
+ openAiMessages.push({ role: "user", content: msg });
634
+ }
635
+ else {
636
+ cursorParts.push({ role: "user", content: msg });
637
+ transcript = transcriptFromCursorParts(cursorParts);
638
+ }
639
+ continue;
640
+ }
641
+ openAiMessages.push({ role: "assistant", content: jsonSlice });
642
+ if (!chatMode) {
643
+ cursorParts.push({ role: "assistant", content: jsonSlice });
644
+ transcript = transcriptFromCursorParts(cursorParts);
645
+ }
646
+ const outcome = await runQuestionWithOptionalBack({
647
+ parsed,
648
+ chatMode,
649
+ openAiMessages,
650
+ cursorParts,
651
+ displayStep1Based: completedPlanningSteps + 1,
652
+ onBacked: () => {
653
+ completedPlanningSteps = Math.max(0, completedPlanningSteps - 1);
654
+ },
655
+ });
656
+ if (outcome.kind === "wrapUp") {
657
+ log.step("Stopping discovery — moving on to a draft summary with what we have.");
658
+ if (chatMode) {
659
+ openAiMessages.push({ role: "user", content: PLAN_SESSION_WRAP_UP_USER });
660
+ }
661
+ else {
662
+ cursorParts.push({ role: "user", content: PLAN_SESSION_WRAP_UP_USER });
663
+ transcript = transcriptFromCursorParts(cursorParts);
664
+ }
665
+ continue;
666
+ }
667
+ completedPlanningSteps++;
668
+ const followUp = buildUserPayload({
669
+ cwd: params.cwd,
670
+ idea: params.idea,
671
+ lastQuestion: parsed.message,
672
+ lastAnswer: outcome.text,
673
+ });
674
+ openAiMessages.push({ role: "user", content: followUp });
675
+ if (!chatMode) {
676
+ cursorParts.push({ role: "user", content: followUp });
677
+ transcript = transcriptFromCursorParts(cursorParts);
678
+ }
679
+ }
680
+ throw new Error(`Planning stopped: exceeded ${MAX_LLM_TURNS} LLM turns without a "done" response.`);
681
+ }
@@ -0,0 +1 @@
1
+ export {};