specrails-hub 0.1.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.
@@ -0,0 +1,526 @@
1
+ import { spawn, ChildProcess } from 'child_process'
2
+ import { createInterface } from 'readline'
3
+ import { existsSync, readdirSync } from 'fs'
4
+ import { join } from 'path'
5
+ import treeKill from 'tree-kill'
6
+ import type { WsMessage } from './types'
7
+
8
+ // ─── Checkpoint definitions ───────────────────────────────────────────────────
9
+
10
+ export interface CheckpointDefinition {
11
+ key: string
12
+ name: string
13
+ }
14
+
15
+ export const CHECKPOINTS: CheckpointDefinition[] = [
16
+ { key: 'base_install', name: 'Base installation' },
17
+ { key: 'repo_analysis', name: 'Repository analysis' },
18
+ { key: 'stack_conventions', name: 'Stack & conventions' },
19
+ { key: 'product_discovery', name: 'Product discovery' },
20
+ { key: 'agent_generation', name: 'Agent generation' },
21
+ { key: 'command_config', name: 'Command configuration' },
22
+ { key: 'final_verification', name: 'Final verification' },
23
+ ]
24
+
25
+ // ─── Checkpoint filesystem checks ─────────────────────────────────────────────
26
+
27
+ export interface CheckpointStatus {
28
+ key: string
29
+ name: string
30
+ status: 'pending' | 'running' | 'done'
31
+ detail?: string
32
+ duration_ms?: number
33
+ }
34
+
35
+ function checkFilesystem(projectPath: string): Partial<Record<string, boolean>> {
36
+ const hasBaseInstall = existsSync(join(projectPath, '.specrails-version'))
37
+ const hasSetupTemplates = existsSync(join(projectPath, '.claude', 'setup-templates'))
38
+ const hasRules = existsSync(join(projectPath, '.claude', 'rules')) &&
39
+ hasFiles(join(projectPath, '.claude', 'rules'), /\.md$/)
40
+ const hasPersonas = existsSync(join(projectPath, '.claude', 'agents', 'personas')) &&
41
+ hasFiles(join(projectPath, '.claude', 'agents', 'personas'), /\.md$/)
42
+ const hasAgents = existsSync(join(projectPath, '.claude', 'agents')) &&
43
+ hasFiles(join(projectPath, '.claude', 'agents'), /^sr-.*\.md$/)
44
+ const hasCommands = existsSync(join(projectPath, '.claude', 'commands', 'sr')) &&
45
+ hasFiles(join(projectPath, '.claude', 'commands', 'sr'), /\.md$/)
46
+ const hasCLAUDE = existsSync(join(projectPath, 'CLAUDE.md'))
47
+
48
+ return {
49
+ base_install: hasBaseInstall,
50
+ // repo_analysis: detected when setup templates exist and CLAUDE.md is written
51
+ // (Claude writes CLAUDE.md after analyzing the repo)
52
+ repo_analysis: hasBaseInstall && (hasCLAUDE || hasSetupTemplates),
53
+ // stack_conventions: detected when rules files are generated
54
+ stack_conventions: hasRules,
55
+ product_discovery: hasPersonas,
56
+ agent_generation: hasAgents,
57
+ command_config: hasCommands,
58
+ // Final verification: agents + commands must exist (manifest from install.sh is unreliable —
59
+ // it's created during scaffolding before /setup generates the actual artifacts)
60
+ final_verification: hasAgents && hasCommands,
61
+ }
62
+ }
63
+
64
+ function hasFiles(dir: string, pattern: RegExp): boolean {
65
+ try {
66
+ return readdirSync(dir).some((f) => pattern.test(f as string))
67
+ } catch {
68
+ return false
69
+ }
70
+ }
71
+
72
+ // ─── Stream-based checkpoint detection ───────────────────────────────────────
73
+
74
+ function detectCheckpointFromText(
75
+ text: string
76
+ ): { key: string; detail?: string }[] {
77
+ const hits: { key: string; detail?: string }[] = []
78
+
79
+ // Match phase headers from Claude's /setup output
80
+ if (/phase\s*1|codebase\s*analysis|repository\s*analysis/i.test(text)) {
81
+ hits.push({ key: 'repo_analysis', detail: 'Analyzing codebase...' })
82
+ }
83
+ if (/phase\s*2|user\s*personas|product\s*discovery/i.test(text)) {
84
+ hits.push({ key: 'product_discovery', detail: 'Generating personas...' })
85
+ }
86
+ if (/phase\s*3|configuration|agent\s*selection|backlog\s*provider/i.test(text)) {
87
+ hits.push({ key: 'stack_conventions', detail: 'Configuring stack...' })
88
+ }
89
+ if (/generating\s*all\s*files|writing.*agent|sr-architect|sr-developer|sr-reviewer/i.test(text)) {
90
+ hits.push({ key: 'agent_generation', detail: 'Generating agents...' })
91
+ }
92
+ if (/command\s*selection|installing.*commands|\.claude\/commands\/sr/i.test(text)) {
93
+ hits.push({ key: 'command_config', detail: 'Configuring commands...' })
94
+ }
95
+
96
+ // File path detection in tool_use events
97
+ if (text.includes('.specrails-version')) hits.push({ key: 'base_install' })
98
+ if (text.includes('/agents/personas/') && text.includes('.md')) {
99
+ hits.push({ key: 'product_discovery', detail: 'Writing personas...' })
100
+ }
101
+ if (/\/agents\/sr-[^/]+\.md/.test(text)) {
102
+ hits.push({ key: 'agent_generation', detail: 'Writing agents...' })
103
+ }
104
+ if (text.includes('/commands/sr/') && text.includes('.md')) {
105
+ hits.push({ key: 'command_config', detail: 'Writing commands...' })
106
+ }
107
+ if (text.includes('/rules/') && text.includes('.md')) {
108
+ hits.push({ key: 'stack_conventions', detail: 'Writing conventions...' })
109
+ }
110
+ if (text.includes('.specrails-manifest.json')) {
111
+ hits.push({ key: 'final_verification' })
112
+ }
113
+
114
+ return hits
115
+ }
116
+
117
+ // ─── Setup summary computation ────────────────────────────────────────────────
118
+
119
+ export interface SetupSummary {
120
+ agents: number
121
+ personas: number
122
+ commands: number
123
+ }
124
+
125
+ function computeSummary(projectPath: string): SetupSummary {
126
+ let agents = 0
127
+ let personas = 0
128
+ let commands = 0
129
+
130
+ try {
131
+ const agentsDir = join(projectPath, '.claude', 'agents')
132
+ if (existsSync(agentsDir)) {
133
+ const files = readdirSync(agentsDir) as string[]
134
+ agents = files.filter((f) => /^sr-.*\.md$/.test(f)).length
135
+ const personasDir = join(agentsDir, 'personas')
136
+ if (existsSync(personasDir)) {
137
+ personas = (readdirSync(personasDir) as string[]).filter((f) => f.endsWith('.md')).length
138
+ }
139
+ }
140
+ const commandsDir = join(projectPath, '.claude', 'commands', 'sr')
141
+ if (existsSync(commandsDir)) {
142
+ commands = (readdirSync(commandsDir) as string[]).filter((f) => f.endsWith('.md')).length
143
+ }
144
+ } catch {
145
+ // non-fatal
146
+ }
147
+
148
+ return { agents, personas, commands }
149
+ }
150
+
151
+ // ─── SetupManager ─────────────────────────────────────────────────────────────
152
+
153
+ export class SetupManager {
154
+ private _broadcast: (msg: WsMessage) => void
155
+ // Map from projectId → active child processes
156
+ private _installProcesses: Map<string, ChildProcess>
157
+ private _setupProcesses: Map<string, ChildProcess>
158
+ // Track checkpoint states per project
159
+ private _checkpoints: Map<string, Map<string, CheckpointStatus>>
160
+ // Track checkpoint start times
161
+ private _checkpointStart: Map<string, Map<string, number>>
162
+
163
+ constructor(broadcast: (msg: WsMessage) => void) {
164
+ this._broadcast = broadcast
165
+ this._installProcesses = new Map()
166
+ this._setupProcesses = new Map()
167
+ this._checkpoints = new Map()
168
+ this._checkpointStart = new Map()
169
+ this._pollTimers = new Map()
170
+ }
171
+
172
+ // ─── Install: npx specrails ──────────────────────────────────────────────────
173
+
174
+ startInstall(projectId: string, projectPath: string): void {
175
+ if (this._installProcesses.has(projectId)) {
176
+ console.warn(`[SetupManager] install already running for ${projectId}`)
177
+ return
178
+ }
179
+
180
+ const child = spawn('npx', ['specrails', 'init', '--yes'], {
181
+ cwd: projectPath,
182
+ env: process.env,
183
+ shell: false,
184
+ stdio: ['ignore', 'pipe', 'pipe'],
185
+ })
186
+
187
+ this._installProcesses.set(projectId, child)
188
+
189
+ const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
190
+ const stderrReader = createInterface({ input: child.stderr!, crlfDelay: Infinity })
191
+
192
+ stdoutReader.on('line', (line) => {
193
+ this._broadcast({ type: 'setup_log', projectId, line, stream: 'stdout' })
194
+ })
195
+
196
+ stderrReader.on('line', (line) => {
197
+ this._broadcast({ type: 'setup_log', projectId, line, stream: 'stderr' })
198
+ })
199
+
200
+ child.on('close', (code) => {
201
+ this._installProcesses.delete(projectId)
202
+ if (code === 0) {
203
+ this._broadcast({
204
+ type: 'setup_install_done',
205
+ projectId,
206
+ timestamp: new Date().toISOString(),
207
+ })
208
+ } else {
209
+ this._broadcast({
210
+ type: 'setup_error',
211
+ projectId,
212
+ error: `npx specrails exited with code ${code ?? 'unknown'}`,
213
+ })
214
+ }
215
+ })
216
+ }
217
+
218
+ // ─── Setup: claude -p "/setup" ───────────────────────────────────────────────
219
+
220
+ startSetup(projectId: string, projectPath: string): void {
221
+ if (this._setupProcesses.has(projectId)) {
222
+ console.warn(`[SetupManager] setup already running for ${projectId}`)
223
+ return
224
+ }
225
+
226
+ this._initCheckpoints(projectId)
227
+
228
+ const args = [
229
+ '-p', '/setup',
230
+ '--dangerously-skip-permissions',
231
+ '--output-format', 'stream-json',
232
+ '--verbose',
233
+ ]
234
+
235
+ this._spawnSetup(projectId, projectPath, args)
236
+ }
237
+
238
+ resumeSetup(projectId: string, projectPath: string, sessionId: string, userMessage: string): void {
239
+ if (this._setupProcesses.has(projectId)) {
240
+ console.warn(`[SetupManager] setup already running for ${projectId}`)
241
+ return
242
+ }
243
+
244
+ const args = [
245
+ '--resume', sessionId,
246
+ '--dangerously-skip-permissions',
247
+ '--output-format', 'stream-json',
248
+ '--verbose',
249
+ '-p', userMessage,
250
+ ]
251
+
252
+ this._spawnSetup(projectId, projectPath, args)
253
+ }
254
+
255
+ // Active filesystem poll timers per project
256
+ private _pollTimers: Map<string, ReturnType<typeof setInterval>>
257
+
258
+ private _startFilesystemPoll(projectId: string, projectPath: string): void {
259
+ this._stopFilesystemPoll(projectId)
260
+ const timer = setInterval(() => {
261
+ this._syncFilesystemCheckpoints(projectId, projectPath)
262
+ }, 3000)
263
+ this._pollTimers.set(projectId, timer)
264
+ }
265
+
266
+ private _stopFilesystemPoll(projectId: string): void {
267
+ const timer = this._pollTimers.get(projectId)
268
+ if (timer) {
269
+ clearInterval(timer)
270
+ this._pollTimers.delete(projectId)
271
+ }
272
+ }
273
+
274
+ private _spawnSetup(projectId: string, projectPath: string, args: string[]): void {
275
+ const child = spawn('claude', args, {
276
+ cwd: projectPath,
277
+ env: process.env,
278
+ shell: false,
279
+ stdio: ['ignore', 'pipe', 'pipe'],
280
+ })
281
+
282
+ this._setupProcesses.set(projectId, child)
283
+
284
+ // Start periodic filesystem polling for checkpoint detection
285
+ this._startFilesystemPoll(projectId, projectPath)
286
+
287
+ let capturedSessionId: string | null = null
288
+
289
+ const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
290
+ const stderrReader = createInterface({ input: child.stderr!, crlfDelay: Infinity })
291
+
292
+ stdoutReader.on('line', (line) => {
293
+ let parsed: Record<string, unknown> | null = null
294
+ try { parsed = JSON.parse(line) } catch { /* plain text */ }
295
+
296
+ if (parsed) {
297
+ this._handleSetupStreamEvent(projectId, projectPath, parsed)
298
+
299
+ if ((parsed.type as string) === 'result') {
300
+ const sid = parsed.session_id as string | undefined
301
+ if (sid) capturedSessionId = sid
302
+ }
303
+
304
+ // Also broadcast as raw log for the collapsible log viewer
305
+ const eventType = parsed.type as string
306
+ if (eventType === 'assistant') {
307
+ const message = parsed.message as { content?: Array<{ type: string; text?: string; name?: string }> } | undefined
308
+ for (const block of message?.content ?? []) {
309
+ if (block.type === 'text' && block.text) {
310
+ this._broadcast({ type: 'setup_log', projectId, line: block.text, stream: 'stdout' })
311
+ } else if (block.type === 'tool_use' && block.name) {
312
+ this._broadcast({ type: 'setup_log', projectId, line: `[tool] ${block.name}`, stream: 'stdout' })
313
+ }
314
+ }
315
+ }
316
+ } else {
317
+ // Plain text line — broadcast as log
318
+ this._broadcast({ type: 'setup_log', projectId, line, stream: 'stdout' })
319
+ }
320
+ })
321
+
322
+ stderrReader.on('line', (line) => {
323
+ this._broadcast({ type: 'setup_log', projectId, line, stream: 'stderr' })
324
+ })
325
+
326
+ child.on('close', (code) => {
327
+ this._setupProcesses.delete(projectId)
328
+ this._stopFilesystemPoll(projectId)
329
+
330
+ // Final filesystem sync
331
+ this._syncFilesystemCheckpoints(projectId, projectPath)
332
+
333
+ if (code === 0) {
334
+ // Sync filesystem checkpoints
335
+ this._syncFilesystemCheckpoints(projectId, projectPath)
336
+
337
+ // Check if setup is truly complete — real artifacts must exist
338
+ const hasAgents = existsSync(join(projectPath, '.claude', 'agents')) &&
339
+ hasFiles(join(projectPath, '.claude', 'agents'), /^sr-.*\.md$/)
340
+ const hasCommands = existsSync(join(projectPath, '.claude', 'commands', 'sr')) &&
341
+ hasFiles(join(projectPath, '.claude', 'commands', 'sr'), /\.md$/)
342
+ const isComplete = hasAgents && hasCommands
343
+
344
+ if (isComplete) {
345
+ const summary = computeSummary(projectPath)
346
+ this._broadcast({
347
+ type: 'setup_complete',
348
+ projectId,
349
+ sessionId: capturedSessionId ?? undefined,
350
+ summary,
351
+ })
352
+ } else {
353
+ // Claude finished one turn but setup isn't done yet.
354
+ // Emit turn_done so the wizard knows to wait for user input.
355
+ this._broadcast({
356
+ type: 'setup_turn_done',
357
+ projectId,
358
+ sessionId: capturedSessionId ?? undefined,
359
+ })
360
+ }
361
+ } else {
362
+ this._broadcast({
363
+ type: 'setup_error',
364
+ projectId,
365
+ error: `claude setup exited with code ${code ?? 'unknown'}`,
366
+ })
367
+ }
368
+ })
369
+ }
370
+
371
+ private _handleSetupStreamEvent(
372
+ projectId: string,
373
+ projectPath: string,
374
+ event: Record<string, unknown>
375
+ ): void {
376
+ const eventType = event.type as string
377
+
378
+ // Extract text for chat messages + detect checkpoints from content
379
+ if (eventType === 'assistant') {
380
+ const message = event.message as { content?: Array<{ type: string; text?: string }> } | undefined
381
+ const texts = (message?.content ?? [])
382
+ .filter((c) => c.type === 'text')
383
+ .map((c) => c.text ?? '')
384
+ const text = texts.join('')
385
+ if (text) {
386
+ this._broadcast({ type: 'setup_chat', projectId, text, role: 'assistant' })
387
+
388
+ // Detect phase transitions from Claude's output text
389
+ const hits = detectCheckpointFromText(text)
390
+ for (const hit of hits) {
391
+ this._advanceCheckpoint(projectId, hit.key, hit.detail)
392
+ }
393
+ }
394
+ }
395
+
396
+ // Tool use events — check if writing to checkpoint-relevant paths
397
+ if (eventType === 'tool_use') {
398
+ const inputStr = JSON.stringify(event.input ?? {})
399
+ const hits = detectCheckpointFromText(inputStr)
400
+ for (const hit of hits) {
401
+ this._advanceCheckpoint(projectId, hit.key, hit.detail)
402
+ }
403
+ }
404
+
405
+ // After any event, sync filesystem checkpoints
406
+ this._syncFilesystemCheckpoints(projectId, projectPath)
407
+ }
408
+
409
+ private _initCheckpoints(projectId: string): void {
410
+ const statuses = new Map<string, CheckpointStatus>()
411
+ const starts = new Map<string, number>()
412
+ for (const def of CHECKPOINTS) {
413
+ statuses.set(def.key, { key: def.key, name: def.name, status: 'pending' })
414
+ }
415
+ this._checkpoints.set(projectId, statuses)
416
+ this._checkpointStart.set(projectId, starts)
417
+ }
418
+
419
+ private _advanceCheckpoint(projectId: string, key: string, detail?: string): void {
420
+ const statuses = this._checkpoints.get(projectId)
421
+ if (!statuses) return
422
+
423
+ const checkpoint = statuses.get(key)
424
+ if (!checkpoint || checkpoint.status === 'done') return
425
+
426
+ const starts = this._checkpointStart.get(projectId)!
427
+
428
+ // When a later checkpoint starts, auto-complete all earlier ones
429
+ const checkpointKeys = CHECKPOINTS.map((c) => c.key)
430
+ const targetIdx = checkpointKeys.indexOf(key)
431
+ for (let i = 0; i < targetIdx; i++) {
432
+ const prevKey = checkpointKeys[i]
433
+ const prev = statuses.get(prevKey)
434
+ if (prev && prev.status !== 'done') {
435
+ this._completeCheckpoint(projectId, prevKey)
436
+ }
437
+ }
438
+
439
+ if (checkpoint.status === 'pending') {
440
+ checkpoint.status = 'running'
441
+ starts.set(key, Date.now())
442
+ if (detail) checkpoint.detail = detail
443
+ this._broadcast({ type: 'setup_checkpoint', projectId, checkpoint: key, status: 'running', detail })
444
+ }
445
+ }
446
+
447
+ private _completeCheckpoint(projectId: string, key: string): void {
448
+ const statuses = this._checkpoints.get(projectId)
449
+ if (!statuses) return
450
+
451
+ const checkpoint = statuses.get(key)
452
+ if (!checkpoint || checkpoint.status === 'done') return
453
+
454
+ const starts = this._checkpointStart.get(projectId)!
455
+ const startTime = starts.get(key) ?? Date.now()
456
+ const duration_ms = Date.now() - startTime
457
+ starts.delete(key)
458
+
459
+ checkpoint.status = 'done'
460
+ checkpoint.duration_ms = duration_ms
461
+
462
+ this._broadcast({ type: 'setup_checkpoint', projectId, checkpoint: key, status: 'done', duration_ms })
463
+ }
464
+
465
+ private _syncFilesystemCheckpoints(projectId: string, projectPath: string): void {
466
+ const statuses = this._checkpoints.get(projectId)
467
+ if (!statuses) return
468
+
469
+ const fsChecks = checkFilesystem(projectPath)
470
+
471
+ for (const [key, exists] of Object.entries(fsChecks)) {
472
+ if (!exists) continue
473
+ const cp = statuses.get(key)
474
+ if (!cp) continue
475
+
476
+ if (cp.status === 'pending') {
477
+ // Fast-path: mark running then done immediately
478
+ this._advanceCheckpoint(projectId, key)
479
+ this._completeCheckpoint(projectId, key)
480
+ } else if (cp.status === 'running') {
481
+ this._completeCheckpoint(projectId, key)
482
+ }
483
+ }
484
+ }
485
+
486
+ // ─── Checkpoint poll endpoint ─────────────────────────────────────────────────
487
+
488
+ getCheckpointStatus(projectId: string, projectPath: string): CheckpointStatus[] {
489
+ // Sync from filesystem before returning
490
+ this._syncFilesystemCheckpoints(projectId, projectPath)
491
+
492
+ const statuses = this._checkpoints.get(projectId)
493
+ if (!statuses) {
494
+ // Return all-pending if setup hasn't started
495
+ return CHECKPOINTS.map((def) => ({ key: def.key, name: def.name, status: 'pending' as const }))
496
+ }
497
+
498
+ return CHECKPOINTS.map((def) => statuses.get(def.key) ?? { key: def.key, name: def.name, status: 'pending' as const })
499
+ }
500
+
501
+ // ─── Abort ────────────────────────────────────────────────────────────────────
502
+
503
+ abort(projectId: string): void {
504
+ this._stopFilesystemPoll(projectId)
505
+
506
+ const installChild = this._installProcesses.get(projectId)
507
+ if (installChild?.pid) {
508
+ treeKill(installChild.pid, 'SIGTERM')
509
+ this._installProcesses.delete(projectId)
510
+ }
511
+
512
+ const setupChild = this._setupProcesses.get(projectId)
513
+ if (setupChild?.pid) {
514
+ treeKill(setupChild.pid, 'SIGTERM')
515
+ this._setupProcesses.delete(projectId)
516
+ }
517
+ }
518
+
519
+ isInstalling(projectId: string): boolean {
520
+ return this._installProcesses.has(projectId)
521
+ }
522
+
523
+ isSettingUp(projectId: string): boolean {
524
+ return this._setupProcesses.has(projectId)
525
+ }
526
+ }