optimal-cli 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/.env.example +17 -0
  4. package/CLAUDE.md +67 -0
  5. package/COMMANDS.md +264 -0
  6. package/PUBLISH.md +70 -0
  7. package/agents/content-ops.md +2 -2
  8. package/agents/financial-ops.md +2 -2
  9. package/agents/infra-ops.md +2 -2
  10. package/apps/.gitkeep +0 -0
  11. package/bin/optimal.ts +1418 -0
  12. package/docs/MIGRATION_NEEDED.md +37 -0
  13. package/docs/plans/.gitkeep +0 -0
  14. package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/lib/budget/projections.ts +561 -0
  17. package/lib/budget/scenarios.ts +312 -0
  18. package/lib/cms/publish-blog.ts +129 -0
  19. package/lib/cms/strapi-client.ts +302 -0
  20. package/lib/config/registry.ts +229 -0
  21. package/lib/config/schema.ts +58 -0
  22. package/lib/config.ts +247 -0
  23. package/lib/infra/.gitkeep +0 -0
  24. package/lib/infra/deploy.ts +70 -0
  25. package/lib/infra/migrate.ts +141 -0
  26. package/lib/kanban-obsidian.ts +232 -0
  27. package/lib/kanban-sync.ts +258 -0
  28. package/lib/kanban.ts +239 -0
  29. package/lib/newsletter/.gitkeep +0 -0
  30. package/lib/newsletter/distribute.ts +256 -0
  31. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  32. package/lib/newsletter/generate.ts +735 -0
  33. package/lib/obsidian-tasks.ts +231 -0
  34. package/lib/returnpro/.gitkeep +0 -0
  35. package/lib/returnpro/anomalies.ts +258 -0
  36. package/lib/returnpro/audit.ts +194 -0
  37. package/lib/returnpro/diagnose.ts +400 -0
  38. package/lib/returnpro/kpis.ts +255 -0
  39. package/lib/returnpro/templates.ts +323 -0
  40. package/lib/returnpro/upload-income.ts +311 -0
  41. package/lib/returnpro/upload-netsuite.ts +696 -0
  42. package/lib/returnpro/upload-r1.ts +563 -0
  43. package/lib/social/post-generator.ts +468 -0
  44. package/lib/social/publish.ts +301 -0
  45. package/lib/social/scraper.ts +503 -0
  46. package/lib/supabase.ts +25 -0
  47. package/lib/transactions/delete-batch.ts +258 -0
  48. package/lib/transactions/ingest.ts +659 -0
  49. package/lib/transactions/stamp.ts +654 -0
  50. package/package.json +5 -18
  51. package/pnpm-workspace.yaml +3 -0
  52. package/scripts/check-table.ts +24 -0
  53. package/scripts/create-tables.ts +94 -0
  54. package/scripts/migrate-kanban.sh +28 -0
  55. package/scripts/migrate-v2.ts +78 -0
  56. package/scripts/migrate.ts +79 -0
  57. package/scripts/run-migration.ts +59 -0
  58. package/scripts/seed-board.ts +203 -0
  59. package/scripts/test-kanban.ts +21 -0
  60. package/skills/audit-financials/SKILL.md +33 -0
  61. package/skills/board-create/SKILL.md +28 -0
  62. package/skills/board-update/SKILL.md +27 -0
  63. package/skills/board-view/SKILL.md +27 -0
  64. package/skills/delete-batch/SKILL.md +77 -0
  65. package/skills/deploy/SKILL.md +40 -0
  66. package/skills/diagnose-months/SKILL.md +68 -0
  67. package/skills/distribute-newsletter/SKILL.md +58 -0
  68. package/skills/export-budget/SKILL.md +44 -0
  69. package/skills/export-kpis/SKILL.md +52 -0
  70. package/skills/generate-netsuite-template/SKILL.md +51 -0
  71. package/skills/generate-newsletter/SKILL.md +53 -0
  72. package/skills/generate-newsletter-insurance/SKILL.md +59 -0
  73. package/skills/generate-social-posts/SKILL.md +67 -0
  74. package/skills/health-check/SKILL.md +42 -0
  75. package/skills/ingest-transactions/SKILL.md +51 -0
  76. package/skills/manage-cms/SKILL.md +50 -0
  77. package/skills/manage-scenarios/SKILL.md +83 -0
  78. package/skills/migrate-db/SKILL.md +79 -0
  79. package/skills/preview-newsletter/SKILL.md +50 -0
  80. package/skills/project-budget/SKILL.md +60 -0
  81. package/skills/publish-blog/SKILL.md +70 -0
  82. package/skills/publish-social-posts/SKILL.md +70 -0
  83. package/skills/rate-anomalies/SKILL.md +62 -0
  84. package/skills/scrape-ads/SKILL.md +49 -0
  85. package/skills/stamp-transactions/SKILL.md +62 -0
  86. package/skills/upload-income-statements/SKILL.md +54 -0
  87. package/skills/upload-netsuite/SKILL.md +56 -0
  88. package/skills/upload-r1/SKILL.md +45 -0
  89. package/supabase/.temp/cli-latest +1 -0
  90. package/supabase/migrations/.gitkeep +0 -0
  91. package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
  92. package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
  93. package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
  94. package/tests/config-command-smoke.test.ts +395 -0
  95. package/tests/config-registry.test.ts +173 -0
  96. package/tsconfig.json +19 -0
  97. package/agents/profiles.json +0 -5
  98. package/dist/bin/optimal.d.ts +0 -2
  99. package/dist/bin/optimal.js +0 -1590
  100. package/dist/lib/assets/index.d.ts +0 -79
  101. package/dist/lib/assets/index.js +0 -153
  102. package/dist/lib/assets.d.ts +0 -20
  103. package/dist/lib/assets.js +0 -112
  104. package/dist/lib/auth/index.d.ts +0 -83
  105. package/dist/lib/auth/index.js +0 -146
  106. package/dist/lib/board/index.d.ts +0 -39
  107. package/dist/lib/board/index.js +0 -285
  108. package/dist/lib/board/types.d.ts +0 -111
  109. package/dist/lib/board/types.js +0 -1
  110. package/dist/lib/bot/claim.d.ts +0 -3
  111. package/dist/lib/bot/claim.js +0 -20
  112. package/dist/lib/bot/coordinator.d.ts +0 -27
  113. package/dist/lib/bot/coordinator.js +0 -178
  114. package/dist/lib/bot/heartbeat.d.ts +0 -6
  115. package/dist/lib/bot/heartbeat.js +0 -30
  116. package/dist/lib/bot/index.d.ts +0 -9
  117. package/dist/lib/bot/index.js +0 -6
  118. package/dist/lib/bot/protocol.d.ts +0 -12
  119. package/dist/lib/bot/protocol.js +0 -74
  120. package/dist/lib/bot/reporter.d.ts +0 -3
  121. package/dist/lib/bot/reporter.js +0 -27
  122. package/dist/lib/bot/skills.d.ts +0 -26
  123. package/dist/lib/bot/skills.js +0 -69
  124. package/dist/lib/budget/projections.d.ts +0 -115
  125. package/dist/lib/budget/projections.js +0 -384
  126. package/dist/lib/budget/scenarios.d.ts +0 -93
  127. package/dist/lib/budget/scenarios.js +0 -214
  128. package/dist/lib/cms/publish-blog.d.ts +0 -62
  129. package/dist/lib/cms/publish-blog.js +0 -74
  130. package/dist/lib/cms/strapi-client.d.ts +0 -123
  131. package/dist/lib/cms/strapi-client.js +0 -213
  132. package/dist/lib/config/registry.d.ts +0 -17
  133. package/dist/lib/config/registry.js +0 -182
  134. package/dist/lib/config/schema.d.ts +0 -31
  135. package/dist/lib/config/schema.js +0 -25
  136. package/dist/lib/config.d.ts +0 -55
  137. package/dist/lib/config.js +0 -206
  138. package/dist/lib/errors.d.ts +0 -25
  139. package/dist/lib/errors.js +0 -91
  140. package/dist/lib/format.d.ts +0 -28
  141. package/dist/lib/format.js +0 -98
  142. package/dist/lib/infra/deploy.d.ts +0 -29
  143. package/dist/lib/infra/deploy.js +0 -58
  144. package/dist/lib/infra/migrate.d.ts +0 -34
  145. package/dist/lib/infra/migrate.js +0 -103
  146. package/dist/lib/newsletter/distribute.d.ts +0 -52
  147. package/dist/lib/newsletter/distribute.js +0 -193
  148. package/dist/lib/newsletter/generate-insurance.js +0 -36
  149. package/dist/lib/newsletter/generate.d.ts +0 -104
  150. package/dist/lib/newsletter/generate.js +0 -571
  151. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  152. package/dist/lib/returnpro/anomalies.js +0 -166
  153. package/dist/lib/returnpro/audit.d.ts +0 -32
  154. package/dist/lib/returnpro/audit.js +0 -147
  155. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  156. package/dist/lib/returnpro/diagnose.js +0 -281
  157. package/dist/lib/returnpro/kpis.d.ts +0 -32
  158. package/dist/lib/returnpro/kpis.js +0 -192
  159. package/dist/lib/returnpro/templates.d.ts +0 -48
  160. package/dist/lib/returnpro/templates.js +0 -229
  161. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  162. package/dist/lib/returnpro/upload-income.js +0 -235
  163. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  164. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  165. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  166. package/dist/lib/returnpro/upload-r1.js +0 -398
  167. package/dist/lib/returnpro/validate.d.ts +0 -37
  168. package/dist/lib/returnpro/validate.js +0 -124
  169. package/dist/lib/social/meta.d.ts +0 -90
  170. package/dist/lib/social/meta.js +0 -160
  171. package/dist/lib/social/post-generator.d.ts +0 -83
  172. package/dist/lib/social/post-generator.js +0 -333
  173. package/dist/lib/social/publish.d.ts +0 -66
  174. package/dist/lib/social/publish.js +0 -226
  175. package/dist/lib/social/scraper.d.ts +0 -67
  176. package/dist/lib/social/scraper.js +0 -361
  177. package/dist/lib/supabase.d.ts +0 -4
  178. package/dist/lib/supabase.js +0 -20
  179. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  180. package/dist/lib/transactions/delete-batch.js +0 -203
  181. package/dist/lib/transactions/ingest.d.ts +0 -43
  182. package/dist/lib/transactions/ingest.js +0 -555
  183. package/dist/lib/transactions/stamp.d.ts +0 -51
  184. package/dist/lib/transactions/stamp.js +0 -524
  185. package/docs/CLI-REFERENCE.md +0 -361
@@ -0,0 +1,258 @@
1
+ /**
2
+ * 3-way sync: Obsidian markdown tasks ↔ Supabase kanban
3
+ *
4
+ * Flow:
5
+ * - obsidian: /home/oracle/Documents/optimal/tasks/*.md (source of truth for humans)
6
+ * - supabase: tasks table (source of truth for agents)
7
+ * - optimal cli: syncs between both
8
+ */
9
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+ import { getSupabase } from './supabase.js'
12
+ import { getProjectBySlug, createTask, updateTask, getBoard, type Task } from './kanban.js'
13
+
14
+ const OBSIDIAN_TASKS_DIR = '/home/oracle/Documents/optimal/tasks'
15
+
16
+ // --- Obsidian task parsing ---
17
+
18
+ interface ObsidianTask {
19
+ id: string // from filename: task__some-id__abc123
20
+ taskId: string // full task__ prefix
21
+ title: string
22
+ status: 'pending' | 'in-progress' | 'done'
23
+ owner: string
24
+ assignee: string
25
+ priority: number
26
+ project: string
27
+ tags: string[]
28
+ source: string
29
+ description: string
30
+ metadata: Record<string, unknown>
31
+ created_at: string
32
+ updated_at: string
33
+ }
34
+
35
+ function parseObsidianTask(filename: string, content: string): ObsidianTask | null {
36
+ // filename format: task__some-slug__abc123.md
37
+ const match = filename.match(/^task__(.+?)__([a-z0-9]+)\.md$/)
38
+ if (!match) return null
39
+
40
+ const [, slug, shortId] = match
41
+ const taskId = `task__${slug}__${shortId}`
42
+
43
+ // Parse frontmatter
44
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
45
+ if (!fmMatch) return null
46
+
47
+ const frontmatter: Record<string, string> = {}
48
+ const lines = fmMatch[1].split('\n')
49
+ for (const line of lines) {
50
+ const [key, ...valueParts] = line.split(':')
51
+ if (key && valueParts.length) {
52
+ frontmatter[key.trim()] = valueParts.join(':').trim()
53
+ }
54
+ }
55
+
56
+ // Extract title from first heading after frontmatter
57
+ const bodyMatch = content.match(/^---\n[\s\S]*?\n---\n#?\s*(.+)/)
58
+ const title = bodyMatch ? bodyMatch[1].trim() : slug.replace(/-/g, ' ')
59
+
60
+ // Parse status
61
+ const statusMap: Record<string, ObsidianTask['status']> = {
62
+ 'pending': 'pending',
63
+ 'in-progress': 'in-progress',
64
+ 'in_progress': 'in-progress',
65
+ 'done': 'done',
66
+ }
67
+ const status = statusMap[frontmatter.status] || 'pending'
68
+
69
+ // Parse priority
70
+ const priorityMap: Record<string, number> = { 'high': 1, 'medium': 2, 'low': 3 }
71
+ const priority = priorityMap[frontmatter.priority] || 3
72
+
73
+ return {
74
+ id: shortId,
75
+ taskId,
76
+ title,
77
+ status,
78
+ owner: frontmatter.owner || 'unassigned',
79
+ assignee: frontmatter.assignee || frontmatter.owner || 'unassigned',
80
+ priority,
81
+ project: frontmatter.project || 'unknown',
82
+ tags: (frontmatter.tags || '[]').replace(/[\[\]]/g, '').split(',').map(t => t.trim()).filter(Boolean),
83
+ source: frontmatter.source || 'obsidian',
84
+ description: content.replace(/^---[\s\S]*?---\n/, '').trim(),
85
+ metadata: {},
86
+ created_at: frontmatter.created_at || new Date().toISOString(),
87
+ updated_at: frontmatter.updated_at || new Date().toISOString(),
88
+ }
89
+ }
90
+
91
+ async function readObsidianTasks(): Promise<ObsidianTask[]> {
92
+ const tasks: ObsidianTask[] = []
93
+
94
+ try {
95
+ const files = await readdir(OBSIDIAN_TASKS_DIR)
96
+ for (const file of files) {
97
+ if (!file.startsWith('task__') || !file.endsWith('.md')) continue
98
+ try {
99
+ const content = await readFile(join(OBSIDIAN_TASKS_DIR, file), 'utf-8')
100
+ const task = parseObsidianTask(file, content)
101
+ if (task) tasks.push(task)
102
+ } catch (e) {
103
+ console.warn(`Failed to read ${file}:`, e)
104
+ }
105
+ }
106
+ } catch (e) {
107
+ console.error(`Failed to read obsidian tasks dir: ${e}`)
108
+ }
109
+
110
+ return tasks
111
+ }
112
+
113
+ // --- Supabase helpers ---
114
+
115
+ function mapStatusToSupabase(status: ObsidianTask['status']): Task['status'] {
116
+ const map: Record<ObsidianTask['status'], Task['status']> = {
117
+ 'pending': 'backlog',
118
+ 'in-progress': 'in_progress',
119
+ 'done': 'done',
120
+ }
121
+ return map[status] || 'backlog'
122
+ }
123
+
124
+ function mapPriorityToSupabase(priority: number): number {
125
+ // obsidian: 1=high, 2=medium, 3=low -> supabase: 1=highest, 4=lowest
126
+ return Math.max(1, Math.min(4, priority))
127
+ }
128
+
129
+ // --- Sync operations ---
130
+
131
+ export async function syncObsidianToSupabase(projectSlug: string, dryRun = false): Promise<void> {
132
+ const sb = getSupabase('optimal')
133
+ const obsidianTasks = await readObsidianTasks()
134
+
135
+ console.log(`\n🔄 Syncing obsidian → supabase (project: ${projectSlug})`)
136
+ console.log(`Found ${obsidianTasks.length} obsidian tasks\n`)
137
+
138
+ // Get or create project
139
+ let projectId: string
140
+ try {
141
+ const { data: project } = await sb.from('projects').select('id').eq('slug', projectSlug).single()
142
+ if (!project) throw new Error('Project not found')
143
+ projectId = project.id
144
+ } catch {
145
+ console.log(`Creating project: ${projectSlug}`)
146
+ if (!dryRun) {
147
+ const { data: newProject } = await sb.from('projects').insert({
148
+ slug: projectSlug,
149
+ name: projectSlug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
150
+ status: 'active',
151
+ priority: 3,
152
+ }).select('id').single()
153
+ projectId = newProject!.id
154
+ } else {
155
+ projectId = 'DRY-RUN-PROJECT-ID'
156
+ }
157
+ }
158
+
159
+ // Get existing supabase tasks
160
+ const { data: existingTasks } = await sb.from('tasks').select('id, title, status, description').eq('project_id', projectId)
161
+ const existingByTitle = new Map((existingTasks || []).map(t => [t.title, t]))
162
+
163
+ let created = 0
164
+ let updated = 0
165
+ let skipped = 0
166
+
167
+ for (const ot of obsidianTasks) {
168
+ const existing = existingByTitle.get(ot.title)
169
+
170
+ if (existing) {
171
+ // Update existing task
172
+ if (!dryRun) {
173
+ await sb.from('tasks').update({
174
+ status: mapStatusToSupabase(ot.status),
175
+ priority: mapPriorityToSupabase(ot.priority),
176
+ assigned_to: ot.assignee !== 'unassigned' ? ot.assignee : null,
177
+ updated_at: new Date().toISOString(),
178
+ description: ot.description || null,
179
+ }).eq('id', existing.id)
180
+ }
181
+ updated++
182
+ console.log(` ✓ Updated: ${ot.title}`)
183
+ } else {
184
+ // Create new task
185
+ if (!dryRun) {
186
+ await sb.from('tasks').insert({
187
+ project_id: projectId,
188
+ title: ot.title,
189
+ description: ot.description || null,
190
+ status: mapStatusToSupabase(ot.status),
191
+ priority: mapPriorityToSupabase(ot.priority),
192
+ assigned_to: ot.assignee !== 'unassigned' ? ot.assignee : null,
193
+ blocked_by: [],
194
+ sort_order: 0,
195
+ })
196
+ }
197
+ created++
198
+ console.log(` + Created: ${ot.title}`)
199
+ }
200
+ }
201
+
202
+ console.log(`\n📊 Sync complete:`)
203
+ console.log(` Created: ${created}`)
204
+ console.log(` Updated: ${updated}`)
205
+ console.log(` Skipped: ${skipped}`)
206
+
207
+ if (dryRun) console.log(`\n⚠️ Dry run — no changes written`)
208
+ }
209
+
210
+ export async function syncSupabaseToObsidian(projectSlug: string, dryRun = false): Promise<void> {
211
+ console.log(`\n🔄 Syncing supabase → obsidian (project: ${projectSlug})`)
212
+ console.log(`Not yet implemented — need to reverse the flow\n`)
213
+ }
214
+
215
+ export async function getSyncStatus(projectSlug: string): Promise<void> {
216
+ const obsidianTasks = await readObsidianTasks()
217
+ const sb = getSupabase('optimal')
218
+
219
+ // Get project
220
+ const { data: project } = await sb.from('projects').select('id').eq('slug', projectSlug).single()
221
+ if (!project) {
222
+ console.log(`Project "${projectSlug}" not found in supabase`)
223
+ return
224
+ }
225
+
226
+ // Get supabase tasks
227
+ const { data: sbTasks } = await sb.from('tasks').select('title, status').eq('project_id', project.id)
228
+
229
+ const sbTitles = new Set((sbTasks || []).map(t => t.title))
230
+ const obTitles = new Set(obsidianTasks.map(t => t.title))
231
+
232
+ const onlyInObsidian = obsidianTasks.filter(t => !sbTitles.has(t.title))
233
+ const onlyInSupabase = (sbTasks || []).filter(t => !obTitles.has(t.title))
234
+ const inBoth = obsidianTasks.filter(t => sbTitles.has(t.title))
235
+
236
+ console.log(`\n📊 Sync Status for "${projectSlug}":\n`)
237
+ console.log(` Obsidian tasks: ${obsidianTasks.length}`)
238
+ console.log(` Supabase tasks: ${sbTasks?.length || 0}`)
239
+ console.log(` In both: ${inBoth.length}`)
240
+ console.log(` Only in obsidian: ${onlyInObsidian.length}`)
241
+ console.log(` Only in supabase: ${onlyInSupabase.length}`)
242
+
243
+ if (onlyInObsidian.length > 0) {
244
+ console.log(`\n📝 Only in obsidian (run 'optimal sync push' to sync):`)
245
+ for (const t of onlyInObsidian.slice(0, 5)) {
246
+ console.log(` - ${t.title}`)
247
+ }
248
+ if (onlyInObsidian.length > 5) console.log(` ... and ${onlyInObsidian.length - 5} more`)
249
+ }
250
+
251
+ if (onlyInSupabase.length > 0) {
252
+ console.log(`\n🗄️ Only in supabase (run 'optimal sync pull' to sync):`)
253
+ for (const t of onlyInSupabase.slice(0, 5)) {
254
+ console.log(` - ${t.title}`)
255
+ }
256
+ if (onlyInSupabase.length > 5) console.log(` ... and ${onlyInSupabase.length - 5} more`)
257
+ }
258
+ }
package/lib/kanban.ts ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Supabase-backed Kanban operations
3
+ * Uses existing projects and tasks tables in supabase
4
+ */
5
+ import { getSupabase } from './supabase.js'
6
+
7
+ const sb = () => getSupabase('optimal')
8
+
9
+ // --- Types ---
10
+
11
+ export interface Task {
12
+ id: string
13
+ project_id: string
14
+ milestone_id: string | null
15
+ title: string
16
+ description: string | null
17
+ status: 'backlog' | 'ready' | 'in_progress' | 'blocked' | 'review' | 'done' | 'cancelled'
18
+ priority: number
19
+ assigned_to: string | null
20
+ claimed_by: string | null
21
+ claimed_at: string | null
22
+ skill_required: string | null
23
+ source_repo: string | null
24
+ target_module: string | null
25
+ estimated_effort: string | null
26
+ blocked_by: string[]
27
+ sort_order: number
28
+ created_at: string
29
+ updated_at: string
30
+ completed_at: string | null
31
+ }
32
+
33
+ export interface Project {
34
+ id: string
35
+ slug: string
36
+ name: string
37
+ description: string | null
38
+ status: 'active' | 'archived' | 'on_hold'
39
+ priority: number
40
+ owner: string | null
41
+ created_at: string
42
+ updated_at: string
43
+ }
44
+
45
+ export interface CreateTaskInput {
46
+ project_slug: string
47
+ title: string
48
+ description?: string
49
+ priority?: number
50
+ skill_required?: string
51
+ source_repo?: string
52
+ }
53
+
54
+ // --- Projects ---
55
+
56
+ export async function getProjectBySlug(slug: string): Promise<Project> {
57
+ const { data, error } = await sb()
58
+ .from('projects')
59
+ .select('*')
60
+ .eq('slug', slug)
61
+ .single()
62
+
63
+ if (error) throw new Error(`Project not found: ${slug} — ${error.message}`)
64
+ return data as Project
65
+ }
66
+
67
+ export async function listProjects(): Promise<Project[]> {
68
+ const { data, error } = await sb()
69
+ .from('projects')
70
+ .select('*')
71
+ .eq('status', 'active')
72
+ .order('name')
73
+
74
+ if (error) throw new Error(`Failed to list projects: ${error.message}`)
75
+ return (data ?? []) as Project[]
76
+ }
77
+
78
+ // --- Tasks ---
79
+
80
+ export async function createTask(input: CreateTaskInput): Promise<Task> {
81
+ const project = await getProjectBySlug(input.project_slug)
82
+
83
+ const { data, error } = await sb()
84
+ .from('tasks')
85
+ .insert({
86
+ project_id: project.id,
87
+ title: input.title,
88
+ description: input.description ?? null,
89
+ priority: input.priority ?? 3,
90
+ skill_required: input.skill_required ?? null,
91
+ source_repo: input.source_repo ?? null,
92
+ status: 'backlog',
93
+ blocked_by: [],
94
+ sort_order: 0,
95
+ })
96
+ .select()
97
+ .single()
98
+
99
+ if (error) throw new Error(`Failed to create task: ${error.message}`)
100
+ return data as Task
101
+ }
102
+
103
+ export async function updateTask(
104
+ taskId: string,
105
+ updates: Partial<Pick<Task, 'status' | 'assigned_to' | 'claimed_by' | 'claimed_at' | 'priority' | 'title' | 'description'>>
106
+ ): Promise<Task> {
107
+ const updateData: Record<string, unknown> = { ...updates, updated_at: new Date().toISOString() }
108
+
109
+ // Handle completed_at
110
+ if (updates.status === 'done') {
111
+ updateData.completed_at = new Date().toISOString()
112
+ }
113
+
114
+ const { data, error } = await sb()
115
+ .from('tasks')
116
+ .update(updateData)
117
+ .eq('id', taskId)
118
+ .select()
119
+ .single()
120
+
121
+ if (error) throw new Error(`Failed to update task ${taskId}: ${error.message}`)
122
+ return data as Task
123
+ }
124
+
125
+ export async function getTask(taskId: string): Promise<Task> {
126
+ const { data, error } = await sb()
127
+ .from('tasks')
128
+ .select('*')
129
+ .eq('id', taskId)
130
+ .single()
131
+
132
+ if (error) throw new Error(`Task not found: ${taskId} — ${error.message}`)
133
+ return data as Task
134
+ }
135
+
136
+ export async function claimTask(taskId: string, agentName: string): Promise<Task> {
137
+ const { data, error } = await sb()
138
+ .from('tasks')
139
+ .update({
140
+ claimed_by: agentName,
141
+ claimed_at: new Date().toISOString(),
142
+ status: 'in_progress',
143
+ updated_at: new Date().toISOString(),
144
+ })
145
+ .eq('id', taskId)
146
+ .select()
147
+ .single()
148
+
149
+ if (error) throw new Error(`Failed to claim task ${taskId}: ${error.message}`)
150
+ return data as Task
151
+ }
152
+
153
+ export async function getNextTask(projectSlug: string, agentName: string): Promise<Task | null> {
154
+ const project = await getProjectBySlug(projectSlug)
155
+
156
+ const { data, error } = await sb()
157
+ .from('tasks')
158
+ .select('*')
159
+ .eq('project_id', project.id)
160
+ .in('status', ['backlog', 'ready'])
161
+ .is('claimed_by', null)
162
+ .order('priority', { ascending: true })
163
+ .order('created_at', { ascending: true })
164
+ .limit(10)
165
+
166
+ if (error) throw new Error(`Failed to fetch tasks: ${error.message}`)
167
+ if (!data || data.length === 0) return null
168
+
169
+ for (const task of data as Task[]) {
170
+ if (!task.blocked_by || task.blocked_by.length === 0) return task
171
+ const { data: blockers } = await sb()
172
+ .from('tasks')
173
+ .select('id, status')
174
+ .in('id', task.blocked_by)
175
+ const allDone = blockers?.every(b => b.status === 'done' || b.status === 'cancelled')
176
+ if (allDone) return task
177
+ }
178
+ return null
179
+ }
180
+
181
+ export async function getBoard(projectSlug: string): Promise<Task[]> {
182
+ const project = await getProjectBySlug(projectSlug)
183
+
184
+ const { data, error } = await sb()
185
+ .from('tasks')
186
+ .select('*')
187
+ .eq('project_id', project.id)
188
+ .not('status', 'eq', 'cancelled')
189
+ .order('sort_order')
190
+ .order('priority', { ascending: true })
191
+ .order('created_at', { ascending: true })
192
+
193
+ if (error) throw new Error(`Failed to fetch board: ${error.message}`)
194
+ return (data ?? []) as Task[]
195
+ }
196
+
197
+ export async function getBoardByStatus(projectSlug: string): Promise<Record<string, Task[]>> {
198
+ const tasks = await getBoard(projectSlug)
199
+
200
+ const byStatus: Record<string, Task[]> = {
201
+ backlog: [],
202
+ ready: [],
203
+ in_progress: [],
204
+ blocked: [],
205
+ review: [],
206
+ done: [],
207
+ }
208
+
209
+ for (const task of tasks) {
210
+ const status = task.status as string
211
+ if (byStatus[status]) {
212
+ byStatus[status].push(task)
213
+ } else {
214
+ byStatus.backlog.push(task)
215
+ }
216
+ }
217
+
218
+ return byStatus
219
+ }
220
+
221
+ // --- Activity/Logs (if activity_log table exists) ---
222
+
223
+ export async function logActivity(
224
+ taskId: string,
225
+ entry: { agent: string; action: string; details?: string }
226
+ ) {
227
+ const { error } = await sb()
228
+ .from('activity_log')
229
+ .insert({
230
+ task_id: taskId,
231
+ agent: entry.agent,
232
+ action: entry.action,
233
+ details: entry.details ?? null,
234
+ })
235
+
236
+ if (error) {
237
+ console.warn(`Failed to log activity: ${error.message}`)
238
+ }
239
+ }
File without changes