optimal-cli 0.1.0 → 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 (107) hide show
  1. package/agents/.gitkeep +0 -0
  2. package/agents/content-ops.md +227 -0
  3. package/agents/financial-ops.md +184 -0
  4. package/agents/infra-ops.md +206 -0
  5. package/agents/profiles.json +5 -0
  6. package/bin/optimal.ts +1731 -0
  7. package/docs/CLI-REFERENCE.md +361 -0
  8. package/lib/assets/index.ts +225 -0
  9. package/lib/assets.ts +124 -0
  10. package/lib/auth/index.ts +189 -0
  11. package/lib/board/index.ts +309 -0
  12. package/lib/board/types.ts +124 -0
  13. package/lib/bot/claim.ts +43 -0
  14. package/lib/bot/coordinator.ts +254 -0
  15. package/lib/bot/heartbeat.ts +37 -0
  16. package/lib/bot/index.ts +9 -0
  17. package/lib/bot/protocol.ts +99 -0
  18. package/lib/bot/reporter.ts +42 -0
  19. package/lib/bot/skills.ts +81 -0
  20. package/lib/budget/projections.ts +561 -0
  21. package/lib/budget/scenarios.ts +312 -0
  22. package/lib/cms/publish-blog.ts +129 -0
  23. package/lib/cms/strapi-client.ts +302 -0
  24. package/lib/config/registry.ts +228 -0
  25. package/lib/config/schema.ts +58 -0
  26. package/lib/config.ts +247 -0
  27. package/lib/errors.ts +129 -0
  28. package/lib/format.ts +120 -0
  29. package/lib/infra/.gitkeep +0 -0
  30. package/lib/infra/deploy.ts +70 -0
  31. package/lib/infra/migrate.ts +141 -0
  32. package/lib/newsletter/.gitkeep +0 -0
  33. package/lib/newsletter/distribute.ts +256 -0
  34. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  35. package/lib/newsletter/generate.ts +735 -0
  36. package/lib/returnpro/.gitkeep +0 -0
  37. package/lib/returnpro/anomalies.ts +258 -0
  38. package/lib/returnpro/audit.ts +194 -0
  39. package/lib/returnpro/diagnose.ts +400 -0
  40. package/lib/returnpro/kpis.ts +255 -0
  41. package/lib/returnpro/templates.ts +323 -0
  42. package/lib/returnpro/upload-income.ts +311 -0
  43. package/lib/returnpro/upload-netsuite.ts +696 -0
  44. package/lib/returnpro/upload-r1.ts +563 -0
  45. package/lib/returnpro/validate.ts +154 -0
  46. package/lib/social/meta.ts +228 -0
  47. package/lib/social/post-generator.ts +468 -0
  48. package/lib/social/publish.ts +301 -0
  49. package/lib/social/scraper.ts +503 -0
  50. package/lib/supabase.ts +25 -0
  51. package/lib/transactions/delete-batch.ts +258 -0
  52. package/lib/transactions/ingest.ts +659 -0
  53. package/lib/transactions/stamp.ts +654 -0
  54. package/package.json +15 -25
  55. package/dist/bin/optimal.d.ts +0 -2
  56. package/dist/bin/optimal.js +0 -995
  57. package/dist/lib/budget/projections.d.ts +0 -115
  58. package/dist/lib/budget/projections.js +0 -384
  59. package/dist/lib/budget/scenarios.d.ts +0 -93
  60. package/dist/lib/budget/scenarios.js +0 -214
  61. package/dist/lib/cms/publish-blog.d.ts +0 -62
  62. package/dist/lib/cms/publish-blog.js +0 -74
  63. package/dist/lib/cms/strapi-client.d.ts +0 -123
  64. package/dist/lib/cms/strapi-client.js +0 -213
  65. package/dist/lib/config.d.ts +0 -55
  66. package/dist/lib/config.js +0 -206
  67. package/dist/lib/infra/deploy.d.ts +0 -29
  68. package/dist/lib/infra/deploy.js +0 -58
  69. package/dist/lib/infra/migrate.d.ts +0 -34
  70. package/dist/lib/infra/migrate.js +0 -103
  71. package/dist/lib/kanban.d.ts +0 -46
  72. package/dist/lib/kanban.js +0 -118
  73. package/dist/lib/newsletter/distribute.d.ts +0 -52
  74. package/dist/lib/newsletter/distribute.js +0 -193
  75. package/dist/lib/newsletter/generate-insurance.js +0 -36
  76. package/dist/lib/newsletter/generate.d.ts +0 -104
  77. package/dist/lib/newsletter/generate.js +0 -571
  78. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  79. package/dist/lib/returnpro/anomalies.js +0 -166
  80. package/dist/lib/returnpro/audit.d.ts +0 -32
  81. package/dist/lib/returnpro/audit.js +0 -147
  82. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  83. package/dist/lib/returnpro/diagnose.js +0 -281
  84. package/dist/lib/returnpro/kpis.d.ts +0 -32
  85. package/dist/lib/returnpro/kpis.js +0 -192
  86. package/dist/lib/returnpro/templates.d.ts +0 -48
  87. package/dist/lib/returnpro/templates.js +0 -229
  88. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  89. package/dist/lib/returnpro/upload-income.js +0 -235
  90. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  91. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  92. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  93. package/dist/lib/returnpro/upload-r1.js +0 -398
  94. package/dist/lib/social/post-generator.d.ts +0 -83
  95. package/dist/lib/social/post-generator.js +0 -333
  96. package/dist/lib/social/publish.d.ts +0 -66
  97. package/dist/lib/social/publish.js +0 -226
  98. package/dist/lib/social/scraper.d.ts +0 -67
  99. package/dist/lib/social/scraper.js +0 -361
  100. package/dist/lib/supabase.d.ts +0 -4
  101. package/dist/lib/supabase.js +0 -20
  102. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  103. package/dist/lib/transactions/delete-batch.js +0 -203
  104. package/dist/lib/transactions/ingest.d.ts +0 -43
  105. package/dist/lib/transactions/ingest.js +0 -555
  106. package/dist/lib/transactions/stamp.d.ts +0 -51
  107. package/dist/lib/transactions/stamp.js +0 -524
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Auth module — ported from optimalOS Supabase auth patterns.
3
+ *
4
+ * OptimalOS uses three client tiers:
5
+ * 1. Browser client (anon key + cookie session) — N/A for CLI
6
+ * 2. Server client (anon key + SSR cookies) — N/A for CLI
7
+ * 3. Admin client (service_role key, no session) — primary CLI path
8
+ *
9
+ * In a headless CLI context there are no cookies or browser sessions.
10
+ * Auth reduces to two modes:
11
+ * - Service-role access (bot / automation operations)
12
+ * - User-scoped access (pass an access_token obtained externally)
13
+ *
14
+ * Environment variables (defined in .env):
15
+ * OPTIMAL_SUPABASE_URL — Supabase project URL
16
+ * OPTIMAL_SUPABASE_SERVICE_KEY — service_role secret
17
+ */
18
+
19
+ import { createClient, type SupabaseClient } from '@supabase/supabase-js'
20
+ import 'dotenv/config'
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Describes how the current invocation is authenticated. */
27
+ export interface AuthContext {
28
+ /** 'service' when using service_role key, 'user' when using a user JWT */
29
+ mode: 'service' | 'user'
30
+ /** The Supabase client for this context */
31
+ client: SupabaseClient
32
+ /** User ID (only set when mode === 'user') */
33
+ userId?: string
34
+ /** User email (only set when mode === 'user' and resolvable) */
35
+ email?: string
36
+ }
37
+
38
+ /** Minimal session shape returned by getSession(). */
39
+ export interface Session {
40
+ accessToken: string
41
+ user: {
42
+ id: string
43
+ email?: string
44
+ }
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Internal helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function envOrThrow(name: string): string {
52
+ const value = process.env[name]
53
+ if (!value) {
54
+ throw new Error(`Missing required environment variable: ${name}`)
55
+ }
56
+ return value
57
+ }
58
+
59
+ /** Singleton service-role client (matches optimalOS admin.ts pattern). */
60
+ let _serviceClient: SupabaseClient | null = null
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Public API
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Return a service-role Supabase client.
68
+ *
69
+ * Mirrors optimalOS `createAdminClient()` from lib/supabase/admin.ts:
70
+ * - Uses SUPABASE_SERVICE_ROLE_KEY
71
+ * - persistSession: false, autoRefreshToken: false
72
+ * - Singleton — safe to call repeatedly
73
+ */
74
+ export function getServiceClient(): SupabaseClient {
75
+ if (_serviceClient) return _serviceClient
76
+
77
+ const url = envOrThrow('OPTIMAL_SUPABASE_URL')
78
+ const key = envOrThrow('OPTIMAL_SUPABASE_SERVICE_KEY')
79
+
80
+ _serviceClient = createClient(url, key, {
81
+ auth: {
82
+ persistSession: false,
83
+ autoRefreshToken: false,
84
+ },
85
+ })
86
+
87
+ return _serviceClient
88
+ }
89
+
90
+ /**
91
+ * Return a user-scoped Supabase client authenticated with the given JWT.
92
+ *
93
+ * This is the CLI equivalent of optimalOS browser/server clients that carry
94
+ * a user session via cookies. The caller is responsible for obtaining the
95
+ * access token (e.g., via `supabase login`, OAuth device flow, or env var).
96
+ *
97
+ * A new client is created on every call — callers should cache if needed.
98
+ */
99
+ export function getUserClient(accessToken: string): SupabaseClient {
100
+ const url = envOrThrow('OPTIMAL_SUPABASE_URL')
101
+
102
+ // Use service key as the initial key — the global auth header override
103
+ // ensures all requests are scoped to the user's JWT instead.
104
+ const anonOrServiceKey = process.env.OPTIMAL_SUPABASE_ANON_KEY
105
+ ?? envOrThrow('OPTIMAL_SUPABASE_SERVICE_KEY')
106
+
107
+ return createClient(url, anonOrServiceKey, {
108
+ global: {
109
+ headers: { Authorization: `Bearer ${accessToken}` },
110
+ },
111
+ auth: {
112
+ persistSession: false,
113
+ autoRefreshToken: false,
114
+ },
115
+ })
116
+ }
117
+
118
+ /**
119
+ * Attempt to retrieve the current session.
120
+ *
121
+ * In the CLI there is no implicit cookie jar. A session exists only when:
122
+ * 1. OPTIMAL_ACCESS_TOKEN env var is set (user JWT), or
123
+ * 2. A future `optimal login` command has cached a token locally.
124
+ *
125
+ * Returns null if no user session is available (service-role only mode).
126
+ */
127
+ export async function getSession(): Promise<Session | null> {
128
+ const token = process.env.OPTIMAL_ACCESS_TOKEN
129
+ if (!token) return null
130
+
131
+ try {
132
+ const client = getUserClient(token)
133
+ const { data: { user }, error } = await client.auth.getUser(token)
134
+
135
+ if (error || !user) return null
136
+
137
+ return {
138
+ accessToken: token,
139
+ user: {
140
+ id: user.id,
141
+ email: user.email,
142
+ },
143
+ }
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Guard that throws if no user session is present.
151
+ *
152
+ * Use at the top of CLI commands that require a logged-in user:
153
+ *
154
+ * const session = await requireAuth()
155
+ * // session.user.id is guaranteed
156
+ */
157
+ export async function requireAuth(): Promise<Session> {
158
+ const session = await getSession()
159
+ if (!session) {
160
+ throw new Error(
161
+ 'Authentication required. Set OPTIMAL_ACCESS_TOKEN or run `optimal login`.',
162
+ )
163
+ }
164
+ return session
165
+ }
166
+
167
+ /**
168
+ * Build an AuthContext describing the current invocation's auth state.
169
+ *
170
+ * Prefers user-scoped auth when OPTIMAL_ACCESS_TOKEN is set;
171
+ * falls back to service-role.
172
+ */
173
+ export async function resolveAuthContext(): Promise<AuthContext> {
174
+ const session = await getSession()
175
+
176
+ if (session) {
177
+ return {
178
+ mode: 'user',
179
+ client: getUserClient(session.accessToken),
180
+ userId: session.user.id,
181
+ email: session.user.email,
182
+ }
183
+ }
184
+
185
+ return {
186
+ mode: 'service',
187
+ client: getServiceClient(),
188
+ }
189
+ }
@@ -0,0 +1,309 @@
1
+ import { getSupabase } from '../supabase.js'
2
+ import type {
3
+ Project, Task, Label, Comment, Milestone, ActivityEntry,
4
+ CreateProjectInput, CreateTaskInput, CreateCommentInput, CreateMilestoneInput,
5
+ UpdateTaskInput, TaskStatus,
6
+ } from './types.js'
7
+
8
+ export * from './types.js'
9
+
10
+ const sb = () => getSupabase('optimal')
11
+
12
+ // --- Helpers ---
13
+
14
+ export function formatBoardTable(tasks: Task[]): string {
15
+ if (tasks.length === 0) return 'No tasks found.'
16
+ const lines = [
17
+ '| Status | P | Title | Agent | Skill | Effort |',
18
+ '|-------------|---|--------------------------------|---------|-----------------|--------|',
19
+ ]
20
+ const order: TaskStatus[] = ['in_progress', 'claimed', 'blocked', 'ready', 'review', 'backlog', 'done']
21
+ const sorted = [...tasks].sort((a, b) => {
22
+ const ai = order.indexOf(a.status)
23
+ const bi = order.indexOf(b.status)
24
+ if (ai !== bi) return ai - bi
25
+ return a.priority - b.priority
26
+ })
27
+ for (const t of sorted) {
28
+ const title = t.title.length > 30 ? t.title.slice(0, 27) + '...' : t.title.padEnd(30)
29
+ const agent = (t.claimed_by ?? t.assigned_to ?? '—').padEnd(7)
30
+ const skill = (t.skill_required ?? '—').padEnd(15)
31
+ const effort = (t.estimated_effort ?? '—').padEnd(6)
32
+ lines.push(`| ${t.status.padEnd(11)} | ${t.priority} | ${title} | ${agent} | ${skill} | ${effort} |`)
33
+ }
34
+ lines.push(`\nTotal: ${tasks.length} tasks`)
35
+ return lines.join('\n')
36
+ }
37
+
38
+ export function getNextClaimable(readyTasks: Task[], allTasks: Task[]): Task | null {
39
+ for (const task of readyTasks) {
40
+ if (!task.blocked_by || task.blocked_by.length === 0) return task
41
+ const allDone = task.blocked_by.every(depId => {
42
+ const dep = allTasks.find(t => t.id === depId)
43
+ return dep && (dep.status === 'done')
44
+ })
45
+ if (allDone) return task
46
+ }
47
+ return null
48
+ }
49
+
50
+ // --- Projects ---
51
+
52
+ export async function createProject(input: CreateProjectInput): Promise<Project> {
53
+ const { data, error } = await sb()
54
+ .from('projects')
55
+ .insert({
56
+ slug: input.slug,
57
+ name: input.name,
58
+ description: input.description ?? null,
59
+ owner: input.owner ?? null,
60
+ priority: input.priority ?? 3,
61
+ })
62
+ .select()
63
+ .single()
64
+ if (error) throw new Error(`Failed to create project: ${error.message}`)
65
+ return data as Project
66
+ }
67
+
68
+ export async function getProjectBySlug(slug: string): Promise<Project> {
69
+ const { data, error } = await sb()
70
+ .from('projects')
71
+ .select('*')
72
+ .eq('slug', slug)
73
+ .single()
74
+ if (error) throw new Error(`Project not found: ${slug} — ${error.message}`)
75
+ return data as Project
76
+ }
77
+
78
+ export async function listProjects(): Promise<Project[]> {
79
+ const { data, error } = await sb()
80
+ .from('projects')
81
+ .select('*')
82
+ .neq('status', 'archived')
83
+ .order('priority', { ascending: true })
84
+ if (error) throw new Error(`Failed to list projects: ${error.message}`)
85
+ return (data ?? []) as Project[]
86
+ }
87
+
88
+ export async function updateProject(slug: string, updates: Partial<Pick<Project, 'status' | 'owner' | 'priority' | 'description'>>): Promise<Project> {
89
+ const { data, error } = await sb()
90
+ .from('projects')
91
+ .update(updates)
92
+ .eq('slug', slug)
93
+ .select()
94
+ .single()
95
+ if (error) throw new Error(`Failed to update project: ${error.message}`)
96
+ return data as Project
97
+ }
98
+
99
+ // --- Milestones ---
100
+
101
+ export async function createMilestone(input: CreateMilestoneInput): Promise<Milestone> {
102
+ const { data, error } = await sb()
103
+ .from('milestones')
104
+ .insert({
105
+ project_id: input.project_id,
106
+ name: input.name,
107
+ description: input.description ?? null,
108
+ due_date: input.due_date ?? null,
109
+ })
110
+ .select()
111
+ .single()
112
+ if (error) throw new Error(`Failed to create milestone: ${error.message}`)
113
+ return data as Milestone
114
+ }
115
+
116
+ export async function listMilestones(projectId?: string): Promise<Milestone[]> {
117
+ let query = sb().from('milestones').select('*').order('due_date', { ascending: true })
118
+ if (projectId) query = query.eq('project_id', projectId)
119
+ const { data, error } = await query
120
+ if (error) throw new Error(`Failed to list milestones: ${error.message}`)
121
+ return (data ?? []) as Milestone[]
122
+ }
123
+
124
+ // --- Labels ---
125
+
126
+ export async function createLabel(name: string, color?: string): Promise<Label> {
127
+ const { data, error } = await sb()
128
+ .from('labels')
129
+ .insert({ name, color: color ?? null })
130
+ .select()
131
+ .single()
132
+ if (error) throw new Error(`Failed to create label: ${error.message}`)
133
+ return data as Label
134
+ }
135
+
136
+ export async function listLabels(): Promise<Label[]> {
137
+ const { data, error } = await sb().from('labels').select('*').order('name')
138
+ if (error) throw new Error(`Failed to list labels: ${error.message}`)
139
+ return (data ?? []) as Label[]
140
+ }
141
+
142
+ export async function getLabelByName(name: string): Promise<Label | null> {
143
+ const { data } = await sb().from('labels').select('*').eq('name', name).single()
144
+ return (data as Label) ?? null
145
+ }
146
+
147
+ // --- Tasks ---
148
+
149
+ export async function createTask(input: CreateTaskInput): Promise<Task> {
150
+ const { labels: labelNames, ...rest } = input
151
+ const { data, error } = await sb()
152
+ .from('tasks')
153
+ .insert({
154
+ ...rest,
155
+ milestone_id: rest.milestone_id ?? null,
156
+ description: rest.description ?? null,
157
+ priority: rest.priority ?? 3,
158
+ skill_required: rest.skill_required ?? null,
159
+ source_repo: rest.source_repo ?? null,
160
+ target_module: rest.target_module ?? null,
161
+ estimated_effort: rest.estimated_effort ?? null,
162
+ blocked_by: rest.blocked_by ?? [],
163
+ })
164
+ .select()
165
+ .single()
166
+ if (error) throw new Error(`Failed to create task: ${error.message}`)
167
+ const task = data as Task
168
+
169
+ if (labelNames && labelNames.length > 0) {
170
+ for (const name of labelNames) {
171
+ const label = await getLabelByName(name)
172
+ if (label) {
173
+ await sb().from('task_labels').insert({ task_id: task.id, label_id: label.id })
174
+ }
175
+ }
176
+ }
177
+
178
+ await logActivity({ task_id: task.id, project_id: task.project_id, actor: 'system', action: 'created', new_value: { title: task.title } })
179
+ return task
180
+ }
181
+
182
+ export async function updateTask(taskId: string, updates: UpdateTaskInput, actor?: string): Promise<Task> {
183
+ const old = await getTask(taskId)
184
+ const { data, error } = await sb()
185
+ .from('tasks')
186
+ .update(updates)
187
+ .eq('id', taskId)
188
+ .select()
189
+ .single()
190
+ if (error) throw new Error(`Failed to update task ${taskId}: ${error.message}`)
191
+ const task = data as Task
192
+
193
+ if (actor) {
194
+ await logActivity({
195
+ task_id: taskId,
196
+ project_id: task.project_id,
197
+ actor,
198
+ action: updates.status ? 'status_changed' : 'updated',
199
+ old_value: { status: old.status, assigned_to: old.assigned_to },
200
+ new_value: updates as Record<string, unknown>,
201
+ })
202
+ }
203
+ return task
204
+ }
205
+
206
+ export async function getTask(taskId: string): Promise<Task> {
207
+ const { data, error } = await sb()
208
+ .from('tasks')
209
+ .select('*')
210
+ .eq('id', taskId)
211
+ .single()
212
+ if (error) throw new Error(`Task not found: ${taskId}`)
213
+ return data as Task
214
+ }
215
+
216
+ export async function listTasks(opts?: {
217
+ project_id?: string
218
+ status?: TaskStatus
219
+ claimed_by?: string
220
+ assigned_to?: string
221
+ }): Promise<Task[]> {
222
+ let query = sb().from('tasks').select('*')
223
+ if (opts?.project_id) query = query.eq('project_id', opts.project_id)
224
+ if (opts?.status) query = query.eq('status', opts.status)
225
+ if (opts?.claimed_by) query = query.eq('claimed_by', opts.claimed_by)
226
+ if (opts?.assigned_to) query = query.eq('assigned_to', opts.assigned_to)
227
+ query = query.order('priority', { ascending: true }).order('sort_order', { ascending: true })
228
+ const { data, error } = await query
229
+ if (error) throw new Error(`Failed to list tasks: ${error.message}`)
230
+ return (data ?? []) as Task[]
231
+ }
232
+
233
+ export async function claimTask(taskId: string, agent: string): Promise<Task> {
234
+ const task = await updateTask(taskId, {
235
+ status: 'claimed',
236
+ claimed_by: agent,
237
+ claimed_at: new Date().toISOString(),
238
+ }, agent)
239
+
240
+ await addComment({ task_id: taskId, author: agent, body: `Claimed by ${agent}`, comment_type: 'claim' })
241
+ return task
242
+ }
243
+
244
+ export async function completeTask(taskId: string, actor: string): Promise<Task> {
245
+ return updateTask(taskId, {
246
+ status: 'done',
247
+ completed_at: new Date().toISOString(),
248
+ }, actor)
249
+ }
250
+
251
+ // --- Comments ---
252
+
253
+ export async function addComment(input: CreateCommentInput): Promise<Comment> {
254
+ const { data, error } = await sb()
255
+ .from('comments')
256
+ .insert({
257
+ task_id: input.task_id,
258
+ author: input.author,
259
+ body: input.body,
260
+ comment_type: input.comment_type ?? 'comment',
261
+ })
262
+ .select()
263
+ .single()
264
+ if (error) throw new Error(`Failed to add comment: ${error.message}`)
265
+ return data as Comment
266
+ }
267
+
268
+ export async function listComments(taskId: string): Promise<Comment[]> {
269
+ const { data, error } = await sb()
270
+ .from('comments')
271
+ .select('*')
272
+ .eq('task_id', taskId)
273
+ .order('created_at', { ascending: true })
274
+ if (error) throw new Error(`Failed to list comments: ${error.message}`)
275
+ return (data ?? []) as Comment[]
276
+ }
277
+
278
+ // --- Activity Log ---
279
+
280
+ export async function logActivity(entry: {
281
+ task_id?: string
282
+ project_id?: string
283
+ actor: string
284
+ action: string
285
+ old_value?: Record<string, unknown>
286
+ new_value?: Record<string, unknown>
287
+ }): Promise<void> {
288
+ const { error } = await sb()
289
+ .from('activity_log')
290
+ .insert({
291
+ task_id: entry.task_id ?? null,
292
+ project_id: entry.project_id ?? null,
293
+ actor: entry.actor,
294
+ action: entry.action,
295
+ old_value: entry.old_value ?? null,
296
+ new_value: entry.new_value ?? null,
297
+ })
298
+ if (error) throw new Error(`Failed to log activity: ${error.message}`)
299
+ }
300
+
301
+ export async function listActivity(opts?: { task_id?: string; actor?: string; limit?: number }): Promise<ActivityEntry[]> {
302
+ let query = sb().from('activity_log').select('*')
303
+ if (opts?.task_id) query = query.eq('task_id', opts.task_id)
304
+ if (opts?.actor) query = query.eq('actor', opts.actor)
305
+ query = query.order('created_at', { ascending: false }).limit(opts?.limit ?? 50)
306
+ const { data, error } = await query
307
+ if (error) throw new Error(`Failed to list activity: ${error.message}`)
308
+ return (data ?? []) as ActivityEntry[]
309
+ }
@@ -0,0 +1,124 @@
1
+ export interface Project {
2
+ id: string
3
+ slug: string
4
+ name: string
5
+ description: string | null
6
+ status: 'active' | 'paused' | 'completed' | 'archived'
7
+ owner: string | null
8
+ priority: 1 | 2 | 3 | 4
9
+ created_at: string
10
+ updated_at: string
11
+ }
12
+
13
+ export interface Milestone {
14
+ id: string
15
+ project_id: string
16
+ name: string
17
+ description: string | null
18
+ due_date: string | null
19
+ status: 'open' | 'completed' | 'missed'
20
+ created_at: string
21
+ updated_at: string
22
+ }
23
+
24
+ export interface Label {
25
+ id: string
26
+ name: string
27
+ color: string | null
28
+ created_at: string
29
+ }
30
+
31
+ export type TaskStatus = 'backlog' | 'ready' | 'claimed' | 'in_progress' | 'review' | 'done' | 'blocked'
32
+ export type Priority = 1 | 2 | 3 | 4
33
+ export type Effort = 'xs' | 's' | 'm' | 'l' | 'xl'
34
+
35
+ export interface Task {
36
+ id: string
37
+ project_id: string
38
+ milestone_id: string | null
39
+ title: string
40
+ description: string | null
41
+ status: TaskStatus
42
+ priority: Priority
43
+ assigned_to: string | null
44
+ claimed_by: string | null
45
+ claimed_at: string | null
46
+ skill_required: string | null
47
+ source_repo: string | null
48
+ target_module: string | null
49
+ estimated_effort: Effort | null
50
+ blocked_by: string[]
51
+ sort_order: number
52
+ created_at: string
53
+ updated_at: string
54
+ completed_at: string | null
55
+ }
56
+
57
+ export interface Comment {
58
+ id: string
59
+ task_id: string
60
+ author: string
61
+ body: string
62
+ comment_type: 'comment' | 'status_change' | 'claim' | 'review'
63
+ created_at: string
64
+ }
65
+
66
+ export interface ActivityEntry {
67
+ id: string
68
+ task_id: string | null
69
+ project_id: string | null
70
+ actor: string
71
+ action: string
72
+ old_value: Record<string, unknown> | null
73
+ new_value: Record<string, unknown> | null
74
+ created_at: string
75
+ }
76
+
77
+ // --- Input types ---
78
+
79
+ export interface CreateProjectInput {
80
+ slug: string
81
+ name: string
82
+ description?: string
83
+ owner?: string
84
+ priority?: Priority
85
+ }
86
+
87
+ export interface CreateMilestoneInput {
88
+ project_id: string
89
+ name: string
90
+ description?: string
91
+ due_date?: string
92
+ }
93
+
94
+ export interface CreateTaskInput {
95
+ project_id: string
96
+ title: string
97
+ description?: string
98
+ priority?: Priority
99
+ milestone_id?: string
100
+ skill_required?: string
101
+ source_repo?: string
102
+ target_module?: string
103
+ estimated_effort?: Effort
104
+ blocked_by?: string[]
105
+ labels?: string[]
106
+ }
107
+
108
+ export interface UpdateTaskInput {
109
+ status?: TaskStatus
110
+ priority?: Priority
111
+ assigned_to?: string | null
112
+ claimed_by?: string | null
113
+ claimed_at?: string | null
114
+ milestone_id?: string | null
115
+ description?: string
116
+ completed_at?: string | null
117
+ }
118
+
119
+ export interface CreateCommentInput {
120
+ task_id: string
121
+ author: string
122
+ body: string
123
+ comment_type?: 'comment' | 'status_change' | 'claim' | 'review'
124
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ listTasks,
3
+ claimTask,
4
+ updateTask,
5
+ getNextClaimable,
6
+ type Task,
7
+ } from '../board/index.js'
8
+
9
+ export async function claimNextTask(
10
+ agentId: string,
11
+ skills?: string[],
12
+ ): Promise<Task | null> {
13
+ const readyTasks = await listTasks({ status: 'ready' })
14
+ const allTasks = await listTasks()
15
+
16
+ let candidates = readyTasks
17
+ if (skills && skills.length > 0) {
18
+ candidates = readyTasks.filter(
19
+ (t) => !t.skill_required || skills.includes(t.skill_required),
20
+ )
21
+ }
22
+
23
+ const next = getNextClaimable(candidates, allTasks)
24
+ if (!next) return null
25
+
26
+ return claimTask(next.id, agentId)
27
+ }
28
+
29
+ export async function releaseTask(
30
+ taskId: string,
31
+ agentId: string,
32
+ reason?: string,
33
+ ): Promise<Task> {
34
+ return updateTask(
35
+ taskId,
36
+ {
37
+ status: 'ready',
38
+ claimed_by: null,
39
+ claimed_at: null,
40
+ },
41
+ agentId,
42
+ )
43
+ }