specrails 0.2.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/.claude/skills/openspec-apply-change/SKILL.md +156 -0
- package/.claude/skills/openspec-archive-change/SKILL.md +114 -0
- package/.claude/skills/openspec-bulk-archive-change/SKILL.md +246 -0
- package/.claude/skills/openspec-continue-change/SKILL.md +118 -0
- package/.claude/skills/openspec-explore/SKILL.md +290 -0
- package/.claude/skills/openspec-ff-change/SKILL.md +101 -0
- package/.claude/skills/openspec-new-change/SKILL.md +74 -0
- package/.claude/skills/openspec-onboard/SKILL.md +529 -0
- package/.claude/skills/openspec-sync-specs/SKILL.md +138 -0
- package/.claude/skills/openspec-verify-change/SKILL.md +168 -0
- package/README.md +226 -0
- package/VERSION +1 -0
- package/bin/specrails.js +41 -0
- package/commands/setup.md +851 -0
- package/install.sh +488 -0
- package/package.json +34 -0
- package/prompts/analyze-codebase.md +87 -0
- package/prompts/generate-personas.md +61 -0
- package/prompts/infer-conventions.md +72 -0
- package/templates/agents/sr-architect.md +194 -0
- package/templates/agents/sr-backend-developer.md +54 -0
- package/templates/agents/sr-backend-reviewer.md +139 -0
- package/templates/agents/sr-developer.md +146 -0
- package/templates/agents/sr-doc-sync.md +167 -0
- package/templates/agents/sr-frontend-developer.md +48 -0
- package/templates/agents/sr-frontend-reviewer.md +132 -0
- package/templates/agents/sr-product-analyst.md +36 -0
- package/templates/agents/sr-product-manager.md +148 -0
- package/templates/agents/sr-reviewer.md +265 -0
- package/templates/agents/sr-security-reviewer.md +178 -0
- package/templates/agents/sr-test-writer.md +163 -0
- package/templates/claude-md/root.md +50 -0
- package/templates/commands/sr/batch-implement.md +282 -0
- package/templates/commands/sr/compat-check.md +271 -0
- package/templates/commands/sr/health-check.md +396 -0
- package/templates/commands/sr/implement.md +972 -0
- package/templates/commands/sr/product-backlog.md +195 -0
- package/templates/commands/sr/refactor-recommender.md +169 -0
- package/templates/commands/sr/update-product-driven-backlog.md +272 -0
- package/templates/commands/sr/why.md +96 -0
- package/templates/personas/persona.md +43 -0
- package/templates/personas/the-maintainer.md +78 -0
- package/templates/rules/layer.md +8 -0
- package/templates/security/security-exemptions.yaml +20 -0
- package/templates/settings/confidence-config.json +17 -0
- package/templates/settings/settings.json +15 -0
- package/templates/web-manager/README.md +107 -0
- package/templates/web-manager/client/index.html +12 -0
- package/templates/web-manager/client/package-lock.json +1727 -0
- package/templates/web-manager/client/package.json +20 -0
- package/templates/web-manager/client/src/App.tsx +83 -0
- package/templates/web-manager/client/src/components/AgentActivity.tsx +19 -0
- package/templates/web-manager/client/src/components/CommandInput.tsx +81 -0
- package/templates/web-manager/client/src/components/LogStream.tsx +57 -0
- package/templates/web-manager/client/src/components/PipelineSidebar.tsx +65 -0
- package/templates/web-manager/client/src/components/SearchBox.tsx +34 -0
- package/templates/web-manager/client/src/hooks/usePipeline.ts +62 -0
- package/templates/web-manager/client/src/hooks/useWebSocket.ts +59 -0
- package/templates/web-manager/client/src/main.tsx +9 -0
- package/templates/web-manager/client/tsconfig.json +21 -0
- package/templates/web-manager/client/tsconfig.node.json +11 -0
- package/templates/web-manager/client/vite.config.ts +13 -0
- package/templates/web-manager/package-lock.json +3327 -0
- package/templates/web-manager/package.json +30 -0
- package/templates/web-manager/server/hooks.test.ts +196 -0
- package/templates/web-manager/server/hooks.ts +71 -0
- package/templates/web-manager/server/index.test.ts +186 -0
- package/templates/web-manager/server/index.ts +99 -0
- package/templates/web-manager/server/spawner.test.ts +319 -0
- package/templates/web-manager/server/spawner.ts +89 -0
- package/templates/web-manager/server/types.ts +46 -0
- package/templates/web-manager/tsconfig.json +14 -0
- package/templates/web-manager/vitest.config.ts +8 -0
- package/update.sh +877 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
import { Readable } from 'stream'
|
|
4
|
+
|
|
5
|
+
// Mock child_process before importing spawner
|
|
6
|
+
vi.mock('child_process', () => ({
|
|
7
|
+
spawn: vi.fn(),
|
|
8
|
+
execSync: vi.fn(),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
// Mock uuid to return predictable IDs
|
|
12
|
+
vi.mock('uuid', () => ({
|
|
13
|
+
v4: vi.fn(() => 'test-uuid-1234'),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import { spawn as mockSpawn, execSync as mockExecSync } from 'child_process'
|
|
17
|
+
import type { WsMessage, LogMessage } from './types'
|
|
18
|
+
import { ClaudeNotFoundError, SpawnBusyError } from './types'
|
|
19
|
+
|
|
20
|
+
// We need to re-import spawner fresh for each describe block because it has module-level state.
|
|
21
|
+
// We'll use dynamic imports and vi.resetModules() for isolation.
|
|
22
|
+
|
|
23
|
+
function createMockChildProcess() {
|
|
24
|
+
const child = new EventEmitter() as any
|
|
25
|
+
child.stdout = new Readable({ read() {} })
|
|
26
|
+
child.stderr = new Readable({ read() {} })
|
|
27
|
+
child.pid = 12345
|
|
28
|
+
return child
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('spawner', () => {
|
|
32
|
+
let spawnClaude: typeof import('./spawner').spawnClaude
|
|
33
|
+
let isSpawnActive: typeof import('./spawner').isSpawnActive
|
|
34
|
+
let getLogBuffer: typeof import('./spawner').getLogBuffer
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
vi.resetModules()
|
|
38
|
+
|
|
39
|
+
// Re-mock after resetModules
|
|
40
|
+
vi.doMock('child_process', () => ({
|
|
41
|
+
spawn: vi.fn(),
|
|
42
|
+
execSync: vi.fn(),
|
|
43
|
+
}))
|
|
44
|
+
vi.doMock('uuid', () => ({
|
|
45
|
+
v4: vi.fn(() => 'test-uuid-1234'),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
const spawnerModule = await import('./spawner')
|
|
49
|
+
spawnClaude = spawnerModule.spawnClaude
|
|
50
|
+
isSpawnActive = spawnerModule.isSpawnActive
|
|
51
|
+
getLogBuffer = spawnerModule.getLogBuffer
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.restoreAllMocks()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('isSpawnActive', () => {
|
|
59
|
+
it('returns false when no process is running', () => {
|
|
60
|
+
expect(isSpawnActive()).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns true after spawning a process', async () => {
|
|
64
|
+
const { execSync, spawn } = await import('child_process')
|
|
65
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
66
|
+
const child = createMockChildProcess()
|
|
67
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
68
|
+
|
|
69
|
+
const broadcast = vi.fn()
|
|
70
|
+
const onReset = vi.fn()
|
|
71
|
+
spawnClaude('/test', broadcast, onReset)
|
|
72
|
+
|
|
73
|
+
expect(isSpawnActive()).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('returns false after process closes', async () => {
|
|
77
|
+
const { execSync, spawn } = await import('child_process')
|
|
78
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
79
|
+
const child = createMockChildProcess()
|
|
80
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
81
|
+
|
|
82
|
+
const broadcast = vi.fn()
|
|
83
|
+
const onReset = vi.fn()
|
|
84
|
+
spawnClaude('/test', broadcast, onReset)
|
|
85
|
+
expect(isSpawnActive()).toBe(true)
|
|
86
|
+
|
|
87
|
+
child.emit('close', 0)
|
|
88
|
+
expect(isSpawnActive()).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('spawnClaude', () => {
|
|
93
|
+
it('throws ClaudeNotFoundError when claude is not on PATH', async () => {
|
|
94
|
+
const { execSync } = await import('child_process')
|
|
95
|
+
vi.mocked(execSync).mockImplementation(() => {
|
|
96
|
+
throw new Error('not found')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const broadcast = vi.fn()
|
|
100
|
+
const onReset = vi.fn()
|
|
101
|
+
expect(() => spawnClaude('/test', broadcast, onReset)).toThrow('claude binary not found')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('throws SpawnBusyError when a process is already running', async () => {
|
|
105
|
+
const { execSync, spawn } = await import('child_process')
|
|
106
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
107
|
+
const child = createMockChildProcess()
|
|
108
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
109
|
+
|
|
110
|
+
const broadcast = vi.fn()
|
|
111
|
+
const onReset = vi.fn()
|
|
112
|
+
spawnClaude('/test', broadcast, onReset)
|
|
113
|
+
|
|
114
|
+
expect(() => spawnClaude('/test2', broadcast, onReset)).toThrow('A process is already running')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('allows spawning again after process exits', async () => {
|
|
118
|
+
const { execSync, spawn } = await import('child_process')
|
|
119
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
120
|
+
const child1 = createMockChildProcess()
|
|
121
|
+
const child2 = createMockChildProcess()
|
|
122
|
+
vi.mocked(spawn).mockReturnValueOnce(child1 as any).mockReturnValueOnce(child2 as any)
|
|
123
|
+
|
|
124
|
+
const broadcast = vi.fn()
|
|
125
|
+
const onReset = vi.fn()
|
|
126
|
+
spawnClaude('/test', broadcast, onReset)
|
|
127
|
+
child1.emit('close', 0)
|
|
128
|
+
|
|
129
|
+
// Should not throw
|
|
130
|
+
expect(() => spawnClaude('/test2', broadcast, onReset)).not.toThrow()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('calls onResetPhases before spawning', async () => {
|
|
134
|
+
const { execSync, spawn } = await import('child_process')
|
|
135
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
136
|
+
const child = createMockChildProcess()
|
|
137
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
138
|
+
|
|
139
|
+
const broadcast = vi.fn()
|
|
140
|
+
const onReset = vi.fn()
|
|
141
|
+
spawnClaude('/test', broadcast, onReset)
|
|
142
|
+
|
|
143
|
+
expect(onReset).toHaveBeenCalledOnce()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('returns a SpawnHandle with processId, command, and startedAt', async () => {
|
|
147
|
+
const { execSync, spawn } = await import('child_process')
|
|
148
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
149
|
+
const child = createMockChildProcess()
|
|
150
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
151
|
+
|
|
152
|
+
const broadcast = vi.fn()
|
|
153
|
+
const onReset = vi.fn()
|
|
154
|
+
const handle = spawnClaude('/implement #42', broadcast, onReset)
|
|
155
|
+
|
|
156
|
+
expect(handle.processId).toBe('test-uuid-1234')
|
|
157
|
+
expect(handle.command).toBe('/implement #42')
|
|
158
|
+
expect(handle.startedAt).toBeDefined()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('spawns claude with --dangerously-skip-permissions and split args', async () => {
|
|
162
|
+
const { execSync, spawn } = await import('child_process')
|
|
163
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
164
|
+
const child = createMockChildProcess()
|
|
165
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
166
|
+
|
|
167
|
+
const broadcast = vi.fn()
|
|
168
|
+
const onReset = vi.fn()
|
|
169
|
+
spawnClaude('/implement #42', broadcast, onReset)
|
|
170
|
+
|
|
171
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
172
|
+
'claude',
|
|
173
|
+
['--dangerously-skip-permissions', '/implement', '#42'],
|
|
174
|
+
{ env: process.env, shell: false }
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('broadcasts stdout lines as log messages', async () => {
|
|
179
|
+
const { execSync, spawn } = await import('child_process')
|
|
180
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
181
|
+
const child = createMockChildProcess()
|
|
182
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
183
|
+
|
|
184
|
+
const broadcast = vi.fn()
|
|
185
|
+
const onReset = vi.fn()
|
|
186
|
+
spawnClaude('/test', broadcast, onReset)
|
|
187
|
+
|
|
188
|
+
// Push data to stdout to trigger readline
|
|
189
|
+
child.stdout.push('hello world\n')
|
|
190
|
+
child.stdout.push(null)
|
|
191
|
+
|
|
192
|
+
// readline is async, give it a tick
|
|
193
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
194
|
+
|
|
195
|
+
const logCalls = broadcast.mock.calls.filter(
|
|
196
|
+
(args: unknown[]) => (args[0] as WsMessage).type === 'log' && ((args[0] as LogMessage).line === 'hello world')
|
|
197
|
+
)
|
|
198
|
+
expect(logCalls.length).toBe(1)
|
|
199
|
+
expect(logCalls[0][0]).toMatchObject({
|
|
200
|
+
type: 'log',
|
|
201
|
+
source: 'stdout',
|
|
202
|
+
line: 'hello world',
|
|
203
|
+
processId: 'test-uuid-1234',
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('broadcasts stderr lines as log messages', async () => {
|
|
208
|
+
const { execSync, spawn } = await import('child_process')
|
|
209
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
210
|
+
const child = createMockChildProcess()
|
|
211
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
212
|
+
|
|
213
|
+
const broadcast = vi.fn()
|
|
214
|
+
const onReset = vi.fn()
|
|
215
|
+
spawnClaude('/test', broadcast, onReset)
|
|
216
|
+
|
|
217
|
+
child.stderr.push('error output\n')
|
|
218
|
+
child.stderr.push(null)
|
|
219
|
+
|
|
220
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
221
|
+
|
|
222
|
+
const logCalls = broadcast.mock.calls.filter(
|
|
223
|
+
(args: unknown[]) => (args[0] as WsMessage).type === 'log' && ((args[0] as LogMessage).line === 'error output')
|
|
224
|
+
)
|
|
225
|
+
expect(logCalls.length).toBe(1)
|
|
226
|
+
expect(logCalls[0][0]).toMatchObject({
|
|
227
|
+
type: 'log',
|
|
228
|
+
source: 'stderr',
|
|
229
|
+
line: 'error output',
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('broadcasts exit message on close', async () => {
|
|
234
|
+
const { execSync, spawn } = await import('child_process')
|
|
235
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
236
|
+
const child = createMockChildProcess()
|
|
237
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
238
|
+
|
|
239
|
+
const broadcast = vi.fn()
|
|
240
|
+
const onReset = vi.fn()
|
|
241
|
+
spawnClaude('/test', broadcast, onReset)
|
|
242
|
+
|
|
243
|
+
child.emit('close', 0)
|
|
244
|
+
|
|
245
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
246
|
+
|
|
247
|
+
const exitCalls = broadcast.mock.calls.filter(
|
|
248
|
+
(args: unknown[]) =>
|
|
249
|
+
(args[0] as WsMessage).type === 'log' && ((args[0] as LogMessage).line.includes('[process exited with code 0]'))
|
|
250
|
+
)
|
|
251
|
+
expect(exitCalls.length).toBe(1)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('getLogBuffer', () => {
|
|
256
|
+
it('returns empty array initially', () => {
|
|
257
|
+
expect(getLogBuffer()).toEqual([])
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('returns a copy, not a reference', () => {
|
|
261
|
+
const buf = getLogBuffer()
|
|
262
|
+
buf.push({} as any)
|
|
263
|
+
expect(getLogBuffer()).toEqual([])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('contains log messages after spawn emits lines', async () => {
|
|
267
|
+
const { execSync, spawn } = await import('child_process')
|
|
268
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
269
|
+
const child = createMockChildProcess()
|
|
270
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
271
|
+
|
|
272
|
+
const broadcast = vi.fn()
|
|
273
|
+
const onReset = vi.fn()
|
|
274
|
+
spawnClaude('/test', broadcast, onReset)
|
|
275
|
+
|
|
276
|
+
child.stdout.push('line1\nline2\n')
|
|
277
|
+
child.stdout.push(null)
|
|
278
|
+
|
|
279
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
280
|
+
|
|
281
|
+
const buf = getLogBuffer()
|
|
282
|
+
expect(buf.length).toBe(2)
|
|
283
|
+
expect(buf[0].line).toBe('line1')
|
|
284
|
+
expect(buf[1].line).toBe('line2')
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('circular buffer', () => {
|
|
289
|
+
it('drops oldest entries when exceeding max size', async () => {
|
|
290
|
+
const { execSync, spawn } = await import('child_process')
|
|
291
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
292
|
+
const child = createMockChildProcess()
|
|
293
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
294
|
+
|
|
295
|
+
const broadcast = vi.fn()
|
|
296
|
+
const onReset = vi.fn()
|
|
297
|
+
spawnClaude('/test', broadcast, onReset)
|
|
298
|
+
|
|
299
|
+
// Push 5001 lines to exceed LOG_BUFFER_MAX (5000)
|
|
300
|
+
const lines: string[] = []
|
|
301
|
+
for (let i = 0; i < 5001; i++) {
|
|
302
|
+
lines.push(`line-${i}`)
|
|
303
|
+
}
|
|
304
|
+
child.stdout.push(lines.join('\n') + '\n')
|
|
305
|
+
child.stdout.push(null)
|
|
306
|
+
|
|
307
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
308
|
+
|
|
309
|
+
const buf = getLogBuffer()
|
|
310
|
+
// After 5001 entries, buffer should have dropped first 1000
|
|
311
|
+
// So we should have 5001 - 1000 = 4001 entries
|
|
312
|
+
expect(buf.length).toBe(4001)
|
|
313
|
+
// First entry should be line-1000 (0-999 were dropped)
|
|
314
|
+
expect(buf[0].line).toBe('line-1000')
|
|
315
|
+
// Last entry should be line-5000
|
|
316
|
+
expect(buf[buf.length - 1].line).toBe('line-5000')
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { spawn, execSync, ChildProcess } from 'child_process'
|
|
2
|
+
import { createInterface } from 'readline'
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
4
|
+
import type { WsMessage, LogMessage, SpawnHandle } from './types'
|
|
5
|
+
import { ClaudeNotFoundError, SpawnBusyError } from './types'
|
|
6
|
+
|
|
7
|
+
// Circular log buffer
|
|
8
|
+
const LOG_BUFFER_MAX = 5000
|
|
9
|
+
const LOG_BUFFER_DROP = 1000
|
|
10
|
+
const logBuffer: LogMessage[] = []
|
|
11
|
+
|
|
12
|
+
let activeProcess: ChildProcess | null = null
|
|
13
|
+
|
|
14
|
+
function appendToBuffer(msg: LogMessage): void {
|
|
15
|
+
logBuffer.push(msg)
|
|
16
|
+
if (logBuffer.length > LOG_BUFFER_MAX) {
|
|
17
|
+
logBuffer.splice(0, LOG_BUFFER_DROP)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function claudeOnPath(): boolean {
|
|
22
|
+
try {
|
|
23
|
+
execSync('which claude', { stdio: 'ignore' })
|
|
24
|
+
return true
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isSpawnActive(): boolean {
|
|
31
|
+
return activeProcess !== null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getLogBuffer(): LogMessage[] {
|
|
35
|
+
return [...logBuffer]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function spawnClaude(
|
|
39
|
+
command: string,
|
|
40
|
+
broadcast: (msg: WsMessage) => void,
|
|
41
|
+
onResetPhases: () => void
|
|
42
|
+
): SpawnHandle {
|
|
43
|
+
if (!claudeOnPath()) {
|
|
44
|
+
throw new ClaudeNotFoundError()
|
|
45
|
+
}
|
|
46
|
+
if (activeProcess !== null) {
|
|
47
|
+
throw new SpawnBusyError()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
onResetPhases()
|
|
51
|
+
|
|
52
|
+
const processId = uuidv4()
|
|
53
|
+
const startedAt = new Date().toISOString()
|
|
54
|
+
|
|
55
|
+
// Split command string into args — simple space split is sufficient for
|
|
56
|
+
// slash commands like /implement #42 for MVP
|
|
57
|
+
const args = ['--dangerously-skip-permissions', ...command.trim().split(/\s+/)]
|
|
58
|
+
const child = spawn('claude', args, {
|
|
59
|
+
env: process.env,
|
|
60
|
+
shell: false,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
activeProcess = child
|
|
64
|
+
|
|
65
|
+
function emitLine(source: 'stdout' | 'stderr', line: string): void {
|
|
66
|
+
const msg: LogMessage = {
|
|
67
|
+
type: 'log',
|
|
68
|
+
source,
|
|
69
|
+
line,
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
processId,
|
|
72
|
+
}
|
|
73
|
+
appendToBuffer(msg)
|
|
74
|
+
broadcast(msg)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
|
|
78
|
+
const stderrReader = createInterface({ input: child.stderr!, crlfDelay: Infinity })
|
|
79
|
+
|
|
80
|
+
stdoutReader.on('line', (line) => emitLine('stdout', line))
|
|
81
|
+
stderrReader.on('line', (line) => emitLine('stderr', line))
|
|
82
|
+
|
|
83
|
+
child.on('close', (code) => {
|
|
84
|
+
emitLine('stdout', `[process exited with code ${code ?? 'unknown'}]`)
|
|
85
|
+
activeProcess = null
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return { processId, command, startedAt }
|
|
89
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type PhaseName = 'architect' | 'developer' | 'reviewer' | 'ship'
|
|
2
|
+
export type PhaseState = 'idle' | 'running' | 'done' | 'error'
|
|
3
|
+
|
|
4
|
+
export interface LogMessage {
|
|
5
|
+
type: 'log'
|
|
6
|
+
source: 'stdout' | 'stderr'
|
|
7
|
+
line: string
|
|
8
|
+
timestamp: string
|
|
9
|
+
processId: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PhaseMessage {
|
|
13
|
+
type: 'phase'
|
|
14
|
+
phase: PhaseName
|
|
15
|
+
state: PhaseState
|
|
16
|
+
timestamp: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface InitMessage {
|
|
20
|
+
type: 'init'
|
|
21
|
+
projectName: string
|
|
22
|
+
phases: Record<PhaseName, PhaseState>
|
|
23
|
+
logBuffer: LogMessage[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type WsMessage = LogMessage | PhaseMessage | InitMessage
|
|
27
|
+
|
|
28
|
+
export interface SpawnHandle {
|
|
29
|
+
processId: string
|
|
30
|
+
command: string
|
|
31
|
+
startedAt: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ClaudeNotFoundError extends Error {
|
|
35
|
+
constructor() {
|
|
36
|
+
super('claude binary not found')
|
|
37
|
+
this.name = 'ClaudeNotFoundError'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class SpawnBusyError extends Error {
|
|
42
|
+
constructor() {
|
|
43
|
+
super('A process is already running')
|
|
44
|
+
this.name = 'SpawnBusyError'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"outDir": "dist/server",
|
|
9
|
+
"rootDir": "server",
|
|
10
|
+
"skipLibCheck": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["server/**/*.ts"],
|
|
13
|
+
"exclude": ["node_modules", "client"]
|
|
14
|
+
}
|