@elyun/bylane 1.33.0 → 1.35.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.
@@ -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.33.0",
3
+ "version": "1.35.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) {
@@ -285,14 +286,14 @@ if (command === 'install') {
285
286
  const mode = resolveLoopMode()
286
287
 
287
288
  if (mode === 'tmux') {
288
- startTmuxLoops(sessionName)
289
+ startTmuxLoops(sessionName, { packageRoot: ROOT, projectDir: process.cwd() })
289
290
  console.log(`\n tmux 세션에 접속하려면: tmux attach -t ${sessionName}\n`)
290
291
  } else {
291
292
  // process 모드: 현재 프로세스에서 직접 실행
292
293
  console.log('\n process 모드: review-loop + respond-loop 실행\n')
293
294
  const { spawn } = await import('child_process')
294
- const review = spawn(process.execPath, ['src/review-loop.js'], { stdio: 'inherit', detached: false })
295
- const respond = spawn(process.execPath, ['src/respond-loop.js'], { stdio: 'inherit', detached: false })
295
+ const review = spawn(process.execPath, [join(ROOT, 'src', 'review-loop.js')], { stdio: 'inherit', detached: false, cwd: process.cwd() })
296
+ const respond = spawn(process.execPath, [join(ROOT, 'src', 'respond-loop.js')], { stdio: 'inherit', detached: false, cwd: process.cwd() })
296
297
 
297
298
  function shutdownAll() {
298
299
  review.kill('SIGTERM')
@@ -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
@@ -3,6 +3,7 @@
3
3
  * 루프 프로세스 공통 유틸
4
4
  */
5
5
  import { execSync } from 'child_process'
6
+ import { join } from 'path'
6
7
  import { readState, writeState } from './state.js'
7
8
  import { loadConfig } from './config.js'
8
9
 
@@ -97,29 +98,107 @@ export function isTmuxSessionAlive(sessionName) {
97
98
  }
98
99
  }
99
100
 
101
+ /**
102
+ * tmux 세션의 두 루프가 정상 기동됐는지 검증한다.
103
+ * state 파일에 running 상태가 기록될 때까지 최대 timeoutMs 대기.
104
+ * @param {string} sessionName
105
+ * @param {string} stateDir
106
+ * @param {number} timeoutMs
107
+ * @returns {{ ok: boolean, review: object, respond: object }}
108
+ */
109
+ export function verifyTmuxLoops(sessionName = 'bylane-loops', stateDir = '.bylane/state', timeoutMs = 8000) {
110
+ const loops = ['review-loop', 'respond-loop']
111
+ const result = {}
112
+
113
+ // 1. tmux 세션 및 윈도우 존재 확인
114
+ if (!isTmuxSessionAlive(sessionName)) {
115
+ return { ok: false, review: { alive: false }, respond: { alive: false } }
116
+ }
117
+
118
+ let windows = []
119
+ try {
120
+ const out = execSync(
121
+ `tmux list-windows -t ${sessionName} -F '#{window_name}'`,
122
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
123
+ ).trim()
124
+ windows = out.split('\n').map(w => w.trim())
125
+ } catch { /* 세션이 막 종료됐을 수 있음 */ }
126
+
127
+ // 2. 각 루프의 state 파일이 running 상태가 될 때까지 폴링
128
+ const deadline = Date.now() + timeoutMs
129
+ const pending = new Set(loops)
130
+
131
+ while (pending.size > 0 && Date.now() < deadline) {
132
+ for (const loop of [...pending]) {
133
+ const state = readState(loop, stateDir)
134
+ if (state?.status === 'running' && state?.pid) {
135
+ result[loop] = { alive: true, pid: state.pid, startedAt: state.startedAt }
136
+ pending.delete(loop)
137
+ }
138
+ }
139
+ if (pending.size > 0) execSync('sleep 0.5')
140
+ }
141
+
142
+ // 타임아웃된 루프는 실패로 처리
143
+ for (const loop of pending) {
144
+ const windowName = loop.replace('-loop', '')
145
+ const windowAlive = windows.includes(windowName)
146
+ result[loop] = { alive: false, windowAlive }
147
+ }
148
+
149
+ const ok = loops.every(l => result[l]?.alive)
150
+ return { ok, review: result['review-loop'], respond: result['respond-loop'] }
151
+ }
152
+
100
153
  /**
101
154
  * tmux 세션에서 review-loop + respond-loop을 실행
102
155
  * @param {string} sessionName
156
+ * @param {object} opts
157
+ * @param {string} [opts.stateDir] 상태 디렉토리 (상대경로)
158
+ * @param {string} [opts.packageRoot] 패키지 루트 경로 (스크립트 절대경로 해석용)
159
+ * @param {string} [opts.projectDir] 프로젝트 디렉토리 (tmux CWD, .bylane/ 기준)
103
160
  */
104
- export function startTmuxLoops(sessionName = 'bylane-loops') {
161
+ export function startTmuxLoops(sessionName = 'bylane-loops', opts = {}) {
162
+ const {
163
+ stateDir = '.bylane/state',
164
+ packageRoot = process.cwd(),
165
+ projectDir = process.cwd()
166
+ } = typeof opts === 'string' ? { stateDir: opts } : opts
167
+
105
168
  if (isTmuxSessionAlive(sessionName)) {
106
169
  console.log(`[tmux] 세션 '${sessionName}'이 이미 실행 중입니다.`)
107
170
  return { started: false, reason: 'already_running' }
108
171
  }
109
172
 
110
- // 번째 윈도우: review-loop
173
+ const reviewScript = join(packageRoot, 'src', 'review-loop.js')
174
+ const respondScript = join(packageRoot, 'src', 'respond-loop.js')
175
+
176
+ // 첫 번째 윈도우: review-loop (-c 로 프로젝트 디렉토리에서 실행)
111
177
  execSync(
112
- `tmux new-session -d -s ${sessionName} -n review 'node src/review-loop.js'`,
178
+ `tmux new-session -d -s ${sessionName} -n review -c '${projectDir}' 'node ${reviewScript}'`,
113
179
  { stdio: 'inherit' }
114
180
  )
115
181
  // 두 번째 윈도우: respond-loop
116
182
  execSync(
117
- `tmux new-window -t ${sessionName} -n respond 'node src/respond-loop.js'`,
183
+ `tmux new-window -t ${sessionName} -n respond -c '${projectDir}' 'node ${respondScript}'`,
118
184
  { stdio: 'inherit' }
119
185
  )
120
186
 
121
- console.log(`[tmux] 세션 '${sessionName}' 시작 완료 (review-loop + respond-loop)`)
122
- return { started: true, sessionName }
187
+ console.log(`[tmux] 세션 '${sessionName}' 시작 루프 기동 확인 중...`)
188
+ const verification = verifyTmuxLoops(sessionName, stateDir)
189
+
190
+ if (verification.ok) {
191
+ console.log(`[tmux] review-loop (PID: ${verification.review.pid}) ✓`)
192
+ console.log(`[tmux] respond-loop (PID: ${verification.respond.pid}) ✓`)
193
+ } else {
194
+ const reviewStatus = verification.review?.alive ? `✓ PID: ${verification.review.pid}` : `✗ 기동 실패 (창: ${verification.review?.windowAlive ? '있음' : '없음'})`
195
+ const respondStatus = verification.respond?.alive ? `✓ PID: ${verification.respond.pid}` : `✗ 기동 실패 (창: ${verification.respond?.windowAlive ? '있음' : '없음'})`
196
+ console.log(`[tmux] review-loop ${reviewStatus}`)
197
+ console.log(`[tmux] respond-loop ${respondStatus}`)
198
+ console.log(`[tmux] 일부 루프가 정상 기동되지 않았습니다. tmux attach -t ${sessionName} 으로 확인하세요.`)
199
+ }
200
+
201
+ return { started: true, sessionName, verified: verification.ok, verification }
123
202
  }
124
203
 
125
204
  /**
@@ -174,9 +253,10 @@ export function resolveLoopMode() {
174
253
  if (mode === 'tmux') {
175
254
  try {
176
255
  execSync('which tmux', { stdio: ['pipe', 'pipe', 'pipe'] })
256
+ execSync('tmux -V', { stdio: ['pipe', 'pipe', 'pipe'] })
177
257
  return 'tmux'
178
258
  } catch {
179
- console.log('[loop] tmux 미설치 — process 모드로 fallback합니다.')
259
+ console.log('[loop] tmux 사용할 수 없음 — process 모드로 fallback합니다.')
180
260
  return 'process'
181
261
  }
182
262
  }
@@ -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() {