loopwork 0.3.0 → 0.3.1

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 (46) hide show
  1. package/bin/loopwork +0 -0
  2. package/package.json +48 -4
  3. package/src/backends/github.ts +6 -3
  4. package/src/backends/json.ts +28 -10
  5. package/src/commands/run.ts +2 -2
  6. package/src/contracts/config.ts +3 -75
  7. package/src/contracts/index.ts +0 -6
  8. package/src/core/cli.ts +25 -16
  9. package/src/core/state.ts +10 -4
  10. package/src/core/utils.ts +10 -4
  11. package/src/monitor/index.ts +56 -34
  12. package/src/plugins/index.ts +9 -131
  13. package/examples/README.md +0 -70
  14. package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
  15. package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
  16. package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
  17. package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
  18. package/examples/basic-json-backend/README.md +0 -32
  19. package/examples/basic-json-backend/TESTING.md +0 -184
  20. package/examples/basic-json-backend/hello.test.ts +0 -9
  21. package/examples/basic-json-backend/hello.ts +0 -3
  22. package/examples/basic-json-backend/loopwork.config.js +0 -35
  23. package/examples/basic-json-backend/math.test.ts +0 -29
  24. package/examples/basic-json-backend/math.ts +0 -3
  25. package/examples/basic-json-backend/package.json +0 -15
  26. package/examples/basic-json-backend/quick-start.sh +0 -80
  27. package/loopwork.config.ts +0 -164
  28. package/src/plugins/asana.ts +0 -192
  29. package/src/plugins/cost-tracking.ts +0 -402
  30. package/src/plugins/discord.ts +0 -269
  31. package/src/plugins/everhour.ts +0 -335
  32. package/src/plugins/telegram/bot.ts +0 -517
  33. package/src/plugins/telegram/index.ts +0 -6
  34. package/src/plugins/telegram/notifications.ts +0 -198
  35. package/src/plugins/todoist.ts +0 -261
  36. package/test/backends.test.ts +0 -929
  37. package/test/cli.test.ts +0 -145
  38. package/test/config.test.ts +0 -90
  39. package/test/e2e.test.ts +0 -458
  40. package/test/github-tasks.test.ts +0 -191
  41. package/test/loopwork-config-types.test.ts +0 -288
  42. package/test/monitor.test.ts +0 -123
  43. package/test/plugins.test.ts +0 -1175
  44. package/test/state.test.ts +0 -295
  45. package/test/utils.test.ts +0 -60
  46. package/tsconfig.json +0 -20
@@ -1,295 +0,0 @@
1
- import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
2
- import fs from 'fs'
3
- import path from 'path'
4
- import os from 'os'
5
- import { StateManager } from '../src/core/state'
6
- import type { Config } from '../src/core/config'
7
-
8
- describe('StateManager', () => {
9
- let tempDir: string
10
- let config: Config
11
- let stateManager: StateManager
12
-
13
- beforeEach(() => {
14
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loopwork-test-'))
15
- config = {
16
- projectRoot: tempDir,
17
- outputDir: path.join(tempDir, 'output'),
18
- sessionId: 'test-session-123',
19
- maxIterations: 50,
20
- timeout: 600,
21
- cli: 'opencode',
22
- autoConfirm: false,
23
- dryRun: false,
24
- debug: false,
25
- } as Config
26
- stateManager = new StateManager(config)
27
- })
28
-
29
- afterEach(() => {
30
- // Clean up temp directory
31
- fs.rmSync(tempDir, { recursive: true, force: true })
32
- })
33
-
34
- describe('acquireLock', () => {
35
- test('acquires lock successfully when no lock exists', () => {
36
- const result = stateManager.acquireLock()
37
- expect(result).toBe(true)
38
-
39
- const lockDir = path.join(tempDir, '.loopwork.lock')
40
- expect(fs.existsSync(lockDir)).toBe(true)
41
-
42
- const pidFile = path.join(lockDir, 'pid')
43
- expect(fs.existsSync(pidFile)).toBe(true)
44
- expect(fs.readFileSync(pidFile, 'utf-8')).toBe(process.pid.toString())
45
- })
46
-
47
- test('fails to acquire lock when another process holds it', () => {
48
- // Create a lock with current process PID (simulating another instance)
49
- const lockDir = path.join(tempDir, '.loopwork.lock')
50
- fs.mkdirSync(lockDir)
51
- fs.writeFileSync(path.join(lockDir, 'pid'), process.pid.toString())
52
-
53
- const result = stateManager.acquireLock()
54
- expect(result).toBe(false)
55
- })
56
-
57
- test('removes stale lock and acquires new one', () => {
58
- // Create a lock with a non-existent PID
59
- const lockDir = path.join(tempDir, '.loopwork.lock')
60
- fs.mkdirSync(lockDir)
61
- fs.writeFileSync(path.join(lockDir, 'pid'), '999999999') // Non-existent PID
62
-
63
- const result = stateManager.acquireLock()
64
- expect(result).toBe(true)
65
-
66
- // Verify new lock has current PID
67
- const pidFile = path.join(lockDir, 'pid')
68
- expect(fs.readFileSync(pidFile, 'utf-8')).toBe(process.pid.toString())
69
- })
70
-
71
- test('fails after max retry attempts', () => {
72
- const result = stateManager.acquireLock(4) // Already exceeded max retries
73
- expect(result).toBe(false)
74
- })
75
- })
76
-
77
- describe('releaseLock', () => {
78
- test('releases existing lock', () => {
79
- stateManager.acquireLock()
80
- const lockDir = path.join(tempDir, '.loopwork.lock')
81
- expect(fs.existsSync(lockDir)).toBe(true)
82
-
83
- stateManager.releaseLock()
84
- expect(fs.existsSync(lockDir)).toBe(false)
85
- })
86
-
87
- test('does nothing when no lock exists', () => {
88
- // Should not throw
89
- stateManager.releaseLock()
90
- })
91
- })
92
-
93
- describe('saveState', () => {
94
- test('saves state to file', () => {
95
- stateManager.saveState(123, 5)
96
-
97
- const stateFile = path.join(tempDir, '.loopwork-state')
98
- expect(fs.existsSync(stateFile)).toBe(true)
99
-
100
- const content = fs.readFileSync(stateFile, 'utf-8')
101
- expect(content).toContain('LAST_ISSUE=123')
102
- expect(content).toContain('LAST_ITERATION=5')
103
- expect(content).toContain('SESSION_ID=test-session-123')
104
- expect(content).toContain('SAVED_AT=')
105
- })
106
-
107
- test('overwrites previous state', () => {
108
- stateManager.saveState(100, 1)
109
- stateManager.saveState(200, 10)
110
-
111
- const stateFile = path.join(tempDir, '.loopwork-state')
112
- const content = fs.readFileSync(stateFile, 'utf-8')
113
- expect(content).toContain('LAST_ISSUE=200')
114
- expect(content).toContain('LAST_ITERATION=10')
115
- expect(content).not.toContain('LAST_ISSUE=100')
116
- })
117
- })
118
-
119
- describe('loadState', () => {
120
- test('returns null when no state file exists', () => {
121
- const result = stateManager.loadState()
122
- expect(result).toBeNull()
123
- })
124
-
125
- test('loads state from file', () => {
126
- stateManager.saveState(456, 20)
127
-
128
- const result = stateManager.loadState()
129
- expect(result).not.toBeNull()
130
- expect(result!.lastIssue).toBe(456)
131
- expect(result!.lastIteration).toBe(20)
132
- })
133
-
134
- test('returns null when state file is malformed', () => {
135
- const stateFile = path.join(tempDir, '.loopwork-state')
136
- fs.writeFileSync(stateFile, 'invalid content without equals sign')
137
-
138
- const result = stateManager.loadState()
139
- expect(result).toBeNull()
140
- })
141
-
142
- test('handles missing optional fields', () => {
143
- const stateFile = path.join(tempDir, '.loopwork-state')
144
- fs.writeFileSync(stateFile, 'LAST_ISSUE=789')
145
-
146
- const result = stateManager.loadState()
147
- expect(result).not.toBeNull()
148
- expect(result!.lastIssue).toBe(789)
149
- expect(result!.lastIteration).toBe(0)
150
- expect(result!.lastOutputDir).toBe('')
151
- })
152
- })
153
-
154
- describe('clearState', () => {
155
- test('removes state file', () => {
156
- stateManager.saveState(123, 5)
157
- const stateFile = path.join(tempDir, '.loopwork-state')
158
- expect(fs.existsSync(stateFile)).toBe(true)
159
-
160
- stateManager.clearState()
161
- expect(fs.existsSync(stateFile)).toBe(false)
162
- })
163
-
164
- test('does nothing when no state file exists', () => {
165
- // Should not throw
166
- stateManager.clearState()
167
- })
168
- })
169
- })
170
-
171
- describe('StateManager with namespace', () => {
172
- let tempDir: string
173
-
174
- beforeEach(() => {
175
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loopwork-ns-test-'))
176
- })
177
-
178
- afterEach(() => {
179
- fs.rmSync(tempDir, { recursive: true, force: true })
180
- })
181
-
182
- test('uses default namespace by default', () => {
183
- const config = {
184
- projectRoot: tempDir,
185
- outputDir: path.join(tempDir, 'output'),
186
- sessionId: 'test-session',
187
- namespace: 'default',
188
- } as Config
189
-
190
- const stateManager = new StateManager(config)
191
- expect(stateManager.getNamespace()).toBe('default')
192
- expect(stateManager.getStateFile()).toBe(path.join(tempDir, '.loopwork-state'))
193
- expect(stateManager.getLockFile()).toBe(path.join(tempDir, '.loopwork.lock'))
194
- })
195
-
196
- test('uses custom namespace in file paths', () => {
197
- const config = {
198
- projectRoot: tempDir,
199
- outputDir: path.join(tempDir, 'output'),
200
- sessionId: 'test-session',
201
- namespace: 'feature-a',
202
- } as Config
203
-
204
- const stateManager = new StateManager(config)
205
- expect(stateManager.getNamespace()).toBe('feature-a')
206
- expect(stateManager.getStateFile()).toBe(path.join(tempDir, '.loopwork-state-feature-a'))
207
- expect(stateManager.getLockFile()).toBe(path.join(tempDir, '.loopwork-feature-a.lock'))
208
- })
209
-
210
- test('multiple namespaces can coexist', () => {
211
- const configA = {
212
- projectRoot: tempDir,
213
- outputDir: path.join(tempDir, 'output-a'),
214
- sessionId: 'session-a',
215
- namespace: 'feature-a',
216
- } as Config
217
-
218
- const configB = {
219
- projectRoot: tempDir,
220
- outputDir: path.join(tempDir, 'output-b'),
221
- sessionId: 'session-b',
222
- namespace: 'feature-b',
223
- } as Config
224
-
225
- const managerA = new StateManager(configA)
226
- const managerB = new StateManager(configB)
227
-
228
- // Both should acquire locks independently
229
- expect(managerA.acquireLock()).toBe(true)
230
- expect(managerB.acquireLock()).toBe(true)
231
-
232
- // Save state independently
233
- managerA.saveState(100, 1)
234
- managerB.saveState(200, 2)
235
-
236
- // Load state independently
237
- const stateA = managerA.loadState()
238
- const stateB = managerB.loadState()
239
-
240
- expect(stateA!.lastIssue).toBe(100)
241
- expect(stateB!.lastIssue).toBe(200)
242
-
243
- // Clean up
244
- managerA.releaseLock()
245
- managerB.releaseLock()
246
- })
247
-
248
- test('saves namespace in state file', () => {
249
- const config = {
250
- projectRoot: tempDir,
251
- outputDir: path.join(tempDir, 'output'),
252
- sessionId: 'test-session',
253
- namespace: 'my-namespace',
254
- } as Config
255
-
256
- const stateManager = new StateManager(config)
257
- stateManager.saveState(123, 5)
258
-
259
- const stateFile = stateManager.getStateFile()
260
- const content = fs.readFileSync(stateFile, 'utf-8')
261
- expect(content).toContain('NAMESPACE=my-namespace')
262
- })
263
-
264
- test('namespace does not affect lock acquisition for different namespaces', () => {
265
- const defaultConfig = {
266
- projectRoot: tempDir,
267
- outputDir: path.join(tempDir, 'output'),
268
- sessionId: 'default-session',
269
- namespace: 'default',
270
- } as Config
271
-
272
- const customConfig = {
273
- projectRoot: tempDir,
274
- outputDir: path.join(tempDir, 'output-custom'),
275
- sessionId: 'custom-session',
276
- namespace: 'custom',
277
- } as Config
278
-
279
- const defaultManager = new StateManager(defaultConfig)
280
- const customManager = new StateManager(customConfig)
281
-
282
- // Acquire lock for default namespace
283
- expect(defaultManager.acquireLock()).toBe(true)
284
-
285
- // Custom namespace should still be able to acquire its own lock
286
- expect(customManager.acquireLock()).toBe(true)
287
-
288
- // Both locks should exist
289
- expect(fs.existsSync(defaultManager.getLockFile())).toBe(true)
290
- expect(fs.existsSync(customManager.getLockFile())).toBe(true)
291
-
292
- defaultManager.releaseLock()
293
- customManager.releaseLock()
294
- })
295
- })
@@ -1,60 +0,0 @@
1
- import { describe, expect, test, beforeEach, afterEach, spyOn } from 'bun:test'
2
- import { logger } from '../src/core/utils'
3
-
4
- describe('utils', () => {
5
- describe('logger', () => {
6
- let consoleSpy: ReturnType<typeof spyOn>
7
-
8
- beforeEach(() => {
9
- consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
10
- })
11
-
12
- afterEach(() => {
13
- consoleSpy.mockRestore()
14
- })
15
-
16
- test('info logs message', () => {
17
- logger.info('test message')
18
- expect(consoleSpy).toHaveBeenCalled()
19
- const args = consoleSpy.mock.calls[0]
20
- expect(args.some((a: string) => a.includes('[INFO]') || a.includes('INFO'))).toBe(true)
21
- })
22
-
23
- test('success logs message', () => {
24
- logger.success('test message')
25
- expect(consoleSpy).toHaveBeenCalled()
26
- const args = consoleSpy.mock.calls[0]
27
- expect(args.some((a: string) => a.includes('[SUCCESS]') || a.includes('SUCCESS'))).toBe(true)
28
- })
29
-
30
- test('warn logs message', () => {
31
- logger.warn('test message')
32
- expect(consoleSpy).toHaveBeenCalled()
33
- const args = consoleSpy.mock.calls[0]
34
- expect(args.some((a: string) => a.includes('[WARN]') || a.includes('WARN'))).toBe(true)
35
- })
36
-
37
- test('error logs message', () => {
38
- logger.error('test message')
39
- expect(consoleSpy).toHaveBeenCalled()
40
- const args = consoleSpy.mock.calls[0]
41
- expect(args.some((a: string) => a.includes('[ERROR]') || a.includes('ERROR'))).toBe(true)
42
- })
43
-
44
- test('debug logs only when LOOPWORK_DEBUG is true', () => {
45
- const originalDebug = process.env.LOOPWORK_DEBUG
46
-
47
- // Should not log when LOOPWORK_DEBUG is not set
48
- process.env.LOOPWORK_DEBUG = ''
49
- logger.debug('test message')
50
- expect(consoleSpy).not.toHaveBeenCalled()
51
-
52
- // Should log when LOOPWORK_DEBUG is true
53
- process.env.LOOPWORK_DEBUG = 'true'
54
- logger.debug('test message')
55
- expect(consoleSpy).toHaveBeenCalled()
56
-
57
- process.env.LOOPWORK_DEBUG = originalDebug
58
- })
59
- })
60
- })
package/tsconfig.json DELETED
@@ -1,20 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "lib": ["ESNext"],
4
- "module": "esnext",
5
- "target": "esnext",
6
- "moduleResolution": "bundler",
7
- "moduleDetection": "force",
8
- "allowImportingTsExtensions": true,
9
- "noEmit": true,
10
- "composite": true,
11
- "strict": true,
12
- "downlevelIteration": true,
13
- "skipLibCheck": true,
14
- "jsx": "react-jsx",
15
- "allowSyntheticDefaultImports": true,
16
- "forceConsistentCasingInFileNames": true,
17
- "allowJs": true,
18
- "types": ["bun-types"]
19
- }
20
- }