prjct-cli 0.13.2 → 0.15.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 (193) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/bin/prjct +10 -13
  3. package/core/agentic/memory-system/semantic-memories.ts +2 -1
  4. package/core/agentic/plan-mode/plan-mode.ts +2 -1
  5. package/core/agentic/prompt-builder.ts +22 -43
  6. package/core/agentic/services.ts +5 -5
  7. package/core/agentic/smart-context.ts +7 -2
  8. package/core/command-registry/core-commands.ts +54 -29
  9. package/core/command-registry/optional-commands.ts +64 -0
  10. package/core/command-registry/setup-commands.ts +18 -3
  11. package/core/commands/analysis.ts +21 -68
  12. package/core/commands/analytics.ts +247 -213
  13. package/core/commands/base.ts +1 -1
  14. package/core/commands/index.ts +41 -36
  15. package/core/commands/maintenance.ts +300 -31
  16. package/core/commands/planning.ts +233 -22
  17. package/core/commands/setup.ts +3 -8
  18. package/core/commands/shipping.ts +14 -18
  19. package/core/commands/types.ts +8 -6
  20. package/core/commands/workflow.ts +105 -100
  21. package/core/context/generator.ts +317 -0
  22. package/core/context-sync.ts +7 -350
  23. package/core/data/index.ts +13 -32
  24. package/core/data/md-ideas-manager.ts +155 -0
  25. package/core/data/md-queue-manager.ts +4 -3
  26. package/core/data/md-shipped-manager.ts +90 -0
  27. package/core/data/md-state-manager.ts +11 -7
  28. package/core/domain/agent-generator.ts +23 -63
  29. package/core/events/index.ts +143 -0
  30. package/core/index.ts +17 -14
  31. package/core/infrastructure/capability-installer.ts +13 -149
  32. package/core/infrastructure/migrator/project-scanner.ts +2 -1
  33. package/core/infrastructure/path-manager.ts +4 -6
  34. package/core/infrastructure/setup.ts +3 -0
  35. package/core/infrastructure/uuid-migration.ts +750 -0
  36. package/core/outcomes/recorder.ts +2 -1
  37. package/core/plugin/loader.ts +4 -7
  38. package/core/plugin/registry.ts +3 -3
  39. package/core/schemas/index.ts +23 -25
  40. package/core/schemas/state.ts +1 -0
  41. package/core/serializers/ideas-serializer.ts +187 -0
  42. package/core/serializers/index.ts +16 -0
  43. package/core/serializers/shipped-serializer.ts +108 -0
  44. package/core/session/utils.ts +3 -9
  45. package/core/storage/ideas-storage.ts +273 -0
  46. package/core/storage/index.ts +204 -0
  47. package/core/storage/queue-storage.ts +297 -0
  48. package/core/storage/shipped-storage.ts +223 -0
  49. package/core/storage/state-storage.ts +235 -0
  50. package/core/storage/storage-manager.ts +175 -0
  51. package/package.json +1 -1
  52. package/packages/web/app/api/projects/[id]/momentum/route.ts +257 -0
  53. package/packages/web/app/api/sessions/current/route.ts +132 -0
  54. package/packages/web/app/api/sessions/history/route.ts +96 -14
  55. package/packages/web/app/globals.css +5 -0
  56. package/packages/web/app/layout.tsx +2 -0
  57. package/packages/web/app/project/[id]/code/layout.tsx +18 -0
  58. package/packages/web/app/project/[id]/code/page.tsx +408 -0
  59. package/packages/web/app/project/[id]/page.tsx +359 -389
  60. package/packages/web/app/project/[id]/reports/page.tsx +59 -0
  61. package/packages/web/app/project/[id]/reports/print/page.tsx +58 -0
  62. package/packages/web/components/ActivityTimeline/ActivityTimeline.tsx +0 -1
  63. package/packages/web/components/AgentsCard/AgentsCard.tsx +64 -34
  64. package/packages/web/components/AgentsCard/AgentsCard.types.ts +1 -0
  65. package/packages/web/components/AppSidebar/AppSidebar.tsx +135 -11
  66. package/packages/web/components/BentoCard/BentoCard.constants.ts +3 -3
  67. package/packages/web/components/BentoCard/BentoCard.tsx +2 -1
  68. package/packages/web/components/BentoGrid/BentoGrid.tsx +2 -2
  69. package/packages/web/components/BlockersCard/BlockersCard.tsx +65 -57
  70. package/packages/web/components/BlockersCard/BlockersCard.types.ts +1 -0
  71. package/packages/web/components/CommandBar/CommandBar.tsx +67 -0
  72. package/packages/web/components/CommandBar/index.ts +1 -0
  73. package/packages/web/components/DashboardContent/DashboardContent.tsx +35 -5
  74. package/packages/web/components/DateGroup/DateGroup.tsx +1 -1
  75. package/packages/web/components/EmptyState/EmptyState.tsx +39 -21
  76. package/packages/web/components/EmptyState/EmptyState.types.ts +1 -0
  77. package/packages/web/components/EventRow/EventRow.tsx +4 -4
  78. package/packages/web/components/EventRow/EventRow.utils.ts +3 -3
  79. package/packages/web/components/HeroSection/HeroSection.tsx +52 -15
  80. package/packages/web/components/HeroSection/HeroSection.types.ts +4 -4
  81. package/packages/web/components/HeroSection/HeroSection.utils.ts +7 -3
  82. package/packages/web/components/IdeasCard/IdeasCard.tsx +94 -27
  83. package/packages/web/components/IdeasCard/IdeasCard.types.ts +1 -0
  84. package/packages/web/components/MasonryGrid/MasonryGrid.tsx +18 -0
  85. package/packages/web/components/MasonryGrid/index.ts +1 -0
  86. package/packages/web/components/MomentumWidget/MomentumWidget.tsx +119 -0
  87. package/packages/web/components/MomentumWidget/MomentumWidget.types.ts +16 -0
  88. package/packages/web/components/MomentumWidget/index.ts +2 -0
  89. package/packages/web/components/NowCard/NowCard.tsx +81 -56
  90. package/packages/web/components/NowCard/NowCard.types.ts +1 -0
  91. package/packages/web/components/PageHeader/PageHeader.tsx +24 -0
  92. package/packages/web/components/PageHeader/index.ts +1 -0
  93. package/packages/web/components/ProgressRing/ProgressRing.constants.ts +2 -2
  94. package/packages/web/components/ProjectAvatar/ProjectAvatar.tsx +2 -2
  95. package/packages/web/components/ProjectColorDot/ProjectColorDot.tsx +37 -0
  96. package/packages/web/components/ProjectColorDot/index.ts +1 -0
  97. package/packages/web/components/ProjectSelectorModal/ProjectSelectorModal.tsx +104 -0
  98. package/packages/web/components/ProjectSelectorModal/index.ts +1 -0
  99. package/packages/web/components/Providers/Providers.tsx +4 -1
  100. package/packages/web/components/QueueCard/QueueCard.tsx +78 -25
  101. package/packages/web/components/QueueCard/QueueCard.types.ts +1 -0
  102. package/packages/web/components/QueueCard/QueueCard.utils.ts +3 -3
  103. package/packages/web/components/RecoverCard/RecoverCard.tsx +72 -0
  104. package/packages/web/components/RecoverCard/RecoverCard.types.ts +16 -0
  105. package/packages/web/components/RecoverCard/index.ts +2 -0
  106. package/packages/web/components/RoadmapCard/RoadmapCard.tsx +101 -33
  107. package/packages/web/components/RoadmapCard/RoadmapCard.types.ts +1 -0
  108. package/packages/web/components/ShipsCard/ShipsCard.tsx +71 -28
  109. package/packages/web/components/ShipsCard/ShipsCard.types.ts +2 -0
  110. package/packages/web/components/SparklineChart/SparklineChart.tsx +20 -18
  111. package/packages/web/components/StatsMasonry/StatsMasonry.tsx +95 -0
  112. package/packages/web/components/StatsMasonry/index.ts +1 -0
  113. package/packages/web/components/StreakCard/StreakCard.tsx +37 -35
  114. package/packages/web/components/TasksCounter/TasksCounter.tsx +1 -1
  115. package/packages/web/components/TechStackBadges/TechStackBadges.tsx +12 -4
  116. package/packages/web/components/TerminalDock/DockToggleTab.tsx +29 -0
  117. package/packages/web/components/TerminalDock/TerminalDock.tsx +386 -0
  118. package/packages/web/components/TerminalDock/TerminalDockTab.tsx +130 -0
  119. package/packages/web/components/TerminalDock/TerminalTabBar.tsx +142 -0
  120. package/packages/web/components/TerminalDock/index.ts +2 -0
  121. package/packages/web/components/VelocityBadge/VelocityBadge.tsx +8 -3
  122. package/packages/web/components/VelocityCard/VelocityCard.tsx +49 -47
  123. package/packages/web/components/WeeklyReports/PrintableReport.tsx +259 -0
  124. package/packages/web/components/WeeklyReports/ReportPreviewCard.tsx +187 -0
  125. package/packages/web/components/WeeklyReports/WeekCalendar.tsx +288 -0
  126. package/packages/web/components/WeeklyReports/WeeklyReports.tsx +149 -0
  127. package/packages/web/components/WeeklyReports/index.ts +4 -0
  128. package/packages/web/components/WeeklySparkline/WeeklySparkline.tsx +16 -4
  129. package/packages/web/components/WeeklySparkline/WeeklySparkline.types.ts +1 -0
  130. package/packages/web/components/charts/SessionsChart.tsx +6 -3
  131. package/packages/web/components/ui/dialog.tsx +143 -0
  132. package/packages/web/components/ui/drawer.tsx +135 -0
  133. package/packages/web/components/ui/select.tsx +187 -0
  134. package/packages/web/context/GlobalTerminalContext.tsx +538 -0
  135. package/packages/web/lib/commands.ts +81 -0
  136. package/packages/web/lib/generate-week-report.ts +285 -0
  137. package/packages/web/lib/parse-prjct-files.ts +56 -55
  138. package/packages/web/lib/project-colors.ts +58 -0
  139. package/packages/web/lib/projects.ts +58 -5
  140. package/packages/web/lib/services/projects.server.ts +11 -1
  141. package/packages/web/next-env.d.ts +1 -1
  142. package/packages/web/package.json +5 -1
  143. package/templates/commands/analyze.md +39 -3
  144. package/templates/commands/ask.md +58 -3
  145. package/templates/commands/bug.md +117 -26
  146. package/templates/commands/dash.md +95 -158
  147. package/templates/commands/done.md +130 -148
  148. package/templates/commands/feature.md +125 -103
  149. package/templates/commands/git.md +18 -3
  150. package/templates/commands/idea.md +121 -38
  151. package/templates/commands/init.md +124 -20
  152. package/templates/commands/migrate-all.md +63 -28
  153. package/templates/commands/migrate.md +140 -0
  154. package/templates/commands/next.md +115 -5
  155. package/templates/commands/now.md +146 -82
  156. package/templates/commands/pause.md +89 -74
  157. package/templates/commands/redo.md +6 -4
  158. package/templates/commands/resume.md +141 -59
  159. package/templates/commands/ship.md +103 -231
  160. package/templates/commands/spec.md +98 -8
  161. package/templates/commands/suggest.md +22 -2
  162. package/templates/commands/sync.md +192 -203
  163. package/templates/commands/undo.md +6 -4
  164. package/core/data/agents-manager.ts +0 -76
  165. package/core/data/analysis-manager.ts +0 -83
  166. package/core/data/base-manager.ts +0 -156
  167. package/core/data/ideas-manager.ts +0 -81
  168. package/core/data/outcomes-manager.ts +0 -96
  169. package/core/data/project-manager.ts +0 -75
  170. package/core/data/roadmap-manager.ts +0 -118
  171. package/core/data/shipped-manager.ts +0 -65
  172. package/core/data/state-manager.ts +0 -214
  173. package/core/state/index.ts +0 -25
  174. package/core/state/manager.ts +0 -376
  175. package/core/state/types.ts +0 -185
  176. package/core/utils/project-capabilities.ts +0 -156
  177. package/core/view-generator.ts +0 -536
  178. package/packages/web/app/project/[id]/stats/loading.tsx +0 -43
  179. package/packages/web/app/project/[id]/stats/page.tsx +0 -253
  180. package/templates/agent-assignment.md +0 -72
  181. package/templates/analysis/project-analysis.md +0 -78
  182. package/templates/checklists/accessibility.md +0 -33
  183. package/templates/commands/build.md +0 -17
  184. package/templates/commands/decision.md +0 -226
  185. package/templates/commands/fix.md +0 -79
  186. package/templates/commands/help.md +0 -61
  187. package/templates/commands/progress.md +0 -14
  188. package/templates/commands/recap.md +0 -14
  189. package/templates/commands/roadmap.md +0 -52
  190. package/templates/commands/status.md +0 -17
  191. package/templates/commands/task.md +0 -63
  192. package/templates/commands/work.md +0 -44
  193. package/templates/commands/workflow.md +0 -12
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Ideas Storage
3
+ *
4
+ * Manages ideas via storage/ideas.json
5
+ * Generates context/ideas.md for Claude
6
+ */
7
+
8
+ import { StorageManager } from './storage-manager'
9
+ import { generateUUID } from '../schemas'
10
+
11
+ export type IdeaStatus = 'pending' | 'converted' | 'archived'
12
+ export type IdeaPriority = 'low' | 'medium' | 'high'
13
+
14
+ export interface Idea {
15
+ id: string
16
+ text: string
17
+ status: IdeaStatus
18
+ priority: IdeaPriority
19
+ tags: string[]
20
+ addedAt: string
21
+ convertedTo?: string // featureId if converted
22
+ }
23
+
24
+ export interface IdeasJson {
25
+ ideas: Idea[]
26
+ lastUpdated: string
27
+ }
28
+
29
+ class IdeasStorage extends StorageManager<IdeasJson> {
30
+ constructor() {
31
+ super('ideas.json')
32
+ }
33
+
34
+ protected getDefault(): IdeasJson {
35
+ return {
36
+ ideas: [],
37
+ lastUpdated: ''
38
+ }
39
+ }
40
+
41
+ protected getMdFilename(): string {
42
+ return 'ideas.md'
43
+ }
44
+
45
+ protected getEventType(action: 'update' | 'create' | 'delete'): string {
46
+ return `ideas.${action}d`
47
+ }
48
+
49
+ protected toMarkdown(data: IdeasJson): string {
50
+ const lines = ['# IDEAS \u{1F4A1}', '']
51
+
52
+ const pending = data.ideas.filter(i => i.status === 'pending')
53
+ const converted = data.ideas.filter(i => i.status === 'converted')
54
+ const archived = data.ideas.filter(i => i.status === 'archived')
55
+
56
+ // Brain Dump (pending)
57
+ lines.push('## Brain Dump')
58
+ if (pending.length > 0) {
59
+ pending.forEach(idea => {
60
+ const date = idea.addedAt.split('T')[0]
61
+ const tags = idea.tags.length > 0 ? ' ' + idea.tags.map(t => `#${t}`).join(' ') : ''
62
+ const priority = idea.priority !== 'medium' ? ` [${idea.priority.toUpperCase()}]` : ''
63
+ lines.push(`- ${idea.text}${priority} _(${date})_${tags}`)
64
+ })
65
+ } else {
66
+ lines.push('_No pending ideas_')
67
+ }
68
+ lines.push('')
69
+
70
+ // Converted
71
+ if (converted.length > 0) {
72
+ lines.push('## Converted')
73
+ converted.forEach(idea => {
74
+ const date = idea.addedAt.split('T')[0]
75
+ const feat = idea.convertedTo ? ` \u2192 ${idea.convertedTo}` : ''
76
+ lines.push(`- \u2713 ${idea.text}${feat} _(${date})_`)
77
+ })
78
+ lines.push('')
79
+ }
80
+
81
+ // Archived
82
+ if (archived.length > 0) {
83
+ lines.push('## Archived')
84
+ archived.forEach(idea => {
85
+ const date = idea.addedAt.split('T')[0]
86
+ lines.push(`- ${idea.text} _(${date})_`)
87
+ })
88
+ lines.push('')
89
+ }
90
+
91
+ return lines.join('\n')
92
+ }
93
+
94
+ // =========== Domain Methods ===========
95
+
96
+ /**
97
+ * Get all ideas
98
+ */
99
+ async getAll(projectId: string): Promise<Idea[]> {
100
+ const data = await this.read(projectId)
101
+ return data.ideas
102
+ }
103
+
104
+ /**
105
+ * Get pending ideas
106
+ */
107
+ async getPending(projectId: string): Promise<Idea[]> {
108
+ const data = await this.read(projectId)
109
+ return data.ideas.filter(i => i.status === 'pending')
110
+ }
111
+
112
+ /**
113
+ * Add a new idea
114
+ */
115
+ async addIdea(
116
+ projectId: string,
117
+ text: string,
118
+ options: { tags?: string[]; priority?: IdeaPriority } = {}
119
+ ): Promise<Idea> {
120
+ const idea: Idea = {
121
+ id: generateUUID(),
122
+ text,
123
+ status: 'pending',
124
+ priority: options.priority || 'medium',
125
+ tags: options.tags || [],
126
+ addedAt: new Date().toISOString()
127
+ }
128
+
129
+ await this.update(projectId, (data) => ({
130
+ ideas: [idea, ...data.ideas], // Prepend new ideas
131
+ lastUpdated: new Date().toISOString()
132
+ }))
133
+
134
+ // Publish event
135
+ await this.publishEvent(projectId, 'idea.created', {
136
+ ideaId: idea.id,
137
+ text: idea.text,
138
+ priority: idea.priority
139
+ })
140
+
141
+ return idea
142
+ }
143
+
144
+ /**
145
+ * Get idea by ID
146
+ */
147
+ async getById(projectId: string, id: string): Promise<Idea | undefined> {
148
+ const data = await this.read(projectId)
149
+ return data.ideas.find(i => i.id === id)
150
+ }
151
+
152
+ /**
153
+ * Convert idea to feature
154
+ */
155
+ async convertToFeature(
156
+ projectId: string,
157
+ ideaId: string,
158
+ featureId: string
159
+ ): Promise<void> {
160
+ await this.update(projectId, (data) => ({
161
+ ideas: data.ideas.map(i =>
162
+ i.id === ideaId
163
+ ? { ...i, status: 'converted' as IdeaStatus, convertedTo: featureId }
164
+ : i
165
+ ),
166
+ lastUpdated: new Date().toISOString()
167
+ }))
168
+
169
+ await this.publishEvent(projectId, 'idea.converted', {
170
+ ideaId,
171
+ featureId
172
+ })
173
+ }
174
+
175
+ /**
176
+ * Archive an idea
177
+ */
178
+ async archive(projectId: string, ideaId: string): Promise<void> {
179
+ await this.update(projectId, (data) => ({
180
+ ideas: data.ideas.map(i =>
181
+ i.id === ideaId ? { ...i, status: 'archived' as IdeaStatus } : i
182
+ ),
183
+ lastUpdated: new Date().toISOString()
184
+ }))
185
+
186
+ await this.publishEvent(projectId, 'idea.archived', { ideaId })
187
+ }
188
+
189
+ /**
190
+ * Set priority
191
+ */
192
+ async setPriority(
193
+ projectId: string,
194
+ ideaId: string,
195
+ priority: IdeaPriority
196
+ ): Promise<void> {
197
+ await this.update(projectId, (data) => ({
198
+ ideas: data.ideas.map(i =>
199
+ i.id === ideaId ? { ...i, priority } : i
200
+ ),
201
+ lastUpdated: new Date().toISOString()
202
+ }))
203
+ }
204
+
205
+ /**
206
+ * Add tags to an idea
207
+ */
208
+ async addTags(
209
+ projectId: string,
210
+ ideaId: string,
211
+ tags: string[]
212
+ ): Promise<void> {
213
+ await this.update(projectId, (data) => ({
214
+ ideas: data.ideas.map(i =>
215
+ i.id === ideaId
216
+ ? { ...i, tags: [...new Set([...i.tags, ...tags])] }
217
+ : i
218
+ ),
219
+ lastUpdated: new Date().toISOString()
220
+ }))
221
+ }
222
+
223
+ /**
224
+ * Remove an idea
225
+ */
226
+ async removeIdea(projectId: string, ideaId: string): Promise<void> {
227
+ await this.update(projectId, (data) => ({
228
+ ideas: data.ideas.filter(i => i.id !== ideaId),
229
+ lastUpdated: new Date().toISOString()
230
+ }))
231
+ }
232
+
233
+ /**
234
+ * Get counts by status
235
+ */
236
+ async getCounts(projectId: string): Promise<{ pending: number; converted: number; archived: number }> {
237
+ const data = await this.read(projectId)
238
+ return {
239
+ pending: data.ideas.filter(i => i.status === 'pending').length,
240
+ converted: data.ideas.filter(i => i.status === 'converted').length,
241
+ archived: data.ideas.filter(i => i.status === 'archived').length
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Cleanup old archived ideas (keep last 50)
247
+ */
248
+ async cleanup(projectId: string): Promise<{ removed: number }> {
249
+ const data = await this.read(projectId)
250
+ const archived = data.ideas.filter(i => i.status === 'archived')
251
+
252
+ if (archived.length <= 50) {
253
+ return { removed: 0 }
254
+ }
255
+
256
+ // Sort by date and keep newest 50
257
+ const sortedArchived = archived.sort(
258
+ (a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime()
259
+ )
260
+ const toRemove = new Set(sortedArchived.slice(50).map(i => i.id))
261
+ const removed = toRemove.size
262
+
263
+ await this.update(projectId, (d) => ({
264
+ ideas: d.ideas.filter(i => !toRemove.has(i.id)),
265
+ lastUpdated: new Date().toISOString()
266
+ }))
267
+
268
+ return { removed }
269
+ }
270
+ }
271
+
272
+ export const ideasStorage = new IdeasStorage()
273
+ export default ideasStorage
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Storage Layer
3
+ *
4
+ * Two storage patterns:
5
+ *
6
+ * 1. AGGREGATE STORAGE (Write-Through Pattern)
7
+ * For main state - writes JSON + regenerates MD + publishes events
8
+ * - stateStorage: storage/state.json → context/now.md
9
+ * - queueStorage: storage/queue.json → context/next.md
10
+ * - ideasStorage: storage/ideas.json → context/ideas.md
11
+ * - shippedStorage: storage/shipped.json → context/shipped.md
12
+ *
13
+ * 2. GRANULAR STORAGE (OpenCode-style) - Legacy
14
+ * For future per-entity storage
15
+ * - getStorage(projectId): data/{entity}s/{id}.json
16
+ *
17
+ * Structure:
18
+ * ~/.prjct-cli/projects/{projectId}/
19
+ * ├── storage/ # Aggregate JSON (source of truth)
20
+ * │ ├── state.json
21
+ * │ ├── queue.json
22
+ * │ ├── ideas.json
23
+ * │ └── shipped.json
24
+ * ├── context/ # Generated MD (for Claude)
25
+ * │ ├── CLAUDE.md
26
+ * │ ├── now.md
27
+ * │ ├── next.md
28
+ * │ ├── ideas.md
29
+ * │ └── shipped.md
30
+ * ├── data/ # Granular JSON (legacy/future)
31
+ * │ └── ...
32
+ * └── sync/ # Backend sync
33
+ * ├── pending.json
34
+ * └── last-sync.json
35
+ */
36
+
37
+ // ========== AGGREGATE STORAGE (Recommended) ==========
38
+ export { StorageManager } from './storage-manager'
39
+ export { stateStorage } from './state-storage'
40
+ export { queueStorage } from './queue-storage'
41
+ export { ideasStorage, type Idea, type IdeasJson, type IdeaStatus, type IdeaPriority } from './ideas-storage'
42
+ export { shippedStorage, type ShippedFeature, type ShippedJson } from './shipped-storage'
43
+
44
+ // ========== GRANULAR STORAGE (Legacy) ==========
45
+
46
+ import fs from 'fs/promises'
47
+ import path from 'path'
48
+ import os from 'os'
49
+ import { eventBus, inferEventType } from '../events'
50
+
51
+ export interface Storage {
52
+ write<T>(path: string[], data: T): Promise<void>
53
+ read<T>(path: string[]): Promise<T | null>
54
+ list(prefix: string[]): Promise<string[][]>
55
+ delete(path: string[]): Promise<void>
56
+ exists(path: string[]): Promise<boolean>
57
+ }
58
+
59
+ class FileStorage implements Storage {
60
+ private projectId: string
61
+ private basePath: string
62
+
63
+ constructor(projectId: string) {
64
+ this.projectId = projectId
65
+ this.basePath = path.join(os.homedir(), '.prjct-cli/projects', projectId, 'data')
66
+ }
67
+
68
+ /**
69
+ * Convert path array to file path
70
+ * ["task", "abc123"] → data/tasks/abc123.json
71
+ * ["project"] → data/project.json
72
+ */
73
+ private pathToFile(pathArray: string[]): string {
74
+ if (pathArray.length === 1) {
75
+ return path.join(this.basePath, `${pathArray[0]}.json`)
76
+ }
77
+
78
+ // Pluralize first segment for directory
79
+ const dir = pathArray[0] + 's'
80
+ const rest = pathArray.slice(1)
81
+ const filename = rest.join('/') + '.json'
82
+
83
+ return path.join(this.basePath, dir, filename)
84
+ }
85
+
86
+ async write<T>(pathArray: string[], data: T): Promise<void> {
87
+ const filePath = this.pathToFile(pathArray)
88
+
89
+ // Ensure directory exists
90
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
91
+
92
+ // Write data
93
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
94
+
95
+ // Publish event for sync
96
+ eventBus.publish({
97
+ type: inferEventType(pathArray, 'write'),
98
+ path: pathArray,
99
+ data,
100
+ timestamp: new Date().toISOString(),
101
+ projectId: this.projectId
102
+ })
103
+
104
+ // Update index if it's a collection item
105
+ if (pathArray.length === 2) {
106
+ await this.updateIndex(pathArray[0], pathArray[1], 'add')
107
+ }
108
+ }
109
+
110
+ async read<T>(pathArray: string[]): Promise<T | null> {
111
+ const filePath = this.pathToFile(pathArray)
112
+
113
+ try {
114
+ const content = await fs.readFile(filePath, 'utf-8')
115
+ return JSON.parse(content) as T
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ async list(prefix: string[]): Promise<string[][]> {
122
+ const dir = path.join(this.basePath, prefix[0] + 's')
123
+
124
+ try {
125
+ const files = await fs.readdir(dir)
126
+ return files
127
+ .filter(f => f.endsWith('.json') && f !== 'index.json')
128
+ .map(f => [...prefix, f.replace('.json', '')])
129
+ } catch {
130
+ return []
131
+ }
132
+ }
133
+
134
+ async delete(pathArray: string[]): Promise<void> {
135
+ const filePath = this.pathToFile(pathArray)
136
+
137
+ try {
138
+ await fs.unlink(filePath)
139
+
140
+ // Publish event for sync
141
+ eventBus.publish({
142
+ type: inferEventType(pathArray, 'delete'),
143
+ path: pathArray,
144
+ data: null,
145
+ timestamp: new Date().toISOString(),
146
+ projectId: this.projectId
147
+ })
148
+
149
+ // Update index if it's a collection item
150
+ if (pathArray.length === 2) {
151
+ await this.updateIndex(pathArray[0], pathArray[1], 'remove')
152
+ }
153
+ } catch {
154
+ // File doesn't exist, ignore
155
+ }
156
+ }
157
+
158
+ async exists(pathArray: string[]): Promise<boolean> {
159
+ const filePath = this.pathToFile(pathArray)
160
+
161
+ try {
162
+ await fs.access(filePath)
163
+ return true
164
+ } catch {
165
+ return false
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Update collection index
171
+ */
172
+ private async updateIndex(collection: string, id: string, action: 'add' | 'remove'): Promise<void> {
173
+ const indexPath = path.join(this.basePath, collection + 's', 'index.json')
174
+
175
+ let index: { ids: string[]; updatedAt: string } = { ids: [], updatedAt: '' }
176
+
177
+ try {
178
+ const content = await fs.readFile(indexPath, 'utf-8')
179
+ index = JSON.parse(content)
180
+ } catch {
181
+ // Index doesn't exist yet
182
+ }
183
+
184
+ if (action === 'add' && !index.ids.includes(id)) {
185
+ index.ids.push(id)
186
+ } else if (action === 'remove') {
187
+ index.ids = index.ids.filter(i => i !== id)
188
+ }
189
+
190
+ index.updatedAt = new Date().toISOString()
191
+
192
+ await fs.mkdir(path.dirname(indexPath), { recursive: true })
193
+ await fs.writeFile(indexPath, JSON.stringify(index, null, 2), 'utf-8')
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Get storage instance for a project
199
+ */
200
+ export function getStorage(projectId: string): Storage {
201
+ return new FileStorage(projectId)
202
+ }
203
+
204
+ export default { getStorage }