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.
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.env.example +17 -0
- package/CLAUDE.md +67 -0
- package/COMMANDS.md +264 -0
- package/PUBLISH.md +70 -0
- package/agents/content-ops.md +2 -2
- package/agents/financial-ops.md +2 -2
- package/agents/infra-ops.md +2 -2
- package/apps/.gitkeep +0 -0
- package/bin/optimal.ts +1418 -0
- package/docs/MIGRATION_NEEDED.md +37 -0
- package/docs/plans/.gitkeep +0 -0
- package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
- package/hooks/.gitkeep +0 -0
- package/lib/budget/projections.ts +561 -0
- package/lib/budget/scenarios.ts +312 -0
- package/lib/cms/publish-blog.ts +129 -0
- package/lib/cms/strapi-client.ts +302 -0
- package/lib/config/registry.ts +229 -0
- package/lib/config/schema.ts +58 -0
- package/lib/config.ts +247 -0
- package/lib/infra/.gitkeep +0 -0
- package/lib/infra/deploy.ts +70 -0
- package/lib/infra/migrate.ts +141 -0
- package/lib/kanban-obsidian.ts +232 -0
- package/lib/kanban-sync.ts +258 -0
- package/lib/kanban.ts +239 -0
- package/lib/newsletter/.gitkeep +0 -0
- package/lib/newsletter/distribute.ts +256 -0
- package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
- package/lib/newsletter/generate.ts +735 -0
- package/lib/obsidian-tasks.ts +231 -0
- package/lib/returnpro/.gitkeep +0 -0
- package/lib/returnpro/anomalies.ts +258 -0
- package/lib/returnpro/audit.ts +194 -0
- package/lib/returnpro/diagnose.ts +400 -0
- package/lib/returnpro/kpis.ts +255 -0
- package/lib/returnpro/templates.ts +323 -0
- package/lib/returnpro/upload-income.ts +311 -0
- package/lib/returnpro/upload-netsuite.ts +696 -0
- package/lib/returnpro/upload-r1.ts +563 -0
- package/lib/social/post-generator.ts +468 -0
- package/lib/social/publish.ts +301 -0
- package/lib/social/scraper.ts +503 -0
- package/lib/supabase.ts +25 -0
- package/lib/transactions/delete-batch.ts +258 -0
- package/lib/transactions/ingest.ts +659 -0
- package/lib/transactions/stamp.ts +654 -0
- package/package.json +5 -18
- package/pnpm-workspace.yaml +3 -0
- package/scripts/check-table.ts +24 -0
- package/scripts/create-tables.ts +94 -0
- package/scripts/migrate-kanban.sh +28 -0
- package/scripts/migrate-v2.ts +78 -0
- package/scripts/migrate.ts +79 -0
- package/scripts/run-migration.ts +59 -0
- package/scripts/seed-board.ts +203 -0
- package/scripts/test-kanban.ts +21 -0
- package/skills/audit-financials/SKILL.md +33 -0
- package/skills/board-create/SKILL.md +28 -0
- package/skills/board-update/SKILL.md +27 -0
- package/skills/board-view/SKILL.md +27 -0
- package/skills/delete-batch/SKILL.md +77 -0
- package/skills/deploy/SKILL.md +40 -0
- package/skills/diagnose-months/SKILL.md +68 -0
- package/skills/distribute-newsletter/SKILL.md +58 -0
- package/skills/export-budget/SKILL.md +44 -0
- package/skills/export-kpis/SKILL.md +52 -0
- package/skills/generate-netsuite-template/SKILL.md +51 -0
- package/skills/generate-newsletter/SKILL.md +53 -0
- package/skills/generate-newsletter-insurance/SKILL.md +59 -0
- package/skills/generate-social-posts/SKILL.md +67 -0
- package/skills/health-check/SKILL.md +42 -0
- package/skills/ingest-transactions/SKILL.md +51 -0
- package/skills/manage-cms/SKILL.md +50 -0
- package/skills/manage-scenarios/SKILL.md +83 -0
- package/skills/migrate-db/SKILL.md +79 -0
- package/skills/preview-newsletter/SKILL.md +50 -0
- package/skills/project-budget/SKILL.md +60 -0
- package/skills/publish-blog/SKILL.md +70 -0
- package/skills/publish-social-posts/SKILL.md +70 -0
- package/skills/rate-anomalies/SKILL.md +62 -0
- package/skills/scrape-ads/SKILL.md +49 -0
- package/skills/stamp-transactions/SKILL.md +62 -0
- package/skills/upload-income-statements/SKILL.md +54 -0
- package/skills/upload-netsuite/SKILL.md +56 -0
- package/skills/upload-r1/SKILL.md +45 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/migrations/.gitkeep +0 -0
- package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
- package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
- package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
- package/tests/config-command-smoke.test.ts +395 -0
- package/tests/config-registry.test.ts +173 -0
- package/tsconfig.json +19 -0
- package/agents/profiles.json +0 -5
- package/dist/bin/optimal.d.ts +0 -2
- package/dist/bin/optimal.js +0 -1590
- package/dist/lib/assets/index.d.ts +0 -79
- package/dist/lib/assets/index.js +0 -153
- package/dist/lib/assets.d.ts +0 -20
- package/dist/lib/assets.js +0 -112
- package/dist/lib/auth/index.d.ts +0 -83
- package/dist/lib/auth/index.js +0 -146
- package/dist/lib/board/index.d.ts +0 -39
- package/dist/lib/board/index.js +0 -285
- package/dist/lib/board/types.d.ts +0 -111
- package/dist/lib/board/types.js +0 -1
- package/dist/lib/bot/claim.d.ts +0 -3
- package/dist/lib/bot/claim.js +0 -20
- package/dist/lib/bot/coordinator.d.ts +0 -27
- package/dist/lib/bot/coordinator.js +0 -178
- package/dist/lib/bot/heartbeat.d.ts +0 -6
- package/dist/lib/bot/heartbeat.js +0 -30
- package/dist/lib/bot/index.d.ts +0 -9
- package/dist/lib/bot/index.js +0 -6
- package/dist/lib/bot/protocol.d.ts +0 -12
- package/dist/lib/bot/protocol.js +0 -74
- package/dist/lib/bot/reporter.d.ts +0 -3
- package/dist/lib/bot/reporter.js +0 -27
- package/dist/lib/bot/skills.d.ts +0 -26
- package/dist/lib/bot/skills.js +0 -69
- package/dist/lib/budget/projections.d.ts +0 -115
- package/dist/lib/budget/projections.js +0 -384
- package/dist/lib/budget/scenarios.d.ts +0 -93
- package/dist/lib/budget/scenarios.js +0 -214
- package/dist/lib/cms/publish-blog.d.ts +0 -62
- package/dist/lib/cms/publish-blog.js +0 -74
- package/dist/lib/cms/strapi-client.d.ts +0 -123
- package/dist/lib/cms/strapi-client.js +0 -213
- package/dist/lib/config/registry.d.ts +0 -17
- package/dist/lib/config/registry.js +0 -182
- package/dist/lib/config/schema.d.ts +0 -31
- package/dist/lib/config/schema.js +0 -25
- package/dist/lib/config.d.ts +0 -55
- package/dist/lib/config.js +0 -206
- package/dist/lib/errors.d.ts +0 -25
- package/dist/lib/errors.js +0 -91
- package/dist/lib/format.d.ts +0 -28
- package/dist/lib/format.js +0 -98
- package/dist/lib/infra/deploy.d.ts +0 -29
- package/dist/lib/infra/deploy.js +0 -58
- package/dist/lib/infra/migrate.d.ts +0 -34
- package/dist/lib/infra/migrate.js +0 -103
- package/dist/lib/newsletter/distribute.d.ts +0 -52
- package/dist/lib/newsletter/distribute.js +0 -193
- package/dist/lib/newsletter/generate-insurance.js +0 -36
- package/dist/lib/newsletter/generate.d.ts +0 -104
- package/dist/lib/newsletter/generate.js +0 -571
- package/dist/lib/returnpro/anomalies.d.ts +0 -64
- package/dist/lib/returnpro/anomalies.js +0 -166
- package/dist/lib/returnpro/audit.d.ts +0 -32
- package/dist/lib/returnpro/audit.js +0 -147
- package/dist/lib/returnpro/diagnose.d.ts +0 -52
- package/dist/lib/returnpro/diagnose.js +0 -281
- package/dist/lib/returnpro/kpis.d.ts +0 -32
- package/dist/lib/returnpro/kpis.js +0 -192
- package/dist/lib/returnpro/templates.d.ts +0 -48
- package/dist/lib/returnpro/templates.js +0 -229
- package/dist/lib/returnpro/upload-income.d.ts +0 -25
- package/dist/lib/returnpro/upload-income.js +0 -235
- package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
- package/dist/lib/returnpro/upload-netsuite.js +0 -566
- package/dist/lib/returnpro/upload-r1.d.ts +0 -48
- package/dist/lib/returnpro/upload-r1.js +0 -398
- package/dist/lib/returnpro/validate.d.ts +0 -37
- package/dist/lib/returnpro/validate.js +0 -124
- package/dist/lib/social/meta.d.ts +0 -90
- package/dist/lib/social/meta.js +0 -160
- package/dist/lib/social/post-generator.d.ts +0 -83
- package/dist/lib/social/post-generator.js +0 -333
- package/dist/lib/social/publish.d.ts +0 -66
- package/dist/lib/social/publish.js +0 -226
- package/dist/lib/social/scraper.d.ts +0 -67
- package/dist/lib/social/scraper.js +0 -361
- package/dist/lib/supabase.d.ts +0 -4
- package/dist/lib/supabase.js +0 -20
- package/dist/lib/transactions/delete-batch.d.ts +0 -60
- package/dist/lib/transactions/delete-batch.js +0 -203
- package/dist/lib/transactions/ingest.d.ts +0 -43
- package/dist/lib/transactions/ingest.js +0 -555
- package/dist/lib/transactions/stamp.d.ts +0 -51
- package/dist/lib/transactions/stamp.js +0 -524
- 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
|