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,254 @@
1
+ import {
2
+ listTasks,
3
+ updateTask,
4
+ claimTask,
5
+ logActivity,
6
+ listActivity,
7
+ type Task,
8
+ } from '../board/index.js'
9
+ import { sendHeartbeat, getActiveAgents } from './heartbeat.js'
10
+ import { claimNextTask } from './claim.js'
11
+ import { getAgentProfiles, matchTasksToAgent, type AgentProfile } from './skills.js'
12
+
13
+ // --- Types ---
14
+
15
+ export interface CoordinatorConfig {
16
+ pollIntervalMs: number
17
+ maxAgents: number
18
+ autoAssign: boolean
19
+ }
20
+
21
+ export interface CoordinatorStatus {
22
+ activeAgents: { agent: string; status: string; lastSeen: string }[]
23
+ idleAgents: AgentProfile[]
24
+ tasksInProgress: number
25
+ tasksReady: number
26
+ tasksBlocked: number
27
+ lastPollAt: string | null
28
+ }
29
+
30
+ export interface RebalanceResult {
31
+ releasedTasks: Task[]
32
+ reassignedTasks: Task[]
33
+ }
34
+
35
+ // --- State ---
36
+
37
+ const DEFAULT_CONFIG: CoordinatorConfig = {
38
+ pollIntervalMs: 30_000,
39
+ maxAgents: 10,
40
+ autoAssign: true,
41
+ }
42
+
43
+ let lastPollAt: string | null = null
44
+
45
+ // --- Coordinator loop ---
46
+
47
+ export async function runCoordinatorLoop(
48
+ config?: Partial<CoordinatorConfig>,
49
+ ): Promise<void> {
50
+ const cfg: CoordinatorConfig = { ...DEFAULT_CONFIG, ...config }
51
+ let running = true
52
+
53
+ const shutdown = () => {
54
+ running = false
55
+ console.log('\nCoordinator shutting down...')
56
+ }
57
+ process.on('SIGINT', shutdown)
58
+
59
+ console.log(
60
+ `Coordinator started — poll every ${cfg.pollIntervalMs}ms, max ${cfg.maxAgents} agents, autoAssign=${cfg.autoAssign}`,
61
+ )
62
+
63
+ while (running) {
64
+ try {
65
+ await pollOnce(cfg)
66
+ } catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err)
68
+ console.error(`Coordinator poll error: ${msg}`)
69
+ await logActivity({
70
+ actor: 'coordinator',
71
+ action: 'poll_error',
72
+ new_value: { error: msg },
73
+ })
74
+ }
75
+
76
+ // Wait for next poll or until interrupted
77
+ await new Promise<void>((resolve) => {
78
+ const timer = setTimeout(resolve, cfg.pollIntervalMs)
79
+ if (!running) {
80
+ clearTimeout(timer)
81
+ resolve()
82
+ }
83
+ })
84
+ }
85
+
86
+ process.removeListener('SIGINT', shutdown)
87
+ console.log('Coordinator stopped.')
88
+ }
89
+
90
+ async function pollOnce(cfg: CoordinatorConfig): Promise<void> {
91
+ lastPollAt = new Date().toISOString()
92
+
93
+ const profiles = getAgentProfiles().slice(0, cfg.maxAgents)
94
+ const readyTasks = await listTasks({ status: 'ready' })
95
+ const claimedTasks = await listTasks({ status: 'claimed' })
96
+
97
+ // Send heartbeats for all active agents that have claimed tasks
98
+ const activeAgentIds = new Set(
99
+ claimedTasks.map((t) => t.claimed_by).filter(Boolean) as string[],
100
+ )
101
+ for (const agentId of activeAgentIds) {
102
+ await sendHeartbeat(agentId, 'working')
103
+ }
104
+
105
+ if (!cfg.autoAssign) {
106
+ await logActivity({
107
+ actor: 'coordinator',
108
+ action: 'poll',
109
+ new_value: {
110
+ readyTasks: readyTasks.length,
111
+ activeAgents: activeAgentIds.size,
112
+ autoAssign: false,
113
+ ts: lastPollAt,
114
+ },
115
+ })
116
+ return
117
+ }
118
+
119
+ // Find idle agents and try to assign tasks
120
+ let assignedCount = 0
121
+ for (const agent of profiles) {
122
+ // Check if agent has capacity
123
+ const agentClaimed = claimedTasks.filter((t) => t.claimed_by === agent.id)
124
+ if (agentClaimed.length >= agent.maxConcurrent) continue
125
+
126
+ // Match available tasks to this agent
127
+ const matched = matchTasksToAgent(agent, readyTasks)
128
+ if (matched.length === 0) continue
129
+
130
+ // Claim the top-priority matched task
131
+ const task = await claimNextTask(agent.id, agent.skills)
132
+ if (task) {
133
+ assignedCount++
134
+ console.log(` Assigned "${task.title}" -> ${agent.id}`)
135
+ // Remove from local readyTasks to avoid double-assign
136
+ const idx = readyTasks.findIndex((t) => t.id === task.id)
137
+ if (idx >= 0) readyTasks.splice(idx, 1)
138
+ }
139
+ }
140
+
141
+ await logActivity({
142
+ actor: 'coordinator',
143
+ action: 'poll',
144
+ new_value: {
145
+ readyTasks: readyTasks.length,
146
+ activeAgents: activeAgentIds.size,
147
+ assigned: assignedCount,
148
+ ts: lastPollAt,
149
+ },
150
+ })
151
+
152
+ if (assignedCount > 0) {
153
+ console.log(`Poll complete: assigned ${assignedCount} task(s)`)
154
+ }
155
+ }
156
+
157
+ // --- Status ---
158
+
159
+ export async function getCoordinatorStatus(): Promise<CoordinatorStatus> {
160
+ const profiles = getAgentProfiles()
161
+ const activeAgents = await getActiveAgents()
162
+ const readyTasks = await listTasks({ status: 'ready' })
163
+ const claimedTasks = await listTasks({ status: 'claimed' })
164
+ const inProgressTasks = await listTasks({ status: 'in_progress' })
165
+ const blockedTasks = await listTasks({ status: 'blocked' })
166
+
167
+ const activeIds = new Set(activeAgents.map((a) => a.agent))
168
+ const idleAgents = profiles.filter((p) => !activeIds.has(p.id))
169
+
170
+ return {
171
+ activeAgents,
172
+ idleAgents,
173
+ tasksInProgress: claimedTasks.length + inProgressTasks.length,
174
+ tasksReady: readyTasks.length,
175
+ tasksBlocked: blockedTasks.length,
176
+ lastPollAt,
177
+ }
178
+ }
179
+
180
+ // --- Manual assignment ---
181
+
182
+ export async function assignTask(
183
+ taskId: string,
184
+ agentId: string,
185
+ ): Promise<Task> {
186
+ const task = await claimTask(taskId, agentId)
187
+
188
+ await logActivity({
189
+ actor: 'coordinator',
190
+ action: 'manual_assign',
191
+ task_id: taskId,
192
+ new_value: { agentId, title: task.title },
193
+ })
194
+
195
+ console.log(`Manually assigned "${task.title}" -> ${agentId}`)
196
+ return task
197
+ }
198
+
199
+ // --- Rebalance ---
200
+
201
+ export async function rebalance(): Promise<RebalanceResult> {
202
+ const claimedTasks = await listTasks({ status: 'claimed' })
203
+ const now = Date.now()
204
+ const staleThreshold = 60 * 60 * 1000 // 1 hour
205
+
206
+ const releasedTasks: Task[] = []
207
+ const reassignedTasks: Task[] = []
208
+
209
+ for (const task of claimedTasks) {
210
+ if (!task.claimed_at) continue
211
+
212
+ const claimedAge = now - new Date(task.claimed_at).getTime()
213
+ if (claimedAge < staleThreshold) continue
214
+
215
+ // Check for recent activity on this task
216
+ const activity = await listActivity({ task_id: task.id, limit: 5 })
217
+ const recentActivity = activity.some((a) => {
218
+ const age = now - new Date(a.created_at).getTime()
219
+ return age < staleThreshold && a.action !== 'poll'
220
+ })
221
+
222
+ if (recentActivity) continue
223
+
224
+ // Release stale task
225
+ const released = await updateTask(
226
+ task.id,
227
+ {
228
+ status: 'ready',
229
+ claimed_by: null,
230
+ claimed_at: null,
231
+ },
232
+ 'coordinator',
233
+ )
234
+
235
+ releasedTasks.push(released)
236
+
237
+ await logActivity({
238
+ actor: 'coordinator',
239
+ action: 'rebalance_release',
240
+ task_id: task.id,
241
+ new_value: {
242
+ previousAgent: task.claimed_by,
243
+ claimedAt: task.claimed_at,
244
+ reason: 'stale_claim',
245
+ },
246
+ })
247
+
248
+ console.log(
249
+ `Released stale task "${task.title}" (was claimed by ${task.claimed_by})`,
250
+ )
251
+ }
252
+
253
+ return { releasedTasks, reassignedTasks }
254
+ }
@@ -0,0 +1,37 @@
1
+ import { logActivity, listActivity } from '../board/index.js'
2
+
3
+ export async function sendHeartbeat(
4
+ agentId: string,
5
+ status: 'idle' | 'working' | 'error',
6
+ ): Promise<void> {
7
+ await logActivity({
8
+ actor: agentId,
9
+ action: 'heartbeat',
10
+ new_value: { status, ts: new Date().toISOString() },
11
+ })
12
+ }
13
+
14
+ export async function getActiveAgents(): Promise<
15
+ { agent: string; status: string; lastSeen: string }[]
16
+ > {
17
+ const entries = await listActivity({ limit: 200 })
18
+ const cutoff = Date.now() - 5 * 60 * 1000
19
+
20
+ const latest = new Map<string, { status: string; lastSeen: string }>()
21
+
22
+ for (const e of entries) {
23
+ if (e.action !== 'heartbeat') continue
24
+ if (new Date(e.created_at).getTime() < cutoff) continue
25
+ if (latest.has(e.actor)) continue
26
+ const nv = e.new_value as { status?: string } | null
27
+ latest.set(e.actor, {
28
+ status: nv?.status ?? 'unknown',
29
+ lastSeen: e.created_at,
30
+ })
31
+ }
32
+
33
+ return Array.from(latest.entries()).map(([agent, info]) => ({
34
+ agent,
35
+ ...info,
36
+ }))
37
+ }
@@ -0,0 +1,9 @@
1
+ export { sendHeartbeat, getActiveAgents } from './heartbeat.js'
2
+ export { claimNextTask, releaseTask } from './claim.js'
3
+ export { reportProgress, reportCompletion, reportBlocked } from './reporter.js'
4
+ export { getAgentProfiles, matchTasksToAgent, findBestAgent } from './skills.js'
5
+ export type { AgentProfile } from './skills.js'
6
+ export { runCoordinatorLoop, getCoordinatorStatus, assignTask, rebalance } from './coordinator.js'
7
+ export type { CoordinatorConfig, CoordinatorStatus, RebalanceResult } from './coordinator.js'
8
+ export { processAgentMessage } from './protocol.js'
9
+ export type { AgentMessage, AgentResponse } from './protocol.js'
@@ -0,0 +1,99 @@
1
+ import { sendHeartbeat } from './heartbeat.js'
2
+ import { claimNextTask, releaseTask } from './claim.js'
3
+ import { reportProgress, reportCompletion, reportBlocked } from './reporter.js'
4
+
5
+ // --- Types ---
6
+
7
+ export interface AgentMessage {
8
+ type: 'heartbeat' | 'claim' | 'progress' | 'complete' | 'blocked' | 'release'
9
+ agentId: string
10
+ taskId?: string
11
+ payload?: Record<string, unknown>
12
+ }
13
+
14
+ export interface AgentResponse {
15
+ success: boolean
16
+ data?: unknown
17
+ error?: string
18
+ }
19
+
20
+ // --- Message processor ---
21
+
22
+ export async function processAgentMessage(
23
+ msg: AgentMessage,
24
+ ): Promise<AgentResponse> {
25
+ try {
26
+ switch (msg.type) {
27
+ case 'heartbeat':
28
+ return await handleHeartbeat(msg)
29
+ case 'claim':
30
+ return await handleClaim(msg)
31
+ case 'progress':
32
+ return await handleProgress(msg)
33
+ case 'complete':
34
+ return await handleComplete(msg)
35
+ case 'blocked':
36
+ return await handleBlocked(msg)
37
+ case 'release':
38
+ return await handleRelease(msg)
39
+ default:
40
+ return { success: false, error: `Unknown message type: ${(msg as AgentMessage).type}` }
41
+ }
42
+ } catch (err) {
43
+ const errorMsg = err instanceof Error ? err.message : String(err)
44
+ return { success: false, error: errorMsg }
45
+ }
46
+ }
47
+
48
+ // --- Handlers ---
49
+
50
+ async function handleHeartbeat(msg: AgentMessage): Promise<AgentResponse> {
51
+ const status = (msg.payload?.status as 'idle' | 'working' | 'error') ?? 'idle'
52
+ await sendHeartbeat(msg.agentId, status)
53
+ return { success: true, data: { agentId: msg.agentId, status } }
54
+ }
55
+
56
+ async function handleClaim(msg: AgentMessage): Promise<AgentResponse> {
57
+ const skills = msg.payload?.skills as string[] | undefined
58
+ const task = await claimNextTask(msg.agentId, skills)
59
+ if (!task) {
60
+ return { success: true, data: null }
61
+ }
62
+ return { success: true, data: { taskId: task.id, title: task.title } }
63
+ }
64
+
65
+ async function handleProgress(msg: AgentMessage): Promise<AgentResponse> {
66
+ if (!msg.taskId) {
67
+ return { success: false, error: 'taskId is required for progress messages' }
68
+ }
69
+ const message = (msg.payload?.message as string) ?? 'Progress update'
70
+ await reportProgress(msg.taskId, msg.agentId, message)
71
+ return { success: true, data: { taskId: msg.taskId } }
72
+ }
73
+
74
+ async function handleComplete(msg: AgentMessage): Promise<AgentResponse> {
75
+ if (!msg.taskId) {
76
+ return { success: false, error: 'taskId is required for complete messages' }
77
+ }
78
+ const summary = (msg.payload?.summary as string) ?? 'Task completed'
79
+ await reportCompletion(msg.taskId, msg.agentId, summary)
80
+ return { success: true, data: { taskId: msg.taskId, status: 'done' } }
81
+ }
82
+
83
+ async function handleBlocked(msg: AgentMessage): Promise<AgentResponse> {
84
+ if (!msg.taskId) {
85
+ return { success: false, error: 'taskId is required for blocked messages' }
86
+ }
87
+ const reason = (msg.payload?.reason as string) ?? 'No reason given'
88
+ await reportBlocked(msg.taskId, msg.agentId, reason)
89
+ return { success: true, data: { taskId: msg.taskId, status: 'blocked' } }
90
+ }
91
+
92
+ async function handleRelease(msg: AgentMessage): Promise<AgentResponse> {
93
+ if (!msg.taskId) {
94
+ return { success: false, error: 'taskId is required for release messages' }
95
+ }
96
+ const reason = msg.payload?.reason as string | undefined
97
+ const task = await releaseTask(msg.taskId, msg.agentId, reason)
98
+ return { success: true, data: { taskId: task.id, status: 'ready' } }
99
+ }
@@ -0,0 +1,42 @@
1
+ import { addComment, updateTask, completeTask } from '../board/index.js'
2
+
3
+ export async function reportProgress(
4
+ taskId: string,
5
+ agentId: string,
6
+ message: string,
7
+ ): Promise<void> {
8
+ await addComment({
9
+ task_id: taskId,
10
+ author: agentId,
11
+ body: message,
12
+ comment_type: 'comment',
13
+ })
14
+ }
15
+
16
+ export async function reportCompletion(
17
+ taskId: string,
18
+ agentId: string,
19
+ summary: string,
20
+ ): Promise<void> {
21
+ await completeTask(taskId, agentId)
22
+ await addComment({
23
+ task_id: taskId,
24
+ author: agentId,
25
+ body: summary,
26
+ comment_type: 'status_change',
27
+ })
28
+ }
29
+
30
+ export async function reportBlocked(
31
+ taskId: string,
32
+ agentId: string,
33
+ reason: string,
34
+ ): Promise<void> {
35
+ await updateTask(taskId, { status: 'blocked' }, agentId)
36
+ await addComment({
37
+ task_id: taskId,
38
+ author: agentId,
39
+ body: `Blocked: ${reason}`,
40
+ comment_type: 'status_change',
41
+ })
42
+ }
@@ -0,0 +1,81 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { resolve, dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import type { Task } from '../board/types.js'
5
+
6
+ export interface AgentProfile {
7
+ id: string
8
+ skills: string[]
9
+ maxConcurrent: number
10
+ status: 'idle' | 'working' | 'error'
11
+ }
12
+
13
+ const DEFAULT_PROFILE: AgentProfile = {
14
+ id: 'default',
15
+ skills: ['*'],
16
+ maxConcurrent: 1,
17
+ status: 'idle',
18
+ }
19
+
20
+ /**
21
+ * Reads agent profiles from agents/profiles.json.
22
+ * Falls back to a single default wildcard profile if the file doesn't exist.
23
+ */
24
+ export function getAgentProfiles(): AgentProfile[] {
25
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
26
+ const profilesPath = resolve(root, 'agents', 'profiles.json')
27
+
28
+ if (!existsSync(profilesPath)) {
29
+ return [DEFAULT_PROFILE]
30
+ }
31
+
32
+ try {
33
+ const raw = readFileSync(profilesPath, 'utf-8')
34
+ const parsed = JSON.parse(raw) as AgentProfile[]
35
+ return parsed
36
+ } catch {
37
+ return [DEFAULT_PROFILE]
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Filters and ranks tasks an agent can work on.
43
+ *
44
+ * - Includes tasks whose skill_required is in the agent's skills list,
45
+ * or tasks with no skill_required, or if agent has wildcard '*'.
46
+ * - Sorts by priority (P1 first), then sort_order.
47
+ * - Returns at most agent.maxConcurrent tasks.
48
+ */
49
+ export function matchTasksToAgent(agent: AgentProfile, tasks: Task[]): Task[] {
50
+ const hasWildcard = agent.skills.includes('*')
51
+
52
+ const matched = tasks.filter((t) => {
53
+ if (hasWildcard) return true
54
+ if (!t.skill_required) return true
55
+ return agent.skills.includes(t.skill_required)
56
+ })
57
+
58
+ matched.sort((a, b) => {
59
+ if (a.priority !== b.priority) return a.priority - b.priority
60
+ return a.sort_order - b.sort_order
61
+ })
62
+
63
+ return matched.slice(0, agent.maxConcurrent)
64
+ }
65
+
66
+ /**
67
+ * Finds the first idle agent whose skills match the task's skill_required.
68
+ * Returns null if no suitable idle agent exists.
69
+ */
70
+ export function findBestAgent(
71
+ profiles: AgentProfile[],
72
+ task: Task,
73
+ ): AgentProfile | null {
74
+ for (const agent of profiles) {
75
+ if (agent.status !== 'idle') continue
76
+ if (agent.skills.includes('*')) return agent
77
+ if (!task.skill_required) return agent
78
+ if (agent.skills.includes(task.skill_required)) return agent
79
+ }
80
+ return null
81
+ }