@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.
- package/commands/bylane-review-agent.md +11 -3
- package/commands/bylane-setup.md +2 -1
- package/package.json +1 -1
- package/src/cli.js +36 -6
- package/src/config.js +1 -1
- package/src/loop-utils.js +87 -7
- package/src/monitor/index.js +1 -1
- package/src/monitor/panels/status.js +32 -2
- package/src/preflight.js +12 -1
|
@@ -18,17 +18,25 @@ description: PR의 diff를 파일별로 분석하여 코드 라인별 인라인
|
|
|
18
18
|
|
|
19
19
|
## GitHub 리뷰 템플릿 탐지
|
|
20
20
|
|
|
21
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
package/commands/bylane-setup.md
CHANGED
|
@@ -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
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'),
|
|
22
|
-
{ src: join(ROOT, '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
|
|
295
|
-
const respond = spawn(process.execPath, ['src
|
|
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
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
|
-
|
|
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
|
|
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
|
|
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}' 시작
|
|
122
|
-
|
|
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
|
|
259
|
+
console.log('[loop] tmux를 사용할 수 없음 — process 모드로 fallback합니다.')
|
|
180
260
|
return 'process'
|
|
181
261
|
}
|
|
182
262
|
}
|
package/src/monitor/index.js
CHANGED
|
@@ -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
|
-
|
|
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() {
|