popeye-cli 1.0.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 (209) hide show
  1. package/.env.example +25 -0
  2. package/.prettierrc +8 -0
  3. package/README.md +320 -0
  4. package/dist/adapters/claude.d.ts +82 -0
  5. package/dist/adapters/claude.d.ts.map +1 -0
  6. package/dist/adapters/claude.js +230 -0
  7. package/dist/adapters/claude.js.map +1 -0
  8. package/dist/adapters/openai.d.ts +48 -0
  9. package/dist/adapters/openai.d.ts.map +1 -0
  10. package/dist/adapters/openai.js +257 -0
  11. package/dist/adapters/openai.js.map +1 -0
  12. package/dist/auth/claude.d.ts +44 -0
  13. package/dist/auth/claude.d.ts.map +1 -0
  14. package/dist/auth/claude.js +139 -0
  15. package/dist/auth/claude.js.map +1 -0
  16. package/dist/auth/index.d.ts +61 -0
  17. package/dist/auth/index.d.ts.map +1 -0
  18. package/dist/auth/index.js +141 -0
  19. package/dist/auth/index.js.map +1 -0
  20. package/dist/auth/keychain.d.ts +66 -0
  21. package/dist/auth/keychain.d.ts.map +1 -0
  22. package/dist/auth/keychain.js +125 -0
  23. package/dist/auth/keychain.js.map +1 -0
  24. package/dist/auth/openai-entry.d.ts +9 -0
  25. package/dist/auth/openai-entry.d.ts.map +1 -0
  26. package/dist/auth/openai-entry.js +410 -0
  27. package/dist/auth/openai-entry.js.map +1 -0
  28. package/dist/auth/openai.d.ts +71 -0
  29. package/dist/auth/openai.d.ts.map +1 -0
  30. package/dist/auth/openai.js +212 -0
  31. package/dist/auth/openai.js.map +1 -0
  32. package/dist/auth/server.d.ts +32 -0
  33. package/dist/auth/server.d.ts.map +1 -0
  34. package/dist/auth/server.js +213 -0
  35. package/dist/auth/server.js.map +1 -0
  36. package/dist/cli/commands/auth.d.ts +10 -0
  37. package/dist/cli/commands/auth.d.ts.map +1 -0
  38. package/dist/cli/commands/auth.js +162 -0
  39. package/dist/cli/commands/auth.js.map +1 -0
  40. package/dist/cli/commands/config.d.ts +10 -0
  41. package/dist/cli/commands/config.d.ts.map +1 -0
  42. package/dist/cli/commands/config.js +215 -0
  43. package/dist/cli/commands/config.js.map +1 -0
  44. package/dist/cli/commands/create.d.ts +10 -0
  45. package/dist/cli/commands/create.d.ts.map +1 -0
  46. package/dist/cli/commands/create.js +240 -0
  47. package/dist/cli/commands/create.js.map +1 -0
  48. package/dist/cli/commands/index.d.ts +10 -0
  49. package/dist/cli/commands/index.d.ts.map +1 -0
  50. package/dist/cli/commands/index.js +10 -0
  51. package/dist/cli/commands/index.js.map +1 -0
  52. package/dist/cli/commands/resume.d.ts +18 -0
  53. package/dist/cli/commands/resume.d.ts.map +1 -0
  54. package/dist/cli/commands/resume.js +241 -0
  55. package/dist/cli/commands/resume.js.map +1 -0
  56. package/dist/cli/commands/status.d.ts +18 -0
  57. package/dist/cli/commands/status.d.ts.map +1 -0
  58. package/dist/cli/commands/status.js +154 -0
  59. package/dist/cli/commands/status.js.map +1 -0
  60. package/dist/cli/index.d.ts +17 -0
  61. package/dist/cli/index.d.ts.map +1 -0
  62. package/dist/cli/index.js +71 -0
  63. package/dist/cli/index.js.map +1 -0
  64. package/dist/cli/interactive.d.ts +9 -0
  65. package/dist/cli/interactive.d.ts.map +1 -0
  66. package/dist/cli/interactive.js +330 -0
  67. package/dist/cli/interactive.js.map +1 -0
  68. package/dist/cli/output.d.ts +182 -0
  69. package/dist/cli/output.d.ts.map +1 -0
  70. package/dist/cli/output.js +355 -0
  71. package/dist/cli/output.js.map +1 -0
  72. package/dist/config/defaults.d.ts +57 -0
  73. package/dist/config/defaults.d.ts.map +1 -0
  74. package/dist/config/defaults.js +103 -0
  75. package/dist/config/defaults.js.map +1 -0
  76. package/dist/config/index.d.ts +138 -0
  77. package/dist/config/index.d.ts.map +1 -0
  78. package/dist/config/index.js +244 -0
  79. package/dist/config/index.js.map +1 -0
  80. package/dist/config/schema.d.ts +220 -0
  81. package/dist/config/schema.d.ts.map +1 -0
  82. package/dist/config/schema.js +141 -0
  83. package/dist/config/schema.js.map +1 -0
  84. package/dist/generators/index.d.ts +101 -0
  85. package/dist/generators/index.d.ts.map +1 -0
  86. package/dist/generators/index.js +200 -0
  87. package/dist/generators/index.js.map +1 -0
  88. package/dist/generators/python.d.ts +48 -0
  89. package/dist/generators/python.d.ts.map +1 -0
  90. package/dist/generators/python.js +262 -0
  91. package/dist/generators/python.js.map +1 -0
  92. package/dist/generators/templates/index.d.ts +6 -0
  93. package/dist/generators/templates/index.d.ts.map +1 -0
  94. package/dist/generators/templates/index.js +6 -0
  95. package/dist/generators/templates/index.js.map +1 -0
  96. package/dist/generators/templates/python.d.ts +53 -0
  97. package/dist/generators/templates/python.d.ts.map +1 -0
  98. package/dist/generators/templates/python.js +454 -0
  99. package/dist/generators/templates/python.js.map +1 -0
  100. package/dist/generators/templates/typescript.d.ts +53 -0
  101. package/dist/generators/templates/typescript.d.ts.map +1 -0
  102. package/dist/generators/templates/typescript.js +394 -0
  103. package/dist/generators/templates/typescript.js.map +1 -0
  104. package/dist/generators/typescript.d.ts +64 -0
  105. package/dist/generators/typescript.d.ts.map +1 -0
  106. package/dist/generators/typescript.js +271 -0
  107. package/dist/generators/typescript.js.map +1 -0
  108. package/dist/index.d.ts +7 -0
  109. package/dist/index.d.ts.map +1 -0
  110. package/dist/index.js +12 -0
  111. package/dist/index.js.map +1 -0
  112. package/dist/state/index.d.ts +168 -0
  113. package/dist/state/index.d.ts.map +1 -0
  114. package/dist/state/index.js +338 -0
  115. package/dist/state/index.js.map +1 -0
  116. package/dist/state/persistence.d.ts +91 -0
  117. package/dist/state/persistence.d.ts.map +1 -0
  118. package/dist/state/persistence.js +201 -0
  119. package/dist/state/persistence.js.map +1 -0
  120. package/dist/types/cli.d.ts +132 -0
  121. package/dist/types/cli.d.ts.map +1 -0
  122. package/dist/types/cli.js +17 -0
  123. package/dist/types/cli.js.map +1 -0
  124. package/dist/types/consensus.d.ts +111 -0
  125. package/dist/types/consensus.d.ts.map +1 -0
  126. package/dist/types/consensus.js +29 -0
  127. package/dist/types/consensus.js.map +1 -0
  128. package/dist/types/index.d.ts +9 -0
  129. package/dist/types/index.d.ts.map +1 -0
  130. package/dist/types/index.js +13 -0
  131. package/dist/types/index.js.map +1 -0
  132. package/dist/types/project.d.ts +73 -0
  133. package/dist/types/project.d.ts.map +1 -0
  134. package/dist/types/project.js +55 -0
  135. package/dist/types/project.js.map +1 -0
  136. package/dist/types/workflow.d.ts +236 -0
  137. package/dist/types/workflow.d.ts.map +1 -0
  138. package/dist/types/workflow.js +74 -0
  139. package/dist/types/workflow.js.map +1 -0
  140. package/dist/workflow/consensus.d.ts +89 -0
  141. package/dist/workflow/consensus.d.ts.map +1 -0
  142. package/dist/workflow/consensus.js +220 -0
  143. package/dist/workflow/consensus.js.map +1 -0
  144. package/dist/workflow/execution-mode.d.ts +82 -0
  145. package/dist/workflow/execution-mode.d.ts.map +1 -0
  146. package/dist/workflow/execution-mode.js +346 -0
  147. package/dist/workflow/execution-mode.js.map +1 -0
  148. package/dist/workflow/index.d.ts +110 -0
  149. package/dist/workflow/index.d.ts.map +1 -0
  150. package/dist/workflow/index.js +283 -0
  151. package/dist/workflow/index.js.map +1 -0
  152. package/dist/workflow/plan-mode.d.ts +83 -0
  153. package/dist/workflow/plan-mode.d.ts.map +1 -0
  154. package/dist/workflow/plan-mode.js +241 -0
  155. package/dist/workflow/plan-mode.js.map +1 -0
  156. package/dist/workflow/test-runner.d.ts +87 -0
  157. package/dist/workflow/test-runner.d.ts.map +1 -0
  158. package/dist/workflow/test-runner.js +273 -0
  159. package/dist/workflow/test-runner.js.map +1 -0
  160. package/eslint.config.js +25 -0
  161. package/package.json +66 -0
  162. package/src/adapters/claude.ts +298 -0
  163. package/src/adapters/openai.ts +300 -0
  164. package/src/auth/claude.ts +166 -0
  165. package/src/auth/index.ts +171 -0
  166. package/src/auth/keychain.ts +138 -0
  167. package/src/auth/openai-entry.ts +410 -0
  168. package/src/auth/openai.ts +260 -0
  169. package/src/auth/server.ts +252 -0
  170. package/src/cli/commands/auth.ts +194 -0
  171. package/src/cli/commands/config.ts +241 -0
  172. package/src/cli/commands/create.ts +308 -0
  173. package/src/cli/commands/index.ts +10 -0
  174. package/src/cli/commands/resume.ts +304 -0
  175. package/src/cli/commands/status.ts +189 -0
  176. package/src/cli/index.ts +90 -0
  177. package/src/cli/interactive.ts +418 -0
  178. package/src/cli/output.ts +410 -0
  179. package/src/config/defaults.ts +114 -0
  180. package/src/config/index.ts +315 -0
  181. package/src/config/schema.ts +164 -0
  182. package/src/generators/index.ts +251 -0
  183. package/src/generators/python.ts +318 -0
  184. package/src/generators/templates/index.ts +6 -0
  185. package/src/generators/templates/python.ts +465 -0
  186. package/src/generators/templates/typescript.ts +417 -0
  187. package/src/generators/typescript.ts +340 -0
  188. package/src/index.ts +13 -0
  189. package/src/state/index.ts +454 -0
  190. package/src/state/persistence.ts +230 -0
  191. package/src/types/cli.ts +146 -0
  192. package/src/types/consensus.ts +116 -0
  193. package/src/types/index.ts +64 -0
  194. package/src/types/project.ts +85 -0
  195. package/src/types/workflow.ts +149 -0
  196. package/src/workflow/consensus.ts +299 -0
  197. package/src/workflow/execution-mode.ts +517 -0
  198. package/src/workflow/index.ts +396 -0
  199. package/src/workflow/plan-mode.ts +356 -0
  200. package/src/workflow/test-runner.ts +345 -0
  201. package/tests/adapters/openai.test.ts +145 -0
  202. package/tests/config/config.test.ts +208 -0
  203. package/tests/generators/generators.test.ts +185 -0
  204. package/tests/types/consensus.test.ts +152 -0
  205. package/tests/types/project.test.ts +134 -0
  206. package/tests/workflow/consensus.test.ts +221 -0
  207. package/tests/workflow/test-runner.test.ts +214 -0
  208. package/tsconfig.json +25 -0
  209. package/vitest.config.ts +22 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * State management module
3
+ * Provides high-level API for managing project state
4
+ */
5
+
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import type {
8
+ ProjectState,
9
+ Task,
10
+ Milestone,
11
+ TaskStatus,
12
+ WorkflowPhase,
13
+ } from '../types/workflow.js';
14
+ import type { ConsensusIteration } from '../types/consensus.js';
15
+ import type { ProjectSpec } from '../types/project.js';
16
+ import {
17
+ loadState,
18
+ saveState,
19
+ stateExists,
20
+ deleteState,
21
+ backupState,
22
+ } from './persistence.js';
23
+
24
+ // Re-export persistence utilities
25
+ export * from './persistence.js';
26
+
27
+ /**
28
+ * Create a new project state
29
+ *
30
+ * @param spec - The project specification
31
+ * @param projectDir - The project root directory
32
+ * @returns The newly created project state
33
+ */
34
+ export async function createProject(
35
+ spec: ProjectSpec,
36
+ projectDir: string
37
+ ): Promise<ProjectState> {
38
+ // Check if project already exists
39
+ if (await stateExists(projectDir)) {
40
+ throw new Error(`Project already exists at ${projectDir}. Use loadProject() instead.`);
41
+ }
42
+
43
+ const now = new Date().toISOString();
44
+
45
+ const state: ProjectState = {
46
+ id: uuidv4(),
47
+ name: spec.name || 'untitled-project',
48
+ idea: spec.idea,
49
+ language: spec.language,
50
+ openaiModel: spec.openaiModel,
51
+ phase: 'plan',
52
+ status: 'pending',
53
+ milestones: [],
54
+ currentMilestone: null,
55
+ currentTask: null,
56
+ consensusHistory: [],
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ };
60
+
61
+ await saveState(projectDir, state);
62
+ return state;
63
+ }
64
+
65
+ /**
66
+ * Load an existing project
67
+ *
68
+ * @param projectDir - The project root directory
69
+ * @returns The project state
70
+ * @throws Error if project doesn't exist
71
+ */
72
+ export async function loadProject(projectDir: string): Promise<ProjectState> {
73
+ const state = await loadState(projectDir);
74
+
75
+ if (!state) {
76
+ throw new Error(`No project found at ${projectDir}. Use createProject() first.`);
77
+ }
78
+
79
+ return state;
80
+ }
81
+
82
+ /**
83
+ * Check if a project exists at the given directory
84
+ *
85
+ * @param projectDir - The project root directory
86
+ * @returns True if project exists
87
+ */
88
+ export async function projectExists(projectDir: string): Promise<boolean> {
89
+ return stateExists(projectDir);
90
+ }
91
+
92
+ /**
93
+ * Update project state with partial updates
94
+ *
95
+ * @param projectDir - The project root directory
96
+ * @param updates - Partial state updates
97
+ * @returns The updated state
98
+ */
99
+ export async function updateState(
100
+ projectDir: string,
101
+ updates: Partial<ProjectState>
102
+ ): Promise<ProjectState> {
103
+ const current = await loadProject(projectDir);
104
+
105
+ const updated: ProjectState = {
106
+ ...current,
107
+ ...updates,
108
+ updatedAt: new Date().toISOString(),
109
+ };
110
+
111
+ await saveState(projectDir, updated);
112
+ return updated;
113
+ }
114
+
115
+ /**
116
+ * Set the current workflow phase
117
+ *
118
+ * @param projectDir - The project root directory
119
+ * @param phase - The new phase
120
+ * @returns The updated state
121
+ */
122
+ export async function setPhase(
123
+ projectDir: string,
124
+ phase: WorkflowPhase
125
+ ): Promise<ProjectState> {
126
+ return updateState(projectDir, { phase });
127
+ }
128
+
129
+ /**
130
+ * Add milestones to the project
131
+ *
132
+ * @param projectDir - The project root directory
133
+ * @param milestones - Milestones to add
134
+ * @returns The updated state
135
+ */
136
+ export async function addMilestones(
137
+ projectDir: string,
138
+ milestones: Omit<Milestone, 'id'>[]
139
+ ): Promise<ProjectState> {
140
+ const current = await loadProject(projectDir);
141
+
142
+ const newMilestones: Milestone[] = milestones.map((m, index) => ({
143
+ ...m,
144
+ id: `milestone-${current.milestones.length + index + 1}`,
145
+ }));
146
+
147
+ return updateState(projectDir, {
148
+ milestones: [...current.milestones, ...newMilestones],
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Add tasks to a milestone
154
+ *
155
+ * @param projectDir - The project root directory
156
+ * @param milestoneId - The milestone ID
157
+ * @param tasks - Tasks to add
158
+ * @returns The updated state
159
+ */
160
+ export async function addTasks(
161
+ projectDir: string,
162
+ milestoneId: string,
163
+ tasks: Omit<Task, 'id' | 'status' | 'testsPassed'>[]
164
+ ): Promise<ProjectState> {
165
+ const current = await loadProject(projectDir);
166
+
167
+ const milestoneIndex = current.milestones.findIndex((m) => m.id === milestoneId);
168
+ if (milestoneIndex === -1) {
169
+ throw new Error(`Milestone ${milestoneId} not found`);
170
+ }
171
+
172
+ const milestone = current.milestones[milestoneIndex];
173
+ const newTasks: Task[] = tasks.map((t, index) => ({
174
+ ...t,
175
+ id: `${milestoneId}-task-${milestone.tasks.length + index + 1}`,
176
+ status: 'pending' as TaskStatus,
177
+ }));
178
+
179
+ const updatedMilestones = [...current.milestones];
180
+ updatedMilestones[milestoneIndex] = {
181
+ ...milestone,
182
+ tasks: [...milestone.tasks, ...newTasks],
183
+ };
184
+
185
+ return updateState(projectDir, { milestones: updatedMilestones });
186
+ }
187
+
188
+ /**
189
+ * Update a task's status
190
+ *
191
+ * @param projectDir - The project root directory
192
+ * @param taskId - The task ID
193
+ * @param status - The new status
194
+ * @param additionalUpdates - Additional task updates
195
+ * @returns The updated state
196
+ */
197
+ export async function updateTaskStatus(
198
+ projectDir: string,
199
+ taskId: string,
200
+ status: TaskStatus,
201
+ additionalUpdates: Partial<Task> = {}
202
+ ): Promise<ProjectState> {
203
+ const current = await loadProject(projectDir);
204
+
205
+ const updatedMilestones = current.milestones.map((milestone) => ({
206
+ ...milestone,
207
+ tasks: milestone.tasks.map((task) =>
208
+ task.id === taskId ? { ...task, status, ...additionalUpdates } : task
209
+ ),
210
+ }));
211
+
212
+ // Update milestone status if all tasks are complete
213
+ const updatedMilestonesWithStatus = updatedMilestones.map((milestone) => {
214
+ const allComplete = milestone.tasks.every((t) => t.status === 'complete');
215
+ const anyInProgress = milestone.tasks.some((t) => t.status === 'in-progress');
216
+
217
+ let milestoneStatus: TaskStatus = 'pending';
218
+ if (allComplete) {
219
+ milestoneStatus = 'complete';
220
+ } else if (anyInProgress || milestone.tasks.some((t) => t.status === 'complete')) {
221
+ milestoneStatus = 'in-progress';
222
+ }
223
+
224
+ return { ...milestone, status: milestoneStatus };
225
+ });
226
+
227
+ return updateState(projectDir, { milestones: updatedMilestonesWithStatus });
228
+ }
229
+
230
+ /**
231
+ * Set the current milestone being worked on
232
+ *
233
+ * @param projectDir - The project root directory
234
+ * @param milestoneId - The milestone ID (or null)
235
+ * @returns The updated state
236
+ */
237
+ export async function setCurrentMilestone(
238
+ projectDir: string,
239
+ milestoneId: string | null
240
+ ): Promise<ProjectState> {
241
+ if (milestoneId) {
242
+ const current = await loadProject(projectDir);
243
+ const milestone = current.milestones.find((m) => m.id === milestoneId);
244
+ if (!milestone) {
245
+ throw new Error(`Milestone ${milestoneId} not found`);
246
+ }
247
+ }
248
+
249
+ return updateState(projectDir, { currentMilestone: milestoneId });
250
+ }
251
+
252
+ /**
253
+ * Set the current task being worked on
254
+ *
255
+ * @param projectDir - The project root directory
256
+ * @param taskId - The task ID (or null)
257
+ * @returns The updated state
258
+ */
259
+ export async function setCurrentTask(
260
+ projectDir: string,
261
+ taskId: string | null
262
+ ): Promise<ProjectState> {
263
+ return updateState(projectDir, { currentTask: taskId });
264
+ }
265
+
266
+ /**
267
+ * Record a consensus iteration
268
+ *
269
+ * @param projectDir - The project root directory
270
+ * @param iteration - The consensus iteration to record
271
+ * @returns The updated state
272
+ */
273
+ export async function recordConsensusIteration(
274
+ projectDir: string,
275
+ iteration: ConsensusIteration
276
+ ): Promise<ProjectState> {
277
+ const current = await loadProject(projectDir);
278
+
279
+ return updateState(projectDir, {
280
+ consensusHistory: [...current.consensusHistory, iteration],
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Store the approved plan
286
+ *
287
+ * @param projectDir - The project root directory
288
+ * @param plan - The approved plan content
289
+ * @returns The updated state
290
+ */
291
+ export async function storePlan(
292
+ projectDir: string,
293
+ plan: string
294
+ ): Promise<ProjectState> {
295
+ return updateState(projectDir, { plan });
296
+ }
297
+
298
+ /**
299
+ * Store the expanded specification
300
+ *
301
+ * @param projectDir - The project root directory
302
+ * @param specification - The expanded specification
303
+ * @returns The updated state
304
+ */
305
+ export async function storeSpecification(
306
+ projectDir: string,
307
+ specification: string
308
+ ): Promise<ProjectState> {
309
+ return updateState(projectDir, { specification });
310
+ }
311
+
312
+ /**
313
+ * Mark the project as complete
314
+ *
315
+ * @param projectDir - The project root directory
316
+ * @returns The updated state
317
+ */
318
+ export async function completeProject(projectDir: string): Promise<ProjectState> {
319
+ return updateState(projectDir, {
320
+ status: 'complete',
321
+ phase: 'complete',
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Mark the project as failed
327
+ *
328
+ * @param projectDir - The project root directory
329
+ * @param error - The error message
330
+ * @returns The updated state
331
+ */
332
+ export async function failProject(
333
+ projectDir: string,
334
+ error: string
335
+ ): Promise<ProjectState> {
336
+ return updateState(projectDir, {
337
+ status: 'failed',
338
+ error,
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Get project progress summary
344
+ *
345
+ * @param projectDir - The project root directory
346
+ * @returns Progress summary
347
+ */
348
+ export async function getProgress(projectDir: string): Promise<{
349
+ totalMilestones: number;
350
+ completedMilestones: number;
351
+ totalTasks: number;
352
+ completedTasks: number;
353
+ percentComplete: number;
354
+ }> {
355
+ const state = await loadProject(projectDir);
356
+
357
+ const totalMilestones = state.milestones.length;
358
+ const completedMilestones = state.milestones.filter((m) => m.status === 'complete').length;
359
+
360
+ const allTasks = state.milestones.flatMap((m) => m.tasks);
361
+ const totalTasks = allTasks.length;
362
+ const completedTasks = allTasks.filter((t) => t.status === 'complete').length;
363
+
364
+ const percentComplete = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
365
+
366
+ return {
367
+ totalMilestones,
368
+ completedMilestones,
369
+ totalTasks,
370
+ completedTasks,
371
+ percentComplete,
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Get the next pending task
377
+ *
378
+ * @param projectDir - The project root directory
379
+ * @returns The next task to work on, or null if none
380
+ */
381
+ export async function getNextTask(projectDir: string): Promise<{
382
+ milestone: Milestone;
383
+ task: Task;
384
+ } | null> {
385
+ const state = await loadProject(projectDir);
386
+
387
+ for (const milestone of state.milestones) {
388
+ if (milestone.status === 'complete') continue;
389
+
390
+ const pendingTask = milestone.tasks.find((t) => t.status === 'pending');
391
+ if (pendingTask) {
392
+ return { milestone, task: pendingTask };
393
+ }
394
+ }
395
+
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Reset project to a specific phase
401
+ *
402
+ * @param projectDir - The project root directory
403
+ * @param phase - The phase to reset to
404
+ * @returns The updated state
405
+ */
406
+ export async function resetToPhase(
407
+ projectDir: string,
408
+ phase: WorkflowPhase
409
+ ): Promise<ProjectState> {
410
+ // Create backup before reset
411
+ await backupState(projectDir);
412
+
413
+ const current = await loadProject(projectDir);
414
+
415
+ const updates: Partial<ProjectState> = {
416
+ phase,
417
+ status: 'pending',
418
+ error: undefined,
419
+ };
420
+
421
+ if (phase === 'plan') {
422
+ // Reset everything
423
+ updates.milestones = [];
424
+ updates.currentMilestone = null;
425
+ updates.currentTask = null;
426
+ updates.plan = undefined;
427
+ } else if (phase === 'execution') {
428
+ // Reset task progress but keep milestones
429
+ updates.milestones = current.milestones.map((m) => ({
430
+ ...m,
431
+ status: 'pending' as TaskStatus,
432
+ tasks: m.tasks.map((t) => ({
433
+ ...t,
434
+ status: 'pending' as TaskStatus,
435
+ testsPassed: undefined,
436
+ error: undefined,
437
+ })),
438
+ }));
439
+ updates.currentMilestone = null;
440
+ updates.currentTask = null;
441
+ }
442
+
443
+ return updateState(projectDir, updates);
444
+ }
445
+
446
+ /**
447
+ * Delete a project
448
+ *
449
+ * @param projectDir - The project root directory
450
+ * @returns True if project was deleted
451
+ */
452
+ export async function deleteProject(projectDir: string): Promise<boolean> {
453
+ return deleteState(projectDir);
454
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * State persistence module
3
+ * Handles atomic read/write operations for project state
4
+ */
5
+
6
+ import { promises as fs } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { ProjectStateSchema, type ProjectState } from '../types/workflow.js';
9
+
10
+ /**
11
+ * Default state directory name
12
+ */
13
+ export const STATE_DIR = '.popeye';
14
+
15
+ /**
16
+ * State file name
17
+ */
18
+ export const STATE_FILE = 'state.json';
19
+
20
+ /**
21
+ * Get the state directory path for a project
22
+ *
23
+ * @param projectDir - The project root directory
24
+ * @returns The state directory path
25
+ */
26
+ export function getStateDir(projectDir: string): string {
27
+ return path.join(projectDir, STATE_DIR);
28
+ }
29
+
30
+ /**
31
+ * Get the state file path for a project
32
+ *
33
+ * @param projectDir - The project root directory
34
+ * @returns The state file path
35
+ */
36
+ export function getStatePath(projectDir: string): string {
37
+ return path.join(getStateDir(projectDir), STATE_FILE);
38
+ }
39
+
40
+ /**
41
+ * Ensure the state directory exists
42
+ *
43
+ * @param projectDir - The project root directory
44
+ */
45
+ export async function ensureStateDir(projectDir: string): Promise<void> {
46
+ const stateDir = getStateDir(projectDir);
47
+ await fs.mkdir(stateDir, { recursive: true });
48
+ }
49
+
50
+ /**
51
+ * Check if a state file exists for a project
52
+ *
53
+ * @param projectDir - The project root directory
54
+ * @returns True if state file exists
55
+ */
56
+ export async function stateExists(projectDir: string): Promise<boolean> {
57
+ try {
58
+ const statePath = getStatePath(projectDir);
59
+ await fs.access(statePath);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Load project state from disk
68
+ *
69
+ * @param projectDir - The project root directory
70
+ * @returns The project state or null if not found
71
+ */
72
+ export async function loadState(projectDir: string): Promise<ProjectState | null> {
73
+ try {
74
+ const statePath = getStatePath(projectDir);
75
+ const content = await fs.readFile(statePath, 'utf-8');
76
+ const data = JSON.parse(content);
77
+
78
+ // Validate with Zod schema
79
+ const result = ProjectStateSchema.safeParse(data);
80
+
81
+ if (!result.success) {
82
+ console.error('Invalid state file format:', result.error.message);
83
+ return null;
84
+ }
85
+
86
+ return result.data;
87
+ } catch (error) {
88
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
89
+ return null;
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Save project state to disk using atomic write
97
+ * Uses temp file + rename pattern to prevent corruption
98
+ *
99
+ * @param projectDir - The project root directory
100
+ * @param state - The state to save
101
+ */
102
+ export async function saveState(projectDir: string, state: ProjectState): Promise<void> {
103
+ // Ensure state directory exists
104
+ await ensureStateDir(projectDir);
105
+
106
+ const statePath = getStatePath(projectDir);
107
+ const tempPath = `${statePath}.tmp.${Date.now()}`;
108
+
109
+ // Update the updatedAt timestamp
110
+ const stateToSave: ProjectState = {
111
+ ...state,
112
+ updatedAt: new Date().toISOString(),
113
+ };
114
+
115
+ // Validate before saving
116
+ const result = ProjectStateSchema.safeParse(stateToSave);
117
+ if (!result.success) {
118
+ throw new Error(`Invalid state: ${result.error.message}`);
119
+ }
120
+
121
+ // Write to temp file
122
+ const content = JSON.stringify(stateToSave, null, 2);
123
+ await fs.writeFile(tempPath, content, 'utf-8');
124
+
125
+ // Atomic rename
126
+ await fs.rename(tempPath, statePath);
127
+ }
128
+
129
+ /**
130
+ * Delete project state
131
+ *
132
+ * @param projectDir - The project root directory
133
+ * @returns True if state was deleted
134
+ */
135
+ export async function deleteState(projectDir: string): Promise<boolean> {
136
+ try {
137
+ const statePath = getStatePath(projectDir);
138
+ await fs.unlink(statePath);
139
+ return true;
140
+ } catch (error) {
141
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
142
+ return false;
143
+ }
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Create a backup of the current state
150
+ *
151
+ * @param projectDir - The project root directory
152
+ * @returns The backup file path or null if no state exists
153
+ */
154
+ export async function backupState(projectDir: string): Promise<string | null> {
155
+ const state = await loadState(projectDir);
156
+
157
+ if (!state) {
158
+ return null;
159
+ }
160
+
161
+ const stateDir = getStateDir(projectDir);
162
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
163
+ const backupPath = path.join(stateDir, `state.backup.${timestamp}.json`);
164
+
165
+ const content = JSON.stringify(state, null, 2);
166
+ await fs.writeFile(backupPath, content, 'utf-8');
167
+
168
+ return backupPath;
169
+ }
170
+
171
+ /**
172
+ * List all state backups
173
+ *
174
+ * @param projectDir - The project root directory
175
+ * @returns List of backup file paths
176
+ */
177
+ export async function listBackups(projectDir: string): Promise<string[]> {
178
+ try {
179
+ const stateDir = getStateDir(projectDir);
180
+ const files = await fs.readdir(stateDir);
181
+
182
+ return files
183
+ .filter((f) => f.startsWith('state.backup.') && f.endsWith('.json'))
184
+ .map((f) => path.join(stateDir, f))
185
+ .sort()
186
+ .reverse(); // Most recent first
187
+ } catch {
188
+ return [];
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Restore state from a backup
194
+ *
195
+ * @param backupPath - Path to the backup file
196
+ * @param projectDir - The project root directory
197
+ */
198
+ export async function restoreFromBackup(
199
+ backupPath: string,
200
+ projectDir: string
201
+ ): Promise<ProjectState> {
202
+ const content = await fs.readFile(backupPath, 'utf-8');
203
+ const data = JSON.parse(content);
204
+
205
+ const result = ProjectStateSchema.safeParse(data);
206
+ if (!result.success) {
207
+ throw new Error(`Invalid backup file: ${result.error.message}`);
208
+ }
209
+
210
+ await saveState(projectDir, result.data);
211
+ return result.data;
212
+ }
213
+
214
+ /**
215
+ * Clean up old backups, keeping only the most recent N
216
+ *
217
+ * @param projectDir - The project root directory
218
+ * @param keepCount - Number of backups to keep (default 5)
219
+ */
220
+ export async function cleanupBackups(projectDir: string, keepCount: number = 5): Promise<void> {
221
+ const backups = await listBackups(projectDir);
222
+
223
+ if (backups.length <= keepCount) {
224
+ return;
225
+ }
226
+
227
+ // Delete old backups
228
+ const toDelete = backups.slice(keepCount);
229
+ await Promise.all(toDelete.map((backup) => fs.unlink(backup)));
230
+ }