loopwork 0.3.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/CHANGELOG.md +52 -0
- package/README.md +528 -0
- package/bin/loopwork +0 -0
- package/examples/README.md +70 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +22 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +23 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +37 -0
- package/examples/basic-json-backend/.specs/tasks/tasks.json +19 -0
- package/examples/basic-json-backend/README.md +32 -0
- package/examples/basic-json-backend/TESTING.md +184 -0
- package/examples/basic-json-backend/hello.test.ts +9 -0
- package/examples/basic-json-backend/hello.ts +3 -0
- package/examples/basic-json-backend/loopwork.config.js +35 -0
- package/examples/basic-json-backend/math.test.ts +29 -0
- package/examples/basic-json-backend/math.ts +3 -0
- package/examples/basic-json-backend/package.json +15 -0
- package/examples/basic-json-backend/quick-start.sh +80 -0
- package/loopwork.config.ts +164 -0
- package/package.json +26 -0
- package/src/backends/github.ts +426 -0
- package/src/backends/index.ts +86 -0
- package/src/backends/json.ts +598 -0
- package/src/backends/plugin.ts +317 -0
- package/src/backends/types.ts +19 -0
- package/src/commands/init.ts +100 -0
- package/src/commands/run.ts +365 -0
- package/src/contracts/backend.ts +127 -0
- package/src/contracts/config.ts +129 -0
- package/src/contracts/index.ts +43 -0
- package/src/contracts/plugin.ts +82 -0
- package/src/contracts/task.ts +78 -0
- package/src/core/cli.ts +275 -0
- package/src/core/config.ts +165 -0
- package/src/core/state.ts +154 -0
- package/src/core/utils.ts +125 -0
- package/src/dashboard/cli.ts +449 -0
- package/src/dashboard/index.ts +6 -0
- package/src/dashboard/kanban.tsx +226 -0
- package/src/dashboard/tui.tsx +372 -0
- package/src/index.ts +19 -0
- package/src/mcp/server.ts +451 -0
- package/src/monitor/index.ts +420 -0
- package/src/plugins/asana.ts +192 -0
- package/src/plugins/cost-tracking.ts +402 -0
- package/src/plugins/discord.ts +269 -0
- package/src/plugins/everhour.ts +335 -0
- package/src/plugins/index.ts +253 -0
- package/src/plugins/telegram/bot.ts +517 -0
- package/src/plugins/telegram/index.ts +6 -0
- package/src/plugins/telegram/notifications.ts +198 -0
- package/src/plugins/todoist.ts +261 -0
- package/test/backends.test.ts +929 -0
- package/test/cli.test.ts +145 -0
- package/test/config.test.ts +90 -0
- package/test/e2e.test.ts +458 -0
- package/test/github-tasks.test.ts +191 -0
- package/test/loopwork-config-types.test.ts +288 -0
- package/test/monitor.test.ts +123 -0
- package/test/plugins.test.ts +1175 -0
- package/test/state.test.ts +295 -0
- package/test/utils.test.ts +60 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* MCP (Model Context Protocol) Server for Loopwork
|
|
4
|
+
*
|
|
5
|
+
* Exposes task management functionality via MCP, allowing AI tools
|
|
6
|
+
* like Claude to interact with the task system.
|
|
7
|
+
*
|
|
8
|
+
* Setup for Claude Desktop:
|
|
9
|
+
* Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
|
|
10
|
+
* {
|
|
11
|
+
* "mcpServers": {
|
|
12
|
+
* "loopwork-tasks": {
|
|
13
|
+
* "command": "bun",
|
|
14
|
+
* "args": ["run", "/path/to/loopwork/src/mcp-server.ts"],
|
|
15
|
+
* "env": {
|
|
16
|
+
* "LOOPWORK_BACKEND": "json",
|
|
17
|
+
* "LOOPWORK_TASKS_FILE": ".specs/tasks/tasks.json"
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createBackend, type TaskBackend, type Task } from './backends'
|
|
25
|
+
import type { BackendConfig, FindTaskOptions } from './backends/types'
|
|
26
|
+
|
|
27
|
+
// MCP Protocol Types
|
|
28
|
+
interface McpRequest {
|
|
29
|
+
jsonrpc: '2.0'
|
|
30
|
+
id: string | number
|
|
31
|
+
method: string
|
|
32
|
+
params?: Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface McpResponse {
|
|
36
|
+
jsonrpc: '2.0'
|
|
37
|
+
id: string | number
|
|
38
|
+
result?: unknown
|
|
39
|
+
error?: { code: number; message: string; data?: unknown }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface McpNotification {
|
|
43
|
+
jsonrpc: '2.0'
|
|
44
|
+
method: string
|
|
45
|
+
params?: Record<string, unknown>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ToolDefinition {
|
|
49
|
+
name: string
|
|
50
|
+
description: string
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object'
|
|
53
|
+
properties: Record<string, { type: string; description: string; enum?: string[] }>
|
|
54
|
+
required?: string[]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// MCP Server
|
|
59
|
+
class LoopworkMcpServer {
|
|
60
|
+
private backend: TaskBackend
|
|
61
|
+
|
|
62
|
+
constructor() {
|
|
63
|
+
const backendType = (process.env.LOOPWORK_BACKEND || 'json') as 'github' | 'json'
|
|
64
|
+
const config: BackendConfig = {
|
|
65
|
+
type: backendType,
|
|
66
|
+
tasksFile: process.env.LOOPWORK_TASKS_FILE || '.specs/tasks/tasks.json',
|
|
67
|
+
repo: process.env.LOOPWORK_REPO,
|
|
68
|
+
}
|
|
69
|
+
this.backend = createBackend(config)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private getToolDefinitions(): ToolDefinition[] {
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
name: 'loopwork_list_tasks',
|
|
76
|
+
description: 'List all pending tasks from the Loopwork task system. Returns task IDs, titles, priorities, and status.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
feature: { type: 'string', description: 'Filter by feature name' },
|
|
81
|
+
priority: { type: 'string', description: 'Filter by priority', enum: ['high', 'medium', 'low'] },
|
|
82
|
+
includeBlocked: { type: 'string', description: 'Include blocked tasks (true/false)' },
|
|
83
|
+
topLevelOnly: { type: 'string', description: 'Only top-level tasks, no sub-tasks (true/false)' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'loopwork_get_task',
|
|
89
|
+
description: 'Get detailed information about a specific task including its full description/PRD.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
taskId: { type: 'string', description: 'The task ID to retrieve' },
|
|
94
|
+
},
|
|
95
|
+
required: ['taskId'],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'loopwork_mark_complete',
|
|
100
|
+
description: 'Mark a task as completed. Use after successfully implementing a task.',
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
taskId: { type: 'string', description: 'The task ID to mark complete' },
|
|
105
|
+
comment: { type: 'string', description: 'Optional completion comment' },
|
|
106
|
+
},
|
|
107
|
+
required: ['taskId'],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'loopwork_mark_failed',
|
|
112
|
+
description: 'Mark a task as failed with an error message.',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
taskId: { type: 'string', description: 'The task ID to mark failed' },
|
|
117
|
+
error: { type: 'string', description: 'Error message explaining why it failed' },
|
|
118
|
+
},
|
|
119
|
+
required: ['taskId', 'error'],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'loopwork_mark_in_progress',
|
|
124
|
+
description: 'Mark a task as in-progress. Use when starting work on a task.',
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
taskId: { type: 'string', description: 'The task ID to mark in progress' },
|
|
129
|
+
},
|
|
130
|
+
required: ['taskId'],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'loopwork_reset_task',
|
|
135
|
+
description: 'Reset a task back to pending status for retry.',
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
taskId: { type: 'string', description: 'The task ID to reset' },
|
|
140
|
+
},
|
|
141
|
+
required: ['taskId'],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'loopwork_get_subtasks',
|
|
146
|
+
description: 'Get all sub-tasks of a parent task.',
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
taskId: { type: 'string', description: 'The parent task ID' },
|
|
151
|
+
},
|
|
152
|
+
required: ['taskId'],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'loopwork_get_dependencies',
|
|
157
|
+
description: 'Get tasks that a task depends on.',
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
taskId: { type: 'string', description: 'The task ID' },
|
|
162
|
+
},
|
|
163
|
+
required: ['taskId'],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'loopwork_check_dependencies',
|
|
168
|
+
description: 'Check if all dependencies of a task are met (completed).',
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: 'object',
|
|
171
|
+
properties: {
|
|
172
|
+
taskId: { type: 'string', description: 'The task ID' },
|
|
173
|
+
},
|
|
174
|
+
required: ['taskId'],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'loopwork_count_pending',
|
|
179
|
+
description: 'Count the number of pending tasks.',
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: {
|
|
183
|
+
feature: { type: 'string', description: 'Filter by feature name' },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'loopwork_backend_status',
|
|
189
|
+
description: 'Check the health and status of the task backend.',
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: {},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private formatTask(task: Task): Record<string, unknown> {
|
|
199
|
+
return {
|
|
200
|
+
id: task.id,
|
|
201
|
+
title: task.title,
|
|
202
|
+
description: task.description,
|
|
203
|
+
status: task.status,
|
|
204
|
+
priority: task.priority,
|
|
205
|
+
feature: task.feature,
|
|
206
|
+
parentId: task.parentId,
|
|
207
|
+
dependsOn: task.dependsOn,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private formatTaskList(tasks: Task[]): Record<string, unknown>[] {
|
|
212
|
+
return tasks.map(t => ({
|
|
213
|
+
id: t.id,
|
|
214
|
+
title: t.title,
|
|
215
|
+
status: t.status,
|
|
216
|
+
priority: t.priority,
|
|
217
|
+
feature: t.feature,
|
|
218
|
+
parentId: t.parentId,
|
|
219
|
+
hasDependencies: (t.dependsOn?.length || 0) > 0,
|
|
220
|
+
}))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async handleToolCall(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
224
|
+
switch (name) {
|
|
225
|
+
case 'loopwork_list_tasks': {
|
|
226
|
+
const options: FindTaskOptions = {}
|
|
227
|
+
if (args.feature) options.feature = String(args.feature)
|
|
228
|
+
if (args.priority) options.priority = args.priority as 'high' | 'medium' | 'low'
|
|
229
|
+
if (args.includeBlocked === 'true') options.includeBlocked = true
|
|
230
|
+
if (args.topLevelOnly === 'true') options.topLevelOnly = true
|
|
231
|
+
|
|
232
|
+
const tasks = await this.backend.listPendingTasks(options)
|
|
233
|
+
return {
|
|
234
|
+
count: tasks.length,
|
|
235
|
+
tasks: this.formatTaskList(tasks),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
case 'loopwork_get_task': {
|
|
240
|
+
const task = await this.backend.getTask(String(args.taskId))
|
|
241
|
+
if (!task) {
|
|
242
|
+
return { error: `Task not found: ${args.taskId}` }
|
|
243
|
+
}
|
|
244
|
+
return this.formatTask(task)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'loopwork_mark_complete': {
|
|
248
|
+
const result = await this.backend.markCompleted(
|
|
249
|
+
String(args.taskId),
|
|
250
|
+
args.comment ? String(args.comment) : undefined
|
|
251
|
+
)
|
|
252
|
+
return result
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case 'loopwork_mark_failed': {
|
|
256
|
+
const result = await this.backend.markFailed(
|
|
257
|
+
String(args.taskId),
|
|
258
|
+
String(args.error)
|
|
259
|
+
)
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case 'loopwork_mark_in_progress': {
|
|
264
|
+
const result = await this.backend.markInProgress(String(args.taskId))
|
|
265
|
+
return result
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 'loopwork_reset_task': {
|
|
269
|
+
const result = await this.backend.resetToPending(String(args.taskId))
|
|
270
|
+
return result
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case 'loopwork_get_subtasks': {
|
|
274
|
+
const subtasks = await this.backend.getSubTasks(String(args.taskId))
|
|
275
|
+
return {
|
|
276
|
+
parentId: args.taskId,
|
|
277
|
+
count: subtasks.length,
|
|
278
|
+
subtasks: this.formatTaskList(subtasks),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case 'loopwork_get_dependencies': {
|
|
283
|
+
const deps = await this.backend.getDependencies(String(args.taskId))
|
|
284
|
+
return {
|
|
285
|
+
taskId: args.taskId,
|
|
286
|
+
count: deps.length,
|
|
287
|
+
dependencies: this.formatTaskList(deps),
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'loopwork_check_dependencies': {
|
|
292
|
+
const met = await this.backend.areDependenciesMet(String(args.taskId))
|
|
293
|
+
return {
|
|
294
|
+
taskId: args.taskId,
|
|
295
|
+
dependenciesMet: met,
|
|
296
|
+
canStart: met,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
case 'loopwork_count_pending': {
|
|
301
|
+
const options: FindTaskOptions = {}
|
|
302
|
+
if (args.feature) options.feature = String(args.feature)
|
|
303
|
+
|
|
304
|
+
const count = await this.backend.countPending(options)
|
|
305
|
+
return { count }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case 'loopwork_backend_status': {
|
|
309
|
+
const ping = await this.backend.ping()
|
|
310
|
+
const count = await this.backend.countPending()
|
|
311
|
+
return {
|
|
312
|
+
backend: this.backend.name,
|
|
313
|
+
healthy: ping.ok,
|
|
314
|
+
latencyMs: ping.latencyMs,
|
|
315
|
+
pendingTasks: count,
|
|
316
|
+
error: ping.error,
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
default:
|
|
321
|
+
throw new Error(`Unknown tool: ${name}`)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async handleRequest(request: McpRequest): Promise<McpResponse> {
|
|
326
|
+
const { id, method, params } = request
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
switch (method) {
|
|
330
|
+
case 'initialize':
|
|
331
|
+
return {
|
|
332
|
+
jsonrpc: '2.0',
|
|
333
|
+
id,
|
|
334
|
+
result: {
|
|
335
|
+
protocolVersion: '2024-11-05',
|
|
336
|
+
capabilities: {
|
|
337
|
+
tools: {},
|
|
338
|
+
},
|
|
339
|
+
serverInfo: {
|
|
340
|
+
name: 'loopwork-tasks',
|
|
341
|
+
version: '1.0.0',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case 'tools/list':
|
|
347
|
+
return {
|
|
348
|
+
jsonrpc: '2.0',
|
|
349
|
+
id,
|
|
350
|
+
result: {
|
|
351
|
+
tools: this.getToolDefinitions(),
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
case 'tools/call': {
|
|
356
|
+
const toolName = params?.name as string
|
|
357
|
+
const toolArgs = (params?.arguments || {}) as Record<string, unknown>
|
|
358
|
+
|
|
359
|
+
const result = await this.handleToolCall(toolName, toolArgs)
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
jsonrpc: '2.0',
|
|
363
|
+
id,
|
|
364
|
+
result: {
|
|
365
|
+
content: [
|
|
366
|
+
{
|
|
367
|
+
type: 'text',
|
|
368
|
+
text: JSON.stringify(result, null, 2),
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
},
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
case 'notifications/initialized':
|
|
376
|
+
// No response needed for notifications
|
|
377
|
+
return { jsonrpc: '2.0', id, result: {} }
|
|
378
|
+
|
|
379
|
+
default:
|
|
380
|
+
return {
|
|
381
|
+
jsonrpc: '2.0',
|
|
382
|
+
id,
|
|
383
|
+
error: {
|
|
384
|
+
code: -32601,
|
|
385
|
+
message: `Method not found: ${method}`,
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch (e: any) {
|
|
390
|
+
return {
|
|
391
|
+
jsonrpc: '2.0',
|
|
392
|
+
id,
|
|
393
|
+
error: {
|
|
394
|
+
code: -32000,
|
|
395
|
+
message: e.message,
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async run(): Promise<void> {
|
|
402
|
+
const decoder = new TextDecoder()
|
|
403
|
+
const encoder = new TextEncoder()
|
|
404
|
+
|
|
405
|
+
// Read from stdin
|
|
406
|
+
const reader = Bun.stdin.stream().getReader()
|
|
407
|
+
let buffer = ''
|
|
408
|
+
|
|
409
|
+
while (true) {
|
|
410
|
+
const { done, value } = await reader.read()
|
|
411
|
+
if (done) break
|
|
412
|
+
|
|
413
|
+
buffer += decoder.decode(value)
|
|
414
|
+
|
|
415
|
+
// Process complete JSON-RPC messages
|
|
416
|
+
const lines = buffer.split('\n')
|
|
417
|
+
buffer = lines.pop() || ''
|
|
418
|
+
|
|
419
|
+
for (const line of lines) {
|
|
420
|
+
if (!line.trim()) continue
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const request = JSON.parse(line) as McpRequest
|
|
424
|
+
const response = await this.handleRequest(request)
|
|
425
|
+
|
|
426
|
+
// Write response to stdout
|
|
427
|
+
const output = JSON.stringify(response) + '\n'
|
|
428
|
+
await Bun.write(Bun.stdout, encoder.encode(output))
|
|
429
|
+
} catch (e: any) {
|
|
430
|
+
const errorResponse: McpResponse = {
|
|
431
|
+
jsonrpc: '2.0',
|
|
432
|
+
id: 0,
|
|
433
|
+
error: {
|
|
434
|
+
code: -32700,
|
|
435
|
+
message: `Parse error: ${e.message}`,
|
|
436
|
+
},
|
|
437
|
+
}
|
|
438
|
+
await Bun.write(Bun.stdout, encoder.encode(JSON.stringify(errorResponse) + '\n'))
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Run server
|
|
446
|
+
if (import.meta.main) {
|
|
447
|
+
const server = new LoopworkMcpServer()
|
|
448
|
+
server.run().catch(console.error)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export { LoopworkMcpServer }
|