@productbrain/cli 0.1.0-beta.34 → 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.
- package/dist/__tests__/capture.test.js +22 -0
- package/dist/__tests__/capture.test.js.map +1 -1
- package/dist/__tests__/onboarding.test.d.ts +1 -1
- package/dist/__tests__/onboarding.test.js +95 -100
- package/dist/__tests__/onboarding.test.js.map +1 -1
- package/dist/__tests__/setup.test.js +22 -74
- package/dist/__tests__/setup.test.js.map +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +32 -83
- package/dist/commands/setup.js.map +1 -1
- package/dist/lib/conversation-engine.d.ts +50 -0
- package/dist/lib/conversation-engine.d.ts.map +1 -0
- package/dist/lib/conversation-engine.js +134 -0
- package/dist/lib/conversation-engine.js.map +1 -0
- package/dist/lib/conversation-phases.d.ts +74 -0
- package/dist/lib/conversation-phases.d.ts.map +1 -0
- package/dist/lib/conversation-phases.js +11 -0
- package/dist/lib/conversation-phases.js.map +1 -0
- package/dist/lib/conversation-signals.d.ts +27 -0
- package/dist/lib/conversation-signals.d.ts.map +1 -0
- package/dist/lib/conversation-signals.js +53 -0
- package/dist/lib/conversation-signals.js.map +1 -0
- package/dist/lib/onboarding-phases.d.ts +9 -0
- package/dist/lib/onboarding-phases.d.ts.map +1 -0
- package/dist/lib/onboarding-phases.js +120 -0
- package/dist/lib/onboarding-phases.js.map +1 -0
- package/dist/lib/onboarding.d.ts +3 -5
- package/dist/lib/onboarding.d.ts.map +1 -1
- package/dist/lib/onboarding.js +224 -116
- package/dist/lib/onboarding.js.map +1 -1
- package/package.json +1 -1
|
@@ -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"}
|
package/dist/lib/onboarding.d.ts
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Onboarding conversation — LLM-powered
|
|
3
|
-
* WP-304
|
|
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:
|
|
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
|
|
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"}
|
package/dist/lib/onboarding.js
CHANGED
|
@@ -1,39 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Onboarding conversation — LLM-powered
|
|
3
|
-
* WP-304
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
+
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,
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
if (
|
|
118
|
-
process.stdout.write(`
|
|
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:
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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:
|
|
180
|
+
placeholder: totalExchanges === 0 ? 'Tell me about your product...' : '',
|
|
165
181
|
});
|
|
166
182
|
}
|
|
167
183
|
catch {
|
|
168
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 —
|
|
199
|
-
|
|
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',
|
|
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
|
-
|
|
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:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 });
|