@elyun/bylane 1.32.0 → 1.34.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/README.md CHANGED
@@ -486,17 +486,45 @@ respond-loop 독립: 5분 주기 리뷰 코멘트 감지
486
486
  }
487
487
  ```
488
488
 
489
- ### Slack 웹훅 설정
489
+ ### Slack 웹훅 설정 (Workflow Builder)
490
490
 
491
- 1. [Slack API](https://api.slack.com/apps) → **Create New App** → **Incoming Webhooks** 활성화
492
- 2. **Add New Webhook to Workspace** → 채널 선택 → Webhook URL 복사
493
- 3. `.bylane/bylane.json`에 입력:
491
+ **1. Workflow 생성**
492
+
493
+ Slack **Automations** → **New Workflow** → **Start from scratch**
494
+ 트리거: **Webhook**
495
+
496
+ **2. 변수 스키마 정의**
497
+
498
+ 웹훅 트리거 설정에서 아래 변수를 추가:
499
+
500
+ | 변수명 | 타입 |
501
+ |--------|------|
502
+ | `title` | 텍스트 |
503
+ | `status` | 텍스트 |
504
+ | `url` | 텍스트 |
505
+ | `elapsed` | 텍스트 |
506
+ | `reason` | 텍스트 |
507
+
508
+ **3. 메시지 단계 추가**
509
+
510
+ "Send a message" 단계에서 변수 참조:
511
+ ```
512
+ {{title}} — {{status}}
513
+ {{url}}
514
+ {{elapsed}}{{reason}}
515
+ ```
516
+
517
+ **4. Workflow 게시 후 Webhook URL 복사**
518
+
519
+ URL 형식: `https://hooks.slack.com/workflows/...`
520
+
521
+ **5. `.bylane/bylane.json`에 입력**
494
522
 
495
523
  ```json
496
524
  "notifications": {
497
525
  "slack": {
498
526
  "enabled": true,
499
- "webhookUrl": "https://hooks.slack.com/services/T.../B.../..."
527
+ "webhookUrl": "https://hooks.slack.com/workflows/..."
500
528
  }
501
529
  }
502
530
  ```
@@ -38,21 +38,44 @@ PR: PR_URL
38
38
 
39
39
  ### Slack 알림 (notifications.slack.enabled: true)
40
40
 
41
- `config.notifications.slack.webhookUrl`로 Incoming Webhook POST:
41
+ Slack Workflow Builder 웹훅으로 POST한다.
42
+ Workflow에 정의된 변수 스키마와 페이로드 키가 일치해야 한다.
42
43
 
43
44
  ```bash
44
- # 완료 메시지
45
+ # 완료 (type: completed)
45
46
  curl -s -X POST "$SLACK_WEBHOOK_URL" \
46
47
  -H "Content-Type: application/json" \
47
- -d '{"text":"[byLane] ✅ 완료: TITLE\nPR: PR_URL | 소요 시간: ELAPSED"}'
48
-
49
- # 개입 필요 메시지
48
+ -d "{
49
+ \"title\": \"TITLE\",
50
+ \"status\": \"completed\",
51
+ \"url\": \"PR_URL\",
52
+ \"elapsed\": \"ELAPSED\",
53
+ \"reason\": \"\"
54
+ }"
55
+
56
+ # 개입 필요 (type: escalated / error)
50
57
  curl -s -X POST "$SLACK_WEBHOOK_URL" \
51
58
  -H "Content-Type: application/json" \
52
- -d '{"text":"[byLane] ⚠️ 개입 필요: TITLE\n이유: REASON | 확인: PR_URL"}'
59
+ -d "{
60
+ \"title\": \"TITLE\",
61
+ \"status\": \"escalated\",
62
+ \"url\": \"PR_URL\",
63
+ \"elapsed\": \"\",
64
+ \"reason\": \"REASON\"
65
+ }"
53
66
  ```
54
67
 
55
- `webhookUrl`이 비어 있으면 Slack 알림을 건너뜬다.
68
+ Workflow Builder에서 정의해야 변수 스키마:
69
+
70
+ | 변수명 | 타입 | 설명 |
71
+ |--------|------|------|
72
+ | `title` | 텍스트 | 작업 제목 |
73
+ | `status` | 텍스트 | `completed` / `escalated` / `error` |
74
+ | `url` | 텍스트 | GitHub PR/Issue URL |
75
+ | `elapsed` | 텍스트 | 소요 시간 (완료 시) |
76
+ | `reason` | 텍스트 | 실패/에스컬레이션 이유 |
77
+
78
+ `webhookUrl`이 비어 있으면 Slack 알림을 건너뛴다.
56
79
 
57
80
  ### Telegram 알림 (notifications.telegram.enabled: true)
58
81
 
@@ -18,17 +18,25 @@ description: PR의 diff를 파일별로 분석하여 코드 라인별 인라인
18
18
 
19
19
  ## GitHub 리뷰 템플릿 탐지
20
20
 
21
- bylane 설정보다 먼저 프로젝트 GitHub 리뷰 템플릿을 탐색한다:
21
+ 다음 순서로 탐색한다. 먼저 발견된 파일을 **최우선**으로 따른다:
22
22
 
23
23
  ```bash
24
+ # 1순위: 프로젝트 .github/ 커스텀 템플릿
24
25
  ls .github/REVIEW_TEMPLATE.md \
25
26
  .github/review_template.md \
26
27
  .github/CODE_REVIEW_TEMPLATE.md \
27
28
  docs/REVIEW_TEMPLATE.md 2>/dev/null | head -1
29
+
30
+ # 2순위: 프로젝트 .bylane/ 설치 템플릿 (bylane setup 시 설치됨)
31
+ # .bylane/templates/review-template.md
32
+
33
+ # 3순위: 글로벌 fallback (~/.claude/templates/bylane/review-template.md)
28
34
  ```
29
35
 
30
- GitHub 템플릿이 있으면 해당 형식을 **최우선**으로 따른다.
31
- 없으면 `templates/review-template.md`를 사용한다.
36
+ 우선순위 요약:
37
+ 1. `.github/` 내 커스텀 템플릿 (프로젝트별 재정의)
38
+ 2. `.bylane/templates/review-template.md` (프로젝트 기본값)
39
+ 3. `~/.claude/templates/bylane/review-template.md` (글로벌 기본값)
32
40
 
33
41
  ## 검사 항목 선택
34
42
 
@@ -10,9 +10,10 @@ ALWAYS complete all 8 steps before saving. NEVER skip steps.
10
10
 
11
11
  ## 실행 전 준비
12
12
 
13
- 1. `.bylane/` 디렉토리가 없으면 생성:
13
+ 1. `.bylane/` 디렉토리가 없으면 생성하고 기본 템플릿을 설치:
14
14
  ```bash
15
15
  mkdir -p .bylane/state
16
+ npx @elyun/bylane templates install
16
17
  ```
17
18
 
18
19
  2. 기존 bylane.json 있으면 현재 설정을 로드해 기본값으로 사용.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elyun/bylane",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Frontend development harness for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -18,8 +18,9 @@ const USER_CONFIG_FILES = [
18
18
  ]
19
19
 
20
20
  const TARGETS = [
21
- { src: join(ROOT, 'commands'), dest: join(CLAUDE_DIR, 'commands'), label: 'Commands' },
22
- { src: join(ROOT, 'hooks'), dest: join(CLAUDE_DIR, 'hooks'), label: 'Hooks' },
21
+ { src: join(ROOT, 'commands'), dest: join(CLAUDE_DIR, 'commands'), label: 'Commands' },
22
+ { src: join(ROOT, 'hooks'), dest: join(CLAUDE_DIR, 'hooks'), label: 'Hooks' },
23
+ { src: join(ROOT, 'templates'), dest: join(CLAUDE_DIR, 'templates', 'bylane'), label: 'Templates' },
23
24
  ]
24
25
 
25
26
  function backupAndCopy(src, dest, file, label) {
@@ -369,6 +370,35 @@ if (command === 'install') {
369
370
  console.error('사용법: bylane loop <start|stop|status>')
370
371
  process.exit(1)
371
372
  }
373
+ } else if (command === 'templates') {
374
+ const subCmd = args[1] || 'install'
375
+ if (subCmd === 'install') {
376
+ const globalTemplatesDir = join(CLAUDE_DIR, 'templates', 'bylane')
377
+ const projectTemplatesDir = join('.bylane', 'templates')
378
+ if (!existsSync(globalTemplatesDir)) {
379
+ console.error(' 글로벌 템플릿이 없습니다. 먼저 npx @elyun/bylane install 을 실행하세요.')
380
+ process.exit(1)
381
+ }
382
+ mkdirSync(projectTemplatesDir, { recursive: true })
383
+ const files = readdirSync(globalTemplatesDir)
384
+ let copied = 0
385
+ for (const file of files) {
386
+ const dest = join(projectTemplatesDir, file)
387
+ if (!existsSync(dest)) {
388
+ copyFileSync(join(globalTemplatesDir, file), dest)
389
+ console.log(` + Templates: ${file}`)
390
+ copied++
391
+ } else {
392
+ console.log(` = Templates: ${file} (이미 존재, 건너뜀)`)
393
+ }
394
+ }
395
+ console.log(copied > 0
396
+ ? `\n 템플릿 ${copied}개가 .bylane/templates/ 에 설치되었습니다.`
397
+ : '\n 모든 템플릿이 이미 설치되어 있습니다.')
398
+ } else {
399
+ console.error('사용법: bylane templates install')
400
+ process.exit(1)
401
+ }
372
402
  } else if (command === 'monitor') {
373
403
  // 항상 현재 패키지의 모니터 실행 (버전 일치 보장)
374
404
  const monitorPath = join(__dirname, 'monitor', 'index.js')
@@ -377,6 +407,6 @@ if (command === 'install') {
377
407
  child.on('exit', code => process.exit(code ?? 0))
378
408
  } else {
379
409
  console.error(`알 수 없는 명령: ${command}`)
380
- console.error('사용법: npx @elyun/bylane [install|uninstall|loop|monitor|preflight|state|memory|cleanup] [--symlink]')
410
+ console.error('사용법: npx @elyun/bylane [install|uninstall|loop|monitor|preflight|state|memory|cleanup|templates] [--symlink]')
381
411
  process.exit(1)
382
412
  }
package/src/config.js CHANGED
@@ -72,7 +72,7 @@ export const DEFAULT_CONFIG = {
72
72
  includeModel: true,
73
73
  includeCodeExample: true,
74
74
  autoApprove: false,
75
- templateFile: '',
75
+ templateFile: '.bylane/templates/review-template.md',
76
76
  severityEmoji: {
77
77
  CRITICAL: '[CRITICAL]',
78
78
  HIGH: '[HIGH]',
package/src/loop-utils.js CHANGED
@@ -97,11 +97,64 @@ export function isTmuxSessionAlive(sessionName) {
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * tmux 세션의 두 루프가 정상 기동됐는지 검증한다.
102
+ * state 파일에 running 상태가 기록될 때까지 최대 timeoutMs 대기.
103
+ * @param {string} sessionName
104
+ * @param {string} stateDir
105
+ * @param {number} timeoutMs
106
+ * @returns {{ ok: boolean, review: object, respond: object }}
107
+ */
108
+ export function verifyTmuxLoops(sessionName = 'bylane-loops', stateDir = '.bylane/state', timeoutMs = 8000) {
109
+ const loops = ['review-loop', 'respond-loop']
110
+ const result = {}
111
+
112
+ // 1. tmux 세션 및 윈도우 존재 확인
113
+ if (!isTmuxSessionAlive(sessionName)) {
114
+ return { ok: false, review: { alive: false }, respond: { alive: false } }
115
+ }
116
+
117
+ let windows = []
118
+ try {
119
+ const out = execSync(
120
+ `tmux list-windows -t ${sessionName} -F '#{window_name}'`,
121
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
122
+ ).trim()
123
+ windows = out.split('\n').map(w => w.trim())
124
+ } catch { /* 세션이 막 종료됐을 수 있음 */ }
125
+
126
+ // 2. 각 루프의 state 파일이 running 상태가 될 때까지 폴링
127
+ const deadline = Date.now() + timeoutMs
128
+ const pending = new Set(loops)
129
+
130
+ while (pending.size > 0 && Date.now() < deadline) {
131
+ for (const loop of [...pending]) {
132
+ const state = readState(loop, stateDir)
133
+ if (state?.status === 'running' && state?.pid) {
134
+ result[loop] = { alive: true, pid: state.pid, startedAt: state.startedAt }
135
+ pending.delete(loop)
136
+ }
137
+ }
138
+ if (pending.size > 0) execSync('sleep 0.5')
139
+ }
140
+
141
+ // 타임아웃된 루프는 실패로 처리
142
+ for (const loop of pending) {
143
+ const windowName = loop.replace('-loop', '')
144
+ const windowAlive = windows.includes(windowName)
145
+ result[loop] = { alive: false, windowAlive }
146
+ }
147
+
148
+ const ok = loops.every(l => result[l]?.alive)
149
+ return { ok, review: result['review-loop'], respond: result['respond-loop'] }
150
+ }
151
+
100
152
  /**
101
153
  * tmux 세션에서 review-loop + respond-loop을 실행
102
154
  * @param {string} sessionName
155
+ * @param {string} stateDir
103
156
  */
104
- export function startTmuxLoops(sessionName = 'bylane-loops') {
157
+ export function startTmuxLoops(sessionName = 'bylane-loops', stateDir = '.bylane/state') {
105
158
  if (isTmuxSessionAlive(sessionName)) {
106
159
  console.log(`[tmux] 세션 '${sessionName}'이 이미 실행 중입니다.`)
107
160
  return { started: false, reason: 'already_running' }
@@ -118,8 +171,21 @@ export function startTmuxLoops(sessionName = 'bylane-loops') {
118
171
  { stdio: 'inherit' }
119
172
  )
120
173
 
121
- console.log(`[tmux] 세션 '${sessionName}' 시작 완료 (review-loop + respond-loop)`)
122
- return { started: true, sessionName }
174
+ console.log(`[tmux] 세션 '${sessionName}' 시작 루프 기동 확인 중...`)
175
+ const verification = verifyTmuxLoops(sessionName, stateDir)
176
+
177
+ if (verification.ok) {
178
+ console.log(`[tmux] review-loop (PID: ${verification.review.pid}) ✓`)
179
+ console.log(`[tmux] respond-loop (PID: ${verification.respond.pid}) ✓`)
180
+ } else {
181
+ const reviewStatus = verification.review?.alive ? `✓ PID: ${verification.review.pid}` : `✗ 기동 실패 (창: ${verification.review?.windowAlive ? '있음' : '없음'})`
182
+ const respondStatus = verification.respond?.alive ? `✓ PID: ${verification.respond.pid}` : `✗ 기동 실패 (창: ${verification.respond?.windowAlive ? '있음' : '없음'})`
183
+ console.log(`[tmux] review-loop ${reviewStatus}`)
184
+ console.log(`[tmux] respond-loop ${respondStatus}`)
185
+ console.log(`[tmux] 일부 루프가 정상 기동되지 않았습니다. tmux attach -t ${sessionName} 으로 확인하세요.`)
186
+ }
187
+
188
+ return { started: true, sessionName, verified: verification.ok, verification }
123
189
  }
124
190
 
125
191
  /**
@@ -174,9 +240,10 @@ export function resolveLoopMode() {
174
240
  if (mode === 'tmux') {
175
241
  try {
176
242
  execSync('which tmux', { stdio: ['pipe', 'pipe', 'pipe'] })
243
+ execSync('tmux -V', { stdio: ['pipe', 'pipe', 'pipe'] })
177
244
  return 'tmux'
178
245
  } catch {
179
- console.log('[loop] tmux 미설치 — process 모드로 fallback합니다.')
246
+ console.log('[loop] tmux 사용할 수 없음 — process 모드로 fallback합니다.')
180
247
  return 'process'
181
248
  }
182
249
  }
@@ -36,7 +36,7 @@ poller.onChange((states) => {
36
36
  pipeline.update(states, readSubagents())
37
37
  log.update(states)
38
38
  queue.update()
39
- status.update()
39
+ status.update(lastStates)
40
40
  })
41
41
 
42
42
  // 's' — 실행 중인 루프/에이전트 선택 종료
@@ -1,6 +1,14 @@
1
1
  import blessed from 'blessed'
2
+ import { execSync } from 'child_process'
2
3
  import { loadConfig } from '../../config.js'
3
4
 
5
+ function isTmuxAlive(sessionName) {
6
+ try {
7
+ execSync(`tmux has-session -t ${sessionName}`, { stdio: ['pipe', 'pipe', 'pipe'] })
8
+ return true
9
+ } catch { return false }
10
+ }
11
+
4
12
  export function createStatusPanel(screen) {
5
13
  const box = blessed.box({
6
14
  top: '60%',
@@ -15,13 +23,35 @@ export function createStatusPanel(screen) {
15
23
  screen.append(box)
16
24
 
17
25
  return {
18
- update() {
26
+ update(states = {}) {
19
27
  const config = loadConfig()
20
28
  const check = (v) => v ? '{green-fg}OK{/green-fg}' : '{red-fg}--{/red-fg}'
29
+
30
+ // 루프 상태
31
+ const reviewState = states['review-loop']
32
+ const respondState = states['respond-loop']
33
+ const loopMode = config.loop?.mode ?? 'tmux'
34
+ const sessionName = config.loop?.sessionName ?? 'bylane-loops'
35
+ const tmuxAlive = loopMode === 'tmux' ? isTmuxAlive(sessionName) : null
36
+
37
+ const loopStatus = (state) => {
38
+ if (!state) return '{red-fg}미실행{/red-fg}'
39
+ if (state.status === 'running') return `{green-fg}실행중{/green-fg} PID:${state.pid ?? '?'}`
40
+ if (state.status === 'stopped') return '{yellow-fg}중지됨{/yellow-fg}'
41
+ return `{grey-fg}${state.status ?? '알 수 없음'}{/grey-fg}`
42
+ }
43
+
21
44
  const lines = [
45
+ ` ── 루프 ──`,
46
+ ` review-loop ${loopStatus(reviewState)}`,
47
+ ` respond-loop ${loopStatus(respondState)}`,
48
+ tmuxAlive !== null
49
+ ? ` tmux [${sessionName}] ${tmuxAlive ? '{green-fg}세션 유지{/green-fg}' : '{red-fg}세션 없음{/red-fg}'}`
50
+ : ` 모드: process`,
51
+ ``,
52
+ ` ── 연동 ──`,
22
53
  ` GitHub ${check(true)} 연결됨`,
23
54
  ` Linear ${check(config.trackers.linear.enabled)} ${config.trackers.linear.enabled ? '활성' : '비활성'}`,
24
- ` Figma MCP ${check(config.extensions.figma.enabled)} ${config.extensions.figma.enabled ? '활성' : '비활성'}`,
25
55
  ` Slack ${check(config.notifications.slack.enabled)} ${config.notifications.slack.channel || '미설정'}`,
26
56
  ` Telegram ${check(config.notifications.telegram.enabled)} ${config.notifications.telegram.chatId || '미설정'}`,
27
57
  ``,
package/src/preflight.js CHANGED
@@ -20,7 +20,18 @@ function run(cmd) {
20
20
  export function checkTmux() {
21
21
  const which = run('which tmux')
22
22
  if (!which.ok) return { ok: false, reason: 'tmux 미설치 — loop을 process 모드로 실행합니다', fix: 'brew install tmux # 또는 https://github.com/tmux/tmux' }
23
- return { ok: true, detail: which.out }
23
+
24
+ const version = run('tmux -V')
25
+ if (!version.ok) return { ok: false, reason: 'tmux 바이너리가 있으나 실행 불가', fix: 'tmux 재설치 또는 PATH 확인' }
26
+
27
+ // 실제 세션 생성/삭제로 동작 검증
28
+ const testSession = `bylane-preflight-test-${Date.now()}`
29
+ const create = run(`tmux new-session -d -s ${testSession}`)
30
+ if (!create.ok) return { ok: false, reason: `tmux 세션 생성 실패: ${create.out || '알 수 없는 오류'}`, fix: 'tmux 서버 상태 확인: tmux kill-server && tmux new-session -d -s test' }
31
+
32
+ run(`tmux kill-session -t ${testSession}`)
33
+
34
+ return { ok: true, detail: version.out }
24
35
  }
25
36
 
26
37
  function checkGhCli() {