@productbrain/cli 0.1.0-beta.35 → 0.1.0-beta.36

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,120 @@
1
+ /**
2
+ * Onboarding conversation phases — the 5-phase arc for first-time setup.
3
+ * WP-304: Each phase has a directive (what the LLM should do) and exit criteria (what the CLI checks).
4
+ */
5
+ export const ONBOARDING_PHASES = [
6
+ {
7
+ id: 'greet',
8
+ directive: `Your job: Say hello and ask what they're building. One sentence. No fluff.
9
+ Example: "Hey! What are you building?"
10
+ Do NOT ask multiple questions. Do NOT explain Product Brain yet.`,
11
+ exitCriteria: [
12
+ {
13
+ name: 'greeting_sent',
14
+ check: (s) => s.phaseExchanges['greet'] !== undefined && (s.phaseExchanges['greet'] ?? 0) >= 0,
15
+ required: true,
16
+ },
17
+ ],
18
+ maxExchanges: 1,
19
+ },
20
+ {
21
+ id: 'explore',
22
+ directive: `Your job: Learn what they're building. Get specific.
23
+ If they gave a vague answer ("something big", "a tool", "an app"), pick one concrete detail and dig in.
24
+ Ask who uses it, what it actually does, or how it works today.
25
+ Good questions: "Who's using it?" / "What does it replace?" / "How does that work today?"
26
+ Bad questions: "What domain are you operating in?" / "Can you elaborate on that?"
27
+ Do NOT ask why they're trying Product Brain yet.
28
+ One question only.`,
29
+ exitCriteria: [
30
+ {
31
+ name: 'user_mentioned_product',
32
+ check: (s) => s.signals.mentionedProduct,
33
+ required: true,
34
+ },
35
+ {
36
+ name: 'has_substantive_answer',
37
+ check: (s) => s.signals.hasSubstantiveAnswer,
38
+ required: true,
39
+ },
40
+ ],
41
+ maxExchanges: 3,
42
+ },
43
+ {
44
+ id: 'deepen',
45
+ directive: `Your job: Understand what's hard about what they're building.
46
+ Pick ONE specific thing they mentioned and ask what breaks or why it's hard. Be direct and short.
47
+ Good: "What breaks when the agent doesn't have that context?" / "Where does the rework hit hardest?"
48
+ Bad: "What makes it hard to ensure X?" / "Can you tell me more about the challenges?"
49
+ Do NOT ask why they're trying Product Brain yet.
50
+ One question only.`,
51
+ exitCriteria: [
52
+ {
53
+ name: 'user_mentioned_challenge',
54
+ check: (s) => s.signals.mentionedChallenge,
55
+ required: false,
56
+ },
57
+ {
58
+ name: 'min_2_substantive_messages',
59
+ check: (s) => s.signals.substantiveMessageCount >= 2,
60
+ required: true,
61
+ },
62
+ ],
63
+ maxExchanges: 2,
64
+ },
65
+ {
66
+ id: 'why_pb',
67
+ directive: `Your job: Ask what made them try Product Brain. Be direct.
68
+ Examples: "What made you try Product Brain?" or "What problem are you hoping Product Brain solves?"
69
+ Do NOT summarize or synthesize yet.
70
+ One question only.`,
71
+ exitCriteria: [
72
+ {
73
+ name: 'user_answered_why_pb',
74
+ check: (s) => s.signals.mentionedWhyPB,
75
+ required: false,
76
+ },
77
+ {
78
+ name: 'min_total_words_25',
79
+ check: (s) => s.totalUserWords >= 25,
80
+ required: true,
81
+ },
82
+ {
83
+ name: 'min_3_substantive_messages',
84
+ check: (s) => s.signals.substantiveMessageCount >= 3,
85
+ required: true,
86
+ },
87
+ ],
88
+ maxExchanges: 2,
89
+ },
90
+ {
91
+ id: 'bridge',
92
+ directive: `Your job: Bridge the conversation to Product Brain's value. Speak as Product Brain (first person "I").
93
+ Keep it SHORT — 2-3 sentences max. Do NOT list bullet items. The CLI will handle showing examples.
94
+
95
+ Structure:
96
+ 1. One sentence connecting their problem to what you (PB) do. Use first person.
97
+ 2. One sentence showing you understood something specific they said.
98
+
99
+ Good example: "I can remember all of that across your AI sessions — so next time you open Claude or Cursor, it already knows about the decision rot problem and your cashflow automation. No more re-explaining."
100
+ Bad example: "Product Brain can help — it remembers context. Here's what I'd save: * item 1 * item 2 * item 3"
101
+
102
+ Do NOT list items. Do NOT use bullet points or asterisks. The CLI shows examples separately.
103
+ This is a STATEMENT. Do NOT ask a question. Do NOT end with a question mark.`,
104
+ exitCriteria: [
105
+ {
106
+ name: 'bridge_delivered',
107
+ check: (s) => (s.phaseExchanges['bridge'] ?? 0) >= 1,
108
+ required: true,
109
+ },
110
+ ],
111
+ maxExchanges: 1,
112
+ },
113
+ ];
114
+ /** User wants to skip — detected by regex on user input. */
115
+ const SKIP_PATTERNS = [/\b(?:skip|done|that'?s it|wrap up|let'?s go|finish|end)\b/i];
116
+ /** Check if user wants to skip the conversation. */
117
+ export function userWantsToSkip(text) {
118
+ return SKIP_PATTERNS.some((p) => p.test(text));
119
+ }
120
+ //# sourceMappingURL=onboarding-phases.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"onboarding-phases.js","sourceRoot":"","sources":["../../src/lib/onboarding-phases.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,MAAM,CAAC,MAAM,iBAAiB,GAAwB;IACpD;QACE,EAAE,EAAE,OAAO;QACX,SAAS,EAAE;;iEAEkD;QAC7D,YAAY,EAAE;YACZ;gBACE,IAAI,EAAE,eAAe;gBACrB,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,cAAc,CAAC,OAAO,CAAC,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;gBAC9F,QAAQ,EAAE,IAAI;aACf;SACF;QACD,YAAY,EAAE,CAAC;KAChB;IACD;QACE,EAAE,EAAE,SAAS;QACb,SAAS,EAAE;;;;;;mBAMI;QACf,YAAY,EAAE;YACZ;gBACE,IAAI,EAAE,wBAAwB;gBAC9B,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,gBAAgB;gBACxC,QAAQ,EAAE,IAAI;aACf;YACD;gBACE,IAAI,EAAE,wBAAwB;gBAC9B,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,oBAAoB;gBAC5C,QAAQ,EAAE,IAAI;aACf;SACF;QACD,YAAY,EAAE,CAAC;KAChB;IACD;QACE,EAAE,EAAE,QAAQ;QACZ,SAAS,EAAE;;;;;mBAKI;QACf,YAAY,EAAE;YACZ;gBACE,IAAI,EAAE,0BAA0B;gBAChC,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,kBAAkB;gBAC1C,QAAQ,EAAE,KAAK;aAChB;YACD;gBACE,IAAI,EAAE,4BAA4B;gBAClC,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,uBAAuB,IAAI,CAAC;gBACpD,QAAQ,EAAE,IAAI;aACf;SACF;QACD,YAAY,EAAE,CAAC;KAChB;IACD;QACE,EAAE,EAAE,QAAQ;QACZ,SAAS,EAAE;;;mBAGI;QACf,YAAY,EAAE;YACZ;gBACE,IAAI,EAAE,sBAAsB;gBAC5B,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,cAAc;gBACtC,QAAQ,EAAE,KAAK;aAChB;YACD;gBACE,IAAI,EAAE,oBAAoB;gBAC1B,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,cAAc,IAAI,EAAE;gBACpC,QAAQ,EAAE,IAAI;aACf;YACD;gBACE,IAAI,EAAE,4BAA4B;gBAClC,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,uBAAuB,IAAI,CAAC;gBACpD,QAAQ,EAAE,IAAI;aACf;SACF;QACD,YAAY,EAAE,CAAC;KAChB;IACD;QACE,EAAE,EAAE,QAAQ;QACZ,SAAS,EAAE;;;;;;;;;;;6EAW8D;QACzE,YAAY,EAAE;YACZ;gBACE,IAAI,EAAE,kBAAkB;gBACxB,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;gBACpD,QAAQ,EAAE,IAAI;aACf;SACF;QACD,YAAY,EAAE,CAAC;KAChB;CACF,CAAC;AAEF,4DAA4D;AAC5D,MAAM,aAAa,GAAG,CAAC,4DAA4D,CAAC,CAAC;AAErF,oDAAoD;AACpD,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,OAAO,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;AACjD,CAAC"}
@@ -1,19 +1,17 @@
1
1
  /**
2
- * Onboarding conversation — LLM-powered natural conversation for new workspaces.
3
- * WP-304 Slice 0: Single-turn proof of concept.
4
- * WP-304 Slice 1: Multi-turn conversation + extraction + auto-capture.
2
+ * Onboarding conversation — LLM-powered phase-gated conversation for new workspaces.
3
+ * WP-304: Deterministic Shell (CLI phases), Stochastic Core (LLM content).
5
4
  *
6
5
  * Uses @clack/prompts (WP-303 S1) and the MCP gateway to call the
7
6
  * Convex onboardingChat action for conversation and extraction,
8
7
  * then chain.createEntry for batch-capture.
9
8
  */
10
9
  /**
11
- * Run the onboarding conversation: multi-turn chat, extraction, and auto-capture.
10
+ * Run the onboarding conversation: phase-gated chat, extraction, and auto-capture.
12
11
  */
13
12
  export declare function runOnboardingConversation(workspaceName: string): Promise<void>;
14
13
  /**
15
14
  * Deterministic fallback when LLM is unavailable.
16
- * Asks 3 hardcoded questions and maps answers to the same capture pipeline.
17
15
  */
18
16
  export declare function runFallbackOnboarding(workspaceName: string): Promise<void>;
19
17
  //# sourceMappingURL=onboarding.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"onboarding.d.ts","sourceRoot":"","sources":["../../src/lib/onboarding.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAgLH;;GAEG;AACH,wBAAsB,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAyLpF;AAmCD;;;GAGG;AACH,wBAAsB,qBAAqB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA6DhF"}
1
+ {"version":3,"file":"onboarding.d.ts","sourceRoot":"","sources":["../../src/lib/onboarding.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AA+KH;;GAEG;AACH,wBAAsB,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA4TpF;AAmCD;;GAEG;AACH,wBAAsB,qBAAqB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAmEhF"}
@@ -1,39 +1,36 @@
1
1
  /**
2
- * Onboarding conversation — LLM-powered natural conversation for new workspaces.
3
- * WP-304 Slice 0: Single-turn proof of concept.
4
- * WP-304 Slice 1: Multi-turn conversation + extraction + auto-capture.
2
+ * Onboarding conversation — LLM-powered phase-gated conversation for new workspaces.
3
+ * WP-304: Deterministic Shell (CLI phases), Stochastic Core (LLM content).
5
4
  *
6
5
  * Uses @clack/prompts (WP-303 S1) and the MCP gateway to call the
7
6
  * Convex onboardingChat action for conversation and extraction,
8
7
  * then chain.createEntry for batch-capture.
9
8
  */
10
9
  import { mcpCall, mcpCallWithSession } from './client.js';
11
- import { ask, confirm } from './prompts.js';
12
- import { bold, dim, green, icons } from './style.js';
10
+ import { ask, confirm, select } from './prompts.js';
11
+ import { bold, cyan, dim, green, icons } from './style.js';
13
12
  import { trackEvent } from './telemetry.js';
14
- /** Max conversation exchanges (including the initial greeting). */
15
- const MAX_TURNS = 5;
16
- /** Signals that the LLM is ready to synthesize (present in its response). */
17
- const SYNTHESIS_SIGNALS = [
18
- 'good picture',
19
- 'organize what',
20
- 'let me organize',
21
- 'enough to get started',
22
- 'have a good sense',
23
- 'got a clear picture',
24
- 'great start',
25
- 'let me pull',
26
- 'let me put',
27
- ];
13
+ import { detectSignals, mergeSignals } from './conversation-signals.js';
14
+ import { evaluatePhase, buildPhaseDirective, sanitizeResponse, enforceBridgeIsStatement, isTooSimilar } from './conversation-engine.js';
15
+ import { ONBOARDING_PHASES, userWantsToSkip } from './onboarding-phases.js';
16
+ /** Hard cap on total exchanges across all phases. */
17
+ const MAX_TOTAL_EXCHANGES = 10;
28
18
  // ── Extraction → Entries Mapping ────────────────────────────────────────────
29
19
  // SYNC: Must match packages/mcp-server/src/tools/start-interview.ts:extractionToBatchEntries
30
20
  // Duplicated due to monorepo import boundary (BR-165). TEN tracked for shared-package extraction.
31
21
  function truncate(str, maxLen) {
32
- return str.length <= maxLen ? str : str.slice(0, maxLen);
22
+ if (str.length <= maxLen)
23
+ return str;
24
+ const truncated = str.slice(0, maxLen);
25
+ const lastSpace = truncated.lastIndexOf(' ');
26
+ return lastSpace > maxLen * 0.6 ? truncated.slice(0, lastSpace) : truncated;
33
27
  }
34
28
  function firstNWords(str, n) {
35
29
  return str.split(/\s+/).slice(0, n).join(' ');
36
30
  }
31
+ function countWords(text) {
32
+ return text.trim().split(/\s+/).filter(Boolean).length;
33
+ }
37
34
  function extractionToEntries(extracted) {
38
35
  const entries = [];
39
36
  if (extracted.vision?.trim()) {
@@ -80,7 +77,7 @@ function extractionToEntries(extracted) {
80
77
  for (const decision of extracted.keyDecisions) {
81
78
  entries.push({
82
79
  collection: 'decisions',
83
- name: truncate(decision, 80),
80
+ name: truncate(decision, 120),
84
81
  description: decision,
85
82
  });
86
83
  }
@@ -89,7 +86,7 @@ function extractionToEntries(extracted) {
89
86
  for (const tension of extracted.tensions) {
90
87
  entries.push({
91
88
  collection: 'tensions',
92
- name: truncate(tension, 80),
89
+ name: truncate(tension, 120),
93
90
  description: tension,
94
91
  });
95
92
  }
@@ -97,119 +94,197 @@ function extractionToEntries(extracted) {
97
94
  return entries;
98
95
  }
99
96
  // ── Display helpers ─────────────────────────────────────────────────────────
100
- function displayExtraction(extracted) {
101
- process.stdout.write(`\n Here's what I picked up:\n\n`);
102
- if (extracted.vision) {
103
- process.stdout.write(` ${green('\u2726')} Your product vision: ${extracted.vision}\n`);
104
- }
105
- if (extracted.audience) {
106
- process.stdout.write(` ${green('\u2726')} Your audience: ${extracted.audience}\n`);
107
- }
108
- if (extracted.techStack?.length) {
109
- process.stdout.write(` ${green('\u2726')} Tech stack: ${extracted.techStack.join(', ')}\n`);
110
- }
111
- if (extracted.keyTerms?.length) {
112
- process.stdout.write(` ${green('\u2726')} Key terms: ${extracted.keyTerms.join(', ')}\n`);
113
- }
114
- if (extracted.keyDecisions?.length) {
115
- process.stdout.write(` ${green('\u2726')} Key decisions: ${extracted.keyDecisions.join('; ')}\n`);
116
- }
117
- if (extracted.tensions?.length) {
118
- process.stdout.write(` ${green('\u2726')} Challenges: ${extracted.tensions.join('; ')}\n`);
97
+ /** Collection type labels — human-friendly names for collections. */
98
+ const COLLECTION_LABELS = {
99
+ strategy: 'Strategy',
100
+ audiences: 'Audience',
101
+ architecture: 'Architecture',
102
+ glossary: 'Term',
103
+ decisions: 'Decision',
104
+ tensions: 'Tension',
105
+ };
106
+ /**
107
+ * Display one entry example with schema structure.
108
+ * Shows collection type, name, and description in a clean format.
109
+ */
110
+ function displayEntryExample(entry) {
111
+ const typeLabel = COLLECTION_LABELS[entry.collection] ?? entry.collection;
112
+ process.stdout.write(` ${cyan(typeLabel)}\n`);
113
+ process.stdout.write(` ${bold(entry.name)}\n`);
114
+ if (entry.description !== entry.name) {
115
+ process.stdout.write(` ${dim(entry.description)}\n`);
119
116
  }
120
117
  process.stdout.write('\n');
121
118
  }
122
- function hasSynthesisSignal(text) {
123
- const lower = text.toLowerCase();
124
- return SYNTHESIS_SIGNALS.some((signal) => lower.includes(signal));
125
- }
126
119
  // ── Main conversation ───────────────────────────────────────────────────────
127
120
  /**
128
- * Run the onboarding conversation: multi-turn chat, extraction, and auto-capture.
121
+ * Run the onboarding conversation: phase-gated chat, extraction, and auto-capture.
129
122
  */
130
123
  export async function runOnboardingConversation(workspaceName) {
131
124
  trackEvent('setup_started', { mode: 'llm' });
132
- const messages = [];
133
- // Step 1: Get the LLM's opening greeting
125
+ // Initialize conversation state
126
+ const state = {
127
+ messages: [],
128
+ userMessageCount: 0,
129
+ totalUserWords: 0,
130
+ phaseExchanges: {},
131
+ completedPhases: new Set(),
132
+ currentPhaseId: ONBOARDING_PHASES[0].id,
133
+ signals: detectSignals([]),
134
+ refusalStreak: 0,
135
+ };
136
+ let currentPhaseIndex = 0;
137
+ let totalExchanges = 0;
138
+ let emptyAttempts = 0;
139
+ const recentAssistantMessages = [];
140
+ const accumulatedFragments = [];
141
+ // Step 1: Get the LLM's opening greeting (greet phase)
142
+ const greetPhase = ONBOARDING_PHASES[0];
143
+ const greetDirective = buildPhaseDirective(greetPhase, 0, ONBOARDING_PHASES.length);
134
144
  let greeting;
135
145
  try {
136
146
  const result = await mcpCall('onboarding.chat', {
137
147
  messages: [],
138
148
  workspaceName,
149
+ phaseDirective: greetDirective,
150
+ turnNumber: 1,
151
+ totalUserWords: 0,
152
+ refusalStreak: 0,
139
153
  });
140
154
  if (!result?.content) {
141
- // LLM unavailable — fall back to deterministic conversation
142
155
  await runFallbackOnboarding(workspaceName);
143
156
  return;
144
157
  }
145
- greeting = result.content;
158
+ greeting = sanitizeResponse(result.content);
146
159
  }
147
160
  catch {
148
- // LLM unavailable — fall back to deterministic conversation
149
161
  await runFallbackOnboarding(workspaceName);
150
162
  return;
151
163
  }
152
- // Display greeting and start conversation loop
153
164
  process.stdout.write(`\n ${greeting}\n\n`);
154
- messages.push({ role: 'assistant', content: greeting });
155
- // Step 2: Multi-turn conversation (max MAX_TURNS exchanges total)
156
- let turnCount = 1; // greeting counts as turn 1
157
- let readyToExtract = false;
158
- while (turnCount < MAX_TURNS && !readyToExtract) {
165
+ state.messages.push({ role: 'assistant', content: greeting });
166
+ recentAssistantMessages.push(greeting);
167
+ // Advance past greet phase
168
+ state.completedPhases.add('greet');
169
+ state.phaseExchanges['greet'] = 1;
170
+ currentPhaseIndex = 1;
171
+ state.currentPhaseId = ONBOARDING_PHASES[1].id;
172
+ // Step 2: Phase-gated conversation loop
173
+ while (currentPhaseIndex < ONBOARDING_PHASES.length && totalExchanges < MAX_TOTAL_EXCHANGES) {
174
+ const phase = ONBOARDING_PHASES[currentPhaseIndex];
159
175
  // Get user input
160
176
  let userInput;
161
177
  try {
162
178
  userInput = await ask({
163
179
  message: 'You',
164
- placeholder: turnCount === 1 ? 'Tell me about your product...' : 'Continue...',
180
+ placeholder: totalExchanges === 0 ? 'Tell me about your product...' : '',
165
181
  });
166
182
  }
167
183
  catch {
168
- // User cancelled
169
- process.stdout.write(`\n ${dim('No worries! Tell me about your product anytime with')} ${bold('pb capture')}\n\n`);
184
+ process.stdout.write(`\n ${dim('No worries! Run')} ${bold('pb capture')} ${dim('anytime to add knowledge.')}\n\n`);
170
185
  return;
171
186
  }
172
187
  if (!userInput.trim()) {
173
- process.stdout.write(`\n ${dim('No worries! Tell me about your product anytime with')} ${bold('pb capture')}\n\n`);
174
- return;
188
+ emptyAttempts++;
189
+ if (emptyAttempts >= 2) {
190
+ process.stdout.write(`\n ${dim('No problem — explore with')} ${bold('pb orient -b')} ${dim('or capture with')} ${bold('pb capture')}\n\n`);
191
+ return;
192
+ }
193
+ process.stdout.write(` ${dim('Just hit Enter again to skip, or type anything to continue.')}\n\n`);
194
+ continue;
195
+ }
196
+ emptyAttempts = 0;
197
+ // Check if user wants to skip
198
+ if (userWantsToSkip(userInput) && state.totalUserWords > 10) {
199
+ // Skip to extraction with what we have
200
+ break;
201
+ }
202
+ // Update state
203
+ state.messages.push({ role: 'user', content: userInput });
204
+ state.userMessageCount++;
205
+ state.totalUserWords += countWords(userInput);
206
+ state.phaseExchanges[phase.id] = (state.phaseExchanges[phase.id] ?? 0) + 1;
207
+ totalExchanges++;
208
+ // Detect refusals (very short non-answers)
209
+ const wordCount = countWords(userInput);
210
+ if (wordCount <= 2) {
211
+ state.refusalStreak++;
212
+ }
213
+ else {
214
+ state.refusalStreak = 0;
175
215
  }
176
- messages.push({ role: 'user', content: userInput });
177
- turnCount++;
178
- // Get LLM response
216
+ // Update heuristic signals
217
+ state.signals = detectSignals(state.messages);
218
+ // Get LLM response with phase directive (bridge gets accumulated fragments)
219
+ const phaseDirective = buildPhaseDirective(phase, currentPhaseIndex, ONBOARDING_PHASES.length, accumulatedFragments);
179
220
  try {
180
221
  const result = await mcpCall('onboarding.chat', {
181
- messages,
222
+ messages: state.messages,
182
223
  workspaceName,
224
+ phaseDirective,
225
+ turnNumber: totalExchanges + 1,
226
+ totalUserWords: state.totalUserWords,
227
+ refusalStreak: state.refusalStreak,
183
228
  });
184
229
  if (result?.content) {
185
- process.stdout.write(`\n ${result.content}\n\n`);
186
- messages.push({ role: 'assistant', content: result.content });
187
- // Check if LLM signals readiness to synthesize
188
- if (hasSynthesisSignal(result.content)) {
189
- readyToExtract = true;
230
+ // Merge LLM-reported signals with heuristic signals
231
+ if (result.signals) {
232
+ state.signals = mergeSignals(state.signals, result.signals);
233
+ // Accumulate quotable fragments for bridge phase
234
+ if (result.signals.quotableFragments?.length) {
235
+ for (const frag of result.signals.quotableFragments) {
236
+ if (frag.trim() && !accumulatedFragments.includes(frag)) {
237
+ accumulatedFragments.push(frag);
238
+ }
239
+ }
240
+ }
190
241
  }
191
- }
192
- else {
193
- // LLM returned nothing — extract what we have
194
- readyToExtract = true;
242
+ // Post-process response
243
+ let displayMessage = sanitizeResponse(result.content);
244
+ displayMessage = enforceBridgeIsStatement(displayMessage, phase.id);
245
+ // Detect repetition — if too similar, add a nudge for next call
246
+ if (isTooSimilar(displayMessage, recentAssistantMessages)) {
247
+ state.refusalStreak++; // Will cause prompt to try different angle
248
+ }
249
+ process.stdout.write(`\n ${displayMessage}\n\n`);
250
+ state.messages.push({ role: 'assistant', content: displayMessage });
251
+ recentAssistantMessages.push(displayMessage);
195
252
  }
196
253
  }
197
254
  catch {
198
- // LLM call failed — extract what we have
199
- readyToExtract = true;
255
+ // LLM call failed — try to continue with what we have
256
+ break;
200
257
  }
258
+ // CLI evaluates phase — not the LLM
259
+ const advance = evaluatePhase(ONBOARDING_PHASES, state);
260
+ if (advance.complete) {
261
+ state.completedPhases.add(phase.id);
262
+ if (advance.readyToExtract) {
263
+ break; // All phases done — proceed to extraction
264
+ }
265
+ if (advance.nextPhase) {
266
+ currentPhaseIndex++;
267
+ state.currentPhaseId = advance.nextPhase.id;
268
+ }
269
+ }
270
+ // else: stay in current phase, loop again
271
+ }
272
+ // Hard floor: barely any content — offer manual capture
273
+ if (state.totalUserWords < 10 && state.userMessageCount > 0) {
274
+ process.stdout.write(`\n ${dim('No problem — you can add knowledge at your own pace with')} ${bold('pb capture')}\n\n`);
275
+ trackEvent('setup_completed', { capturedCount: 0, reason: 'insufficient_content' });
276
+ return;
201
277
  }
202
- trackEvent('first_capture_prompted', { mode: 'llm', turnCount });
278
+ trackEvent('first_capture_prompted', { mode: 'llm', exchanges: totalExchanges });
203
279
  // Step 3: Extract structured data from conversation
204
- if (messages.filter((m) => m.role === 'user').length === 0) {
205
- // No user messages nothing to extract
206
- process.stdout.write(`\n ${dim('No worries! Tell me about your product anytime with')} ${bold('pb capture')}\n\n`);
280
+ if (state.messages.filter((m) => m.role === 'user').length === 0) {
281
+ process.stdout.write(`\n ${dim('No worries! Run')} ${bold('pb capture')} ${dim('anytime to add knowledge.')}\n\n`);
207
282
  return;
208
283
  }
209
284
  let extraction;
210
285
  try {
211
286
  const result = await mcpCall('onboarding.chat', {
212
- messages,
287
+ messages: state.messages,
213
288
  workspaceName,
214
289
  mode: 'extract',
215
290
  });
@@ -223,29 +298,60 @@ export async function runOnboardingConversation(workspaceName) {
223
298
  process.stdout.write(`\n ${dim("Couldn't organize your answers just yet. Try")} ${bold('pb capture')} ${dim('to add knowledge manually.')}\n\n`);
224
299
  return;
225
300
  }
226
- // Step 4: Display extracted items and ask for approval
227
- displayExtraction(extraction);
228
- let approved;
229
- try {
230
- approved = await confirm({
231
- message: 'Ready to save these to your product brain?',
232
- initialValue: true,
233
- });
234
- }
235
- catch {
236
- process.stdout.write(`\n ${dim('No problem. Your answers are safe — run')} ${bold('pb capture')} ${dim('anytime.')}\n\n`);
301
+ // Step 4: Filter and cap entries
302
+ const allEntries = extractionToEntries(extraction);
303
+ // Filter out junk: single-word names, very short descriptions
304
+ const entriesToCapture = allEntries
305
+ .filter((e) => e.name.trim().split(/\s+/).length >= 2 || e.collection === 'glossary')
306
+ .slice(0, 5);
307
+ if (entriesToCapture.length === 0) {
308
+ process.stdout.write(`\n ${dim('Nothing to save yet. Try')} ${bold('pb capture')} ${dim('to add knowledge manually.')}\n\n`);
237
309
  return;
238
310
  }
239
- if (!approved) {
240
- process.stdout.write(`\n ${dim('No problem. Your answers are safe — run')} ${bold('pb capture')} ${dim('anytime.')}\n\n`);
241
- return;
311
+ // Step 5: Show ONE example with schema, offer to browse more
312
+ const itemCount = entriesToCapture.length;
313
+ process.stdout.write(`\n From this conversation, I'd remember ${bold(String(itemCount))} item${itemCount === 1 ? '' : 's'}.\n`);
314
+ process.stdout.write(` Here's an example:\n\n`);
315
+ displayEntryExample(entriesToCapture[0]);
316
+ let exampleIndex = 1; // Already showed index 0
317
+ let shouldSave = false;
318
+ // eslint-disable-next-line no-constant-condition
319
+ while (true) {
320
+ const hasMoreExamples = exampleIndex < entriesToCapture.length;
321
+ const options = [
322
+ { value: 'save', label: 'Save all to my product brain', hint: `${itemCount} item${itemCount === 1 ? '' : 's'}` },
323
+ ];
324
+ if (hasMoreExamples) {
325
+ options.push({ value: 'another', label: 'Show another example' });
326
+ }
327
+ options.push({ value: 'skip', label: 'Not right now' });
328
+ let choice;
329
+ try {
330
+ choice = await select({
331
+ message: '',
332
+ options,
333
+ });
334
+ }
335
+ catch {
336
+ process.stdout.write(`\n ${dim('No problem — run')} ${bold('pb capture')} ${dim('anytime.')}\n\n`);
337
+ return;
338
+ }
339
+ if (choice === 'skip') {
340
+ process.stdout.write(`\n ${dim('No problem — explore with')} ${bold('pb orient -b')} ${dim('or add more with')} ${bold('pb capture')}\n\n`);
341
+ return;
342
+ }
343
+ if (choice === 'save') {
344
+ shouldSave = true;
345
+ break;
346
+ }
347
+ if (choice === 'another') {
348
+ displayEntryExample(entriesToCapture[exampleIndex]);
349
+ exampleIndex++;
350
+ }
242
351
  }
243
- // Step 5: Batch-capture entries via chain.createEntry
244
- const entriesToCapture = extractionToEntries(extraction);
245
- if (entriesToCapture.length === 0) {
246
- process.stdout.write(`\n ${dim('Nothing to save. Try')} ${bold('pb capture')} ${dim('to add knowledge manually.')}\n\n`);
352
+ if (!shouldSave)
247
353
  return;
248
- }
354
+ // Step 6: Batch-capture entries
249
355
  let capturedCount = 0;
250
356
  const errors = [];
251
357
  for (const entry of entriesToCapture) {
@@ -263,22 +369,16 @@ export async function runOnboardingConversation(workspaceName) {
263
369
  errors.push(`${entry.name}: ${msg}`);
264
370
  }
265
371
  }
266
- // Step 6: Report results
372
+ // Step 7: Report results
267
373
  if (capturedCount > 0) {
268
374
  process.stdout.write(`\n ${green(icons.pass)} Saved ${capturedCount} item${capturedCount === 1 ? '' : 's'} to your product brain.\n`);
269
375
  trackEvent('capture_completed', { mode: 'llm', count: capturedCount });
270
376
  }
271
377
  if (errors.length > 0) {
272
- process.stdout.write(`\n ${dim(`${errors.length} item${errors.length === 1 ? '' : 's'} couldn't be saved:`)}\n`);
273
- for (const err of errors.slice(0, 3)) {
274
- process.stdout.write(` ${dim(err)}\n`);
275
- }
378
+ process.stdout.write(`\n ${dim(`${errors.length} couldn't be saved.`)}\n`);
276
379
  }
277
- // Step 7: Auto-retrieval proof (BR-141, PRI-58, FEAT-149)
380
+ // Step 8: Retrieval proof + closing
278
381
  await showRetrievalProof();
279
- // Step 8: Closing message with community link
280
- process.stdout.write(` ${dim('Join the community:')} ${bold('community.productbrain.io')}\n`);
281
- process.stdout.write(` ${dim('Your product brain gets smarter every time you capture.')}\n\n`);
282
382
  process.stdout.write(` ${dim('Explore with')} ${bold('pb orient -b')} ${dim('or add more with')} ${bold('pb capture')}\n\n`);
283
383
  trackEvent('setup_completed', { capturedCount });
284
384
  }
@@ -288,7 +388,7 @@ async function showRetrievalProof() {
288
388
  const orient = await mcpCall('orient', { brief: true });
289
389
  const items = orient?.sections?.flatMap((s) => s.items ?? []).filter((i) => i.name) ?? [];
290
390
  if (items.length > 0) {
291
- process.stdout.write(`\n ${bold('Here\'s how your product brain will use this:')}\n\n`);
391
+ process.stdout.write(`\n ${bold("Here's how your product brain will use this:")}\n\n`);
292
392
  for (const item of items.slice(0, 5)) {
293
393
  process.stdout.write(` ${dim(icons.dot)} ${item.name}\n`);
294
394
  }
@@ -299,18 +399,17 @@ async function showRetrievalProof() {
299
399
  }
300
400
  }
301
401
  catch {
302
- // Retrieval proof is best-effort — never block onboarding
402
+ // Retrieval proof is best-effort
303
403
  }
304
404
  }
305
405
  // ── Deterministic fallback ────────────────────────────────────────────────
306
406
  const FALLBACK_QUESTIONS = [
307
407
  { message: 'What are you building?', placeholder: 'A tool for...' },
308
408
  { message: 'Who is it for?', placeholder: 'Developers, designers, teams...' },
309
- { message: 'What\'s the biggest challenge right now?', placeholder: 'Scaling, onboarding, pricing...' },
409
+ { message: "What's the biggest challenge right now?", placeholder: 'Scaling, onboarding, pricing...' },
310
410
  ];
311
411
  /**
312
412
  * Deterministic fallback when LLM is unavailable.
313
- * Asks 3 hardcoded questions and maps answers to the same capture pipeline.
314
413
  */
315
414
  export async function runFallbackOnboarding(workspaceName) {
316
415
  trackEvent('setup_started', { mode: 'fallback' });
@@ -331,13 +430,22 @@ export async function runFallbackOnboarding(workspaceName) {
331
430
  return;
332
431
  }
333
432
  trackEvent('first_capture_prompted', { mode: 'fallback' });
334
- // Build extraction from answers
335
433
  const extraction = {
336
434
  vision: answers[0],
337
435
  audience: answers[1] || null,
338
436
  tensions: answers[2] ? [answers[2]] : null,
339
437
  };
340
- displayExtraction(extraction);
438
+ // Show extraction
439
+ process.stdout.write('\n');
440
+ if (extraction.vision)
441
+ process.stdout.write(` ${green('\u2726')} ${extraction.vision}\n`);
442
+ if (extraction.audience)
443
+ process.stdout.write(` ${green('\u2726')} ${extraction.audience}\n`);
444
+ if (extraction.tensions?.length) {
445
+ for (const t of extraction.tensions)
446
+ process.stdout.write(` ${green('\u2726')} ${t}\n`);
447
+ }
448
+ process.stdout.write('\n');
341
449
  let approved;
342
450
  try {
343
451
  approved = await confirm({ message: 'Ready to save these to your product brain?', initialValue: true });