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.
Files changed (62) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +528 -0
  3. package/bin/loopwork +0 -0
  4. package/examples/README.md +70 -0
  5. package/examples/basic-json-backend/.specs/tasks/TASK-001.md +22 -0
  6. package/examples/basic-json-backend/.specs/tasks/TASK-002.md +23 -0
  7. package/examples/basic-json-backend/.specs/tasks/TASK-003.md +37 -0
  8. package/examples/basic-json-backend/.specs/tasks/tasks.json +19 -0
  9. package/examples/basic-json-backend/README.md +32 -0
  10. package/examples/basic-json-backend/TESTING.md +184 -0
  11. package/examples/basic-json-backend/hello.test.ts +9 -0
  12. package/examples/basic-json-backend/hello.ts +3 -0
  13. package/examples/basic-json-backend/loopwork.config.js +35 -0
  14. package/examples/basic-json-backend/math.test.ts +29 -0
  15. package/examples/basic-json-backend/math.ts +3 -0
  16. package/examples/basic-json-backend/package.json +15 -0
  17. package/examples/basic-json-backend/quick-start.sh +80 -0
  18. package/loopwork.config.ts +164 -0
  19. package/package.json +26 -0
  20. package/src/backends/github.ts +426 -0
  21. package/src/backends/index.ts +86 -0
  22. package/src/backends/json.ts +598 -0
  23. package/src/backends/plugin.ts +317 -0
  24. package/src/backends/types.ts +19 -0
  25. package/src/commands/init.ts +100 -0
  26. package/src/commands/run.ts +365 -0
  27. package/src/contracts/backend.ts +127 -0
  28. package/src/contracts/config.ts +129 -0
  29. package/src/contracts/index.ts +43 -0
  30. package/src/contracts/plugin.ts +82 -0
  31. package/src/contracts/task.ts +78 -0
  32. package/src/core/cli.ts +275 -0
  33. package/src/core/config.ts +165 -0
  34. package/src/core/state.ts +154 -0
  35. package/src/core/utils.ts +125 -0
  36. package/src/dashboard/cli.ts +449 -0
  37. package/src/dashboard/index.ts +6 -0
  38. package/src/dashboard/kanban.tsx +226 -0
  39. package/src/dashboard/tui.tsx +372 -0
  40. package/src/index.ts +19 -0
  41. package/src/mcp/server.ts +451 -0
  42. package/src/monitor/index.ts +420 -0
  43. package/src/plugins/asana.ts +192 -0
  44. package/src/plugins/cost-tracking.ts +402 -0
  45. package/src/plugins/discord.ts +269 -0
  46. package/src/plugins/everhour.ts +335 -0
  47. package/src/plugins/index.ts +253 -0
  48. package/src/plugins/telegram/bot.ts +517 -0
  49. package/src/plugins/telegram/index.ts +6 -0
  50. package/src/plugins/telegram/notifications.ts +198 -0
  51. package/src/plugins/todoist.ts +261 -0
  52. package/test/backends.test.ts +929 -0
  53. package/test/cli.test.ts +145 -0
  54. package/test/config.test.ts +90 -0
  55. package/test/e2e.test.ts +458 -0
  56. package/test/github-tasks.test.ts +191 -0
  57. package/test/loopwork-config-types.test.ts +288 -0
  58. package/test/monitor.test.ts +123 -0
  59. package/test/plugins.test.ts +1175 -0
  60. package/test/state.test.ts +295 -0
  61. package/test/utils.test.ts +60 -0
  62. package/tsconfig.json +20 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Backend Plugin System for Loopwork
3
+ *
4
+ * Backends (JSON, GitHub) are now plugins that implement both:
5
+ * - LoopworkPlugin: lifecycle hooks
6
+ * - TaskBackend: task CRUD operations
7
+ *
8
+ * Usage:
9
+ * export default compose(
10
+ * withJSONBackend({ tasksFile: 'tasks.json' }),
11
+ * // or
12
+ * withGitHubBackend({ repo: 'owner/repo' }),
13
+ * )(defineConfig({ cli: 'opencode' }))
14
+ */
15
+
16
+ import type { LoopworkPlugin, LoopworkConfig } from '../contracts'
17
+ import type { Task, Priority, FindTaskOptions, UpdateResult } from './types'
18
+
19
+ // ============================================================================
20
+ // Backend Plugin Interface
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Backend plugin combines LoopworkPlugin with TaskBackend operations
25
+ */
26
+ export interface BackendPlugin extends LoopworkPlugin {
27
+ /** Backend type identifier */
28
+ readonly backendType: 'json' | 'github' | string
29
+
30
+ // Task operations
31
+ findNextTask(options?: FindTaskOptions): Promise<Task | null>
32
+ getTask(taskId: string): Promise<Task | null>
33
+ listPendingTasks(options?: FindTaskOptions): Promise<Task[]>
34
+ countPending(options?: FindTaskOptions): Promise<number>
35
+ markInProgress(taskId: string): Promise<UpdateResult>
36
+ markCompleted(taskId: string, comment?: string): Promise<UpdateResult>
37
+ markFailed(taskId: string, error: string): Promise<UpdateResult>
38
+ resetToPending(taskId: string): Promise<UpdateResult>
39
+ addComment?(taskId: string, comment: string): Promise<UpdateResult>
40
+ ping(): Promise<{ ok: boolean; latencyMs: number; error?: string }>
41
+
42
+ // Sub-task and dependency methods
43
+ getSubTasks(taskId: string): Promise<Task[]>
44
+ getDependencies(taskId: string): Promise<Task[]>
45
+ getDependents(taskId: string): Promise<Task[]>
46
+ areDependenciesMet(taskId: string): Promise<boolean>
47
+ createTask?(task: Omit<Task, 'id' | 'status'>): Promise<Task>
48
+ createSubTask?(parentId: string, task: Omit<Task, 'id' | 'parentId' | 'status'>): Promise<Task>
49
+ addDependency?(taskId: string, dependsOnId: string): Promise<UpdateResult>
50
+ removeDependency?(taskId: string, dependsOnId: string): Promise<UpdateResult>
51
+
52
+ // Priority
53
+ setPriority?(taskId: string, priority: Priority): Promise<UpdateResult>
54
+ }
55
+
56
+ // ============================================================================
57
+ // JSON Backend Plugin
58
+ // ============================================================================
59
+
60
+ export interface JSONBackendConfig {
61
+ tasksFile?: string
62
+ tasksDir?: string
63
+ }
64
+
65
+ /**
66
+ * Create JSON backend plugin
67
+ */
68
+ export function createJSONBackendPlugin(config: JSONBackendConfig = {}): BackendPlugin {
69
+ const tasksFile = config.tasksFile || '.specs/tasks/tasks.json'
70
+
71
+ // Lazy load the adapter
72
+ let adapter: any = null
73
+
74
+ const getAdapter = async () => {
75
+ if (!adapter) {
76
+ const { JsonTaskAdapter } = await import('./json')
77
+ adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: config.tasksDir })
78
+ }
79
+ return adapter
80
+ }
81
+
82
+ return {
83
+ name: 'json-backend',
84
+ backendType: 'json',
85
+
86
+ async onConfigLoad(cfg) {
87
+ await getAdapter()
88
+ return cfg
89
+ },
90
+
91
+ // Delegate all backend operations to the adapter
92
+ async findNextTask(options) {
93
+ return (await getAdapter()).findNextTask(options)
94
+ },
95
+ async getTask(taskId) {
96
+ return (await getAdapter()).getTask(taskId)
97
+ },
98
+ async listPendingTasks(options) {
99
+ return (await getAdapter()).listPendingTasks(options)
100
+ },
101
+ async countPending(options) {
102
+ return (await getAdapter()).countPending(options)
103
+ },
104
+ async markInProgress(taskId) {
105
+ return (await getAdapter()).markInProgress(taskId)
106
+ },
107
+ async markCompleted(taskId, comment) {
108
+ return (await getAdapter()).markCompleted(taskId, comment)
109
+ },
110
+ async markFailed(taskId, error) {
111
+ return (await getAdapter()).markFailed(taskId, error)
112
+ },
113
+ async resetToPending(taskId) {
114
+ return (await getAdapter()).resetToPending(taskId)
115
+ },
116
+ async addComment(taskId, comment) {
117
+ const a = await getAdapter()
118
+ return a.addComment?.(taskId, comment) || { success: false, error: 'Not supported' }
119
+ },
120
+ async ping() {
121
+ return (await getAdapter()).ping()
122
+ },
123
+ async getSubTasks(taskId) {
124
+ return (await getAdapter()).getSubTasks(taskId)
125
+ },
126
+ async getDependencies(taskId) {
127
+ return (await getAdapter()).getDependencies(taskId)
128
+ },
129
+ async getDependents(taskId) {
130
+ return (await getAdapter()).getDependents(taskId)
131
+ },
132
+ async areDependenciesMet(taskId) {
133
+ return (await getAdapter()).areDependenciesMet(taskId)
134
+ },
135
+ async createTask(task) {
136
+ const a = await getAdapter()
137
+ if (!a.createTask) throw new Error('createTask not supported')
138
+ return a.createTask(task)
139
+ },
140
+ async createSubTask(parentId, task) {
141
+ const a = await getAdapter()
142
+ if (!a.createSubTask) throw new Error('createSubTask not supported')
143
+ return a.createSubTask(parentId, task)
144
+ },
145
+ async addDependency(taskId, dependsOnId) {
146
+ const a = await getAdapter()
147
+ return a.addDependency?.(taskId, dependsOnId) || { success: false, error: 'Not supported' }
148
+ },
149
+ async removeDependency(taskId, dependsOnId) {
150
+ const a = await getAdapter()
151
+ return a.removeDependency?.(taskId, dependsOnId) || { success: false, error: 'Not supported' }
152
+ },
153
+ async setPriority(taskId, priority) {
154
+ const a = await getAdapter()
155
+ if (a.setPriority) {
156
+ return a.setPriority(taskId, priority)
157
+ }
158
+ return { success: false, error: 'setPriority not supported by JSON adapter' }
159
+ },
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Config wrapper for JSON backend
165
+ */
166
+ export function withJSONBackend(config: JSONBackendConfig = {}) {
167
+ return (baseConfig: LoopworkConfig): LoopworkConfig => ({
168
+ ...baseConfig,
169
+ backend: {
170
+ type: 'json',
171
+ tasksFile: config.tasksFile || '.specs/tasks/tasks.json',
172
+ tasksDir: config.tasksDir,
173
+ },
174
+ plugins: [...(baseConfig.plugins || []), createJSONBackendPlugin(config)],
175
+ })
176
+ }
177
+
178
+ // ============================================================================
179
+ // GitHub Backend Plugin
180
+ // ============================================================================
181
+
182
+ export interface GitHubBackendConfig {
183
+ repo?: string
184
+ token?: string
185
+ }
186
+
187
+ /**
188
+ * Create GitHub backend plugin
189
+ */
190
+ export function createGitHubBackendPlugin(config: GitHubBackendConfig = {}): BackendPlugin {
191
+ const repo = config.repo || process.env.GITHUB_REPOSITORY
192
+
193
+ // Lazy load the adapter
194
+ let adapter: any = null
195
+
196
+ const getAdapter = async () => {
197
+ if (!adapter) {
198
+ const { GitHubTaskAdapter } = await import('./github')
199
+ adapter = new GitHubTaskAdapter({ type: 'github', repo })
200
+ }
201
+ return adapter
202
+ }
203
+
204
+ return {
205
+ name: 'github-backend',
206
+ backendType: 'github',
207
+
208
+ async onConfigLoad(cfg) {
209
+ if (!repo) {
210
+ console.warn('GitHub backend: Missing repo. Set GITHUB_REPOSITORY or pass repo option.')
211
+ }
212
+ await getAdapter()
213
+ return cfg
214
+ },
215
+
216
+ // Delegate all backend operations
217
+ async findNextTask(options) {
218
+ return (await getAdapter()).findNextTask(options)
219
+ },
220
+ async getTask(taskId) {
221
+ return (await getAdapter()).getTask(taskId)
222
+ },
223
+ async listPendingTasks(options) {
224
+ return (await getAdapter()).listPendingTasks(options)
225
+ },
226
+ async countPending(options) {
227
+ return (await getAdapter()).countPending(options)
228
+ },
229
+ async markInProgress(taskId) {
230
+ return (await getAdapter()).markInProgress(taskId)
231
+ },
232
+ async markCompleted(taskId, comment) {
233
+ return (await getAdapter()).markCompleted(taskId, comment)
234
+ },
235
+ async markFailed(taskId, error) {
236
+ return (await getAdapter()).markFailed(taskId, error)
237
+ },
238
+ async resetToPending(taskId) {
239
+ return (await getAdapter()).resetToPending(taskId)
240
+ },
241
+ async addComment(taskId, comment) {
242
+ return (await getAdapter()).addComment(taskId, comment)
243
+ },
244
+ async ping() {
245
+ return (await getAdapter()).ping()
246
+ },
247
+ async getSubTasks(taskId) {
248
+ return (await getAdapter()).getSubTasks(taskId)
249
+ },
250
+ async getDependencies(taskId) {
251
+ return (await getAdapter()).getDependencies(taskId)
252
+ },
253
+ async getDependents(taskId) {
254
+ return (await getAdapter()).getDependents(taskId)
255
+ },
256
+ async areDependenciesMet(taskId) {
257
+ return (await getAdapter()).areDependenciesMet(taskId)
258
+ },
259
+ async createTask(task) {
260
+ return (await getAdapter()).createTask(task)
261
+ },
262
+ async createSubTask(parentId, task) {
263
+ return (await getAdapter()).createSubTask(parentId, task)
264
+ },
265
+ async addDependency(taskId, dependsOnId) {
266
+ return (await getAdapter()).addDependency(taskId, dependsOnId)
267
+ },
268
+ async removeDependency(taskId, dependsOnId) {
269
+ return (await getAdapter()).removeDependency(taskId, dependsOnId)
270
+ },
271
+ async setPriority(taskId, priority) {
272
+ return (await getAdapter()).setPriority(taskId, priority)
273
+ },
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Config wrapper for GitHub backend
279
+ */
280
+ export function withGitHubBackend(config: GitHubBackendConfig = {}) {
281
+ return (baseConfig: LoopworkConfig): LoopworkConfig => ({
282
+ ...baseConfig,
283
+ backend: {
284
+ type: 'github',
285
+ repo: config.repo || process.env.GITHUB_REPOSITORY,
286
+ },
287
+ plugins: [...(baseConfig.plugins || []), createGitHubBackendPlugin(config)],
288
+ })
289
+ }
290
+
291
+ // ============================================================================
292
+ // Helper to get backend from config
293
+ // ============================================================================
294
+
295
+ /**
296
+ * Get the backend plugin from config plugins array
297
+ */
298
+ export function getBackendPlugin(config: LoopworkConfig): BackendPlugin | null {
299
+ const plugins = config.plugins || []
300
+ for (const plugin of plugins) {
301
+ if ('backendType' in plugin) {
302
+ return plugin as BackendPlugin
303
+ }
304
+ }
305
+ return null
306
+ }
307
+
308
+ /**
309
+ * Get backend or throw error
310
+ */
311
+ export function requireBackend(config: LoopworkConfig): BackendPlugin {
312
+ const backend = getBackendPlugin(config)
313
+ if (!backend) {
314
+ throw new Error('No backend plugin found. Use withJSONBackend() or withGitHubBackend().')
315
+ }
316
+ return backend
317
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Backend Types - Re-exports from contracts
3
+ */
4
+
5
+ export type {
6
+ Task,
7
+ TaskStatus,
8
+ Priority,
9
+ } from '../contracts/task'
10
+
11
+ export type {
12
+ TaskBackend,
13
+ BackendPlugin,
14
+ BackendConfig,
15
+ BackendFactory,
16
+ FindTaskOptions,
17
+ UpdateResult,
18
+ PingResult,
19
+ } from '../contracts/backend'
@@ -0,0 +1,100 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { promptUser, logger } from '../core/utils'
4
+ import readline from 'readline'
5
+
6
+ async function ask(question: string, defaultValue: string): Promise<string> {
7
+ const rl = readline.createInterface({
8
+ input: process.stdin,
9
+ output: process.stdout
10
+ })
11
+ return new Promise(resolve => {
12
+ rl.question(`${question} [${defaultValue}]: `, (answer) => {
13
+ rl.close()
14
+ resolve(answer || defaultValue)
15
+ })
16
+ })
17
+ }
18
+
19
+ export async function init() {
20
+ const backendChoice = await promptUser('Backend type (github/json) [json]: ', 'json')
21
+ const backendType = backendChoice.toLowerCase().startsWith('g') ? 'github' : 'json'
22
+
23
+ const aiChoice = await promptUser('AI CLI tool (opencode/claude) [opencode]: ', 'opencode')
24
+ const aiTool = aiChoice.toLowerCase().startsWith('c') ? 'claude' : 'opencode'
25
+
26
+ let backendConfig = ''
27
+ let tasksFile = '.specs/tasks/tasks.json'
28
+ let prdDir = '.specs/tasks'
29
+
30
+ if (backendType === 'github') {
31
+ const repoName = await ask('Repo name', 'current repo')
32
+ backendConfig = `withGitHubBackend({ repo: ${repoName === 'current repo' ? 'undefined' : `'${repoName}'`} })`
33
+ } else {
34
+ prdDir = await ask('Task directory', '.specs/tasks')
35
+ if (prdDir.endsWith('/')) prdDir = prdDir.slice(0, -1)
36
+ tasksFile = path.join(prdDir, 'tasks.json')
37
+ backendConfig = `withJSONBackend({ tasksFile: '${tasksFile}' })`
38
+ }
39
+
40
+ const configContent = `import { defineConfig, compose, withCostTracking } from './src/loopwork-config-types'
41
+ import { withJSONBackend, withGitHubBackend } from './src/backend-plugin'
42
+
43
+ export default compose(
44
+ ${backendConfig},
45
+ withCostTracking({ dailyBudget: 10.00 }),
46
+ )(defineConfig({
47
+ cli: '${aiTool}',
48
+ maxIterations: 50,
49
+ }))
50
+ `
51
+
52
+ if (fs.existsSync('loopwork.config.ts')) {
53
+ const overwrite = await promptUser('loopwork.config.ts already exists. Overwrite? (y/N): ', 'n')
54
+ if (overwrite.toLowerCase() !== 'y') {
55
+ logger.info('Initialization aborted.')
56
+ return
57
+ }
58
+ }
59
+
60
+ fs.writeFileSync('loopwork.config.ts', configContent)
61
+ logger.success('Created loopwork.config.ts')
62
+
63
+ if (backendType === 'json') {
64
+ if (!fs.existsSync(prdDir)) {
65
+ fs.mkdirSync(prdDir, { recursive: true })
66
+ }
67
+
68
+ const tasksJson = {
69
+ "tasks": [
70
+ {
71
+ "id": "TASK-001",
72
+ "status": "pending",
73
+ "priority": "high",
74
+ "title": "My First Task",
75
+ "description": "Implement the first feature"
76
+ }
77
+ ]
78
+ }
79
+
80
+ fs.writeFileSync(tasksFile, JSON.stringify(tasksJson, null, 2))
81
+ logger.success(`Created ${tasksFile}`)
82
+
83
+ const samplePrd = `# TASK-001: My First Task
84
+
85
+ ## Goal
86
+ Implement the first feature
87
+
88
+ ## Requirements
89
+ - [ ] Requirement 1
90
+ - [ ] Requirement 2
91
+ `
92
+ const prdFile = path.join(prdDir, 'TASK-001.md')
93
+ fs.writeFileSync(prdFile, samplePrd)
94
+ logger.success(`Created ${prdFile}`)
95
+ }
96
+
97
+ logger.info('\nNext steps:')
98
+ logger.info('1. Install dependencies: bun install')
99
+ logger.info('2. Run loopwork: bun run start')
100
+ }