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.
Files changed (42) hide show
  1. package/agents/gswd/architecture-drafter.md +70 -0
  2. package/agents/gswd/brainstorm-alternatives.md +60 -0
  3. package/agents/gswd/devils-advocate.md +57 -0
  4. package/agents/gswd/icp-persona.md +58 -0
  5. package/agents/gswd/integrations-checker.md +68 -0
  6. package/agents/gswd/journey-mapper.md +69 -0
  7. package/agents/gswd/market-researcher.md +54 -0
  8. package/agents/gswd/positioning.md +54 -0
  9. package/bin/gswd-tools.cjs +716 -0
  10. package/lib/audit.ts +959 -0
  11. package/lib/bootstrap.ts +617 -0
  12. package/lib/compile.ts +940 -0
  13. package/lib/config.ts +164 -0
  14. package/lib/imagine-agents.ts +154 -0
  15. package/lib/imagine-gate.ts +156 -0
  16. package/lib/imagine-input.ts +242 -0
  17. package/lib/imagine-synthesis.ts +402 -0
  18. package/lib/imagine.ts +433 -0
  19. package/lib/parse.ts +196 -0
  20. package/lib/render.ts +200 -0
  21. package/lib/specify-agents.ts +332 -0
  22. package/lib/specify-journeys.ts +410 -0
  23. package/lib/specify-nfr.ts +208 -0
  24. package/lib/specify-roles.ts +122 -0
  25. package/lib/specify.ts +773 -0
  26. package/lib/state.ts +305 -0
  27. package/package.json +26 -0
  28. package/templates/gswd/ARCHITECTURE.template.md +17 -0
  29. package/templates/gswd/AUDIT.template.md +31 -0
  30. package/templates/gswd/COMPETITION.template.md +18 -0
  31. package/templates/gswd/DECISIONS.template.md +18 -0
  32. package/templates/gswd/GTM.template.md +18 -0
  33. package/templates/gswd/ICP.template.md +18 -0
  34. package/templates/gswd/IMAGINE.template.md +24 -0
  35. package/templates/gswd/INTEGRATIONS.template.md +7 -0
  36. package/templates/gswd/JOURNEYS.template.md +7 -0
  37. package/templates/gswd/NFR.template.md +7 -0
  38. package/templates/gswd/PROJECT.template.md +21 -0
  39. package/templates/gswd/REQUIREMENTS.template.md +31 -0
  40. package/templates/gswd/ROADMAP.template.md +21 -0
  41. package/templates/gswd/SPEC.template.md +19 -0
  42. 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
+ }