gswd 0.1.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.
- package/agents/gswd/architecture-drafter.md +70 -0
- package/agents/gswd/brainstorm-alternatives.md +60 -0
- package/agents/gswd/devils-advocate.md +57 -0
- package/agents/gswd/icp-persona.md +58 -0
- package/agents/gswd/integrations-checker.md +68 -0
- package/agents/gswd/journey-mapper.md +69 -0
- package/agents/gswd/market-researcher.md +54 -0
- package/agents/gswd/positioning.md +54 -0
- package/bin/gswd-tools.cjs +716 -0
- package/lib/audit.ts +959 -0
- package/lib/bootstrap.ts +617 -0
- package/lib/compile.ts +940 -0
- package/lib/config.ts +164 -0
- package/lib/imagine-agents.ts +154 -0
- package/lib/imagine-gate.ts +156 -0
- package/lib/imagine-input.ts +242 -0
- package/lib/imagine-synthesis.ts +402 -0
- package/lib/imagine.ts +433 -0
- package/lib/parse.ts +196 -0
- package/lib/render.ts +200 -0
- package/lib/specify-agents.ts +332 -0
- package/lib/specify-journeys.ts +410 -0
- package/lib/specify-nfr.ts +208 -0
- package/lib/specify-roles.ts +122 -0
- package/lib/specify.ts +773 -0
- package/lib/state.ts +305 -0
- package/package.json +26 -0
- package/templates/gswd/ARCHITECTURE.template.md +17 -0
- package/templates/gswd/AUDIT.template.md +31 -0
- package/templates/gswd/COMPETITION.template.md +18 -0
- package/templates/gswd/DECISIONS.template.md +18 -0
- package/templates/gswd/GTM.template.md +18 -0
- package/templates/gswd/ICP.template.md +18 -0
- package/templates/gswd/IMAGINE.template.md +24 -0
- package/templates/gswd/INTEGRATIONS.template.md +7 -0
- package/templates/gswd/JOURNEYS.template.md +7 -0
- package/templates/gswd/NFR.template.md +7 -0
- package/templates/gswd/PROJECT.template.md +21 -0
- package/templates/gswd/REQUIREMENTS.template.md +31 -0
- package/templates/gswd/ROADMAP.template.md +21 -0
- package/templates/gswd/SPEC.template.md +19 -0
- package/templates/gswd/STATE.template.md +15 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSWD Imagine Input Module — Input collection with file parsing and intake building
|
|
3
|
+
*
|
|
4
|
+
* Two input paths converge to a single StarterBrief interface:
|
|
5
|
+
* 1. parseIdeaFile: Parse @idea.md into a starter brief
|
|
6
|
+
* 2. buildFromIntake: Build from 3-question intake answers
|
|
7
|
+
*
|
|
8
|
+
* Schema: GSWD_SPEC.md Section 8.2, Steps 1-2
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
|
|
13
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface StarterBrief {
|
|
16
|
+
vision: string; // What the product does / core value prop
|
|
17
|
+
target_user: string; // Who it's for — ICP signal
|
|
18
|
+
why_now: string; // Why build this now — timing/opportunity
|
|
19
|
+
raw_themes: string[]; // Key themes extracted from input
|
|
20
|
+
source: 'file' | 'intake';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IntakeAnswers {
|
|
24
|
+
vision: string;
|
|
25
|
+
user: string;
|
|
26
|
+
whyNow: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ValidationResult {
|
|
30
|
+
valid: boolean;
|
|
31
|
+
missing: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Find the first sentence containing any of the given keywords.
|
|
38
|
+
* Returns the full sentence or null if no match.
|
|
39
|
+
*/
|
|
40
|
+
function findSentenceWithKeywords(content: string, keywords: string[]): string | null {
|
|
41
|
+
// Split into sentences (rough: split on . ! ? followed by space or end)
|
|
42
|
+
const sentences = content.split(/(?<=[.!?])\s+/);
|
|
43
|
+
const lowerKeywords = keywords.map(k => k.toLowerCase());
|
|
44
|
+
|
|
45
|
+
for (const sentence of sentences) {
|
|
46
|
+
const lower = sentence.toLowerCase();
|
|
47
|
+
for (const keyword of lowerKeywords) {
|
|
48
|
+
if (lower.includes(keyword)) {
|
|
49
|
+
return sentence.trim();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract the first meaningful paragraph (>20 chars, not a heading).
|
|
58
|
+
*/
|
|
59
|
+
function extractFirstParagraph(content: string): string {
|
|
60
|
+
const lines = content.split('\n');
|
|
61
|
+
let paragraph = '';
|
|
62
|
+
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
// Skip headings and empty lines
|
|
66
|
+
if (trimmed.startsWith('#') || trimmed === '') {
|
|
67
|
+
if (paragraph.length > 20) return paragraph.trim();
|
|
68
|
+
paragraph = '';
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
paragraph += (paragraph ? ' ' : '') + trimmed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return paragraph.length > 20 ? paragraph.trim() : '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract raw themes from content: headings, bullets, bold text.
|
|
79
|
+
* Deduplicates and limits to 20 items.
|
|
80
|
+
*/
|
|
81
|
+
function extractThemes(content: string): string[] {
|
|
82
|
+
const themes = new Set<string>();
|
|
83
|
+
const lines = content.split('\n');
|
|
84
|
+
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
|
|
88
|
+
// Headings (strip # prefix)
|
|
89
|
+
if (trimmed.startsWith('#')) {
|
|
90
|
+
const heading = trimmed.replace(/^#+\s*/, '').trim();
|
|
91
|
+
if (heading.length > 2) themes.add(heading);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Bullet points (strip - or * prefix)
|
|
95
|
+
if (/^[-*]\s+/.test(trimmed)) {
|
|
96
|
+
const bullet = trimmed.replace(/^[-*]\s+/, '').trim();
|
|
97
|
+
if (bullet.length > 2) themes.add(bullet);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Numbered items
|
|
101
|
+
if (/^\d+[.)]\s+/.test(trimmed)) {
|
|
102
|
+
const item = trimmed.replace(/^\d+[.)]\s+/, '').trim();
|
|
103
|
+
if (item.length > 2) themes.add(item);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Also extract bold text (**text**)
|
|
108
|
+
const boldRegex = /\*\*([^*]+)\*\*/g;
|
|
109
|
+
let match: RegExpExecArray | null;
|
|
110
|
+
while ((match = boldRegex.exec(content)) !== null) {
|
|
111
|
+
const bold = match[1].trim();
|
|
112
|
+
if (bold.length > 2) themes.add(bold);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return Array.from(themes).slice(0, 20);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Extract themes from intake answers by splitting on delimiters.
|
|
120
|
+
*/
|
|
121
|
+
function extractIntakeThemes(combined: string): string[] {
|
|
122
|
+
const themes = new Set<string>();
|
|
123
|
+
|
|
124
|
+
// Split on commas, semicolons, and " and " conjunctions
|
|
125
|
+
const parts = combined.split(/[,;]|\s+and\s+/i);
|
|
126
|
+
|
|
127
|
+
for (const part of parts) {
|
|
128
|
+
const trimmed = part.trim();
|
|
129
|
+
// Filter out very short fragments and common filler words
|
|
130
|
+
if (trimmed.length > 3) {
|
|
131
|
+
themes.add(trimmed);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Array.from(themes).slice(0, 20);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse an @idea.md file into a StarterBrief.
|
|
142
|
+
*
|
|
143
|
+
* Extracts vision, target user, timing, and themes from freeform markdown.
|
|
144
|
+
* Falls back to first 200 chars if specific fields can't be extracted.
|
|
145
|
+
* Throws on empty or non-existent files.
|
|
146
|
+
*/
|
|
147
|
+
export function parseIdeaFile(filePath: string): StarterBrief {
|
|
148
|
+
// Read file
|
|
149
|
+
let content: string;
|
|
150
|
+
try {
|
|
151
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
152
|
+
} catch (err: unknown) {
|
|
153
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
154
|
+
throw new Error(`Cannot read idea file "${filePath}": ${message}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check for empty file
|
|
158
|
+
if (content.trim().length < 10) {
|
|
159
|
+
throw new Error(`Idea file "${filePath}" is too short (< 10 chars). Provide a more detailed description.`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const fallback = content.trim().slice(0, 200);
|
|
163
|
+
|
|
164
|
+
// Extract vision: first heading content or first paragraph
|
|
165
|
+
const headingMatch = content.match(/^#+\s+(.+)$/m);
|
|
166
|
+
let vision = headingMatch ? headingMatch[1].trim() : '';
|
|
167
|
+
if (!vision || vision.length <= 20) {
|
|
168
|
+
const para = extractFirstParagraph(content);
|
|
169
|
+
vision = para || fallback;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Extract target user
|
|
173
|
+
const userKeywords = ['for', 'target', 'customer', 'audience', 'users', 'people who', 'designed for', 'built for'];
|
|
174
|
+
let target_user = findSentenceWithKeywords(content, userKeywords) || '';
|
|
175
|
+
if (!target_user) {
|
|
176
|
+
target_user = fallback;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Extract why now
|
|
180
|
+
const whyNowKeywords = ['because', 'opportunity', 'problem', 'pain', 'frustration', 'gap', 'trend', 'growing', 'increasing'];
|
|
181
|
+
let why_now = findSentenceWithKeywords(content, whyNowKeywords) || '';
|
|
182
|
+
if (!why_now) {
|
|
183
|
+
why_now = fallback;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Extract themes
|
|
187
|
+
const raw_themes = extractThemes(content);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
vision,
|
|
191
|
+
target_user,
|
|
192
|
+
why_now,
|
|
193
|
+
raw_themes,
|
|
194
|
+
source: 'file',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Build a StarterBrief from 3-question intake answers.
|
|
200
|
+
*
|
|
201
|
+
* Maps: vision -> vision, user -> target_user, whyNow -> why_now.
|
|
202
|
+
* Extracts themes by splitting answers on delimiters.
|
|
203
|
+
*/
|
|
204
|
+
export function buildFromIntake(answers: IntakeAnswers): StarterBrief {
|
|
205
|
+
const combined = `${answers.vision}, ${answers.user}, ${answers.whyNow}`;
|
|
206
|
+
const raw_themes = extractIntakeThemes(combined);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
vision: answers.vision.trim(),
|
|
210
|
+
target_user: answers.user.trim(),
|
|
211
|
+
why_now: answers.whyNow.trim(),
|
|
212
|
+
raw_themes,
|
|
213
|
+
source: 'intake',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Validate that a StarterBrief has sufficient signal for research agents.
|
|
219
|
+
*
|
|
220
|
+
* Checks: vision > 10 chars, target_user > 5 chars, why_now > 5 chars, raw_themes >= 1.
|
|
221
|
+
*/
|
|
222
|
+
export function validateBrief(brief: StarterBrief): ValidationResult {
|
|
223
|
+
const missing: string[] = [];
|
|
224
|
+
|
|
225
|
+
if (!brief.vision || brief.vision.length <= 10) {
|
|
226
|
+
missing.push('vision');
|
|
227
|
+
}
|
|
228
|
+
if (!brief.target_user || brief.target_user.length <= 5) {
|
|
229
|
+
missing.push('target_user');
|
|
230
|
+
}
|
|
231
|
+
if (!brief.why_now || brief.why_now.length <= 5) {
|
|
232
|
+
missing.push('why_now');
|
|
233
|
+
}
|
|
234
|
+
if (!brief.raw_themes || brief.raw_themes.length < 1) {
|
|
235
|
+
missing.push('raw_themes');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
valid: missing.length === 0,
|
|
240
|
+
missing,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSWD Imagine Synthesis Module — Direction synthesis and auto-mode scoring
|
|
3
|
+
*
|
|
4
|
+
* Combines research from 5 agents into 3 direction options.
|
|
5
|
+
* Provides auto-mode scoring (pain x willingness-to-pay x reachability)
|
|
6
|
+
* for unattended decision-making per GSWD_SPEC Section 8.2 Step 4 and Section 10.
|
|
7
|
+
*
|
|
8
|
+
* Schema: GSWD_SPEC.md Section 8.2 Steps 4-5, Section 10
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AgentResult } from './imagine-agents.js';
|
|
12
|
+
|
|
13
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface Direction {
|
|
16
|
+
label: string; // "Direction 1: [Name]"
|
|
17
|
+
icp_summary: string; // Who this targets
|
|
18
|
+
problem_framing: string; // The problem being solved
|
|
19
|
+
wedge: string; // MVP boundary / entry point
|
|
20
|
+
differentiator: string; // What makes this unique
|
|
21
|
+
risks: string[]; // Top 2-3 risks for this direction
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AutoDecision {
|
|
25
|
+
type: string; // 'icp' | 'wedge' | 'direction' | 'metric'
|
|
26
|
+
chosen: string; // What was selected
|
|
27
|
+
rationale: string; // Why — human-readable
|
|
28
|
+
score?: number; // Numeric score if applicable
|
|
29
|
+
recorded_at: string; // ISO timestamp
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SynthesisResult {
|
|
33
|
+
proposed: Direction; // Primary recommendation
|
|
34
|
+
alternatives: Direction[]; // 2 alternatives
|
|
35
|
+
agent_warnings: string[]; // Any agents that failed or had issues
|
|
36
|
+
raw_agent_outputs: Record<string, string>; // agent name -> content
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ScoreEntry {
|
|
40
|
+
label: string;
|
|
41
|
+
score: number;
|
|
42
|
+
rationale: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ScoreResult {
|
|
46
|
+
index: number;
|
|
47
|
+
scores: ScoreEntry[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Keyword Lists for Scoring ──────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const PAIN_KEYWORDS = [
|
|
53
|
+
'pain', 'frustration', 'struggle', 'hate', 'waste', 'broken',
|
|
54
|
+
'manual', 'slow', 'expensive', 'tedious', 'annoying', 'difficult',
|
|
55
|
+
'complex', 'error-prone', 'time-consuming', 'inefficient',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const WTP_KEYWORDS = [
|
|
59
|
+
'pay', 'budget', 'spend', 'invest', 'subscribe', 'cost',
|
|
60
|
+
'pricing', 'premium', 'revenue', 'monetize', 'purchase',
|
|
61
|
+
'willing to pay', 'price point', 'subscription',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const REACHABILITY_KEYWORDS = [
|
|
65
|
+
'community', 'forum', 'slack', 'twitter', 'meetup', 'conference',
|
|
66
|
+
'newsletter', 'open source', 'reddit', 'discord', 'linkedin',
|
|
67
|
+
'github', 'youtube', 'blog', 'podcast', 'channel',
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Count keyword occurrences in text (case-insensitive).
|
|
74
|
+
* Returns a score clamped to [0, 10].
|
|
75
|
+
*/
|
|
76
|
+
function countKeywords(text: string, keywords: string[]): number {
|
|
77
|
+
const lower = text.toLowerCase();
|
|
78
|
+
let count = 0;
|
|
79
|
+
for (const kw of keywords) {
|
|
80
|
+
// Count all occurrences of each keyword
|
|
81
|
+
let idx = 0;
|
|
82
|
+
while (true) {
|
|
83
|
+
const found = lower.indexOf(kw, idx);
|
|
84
|
+
if (found === -1) break;
|
|
85
|
+
count++;
|
|
86
|
+
idx = found + kw.length;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Clamp to 0-10 range
|
|
90
|
+
return Math.min(10, count);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extract content for a specific agent from the results array.
|
|
95
|
+
* Returns empty string if agent not found or failed.
|
|
96
|
+
*/
|
|
97
|
+
function getAgentContent(results: AgentResult[], agentName: string): string {
|
|
98
|
+
const result = results.find(r => r.agent === agentName);
|
|
99
|
+
if (!result || result.status === 'failed') return '';
|
|
100
|
+
return result.content;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse direction sections from brainstorm-alternatives output.
|
|
105
|
+
* Looks for ## Direction 1/2/3 headings and extracts sub-fields.
|
|
106
|
+
*/
|
|
107
|
+
function parseDirectionSections(content: string): { label: string; body: string }[] {
|
|
108
|
+
const directions: { label: string; body: string }[] = [];
|
|
109
|
+
// Match ## Direction N: ... or ## Direction N — ...
|
|
110
|
+
const regex = /^##\s+(Direction\s+\d+[:\s—–-]*[^\n]*)/gm;
|
|
111
|
+
const matches: { label: string; index: number }[] = [];
|
|
112
|
+
|
|
113
|
+
let match: RegExpExecArray | null;
|
|
114
|
+
while ((match = regex.exec(content)) !== null) {
|
|
115
|
+
matches.push({ label: match[1].trim(), index: match.index });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (let i = 0; i < matches.length; i++) {
|
|
119
|
+
const start = matches[i].index;
|
|
120
|
+
const end = i + 1 < matches.length ? matches[i + 1].index : content.length;
|
|
121
|
+
const body = content.slice(start, end).trim();
|
|
122
|
+
directions.push({ label: matches[i].label, body });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return directions;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extract a named field from a direction body.
|
|
130
|
+
* Looks for **FieldName:** pattern.
|
|
131
|
+
*/
|
|
132
|
+
function extractField(body: string, fieldName: string): string {
|
|
133
|
+
const regex = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+?)(?=\\n\\*\\*|$)`, 'is');
|
|
134
|
+
const match = body.match(regex);
|
|
135
|
+
return match ? match[1].trim() : '';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build a Direction from parsed section data, enriched with ICP and risk data.
|
|
140
|
+
*/
|
|
141
|
+
function buildDirection(
|
|
142
|
+
label: string,
|
|
143
|
+
body: string,
|
|
144
|
+
icpContent: string,
|
|
145
|
+
risksContent: string,
|
|
146
|
+
): Direction {
|
|
147
|
+
const icp = extractField(body, 'ICP') || extractIcpSummary(icpContent);
|
|
148
|
+
const problem = extractField(body, 'Problem') || 'See agent research outputs';
|
|
149
|
+
const wedge = extractField(body, 'Wedge') || extractField(body, 'MVP scope') || 'To be refined';
|
|
150
|
+
const differentiator = extractField(body, 'Differentiator') || 'To be refined';
|
|
151
|
+
const riskField = extractField(body, 'Risk');
|
|
152
|
+
const risks = riskField ? [riskField] : extractRiskList(risksContent);
|
|
153
|
+
|
|
154
|
+
return { label, icp_summary: icp, problem_framing: problem, wedge, differentiator, risks };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Extract a short ICP summary from the icp-persona agent output.
|
|
159
|
+
*/
|
|
160
|
+
function extractIcpSummary(content: string): string {
|
|
161
|
+
if (!content) return 'ICP data unavailable (agent failed)';
|
|
162
|
+
// Try to find ## ICP Profile section and grab first meaningful line
|
|
163
|
+
const profileMatch = content.match(/##\s*ICP Profile\s*\n+([\s\S]*?)(?=\n##|\n$|$)/);
|
|
164
|
+
if (profileMatch) {
|
|
165
|
+
const lines = profileMatch[1].trim().split('\n').filter(l => l.trim().length > 0);
|
|
166
|
+
return lines.slice(0, 2).join('; ') || content.slice(0, 200);
|
|
167
|
+
}
|
|
168
|
+
return content.slice(0, 200);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extract risk items from the devils-advocate agent output.
|
|
173
|
+
*/
|
|
174
|
+
function extractRiskList(content: string): string[] {
|
|
175
|
+
if (!content) return ['Risk data unavailable (agent failed)'];
|
|
176
|
+
const risksMatch = content.match(/##\s*Risks\s*\n+([\s\S]*?)(?=\n##|$)/);
|
|
177
|
+
if (risksMatch) {
|
|
178
|
+
const lines = risksMatch[1].trim().split('\n')
|
|
179
|
+
.filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
|
|
180
|
+
.map(l => l.replace(/^[-*]\s*/, '').trim())
|
|
181
|
+
.filter(l => l.length > 0);
|
|
182
|
+
return lines.slice(0, 3);
|
|
183
|
+
}
|
|
184
|
+
return ['See devils-advocate output for risk details'];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Synthesis ──────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Synthesize 5 agent results into 3 direction options.
|
|
191
|
+
*
|
|
192
|
+
* Handles missing agent data gracefully — failed agents produce degraded
|
|
193
|
+
* output with warnings, not crashes.
|
|
194
|
+
*/
|
|
195
|
+
export function synthesizeDirections(agentResults: AgentResult[]): SynthesisResult {
|
|
196
|
+
const warnings: string[] = [];
|
|
197
|
+
const rawOutputs: Record<string, string> = {};
|
|
198
|
+
|
|
199
|
+
// Collect raw outputs and track failures
|
|
200
|
+
for (const result of agentResults) {
|
|
201
|
+
if (result.status === 'complete') {
|
|
202
|
+
rawOutputs[result.agent] = result.content;
|
|
203
|
+
} else {
|
|
204
|
+
warnings.push(`Agent '${result.agent}' failed: ${result.error || 'unknown error'}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Extract content per agent
|
|
209
|
+
const marketContent = getAgentContent(agentResults, 'market-researcher');
|
|
210
|
+
const icpContent = getAgentContent(agentResults, 'icp-persona');
|
|
211
|
+
const positioningContent = getAgentContent(agentResults, 'positioning');
|
|
212
|
+
const brainstormContent = getAgentContent(agentResults, 'brainstorm-alternatives');
|
|
213
|
+
const risksContent = getAgentContent(agentResults, 'devils-advocate');
|
|
214
|
+
|
|
215
|
+
let proposed: Direction;
|
|
216
|
+
let alternatives: Direction[];
|
|
217
|
+
|
|
218
|
+
if (brainstormContent) {
|
|
219
|
+
// Parse 3 directions from brainstorm agent
|
|
220
|
+
const sections = parseDirectionSections(brainstormContent);
|
|
221
|
+
|
|
222
|
+
if (sections.length >= 3) {
|
|
223
|
+
proposed = buildDirection(sections[0].label, sections[0].body, icpContent, risksContent);
|
|
224
|
+
alternatives = [
|
|
225
|
+
buildDirection(sections[1].label, sections[1].body, icpContent, risksContent),
|
|
226
|
+
buildDirection(sections[2].label, sections[2].body, icpContent, risksContent),
|
|
227
|
+
];
|
|
228
|
+
} else if (sections.length >= 1) {
|
|
229
|
+
// Partial parse — use what we have
|
|
230
|
+
proposed = buildDirection(sections[0].label, sections[0].body, icpContent, risksContent);
|
|
231
|
+
alternatives = sections.slice(1).map(s => buildDirection(s.label, s.body, icpContent, risksContent));
|
|
232
|
+
// Fill missing alternatives with degraded entries
|
|
233
|
+
while (alternatives.length < 2) {
|
|
234
|
+
alternatives.push(buildDegradedDirection(alternatives.length + 2, icpContent, positioningContent, risksContent));
|
|
235
|
+
}
|
|
236
|
+
warnings.push('Brainstorm agent produced fewer than 3 directions; some alternatives are degraded');
|
|
237
|
+
} else {
|
|
238
|
+
// No parseable directions — build fully degraded
|
|
239
|
+
proposed = buildDegradedDirection(1, icpContent, positioningContent, risksContent);
|
|
240
|
+
alternatives = [
|
|
241
|
+
buildDegradedDirection(2, icpContent, positioningContent, risksContent),
|
|
242
|
+
buildDegradedDirection(3, icpContent, positioningContent, risksContent),
|
|
243
|
+
];
|
|
244
|
+
warnings.push('Could not parse directions from brainstorm agent output');
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// Brainstorm agent failed entirely — build from remaining data
|
|
248
|
+
proposed = buildDegradedDirection(1, icpContent, positioningContent, risksContent);
|
|
249
|
+
alternatives = [
|
|
250
|
+
buildDegradedDirection(2, icpContent, positioningContent, risksContent),
|
|
251
|
+
buildDegradedDirection(3, icpContent, positioningContent, risksContent),
|
|
252
|
+
];
|
|
253
|
+
if (!warnings.some(w => w.includes('brainstorm-alternatives'))) {
|
|
254
|
+
warnings.push('Brainstorm-alternatives agent data unavailable; directions are degraded');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Enrich proposed direction with positioning data
|
|
259
|
+
if (positioningContent && proposed.differentiator === 'To be refined') {
|
|
260
|
+
const vpMatch = positioningContent.match(/##\s*Value Proposition\s*\n+([\s\S]*?)(?=\n##|$)/);
|
|
261
|
+
if (vpMatch) {
|
|
262
|
+
const firstLine = vpMatch[1].trim().split('\n')[0];
|
|
263
|
+
if (firstLine) proposed.differentiator = firstLine;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { proposed, alternatives, agent_warnings: warnings, raw_agent_outputs: rawOutputs };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Build a degraded direction when brainstorm data is unavailable.
|
|
272
|
+
*/
|
|
273
|
+
function buildDegradedDirection(
|
|
274
|
+
num: number,
|
|
275
|
+
icpContent: string,
|
|
276
|
+
positioningContent: string,
|
|
277
|
+
risksContent: string,
|
|
278
|
+
): Direction {
|
|
279
|
+
const icp = extractIcpSummary(icpContent);
|
|
280
|
+
const differentiator = positioningContent
|
|
281
|
+
? extractValueProp(positioningContent)
|
|
282
|
+
: 'Requires manual definition';
|
|
283
|
+
const risks = extractRiskList(risksContent);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
label: `Direction ${num}: Requires manual elaboration`,
|
|
287
|
+
icp_summary: icp,
|
|
288
|
+
problem_framing: 'Derived from available agent data — needs founder input',
|
|
289
|
+
wedge: 'To be defined',
|
|
290
|
+
differentiator,
|
|
291
|
+
risks,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Extract value proposition from positioning agent output.
|
|
297
|
+
*/
|
|
298
|
+
function extractValueProp(content: string): string {
|
|
299
|
+
const vpMatch = content.match(/##\s*Value Proposition\s*\n+([\s\S]*?)(?=\n##|$)/);
|
|
300
|
+
if (vpMatch) {
|
|
301
|
+
const firstLine = vpMatch[1].trim().split('\n')[0];
|
|
302
|
+
if (firstLine) return firstLine;
|
|
303
|
+
}
|
|
304
|
+
return 'See positioning agent output';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Scoring ────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Score directions using pain x willingness-to-pay x reachability heuristic.
|
|
311
|
+
*
|
|
312
|
+
* Uses keyword counting as a proxy since these are text-derived signals.
|
|
313
|
+
* Formula: pain * 0.4 + wtp * 0.3 + reachability * 0.3
|
|
314
|
+
*
|
|
315
|
+
* Per GSWD_SPEC: "choose ICP with highest pain x willingness-to-pay x reachability score"
|
|
316
|
+
*/
|
|
317
|
+
export function scoreIcpOptions(directions: Direction[]): ScoreResult {
|
|
318
|
+
const scores: ScoreEntry[] = directions.map(dir => {
|
|
319
|
+
// Combine all text fields for keyword analysis
|
|
320
|
+
const text = [
|
|
321
|
+
dir.icp_summary,
|
|
322
|
+
dir.problem_framing,
|
|
323
|
+
dir.wedge,
|
|
324
|
+
dir.differentiator,
|
|
325
|
+
...dir.risks,
|
|
326
|
+
].join(' ');
|
|
327
|
+
|
|
328
|
+
const painScore = countKeywords(text, PAIN_KEYWORDS);
|
|
329
|
+
const wtpScore = countKeywords(text, WTP_KEYWORDS);
|
|
330
|
+
const reachScore = countKeywords(text, REACHABILITY_KEYWORDS);
|
|
331
|
+
|
|
332
|
+
const composite = painScore * 0.4 + wtpScore * 0.3 + reachScore * 0.3;
|
|
333
|
+
// Round to 2 decimal places
|
|
334
|
+
const score = Math.round(composite * 100) / 100;
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
label: dir.label,
|
|
338
|
+
score,
|
|
339
|
+
rationale: `Pain: ${painScore}/10, WTP: ${wtpScore}/10, Reachability: ${reachScore}/10 => weighted ${score}`,
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Sort descending by score
|
|
344
|
+
scores.sort((a, b) => b.score - a.score);
|
|
345
|
+
|
|
346
|
+
// Find index in original array of highest-scoring direction
|
|
347
|
+
const topLabel = scores[0]?.label;
|
|
348
|
+
const index = directions.findIndex(d => d.label === topLabel);
|
|
349
|
+
|
|
350
|
+
return { index: index >= 0 ? index : 0, scores };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── Auto Selection ─────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Auto-select the best direction and generate decision records.
|
|
357
|
+
*
|
|
358
|
+
* Per GSWD_SPEC Section 8.2 Auto behavior:
|
|
359
|
+
* - Choose ICP with highest pain x WTP x reachability score
|
|
360
|
+
* - Choose wedge with smallest scope that still hits an "aha"
|
|
361
|
+
* - Record decisions as Auto-chosen with rationale
|
|
362
|
+
*/
|
|
363
|
+
export function autoSelectDirection(
|
|
364
|
+
synthesis: SynthesisResult,
|
|
365
|
+
): { selected: Direction; decisions: AutoDecision[] } {
|
|
366
|
+
const allDirections = [synthesis.proposed, ...synthesis.alternatives];
|
|
367
|
+
const scoreResult = scoreIcpOptions(allDirections);
|
|
368
|
+
|
|
369
|
+
const selected = allDirections[scoreResult.index];
|
|
370
|
+
const topScore = scoreResult.scores[0];
|
|
371
|
+
const now = new Date().toISOString();
|
|
372
|
+
|
|
373
|
+
const decisions: AutoDecision[] = [
|
|
374
|
+
{
|
|
375
|
+
type: 'direction',
|
|
376
|
+
chosen: selected.label,
|
|
377
|
+
rationale: `Auto-selected as highest-scoring direction. ${topScore.rationale}`,
|
|
378
|
+
score: topScore.score,
|
|
379
|
+
recorded_at: now,
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
type: 'icp',
|
|
383
|
+
chosen: selected.icp_summary,
|
|
384
|
+
rationale: `ICP from ${selected.label} scored highest on pain x WTP x reachability composite`,
|
|
385
|
+
recorded_at: now,
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
type: 'wedge',
|
|
389
|
+
chosen: selected.wedge,
|
|
390
|
+
rationale: `Wedge selected as smallest scope delivering the "aha" moment from ${selected.label}`,
|
|
391
|
+
recorded_at: now,
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
type: 'metric',
|
|
395
|
+
chosen: 'Activation rate, week-1 retention',
|
|
396
|
+
rationale: 'Default metrics aligned with wedge strategy per GSWD_SPEC auto-mode policy',
|
|
397
|
+
recorded_at: now,
|
|
398
|
+
},
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
return { selected, decisions };
|
|
402
|
+
}
|