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,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
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['server/**/*.test.ts'],
6
+ environment: 'node',
7
+ },
8
+ })