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.
Files changed (74) hide show
  1. package/.claude/skills/openspec-apply-change/SKILL.md +156 -0
  2. package/.claude/skills/openspec-archive-change/SKILL.md +114 -0
  3. package/.claude/skills/openspec-bulk-archive-change/SKILL.md +246 -0
  4. package/.claude/skills/openspec-continue-change/SKILL.md +118 -0
  5. package/.claude/skills/openspec-explore/SKILL.md +290 -0
  6. package/.claude/skills/openspec-ff-change/SKILL.md +101 -0
  7. package/.claude/skills/openspec-new-change/SKILL.md +74 -0
  8. package/.claude/skills/openspec-onboard/SKILL.md +529 -0
  9. package/.claude/skills/openspec-sync-specs/SKILL.md +138 -0
  10. package/.claude/skills/openspec-verify-change/SKILL.md +168 -0
  11. package/README.md +226 -0
  12. package/VERSION +1 -0
  13. package/bin/specrails.js +41 -0
  14. package/commands/setup.md +851 -0
  15. package/install.sh +488 -0
  16. package/package.json +34 -0
  17. package/prompts/analyze-codebase.md +87 -0
  18. package/prompts/generate-personas.md +61 -0
  19. package/prompts/infer-conventions.md +72 -0
  20. package/templates/agents/sr-architect.md +194 -0
  21. package/templates/agents/sr-backend-developer.md +54 -0
  22. package/templates/agents/sr-backend-reviewer.md +139 -0
  23. package/templates/agents/sr-developer.md +146 -0
  24. package/templates/agents/sr-doc-sync.md +167 -0
  25. package/templates/agents/sr-frontend-developer.md +48 -0
  26. package/templates/agents/sr-frontend-reviewer.md +132 -0
  27. package/templates/agents/sr-product-analyst.md +36 -0
  28. package/templates/agents/sr-product-manager.md +148 -0
  29. package/templates/agents/sr-reviewer.md +265 -0
  30. package/templates/agents/sr-security-reviewer.md +178 -0
  31. package/templates/agents/sr-test-writer.md +163 -0
  32. package/templates/claude-md/root.md +50 -0
  33. package/templates/commands/sr/batch-implement.md +282 -0
  34. package/templates/commands/sr/compat-check.md +271 -0
  35. package/templates/commands/sr/health-check.md +396 -0
  36. package/templates/commands/sr/implement.md +972 -0
  37. package/templates/commands/sr/product-backlog.md +195 -0
  38. package/templates/commands/sr/refactor-recommender.md +169 -0
  39. package/templates/commands/sr/update-product-driven-backlog.md +272 -0
  40. package/templates/commands/sr/why.md +96 -0
  41. package/templates/personas/persona.md +43 -0
  42. package/templates/personas/the-maintainer.md +78 -0
  43. package/templates/rules/layer.md +8 -0
  44. package/templates/security/security-exemptions.yaml +20 -0
  45. package/templates/settings/confidence-config.json +17 -0
  46. package/templates/settings/settings.json +15 -0
  47. package/templates/web-manager/README.md +107 -0
  48. package/templates/web-manager/client/index.html +12 -0
  49. package/templates/web-manager/client/package-lock.json +1727 -0
  50. package/templates/web-manager/client/package.json +20 -0
  51. package/templates/web-manager/client/src/App.tsx +83 -0
  52. package/templates/web-manager/client/src/components/AgentActivity.tsx +19 -0
  53. package/templates/web-manager/client/src/components/CommandInput.tsx +81 -0
  54. package/templates/web-manager/client/src/components/LogStream.tsx +57 -0
  55. package/templates/web-manager/client/src/components/PipelineSidebar.tsx +65 -0
  56. package/templates/web-manager/client/src/components/SearchBox.tsx +34 -0
  57. package/templates/web-manager/client/src/hooks/usePipeline.ts +62 -0
  58. package/templates/web-manager/client/src/hooks/useWebSocket.ts +59 -0
  59. package/templates/web-manager/client/src/main.tsx +9 -0
  60. package/templates/web-manager/client/tsconfig.json +21 -0
  61. package/templates/web-manager/client/tsconfig.node.json +11 -0
  62. package/templates/web-manager/client/vite.config.ts +13 -0
  63. package/templates/web-manager/package-lock.json +3327 -0
  64. package/templates/web-manager/package.json +30 -0
  65. package/templates/web-manager/server/hooks.test.ts +196 -0
  66. package/templates/web-manager/server/hooks.ts +71 -0
  67. package/templates/web-manager/server/index.test.ts +186 -0
  68. package/templates/web-manager/server/index.ts +99 -0
  69. package/templates/web-manager/server/spawner.test.ts +319 -0
  70. package/templates/web-manager/server/spawner.ts +89 -0
  71. package/templates/web-manager/server/types.ts +46 -0
  72. package/templates/web-manager/tsconfig.json +14 -0
  73. package/templates/web-manager/vitest.config.ts +8 -0
  74. package/update.sh +877 -0
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "specrails-web-manager",
3
+ "private": true,
4
+ "scripts": {
5
+ "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
6
+ "dev:server": "tsx watch server/index.ts",
7
+ "dev:client": "cd client && npm run dev",
8
+ "build": "cd client && npm run build",
9
+ "typecheck": "tsc --noEmit && cd client && tsc --noEmit",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest"
12
+ },
13
+ "dependencies": {
14
+ "express": "^4.18.0",
15
+ "ws": "^8.16.0",
16
+ "uuid": "^9.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/express": "^4.17.0",
20
+ "@types/ws": "^8.5.0",
21
+ "@types/uuid": "^9.0.0",
22
+ "@types/node": "^20.0.0",
23
+ "concurrently": "^8.2.0",
24
+ "tsx": "^4.7.0",
25
+ "typescript": "^5.4.0",
26
+ "vitest": "^3.0.0",
27
+ "supertest": "^6.3.0",
28
+ "@types/supertest": "^2.0.0"
29
+ }
30
+ }
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import express from 'express'
3
+ import { createHooksRouter, getPhaseStates, resetPhases } from './hooks'
4
+ import type { WsMessage, PhaseName, PhaseState } from './types'
5
+
6
+ // The hooks module uses module-level state, so we need a fresh import for isolation.
7
+ // Since we can't easily re-import, we'll use resetPhases to clean up between tests.
8
+
9
+ function createApp(broadcast: (msg: WsMessage) => void) {
10
+ const app = express()
11
+ app.use(express.json())
12
+ app.use('/hooks', createHooksRouter(broadcast))
13
+ return app
14
+ }
15
+
16
+ describe('getPhaseStates', () => {
17
+ let broadcast: ReturnType<typeof vi.fn>
18
+
19
+ beforeEach(() => {
20
+ broadcast = vi.fn()
21
+ // Reset all phases to idle before each test
22
+ resetPhases(broadcast)
23
+ broadcast.mockClear()
24
+ })
25
+
26
+ it('returns all phases as idle initially', () => {
27
+ const states = getPhaseStates()
28
+ expect(states).toEqual({
29
+ architect: 'idle',
30
+ developer: 'idle',
31
+ reviewer: 'idle',
32
+ ship: 'idle',
33
+ })
34
+ })
35
+
36
+ it('returns a copy, not a reference', () => {
37
+ const states = getPhaseStates()
38
+ states.architect = 'running'
39
+ expect(getPhaseStates().architect).toBe('idle')
40
+ })
41
+ })
42
+
43
+ describe('resetPhases', () => {
44
+ it('broadcasts a phase message for each phase', () => {
45
+ const broadcast = vi.fn()
46
+ resetPhases(broadcast)
47
+
48
+ expect(broadcast).toHaveBeenCalledTimes(4)
49
+ const phases: PhaseName[] = ['architect', 'developer', 'reviewer', 'ship']
50
+ for (const phase of phases) {
51
+ expect(broadcast).toHaveBeenCalledWith(
52
+ expect.objectContaining({
53
+ type: 'phase',
54
+ phase,
55
+ state: 'idle',
56
+ timestamp: expect.any(String),
57
+ })
58
+ )
59
+ }
60
+ })
61
+
62
+ it('sets all phases back to idle', async () => {
63
+ const broadcast = vi.fn()
64
+ // First, transition a phase to running via the router
65
+ const app = createApp(broadcast)
66
+ const { default: request } = await import('supertest')
67
+ await request(app)
68
+ .post('/hooks/events')
69
+ .send({ event: 'agent_start', agent: 'architect' })
70
+
71
+ expect(getPhaseStates().architect).toBe('running')
72
+
73
+ resetPhases(broadcast)
74
+ expect(getPhaseStates().architect).toBe('idle')
75
+ })
76
+ })
77
+
78
+ describe('POST /hooks/events', () => {
79
+ let broadcast: ReturnType<typeof vi.fn>
80
+ let app: express.Express
81
+ let request: any
82
+
83
+ beforeEach(async () => {
84
+ broadcast = vi.fn()
85
+ resetPhases(broadcast)
86
+ broadcast.mockClear()
87
+ app = createApp(broadcast)
88
+ const mod = await import('supertest')
89
+ request = mod.default
90
+ })
91
+
92
+ it('transitions phase to running on agent_start', async () => {
93
+ const res = await request(app)
94
+ .post('/hooks/events')
95
+ .send({ event: 'agent_start', agent: 'architect' })
96
+
97
+ expect(res.status).toBe(200)
98
+ expect(res.body).toEqual({ ok: true })
99
+ expect(getPhaseStates().architect).toBe('running')
100
+ expect(broadcast).toHaveBeenCalledWith(
101
+ expect.objectContaining({
102
+ type: 'phase',
103
+ phase: 'architect',
104
+ state: 'running',
105
+ })
106
+ )
107
+ })
108
+
109
+ it('transitions phase to done on agent_stop', async () => {
110
+ await request(app)
111
+ .post('/hooks/events')
112
+ .send({ event: 'agent_start', agent: 'developer' })
113
+ broadcast.mockClear()
114
+
115
+ const res = await request(app)
116
+ .post('/hooks/events')
117
+ .send({ event: 'agent_stop', agent: 'developer' })
118
+
119
+ expect(res.status).toBe(200)
120
+ expect(getPhaseStates().developer).toBe('done')
121
+ })
122
+
123
+ it('transitions phase to error on agent_error', async () => {
124
+ const res = await request(app)
125
+ .post('/hooks/events')
126
+ .send({ event: 'agent_error', agent: 'reviewer' })
127
+
128
+ expect(res.status).toBe(200)
129
+ expect(getPhaseStates().reviewer).toBe('error')
130
+ })
131
+
132
+ it('ignores unknown agent names gracefully', async () => {
133
+ const res = await request(app)
134
+ .post('/hooks/events')
135
+ .send({ event: 'agent_start', agent: 'unknown_agent' })
136
+
137
+ expect(res.status).toBe(200)
138
+ expect(res.body).toEqual({ ok: true })
139
+ expect(broadcast).not.toHaveBeenCalled()
140
+ })
141
+
142
+ it('ignores unknown event types gracefully', async () => {
143
+ const res = await request(app)
144
+ .post('/hooks/events')
145
+ .send({ event: 'unknown_event', agent: 'architect' })
146
+
147
+ expect(res.status).toBe(200)
148
+ expect(res.body).toEqual({ ok: true })
149
+ expect(broadcast).not.toHaveBeenCalled()
150
+ })
151
+
152
+ it('handles missing body gracefully', async () => {
153
+ const res = await request(app)
154
+ .post('/hooks/events')
155
+ .send({})
156
+
157
+ expect(res.status).toBe(200)
158
+ expect(res.body).toEqual({ ok: true })
159
+ })
160
+
161
+ it('handles all four phases', async () => {
162
+ const phases: PhaseName[] = ['architect', 'developer', 'reviewer', 'ship']
163
+ for (const phase of phases) {
164
+ await request(app)
165
+ .post('/hooks/events')
166
+ .send({ event: 'agent_start', agent: phase })
167
+ expect(getPhaseStates()[phase]).toBe('running')
168
+ }
169
+ })
170
+
171
+ it('can transition through full lifecycle: idle -> running -> done', async () => {
172
+ expect(getPhaseStates().ship).toBe('idle')
173
+
174
+ await request(app)
175
+ .post('/hooks/events')
176
+ .send({ event: 'agent_start', agent: 'ship' })
177
+ expect(getPhaseStates().ship).toBe('running')
178
+
179
+ await request(app)
180
+ .post('/hooks/events')
181
+ .send({ event: 'agent_stop', agent: 'ship' })
182
+ expect(getPhaseStates().ship).toBe('done')
183
+ })
184
+
185
+ it('can transition from running to error', async () => {
186
+ await request(app)
187
+ .post('/hooks/events')
188
+ .send({ event: 'agent_start', agent: 'architect' })
189
+ expect(getPhaseStates().architect).toBe('running')
190
+
191
+ await request(app)
192
+ .post('/hooks/events')
193
+ .send({ event: 'agent_error', agent: 'architect' })
194
+ expect(getPhaseStates().architect).toBe('error')
195
+ })
196
+ })
@@ -0,0 +1,71 @@
1
+ import { Router, Request, Response } from 'express'
2
+ import type { PhaseName, PhaseState, WsMessage } from './types'
3
+
4
+ const PHASE_NAMES: PhaseName[] = ['architect', 'developer', 'reviewer', 'ship']
5
+
6
+ const phases: Record<PhaseName, PhaseState> = {
7
+ architect: 'idle',
8
+ developer: 'idle',
9
+ reviewer: 'idle',
10
+ ship: 'idle',
11
+ }
12
+
13
+ function isValidPhase(value: string): value is PhaseName {
14
+ return PHASE_NAMES.includes(value as PhaseName)
15
+ }
16
+
17
+ function eventToState(event: string): PhaseState | null {
18
+ if (event === 'agent_start') return 'running'
19
+ if (event === 'agent_stop') return 'done'
20
+ if (event === 'agent_error') return 'error'
21
+ return null
22
+ }
23
+
24
+ export function getPhaseStates(): Record<PhaseName, PhaseState> {
25
+ return { ...phases }
26
+ }
27
+
28
+ export function resetPhases(broadcast: (msg: WsMessage) => void): void {
29
+ for (const phase of PHASE_NAMES) {
30
+ phases[phase] = 'idle'
31
+ broadcast({
32
+ type: 'phase',
33
+ phase,
34
+ state: 'idle',
35
+ timestamp: new Date().toISOString(),
36
+ })
37
+ }
38
+ }
39
+
40
+ export function createHooksRouter(broadcast: (msg: WsMessage) => void): Router {
41
+ const router = Router()
42
+
43
+ router.post('/events', (req: Request, res: Response) => {
44
+ const { event, agent } = req.body ?? {}
45
+
46
+ if (!agent || !isValidPhase(agent)) {
47
+ console.warn(`[hooks] unknown agent: ${agent}`)
48
+ res.json({ ok: true })
49
+ return
50
+ }
51
+
52
+ const newState = eventToState(event)
53
+ if (!newState) {
54
+ console.warn(`[hooks] unknown event: ${event}`)
55
+ res.json({ ok: true })
56
+ return
57
+ }
58
+
59
+ phases[agent] = newState
60
+ broadcast({
61
+ type: 'phase',
62
+ phase: agent,
63
+ state: newState,
64
+ timestamp: new Date().toISOString(),
65
+ })
66
+
67
+ res.json({ ok: true })
68
+ })
69
+
70
+ return router
71
+ }
@@ -0,0 +1,186 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
2
+
3
+ // Mock child_process and uuid before any imports
4
+ vi.mock('child_process', () => ({
5
+ spawn: vi.fn(),
6
+ execSync: vi.fn(),
7
+ }))
8
+ vi.mock('uuid', () => ({
9
+ v4: vi.fn(() => 'test-uuid-5678'),
10
+ }))
11
+
12
+ import express from 'express'
13
+ import type { WsMessage } from './types'
14
+ import { ClaudeNotFoundError, SpawnBusyError } from './types'
15
+ import { createHooksRouter, getPhaseStates, resetPhases } from './hooks'
16
+ import { spawnClaude, isSpawnActive, getLogBuffer } from './spawner'
17
+
18
+ // Build the app the same way index.ts does, but without the WebSocket/listen parts
19
+ function createTestApp() {
20
+ const broadcast = vi.fn()
21
+
22
+ const app = express()
23
+ app.use(express.json())
24
+ app.use('/hooks', createHooksRouter(broadcast))
25
+
26
+ app.post('/api/spawn', (req, res) => {
27
+ const { command } = req.body ?? {}
28
+ if (!command || typeof command !== 'string' || !command.trim()) {
29
+ res.status(400).json({ error: 'command is required' })
30
+ return
31
+ }
32
+
33
+ try {
34
+ const handle = spawnClaude(
35
+ command,
36
+ broadcast,
37
+ () => resetPhases(broadcast)
38
+ )
39
+ res.json({ processId: handle.processId })
40
+ } catch (err) {
41
+ if (err instanceof ClaudeNotFoundError) {
42
+ res.status(400).json({ error: err.message })
43
+ } else if (err instanceof SpawnBusyError) {
44
+ res.status(409).json({ error: err.message })
45
+ } else {
46
+ res.status(500).json({ error: 'Internal server error' })
47
+ }
48
+ }
49
+ })
50
+
51
+ app.get('/api/state', (_req, res) => {
52
+ res.json({
53
+ projectName: 'test-project',
54
+ phases: getPhaseStates(),
55
+ busy: isSpawnActive(),
56
+ })
57
+ })
58
+
59
+ return { app, broadcast }
60
+ }
61
+
62
+ describe('API endpoints', () => {
63
+ let app: express.Express
64
+ let broadcast: ReturnType<typeof vi.fn>
65
+ let request: any
66
+
67
+ beforeEach(async () => {
68
+ // Reset phases to clean state
69
+ const dummyBroadcast = vi.fn()
70
+ resetPhases(dummyBroadcast)
71
+
72
+ const created = createTestApp()
73
+ app = created.app
74
+ broadcast = created.broadcast
75
+
76
+ const mod = await import('supertest')
77
+ request = mod.default
78
+ })
79
+
80
+ describe('GET /api/state', () => {
81
+ it('returns project name, phases, and busy status', async () => {
82
+ const res = await request(app).get('/api/state')
83
+
84
+ expect(res.status).toBe(200)
85
+ expect(res.body.projectName).toBe('test-project')
86
+ expect(res.body.phases).toEqual({
87
+ architect: 'idle',
88
+ developer: 'idle',
89
+ reviewer: 'idle',
90
+ ship: 'idle',
91
+ })
92
+ expect(res.body.busy).toBe(false)
93
+ })
94
+ })
95
+
96
+ describe('POST /api/spawn', () => {
97
+ it('returns 400 when command is missing', async () => {
98
+ const res = await request(app).post('/api/spawn').send({})
99
+
100
+ expect(res.status).toBe(400)
101
+ expect(res.body.error).toBe('command is required')
102
+ })
103
+
104
+ it('returns 400 when command is empty string', async () => {
105
+ const res = await request(app).post('/api/spawn').send({ command: ' ' })
106
+
107
+ expect(res.status).toBe(400)
108
+ expect(res.body.error).toBe('command is required')
109
+ })
110
+
111
+ it('returns 400 when command is not a string', async () => {
112
+ const res = await request(app).post('/api/spawn').send({ command: 123 })
113
+
114
+ expect(res.status).toBe(400)
115
+ expect(res.body.error).toBe('command is required')
116
+ })
117
+
118
+ it('returns 400 when claude is not found', async () => {
119
+ const { execSync } = await import('child_process')
120
+ vi.mocked(execSync).mockImplementation(() => {
121
+ throw new Error('not found')
122
+ })
123
+
124
+ const res = await request(app).post('/api/spawn').send({ command: '/test' })
125
+
126
+ expect(res.status).toBe(400)
127
+ expect(res.body.error).toBe('claude binary not found')
128
+ })
129
+
130
+ it('returns 409 when a process is already running', async () => {
131
+ const { execSync, spawn } = await import('child_process')
132
+ const { EventEmitter } = await import('events')
133
+ const { Readable } = await import('stream')
134
+
135
+ vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
136
+ const child = new EventEmitter() as any
137
+ child.stdout = new Readable({ read() {} })
138
+ child.stderr = new Readable({ read() {} })
139
+ vi.mocked(spawn).mockReturnValue(child as any)
140
+
141
+ // First spawn succeeds
142
+ await request(app).post('/api/spawn').send({ command: '/test1' })
143
+
144
+ // Second spawn should fail with 409
145
+ const res = await request(app).post('/api/spawn').send({ command: '/test2' })
146
+
147
+ expect(res.status).toBe(409)
148
+ expect(res.body.error).toBe('A process is already running')
149
+
150
+ // Clean up: close the active process so it doesn't leak into other tests
151
+ child.emit('close', 0)
152
+ })
153
+
154
+ it('returns processId on successful spawn', async () => {
155
+ const { execSync, spawn } = await import('child_process')
156
+ const { EventEmitter } = await import('events')
157
+ const { Readable } = await import('stream')
158
+
159
+ vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
160
+ const child = new EventEmitter() as any
161
+ child.stdout = new Readable({ read() {} })
162
+ child.stderr = new Readable({ read() {} })
163
+ vi.mocked(spawn).mockReturnValue(child as any)
164
+
165
+ const res = await request(app).post('/api/spawn').send({ command: '/implement #42' })
166
+
167
+ expect(res.status).toBe(200)
168
+ expect(res.body.processId).toBe('test-uuid-5678')
169
+ })
170
+ })
171
+
172
+ describe('POST /hooks/events', () => {
173
+ it('transitions phase state and returns ok', async () => {
174
+ const res = await request(app)
175
+ .post('/hooks/events')
176
+ .send({ event: 'agent_start', agent: 'architect' })
177
+
178
+ expect(res.status).toBe(200)
179
+ expect(res.body).toEqual({ ok: true })
180
+
181
+ // Verify state was updated
182
+ const stateRes = await request(app).get('/api/state')
183
+ expect(stateRes.body.phases.architect).toBe('running')
184
+ })
185
+ })
186
+ })
@@ -0,0 +1,99 @@
1
+ import http from 'http'
2
+ import express from 'express'
3
+ import { WebSocketServer, WebSocket } from 'ws'
4
+ import type { WsMessage } from './types'
5
+ import { ClaudeNotFoundError, SpawnBusyError } from './types'
6
+ import { createHooksRouter, getPhaseStates, resetPhases } from './hooks'
7
+ import { spawnClaude, isSpawnActive, getLogBuffer } from './spawner'
8
+
9
+ // Parse CLI args
10
+ let projectName = process.env.SPECRAILS_PROJECT_NAME || require('path').basename(process.cwd())
11
+ let port = 4200
12
+
13
+ for (let i = 2; i < process.argv.length; i++) {
14
+ if (process.argv[i] === '--project' && process.argv[i + 1]) {
15
+ projectName = process.argv[++i]
16
+ } else if (process.argv[i] === '--port' && process.argv[i + 1]) {
17
+ port = parseInt(process.argv[++i], 10)
18
+ }
19
+ }
20
+
21
+ const app = express()
22
+ app.use(express.json())
23
+
24
+ const server = http.createServer(app)
25
+ const wss = new WebSocketServer({ noServer: true })
26
+
27
+ const clients = new Set<WebSocket>()
28
+
29
+ function broadcast(msg: WsMessage): void {
30
+ const data = JSON.stringify(msg)
31
+ for (const client of clients) {
32
+ if (client.readyState === WebSocket.OPEN) {
33
+ client.send(data)
34
+ }
35
+ }
36
+ }
37
+
38
+ server.on('upgrade', (request, socket, head) => {
39
+ wss.handleUpgrade(request, socket, head, (ws) => {
40
+ wss.emit('connection', ws, request)
41
+ })
42
+ })
43
+
44
+ wss.on('connection', (ws: WebSocket) => {
45
+ clients.add(ws)
46
+
47
+ const initMsg: WsMessage = {
48
+ type: 'init',
49
+ projectName,
50
+ phases: getPhaseStates(),
51
+ logBuffer: getLogBuffer().slice(-500),
52
+ }
53
+ ws.send(JSON.stringify(initMsg))
54
+
55
+ ws.on('close', () => {
56
+ clients.delete(ws)
57
+ })
58
+ })
59
+
60
+ // Routes
61
+ app.use('/hooks', createHooksRouter(broadcast))
62
+
63
+ app.post('/api/spawn', (req, res) => {
64
+ const { command } = req.body ?? {}
65
+ if (!command || typeof command !== 'string' || !command.trim()) {
66
+ res.status(400).json({ error: 'command is required' })
67
+ return
68
+ }
69
+
70
+ try {
71
+ const handle = spawnClaude(
72
+ command,
73
+ broadcast,
74
+ () => resetPhases(broadcast)
75
+ )
76
+ res.json({ processId: handle.processId })
77
+ } catch (err) {
78
+ if (err instanceof ClaudeNotFoundError) {
79
+ res.status(400).json({ error: err.message })
80
+ } else if (err instanceof SpawnBusyError) {
81
+ res.status(409).json({ error: err.message })
82
+ } else {
83
+ console.error('[spawn] unexpected error:', err)
84
+ res.status(500).json({ error: 'Internal server error' })
85
+ }
86
+ }
87
+ })
88
+
89
+ app.get('/api/state', (_req, res) => {
90
+ res.json({
91
+ projectName,
92
+ phases: getPhaseStates(),
93
+ busy: isSpawnActive(),
94
+ })
95
+ })
96
+
97
+ server.listen(port, '127.0.0.1', () => {
98
+ console.log(`specrails web manager running on http://127.0.0.1:${port}`)
99
+ })