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,154 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import type { Config } from './config'
4
+
5
+ /**
6
+ * State Manager for loopwork
7
+ *
8
+ * Note: Primary state (task status) lives in GitHub Issues.
9
+ * This manages local session state and locking only.
10
+ */
11
+ export class StateManager {
12
+ private stateFile: string
13
+ private lockFile: string
14
+ private namespace: string
15
+
16
+ constructor(private config: Config) {
17
+ this.namespace = config.namespace || 'default'
18
+ // Use namespace in file paths to allow concurrent loops
19
+ const suffix = this.namespace === 'default' ? '' : `-${this.namespace}`
20
+ this.stateFile = path.join(config.projectRoot, `.loopwork-state${suffix}`)
21
+ this.lockFile = path.join(config.projectRoot, `.loopwork${suffix}.lock`)
22
+ }
23
+
24
+ getNamespace(): string {
25
+ return this.namespace
26
+ }
27
+
28
+ getLockFile(): string {
29
+ return this.lockFile
30
+ }
31
+
32
+ getStateFile(): string {
33
+ return this.stateFile
34
+ }
35
+
36
+ /**
37
+ * Acquire exclusive lock to prevent multiple instances
38
+ */
39
+ acquireLock(retryCount = 0): boolean {
40
+ if (retryCount > 3) {
41
+ console.error('Failed to acquire lock after multiple attempts')
42
+ return false
43
+ }
44
+
45
+ try {
46
+ fs.mkdirSync(this.lockFile)
47
+ fs.writeFileSync(path.join(this.lockFile, 'pid'), process.pid.toString())
48
+ return true
49
+ } catch (error) {
50
+ // Check if lock is stale
51
+ try {
52
+ const pidFile = path.join(this.lockFile, 'pid')
53
+ if (fs.existsSync(pidFile)) {
54
+ const pid = fs.readFileSync(pidFile, 'utf-8')
55
+ try {
56
+ process.kill(parseInt(pid, 10), 0)
57
+ console.error(`Another loopwork is running (PID: ${pid})`)
58
+ return false
59
+ } catch (e) {
60
+ console.warn(`Stale lock found (process ${pid} not running), removing...`)
61
+ fs.rmSync(this.lockFile, { recursive: true, force: true })
62
+ return this.acquireLock(retryCount + 1)
63
+ }
64
+ }
65
+ } catch (e) {
66
+ console.error('Failed to acquire lock (unknown reason)')
67
+ return false
68
+ }
69
+ }
70
+ return false
71
+ }
72
+
73
+ /**
74
+ * Release the lock
75
+ */
76
+ releaseLock(): void {
77
+ try {
78
+ if (fs.existsSync(this.lockFile)) {
79
+ fs.rmSync(this.lockFile, { recursive: true, force: true })
80
+ }
81
+ } catch (e) {
82
+ console.error('Failed to release lock')
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Save current session state
88
+ */
89
+ saveState(currentIssue: number, iteration: number): void {
90
+ const content = [
91
+ `NAMESPACE=${this.namespace}`,
92
+ `LAST_ISSUE=${currentIssue}`,
93
+ `LAST_ITERATION=${iteration}`,
94
+ `LAST_OUTPUT_DIR=${this.config.outputDir}`,
95
+ `SESSION_ID=${this.config.sessionId}`,
96
+ `SAVED_AT=${new Date().toISOString()}`,
97
+ ].join('\n')
98
+
99
+ try {
100
+ fs.writeFileSync(this.stateFile, content, { mode: 0o600 })
101
+ if (this.config.debug) {
102
+ console.log(`State saved: issue=#${currentIssue}, iteration=${iteration}`)
103
+ }
104
+ } catch (e) {
105
+ console.error('Failed to save state')
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Load previous session state
111
+ */
112
+ loadState(): { lastIssue: number; lastIteration: number; lastOutputDir: string } | null {
113
+ if (!fs.existsSync(this.stateFile)) {
114
+ return null
115
+ }
116
+
117
+ try {
118
+ const content = fs.readFileSync(this.stateFile, 'utf-8')
119
+ const state: Record<string, string> = {}
120
+
121
+ content.split('\n').forEach((line) => {
122
+ const idx = line.indexOf('=')
123
+ if (idx !== -1) {
124
+ const key = line.substring(0, idx)
125
+ const value = line.substring(idx + 1)
126
+ if (key && value) state[key] = value
127
+ }
128
+ })
129
+
130
+ if (!state.LAST_ISSUE) return null
131
+
132
+ return {
133
+ lastIssue: parseInt(state.LAST_ISSUE, 10),
134
+ lastIteration: parseInt(state.LAST_ITERATION || '0', 10),
135
+ lastOutputDir: state.LAST_OUTPUT_DIR || '',
136
+ }
137
+ } catch (e) {
138
+ console.error('Failed to load state')
139
+ return null
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Clear saved state
145
+ */
146
+ clearState(): void {
147
+ if (fs.existsSync(this.stateFile)) {
148
+ fs.unlinkSync(this.stateFile)
149
+ if (this.config.debug) {
150
+ console.log('State cleared')
151
+ }
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,125 @@
1
+ import chalk from 'chalk'
2
+
3
+ export function getTimestamp(): string {
4
+ return new Date().toLocaleTimeString('en-US', {
5
+ hour: 'numeric',
6
+ minute: '2-digit',
7
+ second: '2-digit',
8
+ hour12: true,
9
+ })
10
+ }
11
+
12
+ export const logger = {
13
+ info: (msg: string) => {
14
+ process.stdout.write('\r\x1b[K')
15
+ console.log(chalk.gray(getTimestamp()), chalk.blue('[INFO]'), msg)
16
+ },
17
+ success: (msg: string) => {
18
+ process.stdout.write('\r\x1b[K')
19
+ console.log(chalk.gray(getTimestamp()), chalk.green('[SUCCESS]'), msg)
20
+ },
21
+ warn: (msg: string) => {
22
+ process.stdout.write('\r\x1b[K')
23
+ console.log(chalk.gray(getTimestamp()), chalk.yellow('[WARN]'), msg)
24
+ },
25
+ error: (msg: string) => {
26
+ process.stdout.write('\r\x1b[K')
27
+ console.log(chalk.gray(getTimestamp()), chalk.red('[ERROR]'), msg)
28
+ },
29
+ debug: (msg: string) => {
30
+ if (process.env.LOOPWORK_DEBUG === 'true') {
31
+ process.stdout.write('\r\x1b[K')
32
+ console.log(chalk.gray(getTimestamp()), chalk.cyan('[DEBUG]'), msg)
33
+ }
34
+ },
35
+ update: (msg: string) => {
36
+ process.stdout.write(
37
+ `\r\x1b[K${chalk.gray(getTimestamp())} ${chalk.blue('[INFO]')} ${msg}`
38
+ )
39
+ },
40
+ }
41
+
42
+ export async function promptUser(
43
+ question: string,
44
+ defaultValue: string = 'n',
45
+ nonInteractive: boolean = false
46
+ ): Promise<string> {
47
+ if (nonInteractive || !process.stdin.isTTY) {
48
+ logger.debug(`Non-interactive mode, using default: ${defaultValue}`)
49
+ return defaultValue
50
+ }
51
+
52
+ process.stdout.write(question)
53
+ process.stdin.setRawMode(true)
54
+
55
+ return new Promise<string>((resolve) => {
56
+ process.stdin.resume()
57
+
58
+ const cleanup = () => {
59
+ try {
60
+ process.stdin.setRawMode(false)
61
+ } catch {
62
+ // stdin may already be closed
63
+ }
64
+ process.stdin.pause()
65
+ process.stdin.removeListener('data', onData)
66
+ process.stdin.removeListener('error', onError)
67
+ }
68
+
69
+ const onData = (data: Buffer) => {
70
+ const char = data.toString('utf8')
71
+
72
+ // Handle Ctrl+C
73
+ if (char === '\u0003') {
74
+ cleanup()
75
+ process.stdout.write('\n')
76
+ logger.info('Interrupted by user (Ctrl+C)')
77
+ process.exit(130)
78
+ }
79
+
80
+ cleanup()
81
+ process.stdout.write('\n')
82
+ if (char === '\r' || char === '\n') {
83
+ resolve(defaultValue)
84
+ } else {
85
+ resolve(char.trim())
86
+ }
87
+ }
88
+
89
+ const onError = (err: Error) => {
90
+ cleanup()
91
+ logger.debug(`stdin error: ${err.message}`)
92
+ resolve(defaultValue)
93
+ }
94
+
95
+ process.stdin.once('error', onError)
96
+ })
97
+ }
98
+
99
+ export class StreamLogger {
100
+ private buffer: string = ''
101
+
102
+ log(chunk: string | Buffer) {
103
+ this.buffer += chunk.toString('utf8')
104
+ const lines = this.buffer.split('\n')
105
+
106
+ // The last element is either an empty string (if buffer ended with \n)
107
+ // or a partial line. Keep it in the buffer.
108
+ this.buffer = lines.pop() || ''
109
+
110
+ for (const line of lines) {
111
+ this.printLine(line)
112
+ }
113
+ }
114
+
115
+ private printLine(line: string) {
116
+ process.stdout.write(`${chalk.gray(getTimestamp())} ${line}\n`)
117
+ }
118
+
119
+ flush() {
120
+ if (this.buffer) {
121
+ this.printLine(this.buffer)
122
+ this.buffer = ''
123
+ }
124
+ }
125
+ }