popeye-cli 1.4.7 → 1.6.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 (214) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +264 -63
  3. package/dist/adapters/gemini.d.ts +1 -0
  4. package/dist/adapters/gemini.d.ts.map +1 -1
  5. package/dist/adapters/gemini.js +9 -4
  6. package/dist/adapters/gemini.js.map +1 -1
  7. package/dist/adapters/grok.d.ts +1 -0
  8. package/dist/adapters/grok.d.ts.map +1 -1
  9. package/dist/adapters/grok.js +9 -4
  10. package/dist/adapters/grok.js.map +1 -1
  11. package/dist/adapters/openai.d.ts +1 -1
  12. package/dist/adapters/openai.d.ts.map +1 -1
  13. package/dist/adapters/openai.js +35 -9
  14. package/dist/adapters/openai.js.map +1 -1
  15. package/dist/cli/commands/create.d.ts.map +1 -1
  16. package/dist/cli/commands/create.js +54 -4
  17. package/dist/cli/commands/create.js.map +1 -1
  18. package/dist/cli/interactive.d.ts +29 -0
  19. package/dist/cli/interactive.d.ts.map +1 -1
  20. package/dist/cli/interactive.js +132 -7
  21. package/dist/cli/interactive.js.map +1 -1
  22. package/dist/generators/all.d.ts +8 -2
  23. package/dist/generators/all.d.ts.map +1 -1
  24. package/dist/generators/all.js +37 -316
  25. package/dist/generators/all.js.map +1 -1
  26. package/dist/generators/doc-parser.d.ts +64 -0
  27. package/dist/generators/doc-parser.d.ts.map +1 -0
  28. package/dist/generators/doc-parser.js +407 -0
  29. package/dist/generators/doc-parser.js.map +1 -0
  30. package/dist/generators/frontend-design-analyzer.d.ts +30 -0
  31. package/dist/generators/frontend-design-analyzer.d.ts.map +1 -0
  32. package/dist/generators/frontend-design-analyzer.js +208 -0
  33. package/dist/generators/frontend-design-analyzer.js.map +1 -0
  34. package/dist/generators/shared-packages.d.ts +45 -0
  35. package/dist/generators/shared-packages.d.ts.map +1 -0
  36. package/dist/generators/shared-packages.js +456 -0
  37. package/dist/generators/shared-packages.js.map +1 -0
  38. package/dist/generators/templates/index.d.ts +8 -0
  39. package/dist/generators/templates/index.d.ts.map +1 -1
  40. package/dist/generators/templates/index.js +8 -0
  41. package/dist/generators/templates/index.js.map +1 -1
  42. package/dist/generators/templates/website-components.d.ts +33 -0
  43. package/dist/generators/templates/website-components.d.ts.map +1 -0
  44. package/dist/generators/templates/website-components.js +303 -0
  45. package/dist/generators/templates/website-components.js.map +1 -0
  46. package/dist/generators/templates/website-config.d.ts +55 -0
  47. package/dist/generators/templates/website-config.d.ts.map +1 -0
  48. package/dist/generators/templates/website-config.js +425 -0
  49. package/dist/generators/templates/website-config.js.map +1 -0
  50. package/dist/generators/templates/website-conversion.d.ts +27 -0
  51. package/dist/generators/templates/website-conversion.d.ts.map +1 -0
  52. package/dist/generators/templates/website-conversion.js +326 -0
  53. package/dist/generators/templates/website-conversion.js.map +1 -0
  54. package/dist/generators/templates/website-landing.d.ts +24 -0
  55. package/dist/generators/templates/website-landing.d.ts.map +1 -0
  56. package/dist/generators/templates/website-landing.js +276 -0
  57. package/dist/generators/templates/website-landing.js.map +1 -0
  58. package/dist/generators/templates/website-layout.d.ts +42 -0
  59. package/dist/generators/templates/website-layout.d.ts.map +1 -0
  60. package/dist/generators/templates/website-layout.js +408 -0
  61. package/dist/generators/templates/website-layout.js.map +1 -0
  62. package/dist/generators/templates/website-pricing.d.ts +11 -0
  63. package/dist/generators/templates/website-pricing.d.ts.map +1 -0
  64. package/dist/generators/templates/website-pricing.js +313 -0
  65. package/dist/generators/templates/website-pricing.js.map +1 -0
  66. package/dist/generators/templates/website-sections.d.ts +102 -0
  67. package/dist/generators/templates/website-sections.d.ts.map +1 -0
  68. package/dist/generators/templates/website-sections.js +444 -0
  69. package/dist/generators/templates/website-sections.js.map +1 -0
  70. package/dist/generators/templates/website-seo.d.ts +76 -0
  71. package/dist/generators/templates/website-seo.d.ts.map +1 -0
  72. package/dist/generators/templates/website-seo.js +326 -0
  73. package/dist/generators/templates/website-seo.js.map +1 -0
  74. package/dist/generators/templates/website.d.ts +10 -83
  75. package/dist/generators/templates/website.d.ts.map +1 -1
  76. package/dist/generators/templates/website.js +12 -875
  77. package/dist/generators/templates/website.js.map +1 -1
  78. package/dist/generators/website-content-scanner.d.ts +37 -0
  79. package/dist/generators/website-content-scanner.d.ts.map +1 -0
  80. package/dist/generators/website-content-scanner.js +165 -0
  81. package/dist/generators/website-content-scanner.js.map +1 -0
  82. package/dist/generators/website-context.d.ts +119 -0
  83. package/dist/generators/website-context.d.ts.map +1 -0
  84. package/dist/generators/website-context.js +350 -0
  85. package/dist/generators/website-context.js.map +1 -0
  86. package/dist/generators/website-debug.d.ts +68 -0
  87. package/dist/generators/website-debug.d.ts.map +1 -0
  88. package/dist/generators/website-debug.js +93 -0
  89. package/dist/generators/website-debug.js.map +1 -0
  90. package/dist/generators/website.d.ts +5 -0
  91. package/dist/generators/website.d.ts.map +1 -1
  92. package/dist/generators/website.js +136 -11
  93. package/dist/generators/website.js.map +1 -1
  94. package/dist/generators/workspace-root.d.ts +27 -0
  95. package/dist/generators/workspace-root.d.ts.map +1 -0
  96. package/dist/generators/workspace-root.js +100 -0
  97. package/dist/generators/workspace-root.js.map +1 -0
  98. package/dist/state/index.d.ts +35 -0
  99. package/dist/state/index.d.ts.map +1 -1
  100. package/dist/state/index.js +40 -0
  101. package/dist/state/index.js.map +1 -1
  102. package/dist/types/consensus.d.ts +3 -0
  103. package/dist/types/consensus.d.ts.map +1 -1
  104. package/dist/types/consensus.js +1 -0
  105. package/dist/types/consensus.js.map +1 -1
  106. package/dist/types/website-strategy.d.ts +263 -0
  107. package/dist/types/website-strategy.d.ts.map +1 -0
  108. package/dist/types/website-strategy.js +105 -0
  109. package/dist/types/website-strategy.js.map +1 -0
  110. package/dist/types/workflow.d.ts +21 -0
  111. package/dist/types/workflow.d.ts.map +1 -1
  112. package/dist/types/workflow.js +8 -0
  113. package/dist/types/workflow.js.map +1 -1
  114. package/dist/upgrade/handlers.d.ts +15 -0
  115. package/dist/upgrade/handlers.d.ts.map +1 -1
  116. package/dist/upgrade/handlers.js +52 -0
  117. package/dist/upgrade/handlers.js.map +1 -1
  118. package/dist/workflow/auto-fix-bundler.d.ts +37 -0
  119. package/dist/workflow/auto-fix-bundler.d.ts.map +1 -0
  120. package/dist/workflow/auto-fix-bundler.js +320 -0
  121. package/dist/workflow/auto-fix-bundler.js.map +1 -0
  122. package/dist/workflow/auto-fix.d.ts.map +1 -1
  123. package/dist/workflow/auto-fix.js +10 -3
  124. package/dist/workflow/auto-fix.js.map +1 -1
  125. package/dist/workflow/consensus.d.ts.map +1 -1
  126. package/dist/workflow/consensus.js +2 -0
  127. package/dist/workflow/consensus.js.map +1 -1
  128. package/dist/workflow/execution-mode.d.ts.map +1 -1
  129. package/dist/workflow/execution-mode.js +18 -0
  130. package/dist/workflow/execution-mode.js.map +1 -1
  131. package/dist/workflow/index.d.ts +4 -0
  132. package/dist/workflow/index.d.ts.map +1 -1
  133. package/dist/workflow/index.js +37 -0
  134. package/dist/workflow/index.js.map +1 -1
  135. package/dist/workflow/overview.d.ts +89 -0
  136. package/dist/workflow/overview.d.ts.map +1 -0
  137. package/dist/workflow/overview.js +358 -0
  138. package/dist/workflow/overview.js.map +1 -0
  139. package/dist/workflow/plan-mode.d.ts +6 -4
  140. package/dist/workflow/plan-mode.d.ts.map +1 -1
  141. package/dist/workflow/plan-mode.js +148 -6
  142. package/dist/workflow/plan-mode.js.map +1 -1
  143. package/dist/workflow/website-strategy.d.ts +79 -0
  144. package/dist/workflow/website-strategy.d.ts.map +1 -0
  145. package/dist/workflow/website-strategy.js +310 -0
  146. package/dist/workflow/website-strategy.js.map +1 -0
  147. package/dist/workflow/website-updater.d.ts +17 -0
  148. package/dist/workflow/website-updater.d.ts.map +1 -0
  149. package/dist/workflow/website-updater.js +116 -0
  150. package/dist/workflow/website-updater.js.map +1 -0
  151. package/dist/workflow/workflow-logger.d.ts +1 -1
  152. package/dist/workflow/workflow-logger.d.ts.map +1 -1
  153. package/dist/workflow/workflow-logger.js.map +1 -1
  154. package/package.json +1 -1
  155. package/src/adapters/gemini.ts +10 -4
  156. package/src/adapters/grok.ts +10 -4
  157. package/src/adapters/openai.ts +38 -6
  158. package/src/cli/commands/create.ts +58 -4
  159. package/src/cli/interactive.ts +143 -7
  160. package/src/generators/all.ts +49 -332
  161. package/src/generators/doc-parser.ts +449 -0
  162. package/src/generators/frontend-design-analyzer.ts +261 -0
  163. package/src/generators/shared-packages.ts +500 -0
  164. package/src/generators/templates/index.ts +8 -0
  165. package/src/generators/templates/website-components.ts +330 -0
  166. package/src/generators/templates/website-config.ts +444 -0
  167. package/src/generators/templates/website-conversion.ts +341 -0
  168. package/src/generators/templates/website-landing.ts +331 -0
  169. package/src/generators/templates/website-layout.ts +443 -0
  170. package/src/generators/templates/website-pricing.ts +330 -0
  171. package/src/generators/templates/website-sections.ts +541 -0
  172. package/src/generators/templates/website-seo.ts +370 -0
  173. package/src/generators/templates/website.ts +38 -905
  174. package/src/generators/website-content-scanner.ts +208 -0
  175. package/src/generators/website-context.ts +493 -0
  176. package/src/generators/website-debug.ts +130 -0
  177. package/src/generators/website.ts +178 -20
  178. package/src/generators/workspace-root.ts +113 -0
  179. package/src/state/index.ts +56 -0
  180. package/src/types/consensus.ts +3 -0
  181. package/src/types/website-strategy.ts +243 -0
  182. package/src/types/workflow.ts +21 -0
  183. package/src/upgrade/handlers.ts +65 -0
  184. package/src/workflow/auto-fix-bundler.ts +392 -0
  185. package/src/workflow/auto-fix.ts +11 -3
  186. package/src/workflow/consensus.ts +2 -0
  187. package/src/workflow/execution-mode.ts +21 -0
  188. package/src/workflow/index.ts +37 -0
  189. package/src/workflow/overview.ts +475 -0
  190. package/src/workflow/plan-mode.ts +193 -8
  191. package/src/workflow/website-strategy.ts +379 -0
  192. package/src/workflow/website-updater.ts +142 -0
  193. package/src/workflow/workflow-logger.ts +1 -0
  194. package/tests/adapters/persona-switching.test.ts +63 -0
  195. package/tests/cli/project-naming.test.ts +136 -0
  196. package/tests/generators/doc-parser.test.ts +121 -0
  197. package/tests/generators/frontend-design-analyzer.test.ts +90 -0
  198. package/tests/generators/quality-gate.test.ts +183 -0
  199. package/tests/generators/shared-packages.test.ts +83 -0
  200. package/tests/generators/website-components.test.ts +159 -0
  201. package/tests/generators/website-config.test.ts +84 -0
  202. package/tests/generators/website-content-scanner.test.ts +181 -0
  203. package/tests/generators/website-context.test.ts +331 -0
  204. package/tests/generators/website-debug.test.ts +77 -0
  205. package/tests/generators/website-landing.test.ts +188 -0
  206. package/tests/generators/website-pricing.test.ts +98 -0
  207. package/tests/generators/website-sections.test.ts +245 -0
  208. package/tests/generators/website-seo-quality.test.ts +246 -0
  209. package/tests/generators/workspace-root.test.ts +105 -0
  210. package/tests/upgrade/handlers.test.ts +162 -0
  211. package/tests/workflow/auto-fix-bundler.test.ts +242 -0
  212. package/tests/workflow/overview.test.ts +392 -0
  213. package/tests/workflow/plan-mode.test.ts +111 -1
  214. package/tests/workflow/website-strategy.test.ts +246 -0
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Website strategy generator
3
+ * AI-powered marketing strategy creation from product context,
4
+ * with caching via input hash and file-based storage
5
+ */
6
+
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { createHash } from 'node:crypto';
10
+ import type {
11
+ WebsiteStrategyDocument,
12
+ StrategyMetadata,
13
+ BrandAssetsContract,
14
+ } from '../types/website-strategy.js';
15
+ import { WebsiteStrategySchema } from '../types/website-strategy.js';
16
+ import { createClient } from '../adapters/openai.js';
17
+
18
+ /** File name for persisted strategy */
19
+ const STRATEGY_FILE = 'website-strategy.json';
20
+
21
+ /**
22
+ * Input for strategy generation
23
+ * Market research is user-assisted, not hallucinated
24
+ */
25
+ export interface StrategyInput {
26
+ /** Combined userDocs + specification */
27
+ productContext: string;
28
+ /** Project name */
29
+ projectName: string;
30
+ /** Resolved brand assets */
31
+ brandAssets: BrandAssetsContract;
32
+ /** User-supplied competitors (from --competitors flag or docs) */
33
+ competitors?: string[];
34
+ /** User-supplied keywords (from --keywords flag or docs) */
35
+ keywords?: string[];
36
+ /** Optional market notes from user */
37
+ marketNotes?: string;
38
+ }
39
+
40
+ /**
41
+ * Generate a website marketing strategy from product context
42
+ *
43
+ * @param input - Strategy generation inputs
44
+ * @param onProgress - Optional progress callback
45
+ * @returns Strategy document with metadata
46
+ */
47
+ export async function generateWebsiteStrategy(
48
+ input: StrategyInput,
49
+ onProgress?: (msg: string) => void
50
+ ): Promise<{ strategy: WebsiteStrategyDocument; metadata: StrategyMetadata }> {
51
+ onProgress?.('Analyzing product context for strategy...');
52
+
53
+ const client = await createClient();
54
+
55
+ const competitorsBlock = input.competitors && input.competitors.length > 0
56
+ ? `\n\nKNOWN COMPETITORS (user-supplied):\n${input.competitors.map(c => `- ${c}`).join('\n')}`
57
+ : '';
58
+
59
+ const keywordsBlock = input.keywords && input.keywords.length > 0
60
+ ? `\n\nTARGET KEYWORDS (user-supplied):\n${input.keywords.map(k => `- ${k}`).join('\n')}`
61
+ : '';
62
+
63
+ const marketNotesBlock = input.marketNotes
64
+ ? `\n\nMARKET NOTES FROM USER:\n${input.marketNotes}`
65
+ : '';
66
+
67
+ const prompt = `You are a Senior Product Marketing Strategist and SEO expert.
68
+ Analyze the following product documentation and generate a complete website marketing strategy.
69
+
70
+ PRODUCT NAME: ${input.projectName}
71
+
72
+ PRODUCT DOCUMENTATION:
73
+ ${packProductContext(input.productContext)}
74
+ ${competitorsBlock}${keywordsBlock}${marketNotesBlock}
75
+
76
+ Generate a JSON response matching this exact structure:
77
+ {
78
+ "icp": {
79
+ "primaryPersona": "string - describe the ideal customer",
80
+ "painPoints": ["array of pain points this product solves"],
81
+ "goals": ["array of goals the customer has"],
82
+ "objections": ["array of common objections/concerns"]
83
+ },
84
+ "positioning": {
85
+ "category": "product category",
86
+ "differentiators": ["what makes this unique"],
87
+ "valueProposition": "one-sentence value prop",
88
+ "proofPoints": ["evidence that supports the value prop"]
89
+ },
90
+ "messaging": {
91
+ "headline": "primary H1 headline for landing page",
92
+ "subheadline": "supporting subheadline",
93
+ "elevatorPitch": "30-second pitch",
94
+ "longDescription": "2-3 sentence detailed description"
95
+ },
96
+ "seoStrategy": {
97
+ "primaryKeywords": ["3-5 primary keywords"],
98
+ "secondaryKeywords": ["5-8 secondary keywords"],
99
+ "longTailKeywords": ["5-10 long-tail keyword phrases"],
100
+ "titleTemplates": {"home": "title", "pricing": "title", ...},
101
+ "metaDescriptions": {"home": "description", "pricing": "description", ...}
102
+ },
103
+ "siteArchitecture": {
104
+ "pages": [
105
+ {
106
+ "path": "/",
107
+ "title": "Home",
108
+ "purpose": "primary landing and conversion",
109
+ "pageType": "landing",
110
+ "sections": ["hero", "features", "social-proof", "cta"],
111
+ "seoKeywords": ["keyword1"],
112
+ "conversionGoal": "sign up for trial"
113
+ }
114
+ ],
115
+ "navigation": [{"label": "Home", "href": "/"}],
116
+ "footerSections": [{"title": "Product", "links": [{"label": "Features", "href": "/#features"}]}]
117
+ },
118
+ "conversionStrategy": {
119
+ "primaryCta": {"text": "Get Started", "href": "/pricing"},
120
+ "secondaryCta": {"text": "Learn More", "href": "/docs"},
121
+ "trustSignals": ["signal 1"],
122
+ "socialProof": ["proof point 1"],
123
+ "leadCapture": "webhook"
124
+ },
125
+ "competitiveContext": {
126
+ "category": "product category",
127
+ "competitors": ["only competitors from user input or clearly mentioned in docs"],
128
+ "differentiators": ["competitive advantages"]
129
+ }
130
+ }
131
+
132
+ IMPORTANT RULES:
133
+ 1. For competitors: ONLY use competitors provided in the input or clearly mentioned in the product docs. Do NOT invent competitors you are unsure about.
134
+ 2. For keywords: If keywords are provided, use them. Otherwise, infer from product capabilities.
135
+ 3. Every page must have a clear conversion goal.
136
+ 4. Headlines must be specific to the product, not generic marketing copy.
137
+ 5. Always include at minimum: landing, pricing pages.
138
+ 6. Site architecture should have realistic navigation and footer sections.
139
+
140
+ Respond with ONLY valid JSON, no markdown code fences or explanation.`;
141
+
142
+ onProgress?.('Generating website strategy via AI...');
143
+
144
+ const completion = await client.chat.completions.create({
145
+ model: 'gpt-4o',
146
+ messages: [{ role: 'user', content: prompt }],
147
+ temperature: 0.4,
148
+ max_tokens: 4096,
149
+ response_format: { type: 'json_object' },
150
+ });
151
+
152
+ const rawResponse = completion.choices[0]?.message?.content || '{}';
153
+
154
+ onProgress?.('Validating strategy schema...');
155
+
156
+ let parsed: unknown;
157
+ try {
158
+ parsed = JSON.parse(rawResponse);
159
+ } catch {
160
+ throw new Error('Strategy generation returned invalid JSON');
161
+ }
162
+
163
+ const result = WebsiteStrategySchema.safeParse(parsed);
164
+ if (!result.success) {
165
+ const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ');
166
+ throw new Error(`Strategy validation failed: ${issues}`);
167
+ }
168
+
169
+ const strategy = result.data as WebsiteStrategyDocument;
170
+ const inputHash = computeInputHash(input);
171
+ const metadata: StrategyMetadata = {
172
+ inputHash,
173
+ generatedAt: new Date().toISOString(),
174
+ version: 1,
175
+ };
176
+
177
+ return { strategy, metadata };
178
+ }
179
+
180
+ /**
181
+ * Format strategy for injection into plan context
182
+ * Returns a structured text block, NOT appended to specification
183
+ *
184
+ * @param strategy - The strategy document
185
+ * @returns Formatted context string
186
+ */
187
+ export function formatStrategyForPlanContext(
188
+ strategy: WebsiteStrategyDocument
189
+ ): string {
190
+ const lines: string[] = [];
191
+
192
+ lines.push(`### Target Customer`);
193
+ lines.push(`- Persona: ${strategy.icp.primaryPersona}`);
194
+ lines.push(`- Pain points: ${strategy.icp.painPoints.join(', ')}`);
195
+ lines.push('');
196
+
197
+ lines.push(`### Positioning`);
198
+ lines.push(`- Category: ${strategy.positioning.category}`);
199
+ lines.push(`- Value proposition: ${strategy.positioning.valueProposition}`);
200
+ lines.push(`- Differentiators: ${strategy.positioning.differentiators.join(', ')}`);
201
+ lines.push('');
202
+
203
+ lines.push(`### Messaging`);
204
+ lines.push(`- Headline: ${strategy.messaging.headline}`);
205
+ lines.push(`- Subheadline: ${strategy.messaging.subheadline}`);
206
+ lines.push('');
207
+
208
+ lines.push(`### SEO Keywords`);
209
+ lines.push(`- Primary: ${strategy.seoStrategy.primaryKeywords.join(', ')}`);
210
+ lines.push(`- Secondary: ${strategy.seoStrategy.secondaryKeywords.join(', ')}`);
211
+ lines.push('');
212
+
213
+ lines.push(`### Site Architecture`);
214
+ for (const page of strategy.siteArchitecture.pages) {
215
+ lines.push(`- ${page.path} (${page.pageType}): ${page.purpose}`);
216
+ }
217
+ lines.push('');
218
+
219
+ lines.push(`### Conversion Strategy`);
220
+ lines.push(`- Primary CTA: "${strategy.conversionStrategy.primaryCta.text}" -> ${strategy.conversionStrategy.primaryCta.href}`);
221
+ lines.push(`- Secondary CTA: "${strategy.conversionStrategy.secondaryCta.text}" -> ${strategy.conversionStrategy.secondaryCta.href}`);
222
+ lines.push(`- Trust signals: ${strategy.conversionStrategy.trustSignals.join(', ')}`);
223
+ lines.push(`- Lead capture: ${strategy.conversionStrategy.leadCapture}`);
224
+
225
+ return lines.join('\n');
226
+ }
227
+
228
+ /**
229
+ * Store website strategy to .popeye/ directory
230
+ *
231
+ * @param projectDir - Project directory (contains .popeye/)
232
+ * @param strategy - Strategy document
233
+ * @param metadata - Strategy metadata
234
+ */
235
+ export async function storeWebsiteStrategy(
236
+ projectDir: string,
237
+ strategy: WebsiteStrategyDocument,
238
+ metadata: StrategyMetadata
239
+ ): Promise<void> {
240
+ const popeyeDir = path.join(projectDir, '.popeye');
241
+ await fs.mkdir(popeyeDir, { recursive: true });
242
+
243
+ const filePath = path.join(popeyeDir, STRATEGY_FILE);
244
+ const content = JSON.stringify({ strategy, metadata }, null, 2);
245
+ await fs.writeFile(filePath, content, 'utf-8');
246
+ }
247
+
248
+ /**
249
+ * Load website strategy from .popeye/ directory
250
+ *
251
+ * @param projectDir - Project directory
252
+ * @returns Strategy and metadata, or null if not found
253
+ */
254
+ export async function loadWebsiteStrategy(
255
+ projectDir: string
256
+ ): Promise<{ strategy: WebsiteStrategyDocument; metadata: StrategyMetadata } | null> {
257
+ const filePath = path.join(projectDir, '.popeye', STRATEGY_FILE);
258
+
259
+ try {
260
+ const content = await fs.readFile(filePath, 'utf-8');
261
+ const parsed = JSON.parse(content);
262
+
263
+ const strategyResult = WebsiteStrategySchema.safeParse(parsed.strategy);
264
+ if (!strategyResult.success) return null;
265
+
266
+ return {
267
+ strategy: strategyResult.data as WebsiteStrategyDocument,
268
+ metadata: parsed.metadata,
269
+ };
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Check if a stored strategy is stale by comparing input hashes
277
+ *
278
+ * @param projectDir - Project directory
279
+ * @param currentInput - Current strategy input
280
+ * @returns True if strategy is stale or missing
281
+ */
282
+ export async function isStrategyStale(
283
+ projectDir: string,
284
+ currentInput: StrategyInput
285
+ ): Promise<boolean> {
286
+ const stored = await loadWebsiteStrategy(projectDir);
287
+ if (!stored) return true;
288
+
289
+ const currentHash = computeInputHash(currentInput);
290
+ return stored.metadata.inputHash !== currentHash;
291
+ }
292
+
293
+ /**
294
+ * Pack product context into a budget with priority-based ordering
295
+ * Ensures high-priority docs (spec, pricing, brand) are included first
296
+ *
297
+ * @param productContext - Raw concatenated docs with "--- filename ---" headers
298
+ * @param budget - Maximum character budget (default 16000)
299
+ * @returns Packed context string within budget
300
+ */
301
+ export function packProductContext(productContext: string, budget: number = 16000): string {
302
+ // Split by doc headers (--- filename ---)
303
+ const headerPattern = /^---\s+(.+?)\s+---$/gm;
304
+ const sections: Array<{ header: string; content: string; priority: number }> = [];
305
+ let lastIndex = 0;
306
+ let lastHeader = '';
307
+ let match;
308
+
309
+ while ((match = headerPattern.exec(productContext)) !== null) {
310
+ if (lastIndex > 0) {
311
+ sections.push({
312
+ header: lastHeader,
313
+ content: productContext.slice(lastIndex, match.index).trim(),
314
+ priority: getDocSortPriority(lastHeader),
315
+ });
316
+ }
317
+ lastHeader = match[0];
318
+ lastIndex = match.index + match[0].length;
319
+ }
320
+ // Push the last section
321
+ if (lastIndex > 0 && lastIndex < productContext.length) {
322
+ sections.push({
323
+ header: lastHeader,
324
+ content: productContext.slice(lastIndex).trim(),
325
+ priority: getDocSortPriority(lastHeader),
326
+ });
327
+ }
328
+
329
+ // If no headers found, return the raw context trimmed to budget
330
+ if (sections.length === 0) {
331
+ return productContext.slice(0, budget);
332
+ }
333
+
334
+ // Sort by priority (lower = more important)
335
+ sections.sort((a, b) => a.priority - b.priority);
336
+
337
+ // Pack into budget
338
+ let packed = '';
339
+ for (const section of sections) {
340
+ const block = `${section.header}\n${section.content}\n\n`;
341
+ if (packed.length + block.length <= budget) {
342
+ packed += block;
343
+ } else {
344
+ const remaining = budget - packed.length;
345
+ if (remaining > 200) {
346
+ packed += `${section.header}\n${section.content.slice(0, remaining - section.header.length - 10)}...\n`;
347
+ }
348
+ break;
349
+ }
350
+ }
351
+
352
+ return packed.trim();
353
+ }
354
+
355
+ /**
356
+ * Get sort priority for a doc section header (lower = higher priority)
357
+ */
358
+ function getDocSortPriority(header: string): number {
359
+ const lower = header.toLowerCase();
360
+ if (/spec/i.test(lower)) return 1;
361
+ if (/pricing/i.test(lower)) return 2;
362
+ if (/color|brand/i.test(lower)) return 3;
363
+ if (/feature/i.test(lower)) return 4;
364
+ return 5;
365
+ }
366
+
367
+ /**
368
+ * Compute SHA-256 hash of strategy inputs for staleness detection
369
+ */
370
+ function computeInputHash(input: StrategyInput): string {
371
+ const data = [
372
+ input.productContext,
373
+ input.projectName,
374
+ (input.competitors || []).sort().join(','),
375
+ (input.keywords || []).sort().join(','),
376
+ ].join('|');
377
+
378
+ return createHash('sha256').update(data).digest('hex');
379
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Website content updater
3
+ * Refreshes website template content after plan mode succeeds,
4
+ * using the expanded specification and discovered user docs
5
+ */
6
+
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+ import type { ProjectState } from '../types/workflow.js';
10
+ import type { OutputLanguage } from '../types/project.js';
11
+ import { isWorkspace } from '../types/project.js';
12
+ import { buildWebsiteContext, resolveBrandAssets, validateWebsiteContext } from '../generators/website-context.js';
13
+ import { resolveWorkspaceRoot } from '../generators/workspace-root.js';
14
+ import { generateWebsiteLandingPage } from '../generators/templates/website-landing.js';
15
+ import { generateWebsitePricingPage } from '../generators/templates/website-pricing.js';
16
+ import {
17
+ generateWebsiteLayout,
18
+ generateWebsiteGlobalsCss,
19
+ } from '../generators/templates/website-layout.js';
20
+ import {
21
+ generateWebsiteHeader,
22
+ generateWebsiteFooter,
23
+ } from '../generators/templates/website-components.js';
24
+ import { loadWebsiteStrategy } from './website-strategy.js';
25
+
26
+ /**
27
+ * Update website content files with project context after plan mode
28
+ *
29
+ * @param projectDir - The project directory
30
+ * @param state - Current project state (with specification, userDocs, brandContext)
31
+ * @param language - Project language type
32
+ * @param onProgress - Optional progress callback
33
+ */
34
+ export async function updateWebsiteContent(
35
+ projectDir: string,
36
+ state: ProjectState,
37
+ language: OutputLanguage,
38
+ onProgress?: (message: string) => void
39
+ ): Promise<void> {
40
+ // Determine website directory based on project type
41
+ const websiteDir = isWorkspace(language)
42
+ ? path.join(projectDir, 'apps', 'website')
43
+ : projectDir;
44
+
45
+ // Check if website directory exists
46
+ try {
47
+ await fs.access(websiteDir);
48
+ } catch {
49
+ onProgress?.('Website directory not found, skipping content update');
50
+ return;
51
+ }
52
+
53
+ // Build content context from user docs and specification
54
+ const parentDir = path.dirname(projectDir);
55
+ const context = await buildWebsiteContext(
56
+ parentDir,
57
+ state.name,
58
+ state.specification
59
+ );
60
+
61
+ // Apply brand context from state if available
62
+ if (state.brandContext?.primaryColor) {
63
+ context.brand = {
64
+ ...context.brand,
65
+ primaryColor: state.brandContext.primaryColor,
66
+ };
67
+ }
68
+ if (state.brandContext?.logoPath) {
69
+ context.brand = {
70
+ ...context.brand,
71
+ logoPath: state.brandContext.logoPath,
72
+ };
73
+ }
74
+
75
+ // Resolve brand assets using workspace root for proper logo resolution
76
+ const workspaceRoot = await resolveWorkspaceRoot(parentDir);
77
+ context.brandAssets = await resolveBrandAssets(workspaceRoot, context.brand);
78
+
79
+ // Load website strategy if available
80
+ const strategyData = await loadWebsiteStrategy(projectDir);
81
+ if (strategyData) {
82
+ context.strategy = strategyData.strategy;
83
+ onProgress?.('Loaded website strategy for content update');
84
+ }
85
+
86
+ // Soft validation: report quality issues via progress callback
87
+ const validation = validateWebsiteContext(context, state.name);
88
+ for (const msg of [...validation.issues, ...validation.warnings]) {
89
+ onProgress?.(`[quality-gate] ${msg}`);
90
+ }
91
+
92
+ onProgress?.('Updating website content with project context...');
93
+
94
+ // Re-generate content files
95
+ const updates: Array<{ path: string; content: string }> = [
96
+ {
97
+ path: path.join(websiteDir, 'src', 'app', 'page.tsx'),
98
+ content: generateWebsiteLandingPage(state.name, context),
99
+ },
100
+ {
101
+ path: path.join(websiteDir, 'src', 'app', 'pricing', 'page.tsx'),
102
+ content: generateWebsitePricingPage(state.name, context),
103
+ },
104
+ {
105
+ path: path.join(websiteDir, 'src', 'app', 'layout.tsx'),
106
+ content: generateWebsiteLayout(state.name, context),
107
+ },
108
+ {
109
+ path: path.join(websiteDir, 'src', 'app', 'globals.css'),
110
+ content: generateWebsiteGlobalsCss(context),
111
+ },
112
+ {
113
+ path: path.join(websiteDir, 'src', 'components', 'Header.tsx'),
114
+ content: generateWebsiteHeader(state.name, context, context.strategy),
115
+ },
116
+ {
117
+ path: path.join(websiteDir, 'src', 'components', 'Footer.tsx'),
118
+ content: generateWebsiteFooter(state.name, context, context.strategy),
119
+ },
120
+ ];
121
+
122
+ for (const update of updates) {
123
+ try {
124
+ await fs.writeFile(update.path, update.content, 'utf-8');
125
+ } catch {
126
+ // Non-blocking: individual file update failures should not stop the workflow
127
+ }
128
+ }
129
+
130
+ // Copy logo to public/brand/ if brand context has one
131
+ if (context.brand?.logoPath) {
132
+ try {
133
+ const logoExt = path.extname(context.brand.logoPath);
134
+ const destPath = path.join(websiteDir, 'public', 'brand', `logo${logoExt}`);
135
+ await fs.copyFile(context.brand.logoPath, destPath);
136
+ } catch {
137
+ // Non-blocking
138
+ }
139
+ }
140
+
141
+ onProgress?.('Website content updated with project context');
142
+ }
@@ -39,6 +39,7 @@ export type WorkflowStage =
39
39
  | 'verification'
40
40
  | 'ui-design'
41
41
  | 'ui-setup'
42
+ | 'website-strategy'
42
43
  | 'completion';
43
44
 
44
45
  /**
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Persona switching tests
3
+ * Validates that reviewerPersona is correctly threaded through consensus config
4
+ * and that website projects use marketing persona
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import {
9
+ ConsensusConfigSchema,
10
+ DEFAULT_CONSENSUS_CONFIG,
11
+ } from '../../src/types/consensus.js';
12
+
13
+ /** Minimal required fields to parse ConsensusConfigSchema */
14
+ const BASE_CONFIG = { openaiModel: 'gpt-4o' };
15
+
16
+ describe('Reviewer Persona in ConsensusConfig', () => {
17
+ it('uses default architect persona when reviewerPersona is undefined', () => {
18
+ const config = ConsensusConfigSchema.parse(BASE_CONFIG);
19
+ expect(config.reviewerPersona).toBeUndefined();
20
+ // Default config should not have a persona
21
+ expect(DEFAULT_CONSENSUS_CONFIG.reviewerPersona).toBeUndefined();
22
+ });
23
+
24
+ it('accepts custom marketing persona when reviewerPersona is set', () => {
25
+ const marketingPersona =
26
+ 'a Senior Product Marketing Strategist, SEO expert, and Fullstack Web Architect';
27
+ const config = ConsensusConfigSchema.parse({
28
+ ...BASE_CONFIG,
29
+ reviewerPersona: marketingPersona,
30
+ });
31
+ expect(config.reviewerPersona).toBe(marketingPersona);
32
+ });
33
+
34
+ it('validates reviewerPersona as optional string', () => {
35
+ // Omitted entirely
36
+ const withoutPersona = ConsensusConfigSchema.parse(BASE_CONFIG);
37
+ expect(withoutPersona.reviewerPersona).toBeUndefined();
38
+
39
+ // Explicit undefined
40
+ const withUndefined = ConsensusConfigSchema.parse({
41
+ ...BASE_CONFIG,
42
+ reviewerPersona: undefined,
43
+ });
44
+ expect(withUndefined.reviewerPersona).toBeUndefined();
45
+ });
46
+
47
+ it('preserves persona through full config with other fields', () => {
48
+ const config = ConsensusConfigSchema.parse({
49
+ ...BASE_CONFIG,
50
+ reviewer: 'gemini',
51
+ threshold: 90,
52
+ maxIterations: 5,
53
+ reviewerPersona: 'a DevOps engineer with 10 years of experience',
54
+ });
55
+
56
+ expect(config.reviewer).toBe('gemini');
57
+ expect(config.threshold).toBe(90);
58
+ expect(config.maxIterations).toBe(5);
59
+ expect(config.reviewerPersona).toBe(
60
+ 'a DevOps engineer with 10 years of experience'
61
+ );
62
+ });
63
+ });