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.
- package/agents/.gitkeep +0 -0
- package/agents/content-ops.md +227 -0
- package/agents/financial-ops.md +184 -0
- package/agents/infra-ops.md +206 -0
- package/agents/profiles.json +5 -0
- package/bin/optimal.ts +1731 -0
- package/docs/CLI-REFERENCE.md +361 -0
- package/lib/assets/index.ts +225 -0
- package/lib/assets.ts +124 -0
- package/lib/auth/index.ts +189 -0
- package/lib/board/index.ts +309 -0
- package/lib/board/types.ts +124 -0
- package/lib/bot/claim.ts +43 -0
- package/lib/bot/coordinator.ts +254 -0
- package/lib/bot/heartbeat.ts +37 -0
- package/lib/bot/index.ts +9 -0
- package/lib/bot/protocol.ts +99 -0
- package/lib/bot/reporter.ts +42 -0
- package/lib/bot/skills.ts +81 -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 +228 -0
- package/lib/config/schema.ts +58 -0
- package/lib/config.ts +247 -0
- package/lib/errors.ts +129 -0
- package/lib/format.ts +120 -0
- package/lib/infra/.gitkeep +0 -0
- package/lib/infra/deploy.ts +70 -0
- package/lib/infra/migrate.ts +141 -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/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/returnpro/validate.ts +154 -0
- package/lib/social/meta.ts +228 -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 +15 -25
- package/dist/bin/optimal.d.ts +0 -2
- package/dist/bin/optimal.js +0 -995
- 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.d.ts +0 -55
- package/dist/lib/config.js +0 -206
- 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/kanban.d.ts +0 -46
- package/dist/lib/kanban.js +0 -118
- 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/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
|
@@ -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
|
+
}
|
package/lib/bot/claim.ts
ADDED
|
@@ -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
|
+
}
|