popeye-cli 1.0.1 → 1.2.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 (216) hide show
  1. package/.env.example +24 -1
  2. package/CONTRIBUTING.md +275 -0
  3. package/OPEN_SOURCE_MANIFESTO.md +172 -0
  4. package/README.md +832 -123
  5. package/dist/adapters/claude.d.ts +19 -4
  6. package/dist/adapters/claude.d.ts.map +1 -1
  7. package/dist/adapters/claude.js +908 -42
  8. package/dist/adapters/claude.js.map +1 -1
  9. package/dist/adapters/gemini.d.ts +55 -0
  10. package/dist/adapters/gemini.d.ts.map +1 -0
  11. package/dist/adapters/gemini.js +318 -0
  12. package/dist/adapters/gemini.js.map +1 -0
  13. package/dist/adapters/grok.d.ts +73 -0
  14. package/dist/adapters/grok.d.ts.map +1 -0
  15. package/dist/adapters/grok.js +430 -0
  16. package/dist/adapters/grok.js.map +1 -0
  17. package/dist/adapters/openai.d.ts +1 -1
  18. package/dist/adapters/openai.d.ts.map +1 -1
  19. package/dist/adapters/openai.js +47 -8
  20. package/dist/adapters/openai.js.map +1 -1
  21. package/dist/auth/claude.d.ts +11 -9
  22. package/dist/auth/claude.d.ts.map +1 -1
  23. package/dist/auth/claude.js +107 -71
  24. package/dist/auth/claude.js.map +1 -1
  25. package/dist/auth/gemini.d.ts +58 -0
  26. package/dist/auth/gemini.d.ts.map +1 -0
  27. package/dist/auth/gemini.js +172 -0
  28. package/dist/auth/gemini.js.map +1 -0
  29. package/dist/auth/grok.d.ts +73 -0
  30. package/dist/auth/grok.d.ts.map +1 -0
  31. package/dist/auth/grok.js +211 -0
  32. package/dist/auth/grok.js.map +1 -0
  33. package/dist/auth/index.d.ts +14 -7
  34. package/dist/auth/index.d.ts.map +1 -1
  35. package/dist/auth/index.js +41 -6
  36. package/dist/auth/index.js.map +1 -1
  37. package/dist/auth/keychain.d.ts +20 -7
  38. package/dist/auth/keychain.d.ts.map +1 -1
  39. package/dist/auth/keychain.js +85 -29
  40. package/dist/auth/keychain.js.map +1 -1
  41. package/dist/auth/openai.d.ts +2 -2
  42. package/dist/auth/openai.d.ts.map +1 -1
  43. package/dist/auth/openai.js +30 -32
  44. package/dist/auth/openai.js.map +1 -1
  45. package/dist/cli/commands/auth.d.ts +1 -1
  46. package/dist/cli/commands/auth.d.ts.map +1 -1
  47. package/dist/cli/commands/auth.js +79 -8
  48. package/dist/cli/commands/auth.js.map +1 -1
  49. package/dist/cli/commands/create.d.ts.map +1 -1
  50. package/dist/cli/commands/create.js +15 -4
  51. package/dist/cli/commands/create.js.map +1 -1
  52. package/dist/cli/interactive.d.ts.map +1 -1
  53. package/dist/cli/interactive.js +1494 -114
  54. package/dist/cli/interactive.js.map +1 -1
  55. package/dist/config/defaults.d.ts +9 -1
  56. package/dist/config/defaults.d.ts.map +1 -1
  57. package/dist/config/defaults.js +19 -2
  58. package/dist/config/defaults.js.map +1 -1
  59. package/dist/config/index.d.ts +19 -0
  60. package/dist/config/index.d.ts.map +1 -1
  61. package/dist/config/index.js +33 -1
  62. package/dist/config/index.js.map +1 -1
  63. package/dist/config/schema.d.ts +47 -0
  64. package/dist/config/schema.d.ts.map +1 -1
  65. package/dist/config/schema.js +29 -1
  66. package/dist/config/schema.js.map +1 -1
  67. package/dist/generators/fullstack.d.ts +32 -0
  68. package/dist/generators/fullstack.d.ts.map +1 -0
  69. package/dist/generators/fullstack.js +497 -0
  70. package/dist/generators/fullstack.js.map +1 -0
  71. package/dist/generators/index.d.ts +4 -3
  72. package/dist/generators/index.d.ts.map +1 -1
  73. package/dist/generators/index.js +15 -1
  74. package/dist/generators/index.js.map +1 -1
  75. package/dist/generators/python.d.ts +17 -1
  76. package/dist/generators/python.d.ts.map +1 -1
  77. package/dist/generators/python.js +34 -20
  78. package/dist/generators/python.js.map +1 -1
  79. package/dist/generators/templates/fullstack.d.ts +113 -0
  80. package/dist/generators/templates/fullstack.d.ts.map +1 -0
  81. package/dist/generators/templates/fullstack.js +1004 -0
  82. package/dist/generators/templates/fullstack.js.map +1 -0
  83. package/dist/generators/typescript.d.ts +19 -1
  84. package/dist/generators/typescript.d.ts.map +1 -1
  85. package/dist/generators/typescript.js +37 -20
  86. package/dist/generators/typescript.js.map +1 -1
  87. package/dist/state/index.d.ts +108 -0
  88. package/dist/state/index.d.ts.map +1 -1
  89. package/dist/state/index.js +551 -4
  90. package/dist/state/index.js.map +1 -1
  91. package/dist/state/registry.d.ts +52 -0
  92. package/dist/state/registry.d.ts.map +1 -0
  93. package/dist/state/registry.js +215 -0
  94. package/dist/state/registry.js.map +1 -0
  95. package/dist/types/cli.d.ts +8 -0
  96. package/dist/types/cli.d.ts.map +1 -1
  97. package/dist/types/cli.js.map +1 -1
  98. package/dist/types/consensus.d.ts +186 -4
  99. package/dist/types/consensus.d.ts.map +1 -1
  100. package/dist/types/consensus.js +35 -3
  101. package/dist/types/consensus.js.map +1 -1
  102. package/dist/types/project.d.ts +76 -0
  103. package/dist/types/project.d.ts.map +1 -1
  104. package/dist/types/project.js +1 -1
  105. package/dist/types/project.js.map +1 -1
  106. package/dist/types/workflow.d.ts +217 -16
  107. package/dist/types/workflow.d.ts.map +1 -1
  108. package/dist/types/workflow.js +40 -1
  109. package/dist/types/workflow.js.map +1 -1
  110. package/dist/workflow/auto-fix.d.ts +45 -0
  111. package/dist/workflow/auto-fix.d.ts.map +1 -0
  112. package/dist/workflow/auto-fix.js +274 -0
  113. package/dist/workflow/auto-fix.js.map +1 -0
  114. package/dist/workflow/consensus.d.ts +70 -2
  115. package/dist/workflow/consensus.d.ts.map +1 -1
  116. package/dist/workflow/consensus.js +872 -17
  117. package/dist/workflow/consensus.js.map +1 -1
  118. package/dist/workflow/execution-mode.d.ts +10 -4
  119. package/dist/workflow/execution-mode.d.ts.map +1 -1
  120. package/dist/workflow/execution-mode.js +547 -58
  121. package/dist/workflow/execution-mode.js.map +1 -1
  122. package/dist/workflow/index.d.ts +14 -2
  123. package/dist/workflow/index.d.ts.map +1 -1
  124. package/dist/workflow/index.js +69 -6
  125. package/dist/workflow/index.js.map +1 -1
  126. package/dist/workflow/milestone-workflow.d.ts +34 -0
  127. package/dist/workflow/milestone-workflow.d.ts.map +1 -0
  128. package/dist/workflow/milestone-workflow.js +414 -0
  129. package/dist/workflow/milestone-workflow.js.map +1 -0
  130. package/dist/workflow/plan-mode.d.ts +80 -3
  131. package/dist/workflow/plan-mode.d.ts.map +1 -1
  132. package/dist/workflow/plan-mode.js +767 -49
  133. package/dist/workflow/plan-mode.js.map +1 -1
  134. package/dist/workflow/plan-storage.d.ts +386 -0
  135. package/dist/workflow/plan-storage.d.ts.map +1 -0
  136. package/dist/workflow/plan-storage.js +878 -0
  137. package/dist/workflow/plan-storage.js.map +1 -0
  138. package/dist/workflow/project-verification.d.ts +37 -0
  139. package/dist/workflow/project-verification.d.ts.map +1 -0
  140. package/dist/workflow/project-verification.js +381 -0
  141. package/dist/workflow/project-verification.js.map +1 -0
  142. package/dist/workflow/task-workflow.d.ts +37 -0
  143. package/dist/workflow/task-workflow.d.ts.map +1 -0
  144. package/dist/workflow/task-workflow.js +386 -0
  145. package/dist/workflow/task-workflow.js.map +1 -0
  146. package/dist/workflow/test-runner.d.ts +9 -0
  147. package/dist/workflow/test-runner.d.ts.map +1 -1
  148. package/dist/workflow/test-runner.js +101 -5
  149. package/dist/workflow/test-runner.js.map +1 -1
  150. package/dist/workflow/ui-designer.d.ts +82 -0
  151. package/dist/workflow/ui-designer.d.ts.map +1 -0
  152. package/dist/workflow/ui-designer.js +234 -0
  153. package/dist/workflow/ui-designer.js.map +1 -0
  154. package/dist/workflow/ui-setup.d.ts +58 -0
  155. package/dist/workflow/ui-setup.d.ts.map +1 -0
  156. package/dist/workflow/ui-setup.js +685 -0
  157. package/dist/workflow/ui-setup.js.map +1 -0
  158. package/dist/workflow/ui-verification.d.ts +114 -0
  159. package/dist/workflow/ui-verification.d.ts.map +1 -0
  160. package/dist/workflow/ui-verification.js +258 -0
  161. package/dist/workflow/ui-verification.js.map +1 -0
  162. package/dist/workflow/workflow-logger.d.ts +110 -0
  163. package/dist/workflow/workflow-logger.d.ts.map +1 -0
  164. package/dist/workflow/workflow-logger.js +267 -0
  165. package/dist/workflow/workflow-logger.js.map +1 -0
  166. package/dist/workflow/workspace-manager.d.ts +342 -0
  167. package/dist/workflow/workspace-manager.d.ts.map +1 -0
  168. package/dist/workflow/workspace-manager.js +733 -0
  169. package/dist/workflow/workspace-manager.js.map +1 -0
  170. package/package.json +2 -2
  171. package/src/adapters/claude.ts +1067 -47
  172. package/src/adapters/gemini.ts +373 -0
  173. package/src/adapters/grok.ts +492 -0
  174. package/src/adapters/openai.ts +48 -9
  175. package/src/auth/claude.ts +120 -78
  176. package/src/auth/gemini.ts +207 -0
  177. package/src/auth/grok.ts +255 -0
  178. package/src/auth/index.ts +47 -9
  179. package/src/auth/keychain.ts +95 -28
  180. package/src/auth/openai.ts +29 -36
  181. package/src/cli/commands/auth.ts +89 -10
  182. package/src/cli/commands/create.ts +13 -4
  183. package/src/cli/interactive.ts +1774 -142
  184. package/src/config/defaults.ts +19 -2
  185. package/src/config/index.ts +36 -1
  186. package/src/config/schema.ts +30 -1
  187. package/src/generators/fullstack.ts +551 -0
  188. package/src/generators/index.ts +25 -1
  189. package/src/generators/python.ts +65 -20
  190. package/src/generators/templates/fullstack.ts +1047 -0
  191. package/src/generators/typescript.ts +69 -20
  192. package/src/state/index.ts +713 -4
  193. package/src/state/registry.ts +278 -0
  194. package/src/types/cli.ts +8 -0
  195. package/src/types/consensus.ts +197 -6
  196. package/src/types/project.ts +82 -1
  197. package/src/types/workflow.ts +90 -1
  198. package/src/workflow/auto-fix.ts +340 -0
  199. package/src/workflow/consensus.ts +1180 -16
  200. package/src/workflow/execution-mode.ts +673 -74
  201. package/src/workflow/index.ts +95 -6
  202. package/src/workflow/milestone-workflow.ts +576 -0
  203. package/src/workflow/plan-mode.ts +924 -50
  204. package/src/workflow/plan-storage.ts +1282 -0
  205. package/src/workflow/project-verification.ts +471 -0
  206. package/src/workflow/task-workflow.ts +528 -0
  207. package/src/workflow/test-runner.ts +120 -5
  208. package/src/workflow/ui-designer.ts +337 -0
  209. package/src/workflow/ui-setup.ts +797 -0
  210. package/src/workflow/ui-verification.ts +357 -0
  211. package/src/workflow/workflow-logger.ts +353 -0
  212. package/src/workflow/workspace-manager.ts +912 -0
  213. package/tests/config/config.test.ts +1 -1
  214. package/tests/types/consensus.test.ts +3 -3
  215. package/tests/workflow/plan-mode.test.ts +213 -0
  216. package/tests/workflow/test-runner.test.ts +5 -3
@@ -0,0 +1,912 @@
1
+ /**
2
+ * Workspace manager module
3
+ * Handles loading, saving, and querying workspace configuration for fullstack projects
4
+ * Also provides app-specific context for AI reviews
5
+ */
6
+
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+ import type { WorkspaceConfig, WorkspaceApp } from '../types/project.js';
10
+ import type { ReviewAppTarget } from '../types/consensus.js';
11
+
12
+ /**
13
+ * Context for AI review of a specific app
14
+ */
15
+ export interface AppReviewContext {
16
+ appName: 'frontend' | 'backend';
17
+ language: 'python' | 'typescript';
18
+ path: string;
19
+ /** Key source files content for review */
20
+ sourceFiles: Array<{ path: string; content: string }>;
21
+ /** UI spec for frontend */
22
+ uiSpec?: string;
23
+ /** API contracts (OpenAPI) */
24
+ apiContracts?: string;
25
+ /** Test file content */
26
+ testFiles?: Array<{ path: string; content: string }>;
27
+ /** Dependencies (package.json or pyproject.toml) */
28
+ dependencies?: string;
29
+ }
30
+
31
+ /**
32
+ * Combined review context for fullstack projects
33
+ */
34
+ export interface FullstackReviewContext {
35
+ frontend?: AppReviewContext;
36
+ backend?: AppReviewContext;
37
+ /** Shared contracts (OpenAPI spec) */
38
+ contracts?: string;
39
+ /** Project-level context */
40
+ projectName: string;
41
+ projectIdea?: string;
42
+ }
43
+
44
+ /**
45
+ * Workspace manager class
46
+ */
47
+ export class WorkspaceManager {
48
+ private projectDir: string;
49
+ private config: WorkspaceConfig | null = null;
50
+
51
+ constructor(projectDir: string) {
52
+ this.projectDir = projectDir;
53
+ }
54
+
55
+ /**
56
+ * Get the path to workspace.json
57
+ */
58
+ private getWorkspacePath(): string {
59
+ return path.join(this.projectDir, '.popeye', 'workspace.json');
60
+ }
61
+
62
+ /**
63
+ * Check if this is a workspace project
64
+ */
65
+ async isWorkspaceProject(): Promise<boolean> {
66
+ try {
67
+ await fs.access(this.getWorkspacePath());
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Load workspace configuration
76
+ */
77
+ async load(): Promise<WorkspaceConfig | null> {
78
+ try {
79
+ const content = await fs.readFile(this.getWorkspacePath(), 'utf-8');
80
+ this.config = JSON.parse(content) as WorkspaceConfig;
81
+ return this.config;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Save workspace configuration
89
+ */
90
+ async save(config: WorkspaceConfig): Promise<void> {
91
+ const workspacePath = this.getWorkspacePath();
92
+ const dir = path.dirname(workspacePath);
93
+
94
+ // Ensure directory exists
95
+ await fs.mkdir(dir, { recursive: true });
96
+
97
+ // Write config
98
+ await fs.writeFile(workspacePath, JSON.stringify(config, null, 2), 'utf-8');
99
+ this.config = config;
100
+ }
101
+
102
+ /**
103
+ * Get the loaded configuration
104
+ */
105
+ getConfig(): WorkspaceConfig | null {
106
+ return this.config;
107
+ }
108
+
109
+ /**
110
+ * Get a specific app configuration
111
+ */
112
+ getApp(appName: 'frontend' | 'backend'): WorkspaceApp | undefined {
113
+ return this.config?.apps[appName];
114
+ }
115
+
116
+ /**
117
+ * Get the absolute path to an app
118
+ */
119
+ getAppPath(appName: 'frontend' | 'backend'): string | null {
120
+ const app = this.getApp(appName);
121
+ if (!app) return null;
122
+ return path.join(this.projectDir, app.path);
123
+ }
124
+
125
+ /**
126
+ * Get all app names
127
+ */
128
+ getAppNames(): ('frontend' | 'backend')[] {
129
+ if (!this.config) return [];
130
+ const names: ('frontend' | 'backend')[] = [];
131
+ if (this.config.apps.frontend) names.push('frontend');
132
+ if (this.config.apps.backend) names.push('backend');
133
+ return names;
134
+ }
135
+
136
+ /**
137
+ * Get test command for a specific app
138
+ */
139
+ getTestCommand(appName: 'frontend' | 'backend'): string | null {
140
+ const app = this.getApp(appName);
141
+ return app?.commands.test ?? null;
142
+ }
143
+
144
+ /**
145
+ * Get all test commands (returns object with app names as keys)
146
+ */
147
+ getAllTestCommands(): Record<string, { path: string; command: string }> {
148
+ if (!this.config) return {};
149
+
150
+ const commands: Record<string, { path: string; command: string }> = {};
151
+
152
+ for (const appName of this.getAppNames()) {
153
+ const app = this.getApp(appName);
154
+ if (app) {
155
+ commands[appName] = {
156
+ path: path.join(this.projectDir, app.path),
157
+ command: app.commands.test,
158
+ };
159
+ }
160
+ }
161
+
162
+ return commands;
163
+ }
164
+
165
+ /**
166
+ * Get the combined test-all command
167
+ */
168
+ getTestAllCommand(): string | null {
169
+ return this.config?.commands.testAll ?? null;
170
+ }
171
+
172
+ /**
173
+ * Get lint command for a specific app
174
+ */
175
+ getLintCommand(appName: 'frontend' | 'backend'): string | null {
176
+ const app = this.getApp(appName);
177
+ return app?.commands.lint ?? null;
178
+ }
179
+
180
+ /**
181
+ * Get all lint commands
182
+ */
183
+ getAllLintCommands(): Record<string, { path: string; command: string }> {
184
+ if (!this.config) return {};
185
+
186
+ const commands: Record<string, { path: string; command: string }> = {};
187
+
188
+ for (const appName of this.getAppNames()) {
189
+ const app = this.getApp(appName);
190
+ if (app) {
191
+ commands[appName] = {
192
+ path: path.join(this.projectDir, app.path),
193
+ command: app.commands.lint,
194
+ };
195
+ }
196
+ }
197
+
198
+ return commands;
199
+ }
200
+
201
+ /**
202
+ * Get the combined lint-all command
203
+ */
204
+ getLintAllCommand(): string | null {
205
+ return this.config?.commands.lintAll ?? null;
206
+ }
207
+
208
+ /**
209
+ * Get build command for a specific app
210
+ */
211
+ getBuildCommand(appName: 'frontend' | 'backend'): string | null {
212
+ const app = this.getApp(appName);
213
+ return app?.commands.build ?? null;
214
+ }
215
+
216
+ /**
217
+ * Get all build commands
218
+ */
219
+ getAllBuildCommands(): Record<string, { path: string; command: string }> {
220
+ if (!this.config) return {};
221
+
222
+ const commands: Record<string, { path: string; command: string }> = {};
223
+
224
+ for (const appName of this.getAppNames()) {
225
+ const app = this.getApp(appName);
226
+ if (app) {
227
+ commands[appName] = {
228
+ path: path.join(this.projectDir, app.path),
229
+ command: app.commands.build,
230
+ };
231
+ }
232
+ }
233
+
234
+ return commands;
235
+ }
236
+
237
+ /**
238
+ * Get the combined build-all command
239
+ */
240
+ getBuildAllCommand(): string | null {
241
+ return this.config?.commands.buildAll ?? null;
242
+ }
243
+
244
+ /**
245
+ * Get dev command for a specific app
246
+ */
247
+ getDevCommand(appName: 'frontend' | 'backend'): string | null {
248
+ const app = this.getApp(appName);
249
+ return app?.commands.dev ?? null;
250
+ }
251
+
252
+ /**
253
+ * Get all dev commands
254
+ */
255
+ getAllDevCommands(): Record<string, { path: string; command: string }> {
256
+ if (!this.config) return {};
257
+
258
+ const commands: Record<string, { path: string; command: string }> = {};
259
+
260
+ for (const appName of this.getAppNames()) {
261
+ const app = this.getApp(appName);
262
+ if (app) {
263
+ commands[appName] = {
264
+ path: path.join(this.projectDir, app.path),
265
+ command: app.commands.dev,
266
+ };
267
+ }
268
+ }
269
+
270
+ return commands;
271
+ }
272
+
273
+ /**
274
+ * Get the combined dev-all command (usually docker-compose up)
275
+ */
276
+ getDevAllCommand(): string | null {
277
+ return this.config?.commands.devAll ?? null;
278
+ }
279
+
280
+ /**
281
+ * Get docker-compose path
282
+ */
283
+ getDockerComposePath(): string | null {
284
+ if (!this.config) return null;
285
+ return path.join(this.projectDir, this.config.docker.composePath);
286
+ }
287
+
288
+ /**
289
+ * Get context roots for an app (files to include in AI context)
290
+ */
291
+ getContextRoots(appName: 'frontend' | 'backend'): string[] {
292
+ const app = this.getApp(appName);
293
+ if (!app || !app.contextRoots) return [];
294
+
295
+ return app.contextRoots.map((root) => path.join(this.projectDir, app.path, root));
296
+ }
297
+
298
+ /**
299
+ * Get UI spec path (for frontend)
300
+ */
301
+ getUiSpecPath(): string | null {
302
+ const frontend = this.getApp('frontend');
303
+ if (!frontend || !frontend.uiSpec) return null;
304
+ return path.join(this.projectDir, frontend.uiSpec);
305
+ }
306
+
307
+ /**
308
+ * Get contracts path (OpenAPI spec)
309
+ */
310
+ getContractsPath(): string | null {
311
+ if (!this.config?.shared?.contracts) return null;
312
+ return path.join(this.projectDir, this.config.shared.contracts);
313
+ }
314
+
315
+ /**
316
+ * Get app language
317
+ */
318
+ getAppLanguage(appName: 'frontend' | 'backend'): 'python' | 'typescript' | null {
319
+ const app = this.getApp(appName);
320
+ return app?.language ?? null;
321
+ }
322
+
323
+ /**
324
+ * Determine which app should handle a file based on path
325
+ */
326
+ getAppForFile(filePath: string): 'frontend' | 'backend' | null {
327
+ const relativePath = path.relative(this.projectDir, filePath);
328
+
329
+ for (const appName of this.getAppNames()) {
330
+ const app = this.getApp(appName);
331
+ if (app && relativePath.startsWith(app.path)) {
332
+ return appName;
333
+ }
334
+ }
335
+
336
+ return null;
337
+ }
338
+
339
+ /**
340
+ * Get review context for a specific app
341
+ * Reads key files from contextRoots to provide to AI reviewers
342
+ */
343
+ async getAppReviewContext(
344
+ appName: 'frontend' | 'backend',
345
+ options: {
346
+ maxFiles?: number;
347
+ maxFileSize?: number;
348
+ includeTests?: boolean;
349
+ } = {}
350
+ ): Promise<AppReviewContext | null> {
351
+ const { maxFiles = 20, maxFileSize = 50000, includeTests = true } = options;
352
+
353
+ const app = this.getApp(appName);
354
+ if (!app) return null;
355
+
356
+ const appPath = this.getAppPath(appName);
357
+ if (!appPath) return null;
358
+
359
+ const context: AppReviewContext = {
360
+ appName,
361
+ language: app.language,
362
+ path: app.path,
363
+ sourceFiles: [],
364
+ };
365
+
366
+ // Read files from context roots
367
+ const contextRoots = this.getContextRoots(appName);
368
+ let filesRead = 0;
369
+
370
+ for (const root of contextRoots) {
371
+ if (filesRead >= maxFiles) break;
372
+
373
+ try {
374
+ const files = await this.readDirectoryRecursive(root, maxFileSize);
375
+ for (const file of files) {
376
+ if (filesRead >= maxFiles) break;
377
+ context.sourceFiles.push(file);
378
+ filesRead++;
379
+ }
380
+ } catch {
381
+ // Directory doesn't exist, skip
382
+ }
383
+ }
384
+
385
+ // Read UI spec for frontend
386
+ if (appName === 'frontend') {
387
+ const uiSpecPath = this.getUiSpecPath();
388
+ if (uiSpecPath) {
389
+ try {
390
+ context.uiSpec = await fs.readFile(uiSpecPath, 'utf-8');
391
+ } catch {
392
+ // UI spec doesn't exist
393
+ }
394
+ }
395
+ }
396
+
397
+ // Read API contracts
398
+ const contractsPath = this.getContractsPath();
399
+ if (contractsPath) {
400
+ try {
401
+ context.apiContracts = await fs.readFile(contractsPath, 'utf-8');
402
+ } catch {
403
+ // Contracts don't exist
404
+ }
405
+ }
406
+
407
+ // Read test files
408
+ if (includeTests) {
409
+ const testDir = path.join(appPath, appName === 'frontend' ? 'src' : 'tests');
410
+ try {
411
+ const testFiles = await this.findTestFiles(testDir, app.language);
412
+ context.testFiles = testFiles.slice(0, 5); // Limit test files
413
+ } catch {
414
+ // Test directory doesn't exist
415
+ }
416
+ }
417
+
418
+ // Read dependencies file
419
+ try {
420
+ const depsFile = app.language === 'typescript'
421
+ ? path.join(appPath, 'package.json')
422
+ : path.join(appPath, 'pyproject.toml');
423
+ context.dependencies = await fs.readFile(depsFile, 'utf-8');
424
+ } catch {
425
+ // Dependencies file doesn't exist
426
+ }
427
+
428
+ return context;
429
+ }
430
+
431
+ /**
432
+ * Get combined review context for fullstack project
433
+ */
434
+ async getFullstackReviewContext(
435
+ projectName: string,
436
+ projectIdea?: string,
437
+ options: {
438
+ maxFilesPerApp?: number;
439
+ includeTests?: boolean;
440
+ } = {}
441
+ ): Promise<FullstackReviewContext> {
442
+ const { maxFilesPerApp = 15, includeTests = true } = options;
443
+
444
+ const context: FullstackReviewContext = {
445
+ projectName,
446
+ projectIdea,
447
+ };
448
+
449
+ // Get frontend context
450
+ const frontend = await this.getAppReviewContext('frontend', {
451
+ maxFiles: maxFilesPerApp,
452
+ includeTests,
453
+ });
454
+ if (frontend) {
455
+ context.frontend = frontend;
456
+ }
457
+
458
+ // Get backend context
459
+ const backend = await this.getAppReviewContext('backend', {
460
+ maxFiles: maxFilesPerApp,
461
+ includeTests,
462
+ });
463
+ if (backend) {
464
+ context.backend = backend;
465
+ }
466
+
467
+ // Get shared contracts
468
+ const contractsPath = this.getContractsPath();
469
+ if (contractsPath) {
470
+ try {
471
+ context.contracts = await fs.readFile(contractsPath, 'utf-8');
472
+ } catch {
473
+ // Contracts don't exist
474
+ }
475
+ }
476
+
477
+ return context;
478
+ }
479
+
480
+ /**
481
+ * Format app context for AI review prompt
482
+ */
483
+ formatContextForReview(context: AppReviewContext): string {
484
+ const lines: string[] = [];
485
+
486
+ lines.push(`## ${context.appName.toUpperCase()} (${context.language})`);
487
+ lines.push(`Path: ${context.path}`);
488
+ lines.push('');
489
+
490
+ if (context.dependencies) {
491
+ lines.push('### Dependencies');
492
+ lines.push('```');
493
+ lines.push(context.dependencies.slice(0, 2000)); // Limit size
494
+ lines.push('```');
495
+ lines.push('');
496
+ }
497
+
498
+ if (context.uiSpec) {
499
+ lines.push('### UI Specification');
500
+ lines.push('```json');
501
+ lines.push(context.uiSpec.slice(0, 3000));
502
+ lines.push('```');
503
+ lines.push('');
504
+ }
505
+
506
+ if (context.apiContracts) {
507
+ lines.push('### API Contracts (OpenAPI)');
508
+ lines.push('```yaml');
509
+ lines.push(context.apiContracts.slice(0, 3000));
510
+ lines.push('```');
511
+ lines.push('');
512
+ }
513
+
514
+ if (context.sourceFiles.length > 0) {
515
+ lines.push('### Key Source Files');
516
+ for (const file of context.sourceFiles) {
517
+ const relativePath = path.relative(this.projectDir, file.path);
518
+ lines.push(`#### ${relativePath}`);
519
+ lines.push('```');
520
+ lines.push(file.content.slice(0, 5000)); // Limit per file
521
+ lines.push('```');
522
+ lines.push('');
523
+ }
524
+ }
525
+
526
+ return lines.join('\n');
527
+ }
528
+
529
+ /**
530
+ * Format fullstack context for review prompt
531
+ */
532
+ formatFullstackContextForReview(context: FullstackReviewContext): string {
533
+ const lines: string[] = [];
534
+
535
+ lines.push(`# Project: ${context.projectName}`);
536
+ if (context.projectIdea) {
537
+ lines.push(`**Idea:** ${context.projectIdea}`);
538
+ }
539
+ lines.push('');
540
+
541
+ if (context.contracts) {
542
+ lines.push('## Shared API Contracts');
543
+ lines.push('```yaml');
544
+ lines.push(context.contracts.slice(0, 3000));
545
+ lines.push('```');
546
+ lines.push('');
547
+ }
548
+
549
+ if (context.frontend) {
550
+ lines.push(this.formatContextForReview(context.frontend));
551
+ }
552
+
553
+ if (context.backend) {
554
+ lines.push(this.formatContextForReview(context.backend));
555
+ }
556
+
557
+ return lines.join('\n');
558
+ }
559
+
560
+ /**
561
+ * Determine review app target based on plan content
562
+ * Analyzes plan text to determine if it's frontend, backend, or unified
563
+ */
564
+ categorizeByPlanContent(planContent: string): ReviewAppTarget {
565
+ const lowerContent = planContent.toLowerCase();
566
+
567
+ // Frontend indicators
568
+ const frontendKeywords = [
569
+ 'react', 'component', 'jsx', 'tsx', 'css', 'tailwind', 'ui',
570
+ 'button', 'form', 'page', 'layout', 'style', 'vite', 'frontend',
571
+ 'client', 'browser', 'dom', 'render', 'hook', 'state',
572
+ ];
573
+
574
+ // Backend indicators
575
+ const backendKeywords = [
576
+ 'api', 'endpoint', 'route', 'database', 'model', 'schema',
577
+ 'fastapi', 'flask', 'django', 'express', 'server', 'backend',
578
+ 'authentication', 'middleware', 'orm', 'sql', 'query', 'crud',
579
+ ];
580
+
581
+ const frontendScore = frontendKeywords.filter(kw => lowerContent.includes(kw)).length;
582
+ const backendScore = backendKeywords.filter(kw => lowerContent.includes(kw)).length;
583
+
584
+ // Threshold for classification
585
+ if (frontendScore > backendScore * 2 && frontendScore >= 3) {
586
+ return 'frontend';
587
+ }
588
+ if (backendScore > frontendScore * 2 && backendScore >= 3) {
589
+ return 'backend';
590
+ }
591
+
592
+ // Mixed or unclear - unified
593
+ return 'unified';
594
+ }
595
+
596
+ /**
597
+ * Read directory recursively and return file contents
598
+ */
599
+ private async readDirectoryRecursive(
600
+ dir: string,
601
+ maxFileSize: number
602
+ ): Promise<Array<{ path: string; content: string }>> {
603
+ const files: Array<{ path: string; content: string }> = [];
604
+
605
+ try {
606
+ const entries = await fs.readdir(dir, { withFileTypes: true });
607
+
608
+ for (const entry of entries) {
609
+ const fullPath = path.join(dir, entry.name);
610
+
611
+ // Skip node_modules, __pycache__, etc.
612
+ if (entry.name.startsWith('.') ||
613
+ entry.name === 'node_modules' ||
614
+ entry.name === '__pycache__' ||
615
+ entry.name === 'dist' ||
616
+ entry.name === 'build') {
617
+ continue;
618
+ }
619
+
620
+ if (entry.isDirectory()) {
621
+ const subFiles = await this.readDirectoryRecursive(fullPath, maxFileSize);
622
+ files.push(...subFiles);
623
+ } else if (entry.isFile()) {
624
+ // Only read code files
625
+ const ext = path.extname(entry.name);
626
+ if (['.ts', '.tsx', '.js', '.jsx', '.py', '.json', '.yaml', '.yml'].includes(ext)) {
627
+ try {
628
+ const stat = await fs.stat(fullPath);
629
+ if (stat.size <= maxFileSize) {
630
+ const content = await fs.readFile(fullPath, 'utf-8');
631
+ files.push({ path: fullPath, content });
632
+ }
633
+ } catch {
634
+ // Skip unreadable files
635
+ }
636
+ }
637
+ }
638
+ }
639
+ } catch {
640
+ // Directory doesn't exist or isn't readable
641
+ }
642
+
643
+ return files;
644
+ }
645
+
646
+ /**
647
+ * Find test files in a directory
648
+ */
649
+ private async findTestFiles(
650
+ dir: string,
651
+ language: 'python' | 'typescript'
652
+ ): Promise<Array<{ path: string; content: string }>> {
653
+ const files: Array<{ path: string; content: string }> = [];
654
+
655
+ const testPatterns = language === 'typescript'
656
+ ? ['.test.ts', '.test.tsx', '.spec.ts', '.spec.tsx']
657
+ : ['test_', '_test.py'];
658
+
659
+ try {
660
+ const allFiles = await this.readDirectoryRecursive(dir, 30000);
661
+
662
+ for (const file of allFiles) {
663
+ const fileName = path.basename(file.path);
664
+ const isTestFile = testPatterns.some(pattern =>
665
+ language === 'typescript'
666
+ ? fileName.endsWith(pattern)
667
+ : fileName.startsWith(pattern) || fileName.endsWith(pattern)
668
+ );
669
+
670
+ if (isTestFile) {
671
+ files.push(file);
672
+ }
673
+ }
674
+ } catch {
675
+ // Directory doesn't exist
676
+ }
677
+
678
+ return files;
679
+ }
680
+
681
+ /**
682
+ * Get feedback document paths for workspace
683
+ */
684
+ getFeedbackPaths(): {
685
+ master: { unified: string; frontend: string; backend: string };
686
+ getMilestonePaths: (milestoneId: string) => { unified: string; frontend: string; backend: string };
687
+ getTaskPaths: (milestoneId: string, taskId: string) => { unified: string; frontend: string; backend: string };
688
+ } {
689
+ const plansDir = path.join(this.projectDir, 'docs', 'plans');
690
+
691
+ return {
692
+ master: {
693
+ unified: path.join(plansDir, 'master', 'unified', 'feedback.md'),
694
+ frontend: path.join(plansDir, 'master', 'frontend', 'feedback.md'),
695
+ backend: path.join(plansDir, 'master', 'backend', 'feedback.md'),
696
+ },
697
+ getMilestonePaths: (milestoneId: string) => ({
698
+ unified: path.join(plansDir, `milestone-${milestoneId}`, 'unified', 'feedback.md'),
699
+ frontend: path.join(plansDir, `milestone-${milestoneId}`, 'frontend', 'feedback.md'),
700
+ backend: path.join(plansDir, `milestone-${milestoneId}`, 'backend', 'feedback.md'),
701
+ }),
702
+ getTaskPaths: (milestoneId: string, taskId: string) => ({
703
+ unified: path.join(plansDir, `milestone-${milestoneId}`, 'tasks', `task-${taskId}`, 'unified', 'feedback.md'),
704
+ frontend: path.join(plansDir, `milestone-${milestoneId}`, 'tasks', `task-${taskId}`, 'frontend', 'feedback.md'),
705
+ backend: path.join(plansDir, `milestone-${milestoneId}`, 'tasks', `task-${taskId}`, 'backend', 'feedback.md'),
706
+ }),
707
+ };
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Load workspace configuration from a project directory
713
+ *
714
+ * @param projectDir - Project directory
715
+ * @returns WorkspaceConfig or null if not a workspace project
716
+ */
717
+ export async function loadWorkspace(projectDir: string): Promise<WorkspaceConfig | null> {
718
+ const manager = new WorkspaceManager(projectDir);
719
+ return manager.load();
720
+ }
721
+
722
+ /**
723
+ * Save workspace configuration to a project directory
724
+ *
725
+ * @param projectDir - Project directory
726
+ * @param config - Workspace configuration
727
+ */
728
+ export async function saveWorkspace(projectDir: string, config: WorkspaceConfig): Promise<void> {
729
+ const manager = new WorkspaceManager(projectDir);
730
+ return manager.save(config);
731
+ }
732
+
733
+ /**
734
+ * Check if a directory is a workspace project
735
+ *
736
+ * @param projectDir - Project directory
737
+ * @returns True if workspace project
738
+ */
739
+ export async function isWorkspaceProject(projectDir: string): Promise<boolean> {
740
+ const manager = new WorkspaceManager(projectDir);
741
+ return manager.isWorkspaceProject();
742
+ }
743
+
744
+ /**
745
+ * Get app context for AI code generation
746
+ *
747
+ * @param projectDir - Project directory
748
+ * @param appName - App name
749
+ * @returns Object with app info and context files
750
+ */
751
+ export async function getAppContext(
752
+ projectDir: string,
753
+ appName: 'frontend' | 'backend'
754
+ ): Promise<{
755
+ app: WorkspaceApp | undefined;
756
+ language: 'python' | 'typescript' | null;
757
+ contextRoots: string[];
758
+ path: string | null;
759
+ } | null> {
760
+ const manager = new WorkspaceManager(projectDir);
761
+ const config = await manager.load();
762
+
763
+ if (!config) return null;
764
+
765
+ const app = manager.getApp(appName);
766
+
767
+ return {
768
+ app,
769
+ language: manager.getAppLanguage(appName),
770
+ contextRoots: manager.getContextRoots(appName),
771
+ path: manager.getAppPath(appName),
772
+ };
773
+ }
774
+
775
+ /**
776
+ * Get test commands for workspace
777
+ *
778
+ * @param projectDir - Project directory
779
+ * @returns Test commands per app and combined command
780
+ */
781
+ export async function getTestCommands(projectDir: string): Promise<{
782
+ perApp: Record<string, { path: string; command: string }>;
783
+ combined: string | null;
784
+ } | null> {
785
+ const manager = new WorkspaceManager(projectDir);
786
+ const config = await manager.load();
787
+
788
+ if (!config) return null;
789
+
790
+ return {
791
+ perApp: manager.getAllTestCommands(),
792
+ combined: manager.getTestAllCommand(),
793
+ };
794
+ }
795
+
796
+ /**
797
+ * Get build commands for workspace
798
+ *
799
+ * @param projectDir - Project directory
800
+ * @returns Build commands per app and combined command
801
+ */
802
+ export async function getBuildCommands(projectDir: string): Promise<{
803
+ perApp: Record<string, { path: string; command: string }>;
804
+ combined: string | null;
805
+ } | null> {
806
+ const manager = new WorkspaceManager(projectDir);
807
+ const config = await manager.load();
808
+
809
+ if (!config) return null;
810
+
811
+ return {
812
+ perApp: manager.getAllBuildCommands(),
813
+ combined: manager.getBuildAllCommand(),
814
+ };
815
+ }
816
+
817
+ /**
818
+ * Get app-specific review context
819
+ *
820
+ * @param projectDir - Project directory
821
+ * @param appName - App name (frontend or backend)
822
+ * @returns Review context with source files and metadata
823
+ */
824
+ export async function getAppReviewContext(
825
+ projectDir: string,
826
+ appName: 'frontend' | 'backend'
827
+ ): Promise<AppReviewContext | null> {
828
+ const manager = new WorkspaceManager(projectDir);
829
+ const config = await manager.load();
830
+
831
+ if (!config) return null;
832
+
833
+ return manager.getAppReviewContext(appName);
834
+ }
835
+
836
+ /**
837
+ * Get fullstack review context for AI reviews
838
+ *
839
+ * @param projectDir - Project directory
840
+ * @param projectName - Project name
841
+ * @param projectIdea - Original project idea
842
+ * @returns Combined review context for both apps
843
+ */
844
+ export async function getFullstackReviewContext(
845
+ projectDir: string,
846
+ projectName: string,
847
+ projectIdea?: string
848
+ ): Promise<FullstackReviewContext | null> {
849
+ const manager = new WorkspaceManager(projectDir);
850
+ const config = await manager.load();
851
+
852
+ if (!config) return null;
853
+
854
+ return manager.getFullstackReviewContext(projectName, projectIdea);
855
+ }
856
+
857
+ /**
858
+ * Format context for AI review prompt
859
+ *
860
+ * @param projectDir - Project directory
861
+ * @param projectName - Project name
862
+ * @param projectIdea - Original project idea
863
+ * @returns Formatted string for AI review
864
+ */
865
+ export async function formatContextForAIReview(
866
+ projectDir: string,
867
+ projectName: string,
868
+ projectIdea?: string
869
+ ): Promise<string | null> {
870
+ const manager = new WorkspaceManager(projectDir);
871
+ const config = await manager.load();
872
+
873
+ if (!config) return null;
874
+
875
+ const context = await manager.getFullstackReviewContext(projectName, projectIdea);
876
+ return manager.formatFullstackContextForReview(context);
877
+ }
878
+
879
+ /**
880
+ * Categorize a task/plan as frontend, backend, or unified
881
+ *
882
+ * @param projectDir - Project directory
883
+ * @param planContent - Plan or task content to analyze
884
+ * @returns App target category
885
+ */
886
+ export async function categorizePlanContent(
887
+ projectDir: string,
888
+ planContent: string
889
+ ): Promise<ReviewAppTarget> {
890
+ const manager = new WorkspaceManager(projectDir);
891
+ await manager.load();
892
+ return manager.categorizeByPlanContent(planContent);
893
+ }
894
+
895
+ /**
896
+ * Get feedback paths for a workspace project
897
+ *
898
+ * @param projectDir - Project directory
899
+ * @returns Object with feedback path getters
900
+ */
901
+ export async function getWorkspaceFeedbackPaths(projectDir: string): Promise<{
902
+ master: { unified: string; frontend: string; backend: string };
903
+ getMilestonePaths: (milestoneId: string) => { unified: string; frontend: string; backend: string };
904
+ getTaskPaths: (milestoneId: string, taskId: string) => { unified: string; frontend: string; backend: string };
905
+ } | null> {
906
+ const manager = new WorkspaceManager(projectDir);
907
+ const config = await manager.load();
908
+
909
+ if (!config) return null;
910
+
911
+ return manager.getFeedbackPaths();
912
+ }