@elyun/bylane 1.16.0 → 1.18.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/src/cleanup.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * cleanup.js
3
+ * 상태 파일 정리, 권한 수정, 좀비 프로세스/에이전트 초기화
4
+ * monitor [r] 키 또는 `npx @elyun/bylane cleanup`으로 실행
5
+ */
6
+ import { readdirSync, chmodSync, existsSync, unlinkSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { readState, writeState } from './state.js'
9
+
10
+ const STATE_DIR = '.bylane/state'
11
+ const STALE_MS = 30 * 60 * 1000 // 30분 이상 in_progress → 초기화
12
+ const SUBAGENTS_FILE = join(STATE_DIR, 'subagents.json')
13
+
14
+ /** 프로세스가 살아있는지 확인 (kill -0) */
15
+ function isPidAlive(pid) {
16
+ try {
17
+ process.kill(Number(pid), 0)
18
+ return true
19
+ } catch {
20
+ return false
21
+ }
22
+ }
23
+
24
+ /** subagents.json에서 죽은 PID 제거 */
25
+ function cleanSubagents() {
26
+ if (!existsSync(SUBAGENTS_FILE)) return []
27
+ let data
28
+ try { data = JSON.parse(readFileSync(SUBAGENTS_FILE, 'utf8')) } catch { return [] }
29
+
30
+ const before = (data.active ?? []).length
31
+ const active = (data.active ?? []).filter(a => {
32
+ if (!a.pid) return false
33
+ return isPidAlive(a.pid)
34
+ })
35
+ const cleaned = before - active.length
36
+
37
+ if (cleaned > 0) {
38
+ writeFileSync(SUBAGENTS_FILE, JSON.stringify({ ...data, active }, null, 2))
39
+ }
40
+ return cleaned > 0 ? [`subagents.json: active ${before}개 → ${active.length}개 (${cleaned}개 제거)`] : []
41
+ }
42
+
43
+ /** 디렉토리 및 파일 권한 수정 */
44
+ function fixPermissions() {
45
+ const fixed = []
46
+ try {
47
+ chmodSync(STATE_DIR, 0o755)
48
+ } catch {}
49
+
50
+ const files = existsSync(STATE_DIR)
51
+ ? readdirSync(STATE_DIR).filter(f => f.endsWith('.json'))
52
+ : []
53
+
54
+ for (const file of files) {
55
+ try {
56
+ chmodSync(join(STATE_DIR, file), 0o644)
57
+ } catch (e) {
58
+ fixed.push(`권한 수정 실패: ${file} — ${e.message}`)
59
+ }
60
+ }
61
+ return fixed
62
+ }
63
+
64
+ /**
65
+ * 전체 정리 실행
66
+ * @returns {{ fixed: string[], killed: string[], reset: string[], cleared: string[] }}
67
+ */
68
+ export function runCleanup() {
69
+ const result = { fixed: [], killed: [], reset: [], cleared: [] }
70
+
71
+ if (!existsSync(STATE_DIR)) {
72
+ mkdirSync(STATE_DIR, { recursive: true })
73
+ return result
74
+ }
75
+
76
+ // 1. 파일 권한 수정
77
+ result.fixed.push(...fixPermissions())
78
+
79
+ // 2. 상태 파일 순회
80
+ const files = readdirSync(STATE_DIR).filter(f => f.endsWith('.json'))
81
+
82
+ for (const file of files) {
83
+ const name = file.replace('.json', '')
84
+
85
+ // 특수 파일 건너뜀
86
+ if (name === 'cancel') continue
87
+
88
+ // subagents 별도 처리
89
+ if (name === 'subagents') {
90
+ result.cleared.push(...cleanSubagents())
91
+ continue
92
+ }
93
+
94
+ const state = readState(name, STATE_DIR)
95
+ if (!state) continue
96
+
97
+ // 루프: PID가 죽었으면 stopped로 전환
98
+ if (name.endsWith('-loop') && state.pid && state.status === 'running') {
99
+ if (!isPidAlive(state.pid)) {
100
+ writeState(name, { ...state, status: 'stopped', stoppedAt: new Date().toISOString() }, STATE_DIR)
101
+ result.killed.push(`${name}: PID ${state.pid} 없음 → stopped`)
102
+ }
103
+ }
104
+
105
+ // in_progress 상태가 30분 이상 → failed로 초기화
106
+ if (state.status === 'in_progress' && state.startedAt) {
107
+ const age = Date.now() - new Date(state.startedAt).getTime()
108
+ if (age > STALE_MS) {
109
+ writeState(name, {
110
+ ...state,
111
+ status: 'failed',
112
+ error: `stale: ${Math.floor(age / 60000)}분 초과 in_progress`
113
+ }, STATE_DIR)
114
+ result.reset.push(`${name}: ${Math.floor(age / 60000)}분 in_progress → failed`)
115
+ }
116
+ }
117
+
118
+ // 큐 파일: responding/reviewing 상태가 남아있으면 pending으로 복구
119
+ if ((name === 'review-queue' || name === 'respond-queue') && Array.isArray(state.queue)) {
120
+ const fixed = state.queue.map(item =>
121
+ item.status === 'reviewing' || item.status === 'responding'
122
+ ? { ...item, status: 'pending', recoveredAt: new Date().toISOString() }
123
+ : item
124
+ )
125
+ const changedCount = fixed.filter((item, i) => item.status !== state.queue[i].status).length
126
+ if (changedCount > 0) {
127
+ writeState(name, { ...state, queue: fixed }, STATE_DIR)
128
+ result.reset.push(`${name}: ${changedCount}개 진행중 항목 → pending 복구`)
129
+ }
130
+ }
131
+ }
132
+
133
+ return result
134
+ }
135
+
136
+ /** 결과를 사람이 읽기 쉬운 문자열로 출력 */
137
+ export function formatCleanupResult(result) {
138
+ const lines = []
139
+ const total = Object.values(result).reduce((s, arr) => s + arr.length, 0)
140
+
141
+ if (total === 0) {
142
+ lines.push('정리할 항목 없음.')
143
+ return lines.join('\n')
144
+ }
145
+
146
+ if (result.killed.length) lines.push(' [종료]', ...result.killed.map(s => ` · ${s}`))
147
+ if (result.reset.length) lines.push(' [초기화]', ...result.reset.map(s => ` · ${s}`))
148
+ if (result.cleared.length) lines.push(' [정리]', ...result.cleared.map(s => ` · ${s}`))
149
+ if (result.fixed.length) lines.push(' [오류]', ...result.fixed.map(s => ` · ${s}`))
150
+
151
+ return lines.join('\n')
152
+ }
package/src/cli.js CHANGED
@@ -141,6 +141,45 @@ function install() {
141
141
 
142
142
  if (command === 'install') {
143
143
  install()
144
+ } else if (command === 'models') {
145
+ // models → 에이전트별 모델 목록 출력 (KEY=VALUE 형식)
146
+ const { loadConfig, getAgentModel } = await import('./config.js')
147
+ const config = loadConfig()
148
+ const agents = ['orchestrator','issue-agent','code-agent','test-agent',
149
+ 'commit-agent','pr-agent','review-agent','respond-agent','notify-agent','analyze-agent']
150
+ agents.forEach(a => console.log(`${a}=${getAgentModel(config, a)}`))
151
+ } else if (command === 'branch') {
152
+ // branch ISSUE_NUMBER → 브랜치명 출력
153
+ const issueNumber = Number(args[1])
154
+ if (!issueNumber) { console.error('사용법: bylane branch <issueNumber>'); process.exit(1) }
155
+ const { buildBranchNameFromConfig } = await import('./branch.js')
156
+ const { loadConfig } = await import('./config.js')
157
+ console.log(buildBranchNameFromConfig(loadConfig(), issueNumber))
158
+ } else if (command === 'state') {
159
+ // state write AGENT '{"status":"in_progress",...}'
160
+ // state append AGENT "메시지"
161
+ // state read AGENT
162
+ const subCmd = args[1]
163
+ const agentName = args[2]
164
+ const payload = args[3]
165
+ const { writeState, appendLog, readState } = await import('./state.js')
166
+
167
+ if (subCmd === 'write' && agentName && payload) {
168
+ writeState(agentName, JSON.parse(payload))
169
+ } else if (subCmd === 'append' && agentName && payload) {
170
+ appendLog(agentName, payload)
171
+ } else if (subCmd === 'read' && agentName) {
172
+ console.log(JSON.stringify(readState(agentName), null, 2))
173
+ } else {
174
+ console.error('사용법: bylane state <write|append|read> <agentName> [payload]')
175
+ process.exit(1)
176
+ }
177
+ } else if (command === 'cleanup') {
178
+ const { runCleanup, formatCleanupResult } = await import('./cleanup.js')
179
+ console.log('\n byLane 상태 정리 중...\n')
180
+ const result = runCleanup()
181
+ console.log(formatCleanupResult(result))
182
+ console.log('\n 완료.\n')
144
183
  } else if (command === 'monitor') {
145
184
  // 항상 현재 패키지의 모니터 실행 (버전 일치 보장)
146
185
  const monitorPath = join(__dirname, 'monitor', 'index.js')
@@ -1,14 +1,17 @@
1
1
  #!/usr/bin/env node
2
+ import blessed from 'blessed'
2
3
  import { createLayout } from './layout.js'
3
4
  import { createPoller } from './poller.js'
4
5
  import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs'
5
6
  import { join } from 'path'
7
+ import { runCleanup, formatCleanupResult } from '../cleanup.js'
6
8
 
7
9
  const { screen, header, pipeline, log, queue, status, onCleanup } = createLayout()
8
10
  const poller = createPoller()
9
11
 
10
12
  const startTime = Date.now()
11
13
  const STATE_DIR = '.bylane/state'
14
+ let lastStates = {}
12
15
  const SUBAGENTS_FILE = join(STATE_DIR, 'subagents.json')
13
16
  const CANCEL_FILE = join(STATE_DIR, 'cancel.json')
14
17
 
@@ -20,6 +23,7 @@ function readSubagents() {
20
23
  onCleanup(() => poller.stop())
21
24
 
22
25
  poller.onChange((states) => {
26
+ lastStates = states
23
27
  const active = Object.values(states).find(s => s.status === 'in_progress')
24
28
  const workflowTitle = active ? (active.currentTask ?? active.agent) : 'Idle'
25
29
 
@@ -34,8 +38,99 @@ poller.onChange((states) => {
34
38
  status.update()
35
39
  })
36
40
 
41
+ // 's' — 실행 중인 루프 선택 종료
42
+ screen.key('s', () => {
43
+ const runningLoops = Object.entries(lastStates)
44
+ .filter(([name, s]) => name.endsWith('-loop') && s?.status === 'running' && s?.pid)
45
+ .map(([name, s]) => ({ name, pid: s.pid }))
46
+
47
+ if (runningLoops.length === 0) {
48
+ header.update({
49
+ workflowTitle: '실행 중인 루프 없음',
50
+ time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
51
+ elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
52
+ })
53
+ screen.render()
54
+ return
55
+ }
56
+
57
+ // blessed list 오버레이
58
+ const list = blessed.list({
59
+ top: 'center',
60
+ left: 'center',
61
+ width: 40,
62
+ height: runningLoops.length + 4,
63
+ label: ' 종료할 루프 선택 (Enter/Esc) ',
64
+ tags: true,
65
+ border: { type: 'line' },
66
+ style: {
67
+ border: { fg: 'yellow' },
68
+ selected: { bg: 'blue', fg: 'white' },
69
+ item: { fg: 'white' }
70
+ },
71
+ keys: true,
72
+ items: runningLoops.map(l => ` ${l.name} (PID: ${l.pid})`)
73
+ })
74
+
75
+ screen.append(list)
76
+ list.focus()
77
+ screen.render()
78
+
79
+ list.on('select', (item, idx) => {
80
+ const loop = runningLoops[idx]
81
+ try {
82
+ process.kill(loop.pid, 'SIGTERM')
83
+ header.update({
84
+ workflowTitle: `${loop.name} 종료 요청됨 (PID: ${loop.pid})`,
85
+ time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
86
+ elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
87
+ })
88
+ } catch {
89
+ header.update({
90
+ workflowTitle: `${loop.name} 종료 실패 (이미 종료됨?)`,
91
+ time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
92
+ elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
93
+ })
94
+ }
95
+ screen.remove(list)
96
+ screen.render()
97
+ })
98
+
99
+ list.key(['escape', 'q'], () => {
100
+ screen.remove(list)
101
+ screen.render()
102
+ })
103
+ })
104
+
105
+ // 'r' — 상태 정리 (권한 수정, 좀비 초기화, 큐 복구)
106
+ screen.key('r', () => {
107
+ header.update({
108
+ workflowTitle: '상태 정리 중...',
109
+ time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
110
+ elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
111
+ })
112
+ screen.render()
113
+
114
+ try {
115
+ const result = runCleanup()
116
+ const summary = formatCleanupResult(result)
117
+ const total = Object.values(result).reduce((s, a) => s + a.length, 0)
118
+ header.update({
119
+ workflowTitle: total > 0 ? `정리 완료 (${total}건)` : '정리 완료 — 이상 없음',
120
+ time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
121
+ elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
122
+ })
123
+ } catch (e) {
124
+ header.update({
125
+ workflowTitle: `정리 실패: ${e.message}`,
126
+ time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
127
+ elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
128
+ })
129
+ }
130
+ screen.render()
131
+ })
132
+
37
133
  // 'c' — 하위 에이전트 취소 플래그 토글
38
- const cancelLabel = () => existsSync(CANCEL_FILE) ? '[취소 활성]' : ''
39
134
  screen.key('c', () => {
40
135
  if (existsSync(CANCEL_FILE)) {
41
136
  unlinkSync(CANCEL_FILE)
@@ -62,7 +62,7 @@ export function createLayout() {
62
62
  left: 0,
63
63
  width: '100%',
64
64
  height: 1,
65
- content: ' [q]종료 [c]에이전트취소토글 [Tab]포커스 [j/k]로그스크롤',
65
+ content: ' [q]종료 [r]상태정리 [c]에이전트취소토글 [s]루프종료 [Tab]포커스 [j/k]로그스크롤',
66
66
  style: { fg: 'black', bg: 'cyan' }
67
67
  })
68
68
  screen.append(footer)
@@ -186,5 +186,5 @@ process.on('SIGTERM', () => {
186
186
  process.exit(0)
187
187
  })
188
188
 
189
- writeState('respond-loop', { status: 'running', startedAt: new Date().toISOString() }, STATE_DIR)
189
+ writeState('respond-loop', { status: 'running', startedAt: new Date().toISOString(), pid: process.pid }, STATE_DIR)
190
190
  console.log('respond-loop 시작. Ctrl+C로 종료.')
@@ -133,5 +133,5 @@ process.on('SIGTERM', () => {
133
133
  process.exit(0)
134
134
  })
135
135
 
136
- writeState('review-loop', { status: 'running', startedAt: new Date().toISOString() }, STATE_DIR)
136
+ writeState('review-loop', { status: 'running', startedAt: new Date().toISOString(), pid: process.pid }, STATE_DIR)
137
137
  console.log('review-loop 시작. Ctrl+C로 종료.')
@@ -1,86 +1,56 @@
1
- # byLane 리뷰 템플릿
1
+ # 리뷰 템플릿
2
2
 
3
3
  이 파일을 복사해 프로젝트별로 커스터마이즈하세요.
4
4
  `.bylane/bylane.json`의 `review.templateFile`에 경로를 지정하면 적용됩니다.
5
5
 
6
6
  ---
7
7
 
8
- ## 코멘트 형식
8
+ ## 인라인 코멘트 형식
9
9
 
10
- 각 리뷰 코멘트는 아래 형식을 따릅니다:
10
+ 각 리뷰 코멘트는 **해당 코드 라인에 직접** 작성합니다.
11
11
 
12
12
  ```
13
- {severity} {title}
13
+ {title}
14
14
 
15
15
  {description}
16
16
 
17
- {code_example}
18
-
19
- {suggestion}
17
+ {suggestion_block}
20
18
  ```
21
19
 
22
20
  ### 필드 설명
23
21
 
24
- - `{severity}` — 심각도 레이블 (`[CRITICAL]` / `[HIGH]` / `[MEDIUM]` / `[LOW]`)
25
- - `{title}` — 문제 제목 (한 줄)
22
+ - `{title}` — 문제 제목 ( 줄, 간결하게)
26
23
  - `{description}` — 문제 설명 및 이유
27
- - `{code_example}` — 문제가 있는 코드 예시 (선택, `review.includeCodeExample: false`로 비활성)
28
- - `{suggestion}` — 수정 방법 또는 개선 예시 코드
29
-
30
- ---
24
+ - `{suggestion_block}` — GitHub suggestion 블록 (수정 제안 코드)
31
25
 
32
- ## 심각도 기준
33
-
34
- | 심각도 | 기준 | 처리 |
35
- |--------|------|------|
36
- | CRITICAL | 버그, 보안 취약점, 데이터 손실 가능성 | 즉시 수정 필요, PR 차단 |
37
- | HIGH | 잘못된 로직, 성능 심각 저하 | 수정 강력 권장 |
38
- | MEDIUM | 코드 품질, 가독성, 테스트 누락 | 개선 권장 |
39
- | LOW | 네이밍, 스타일, 선택적 개선 | 참고용 |
40
-
41
- ---
26
+ ### suggestion 블록 형식
42
27
 
43
- ## 코드 예시 형식
44
-
45
- **Before (문제 코드):**
46
- ```language
47
- // 문제가 있는 코드
28
+ ````
29
+ ```suggestion
30
+ // 수정된 코드 (원본 라인을 그대로 대체)
48
31
  ```
32
+ ````
49
33
 
50
- **After (수정 제안):**
51
- ```language
52
- // 개선된 코드
53
- ```
34
+ GitHub에서 "Apply suggestion" 버튼으로 바로 적용 가능합니다.
54
35
 
55
36
  ---
56
37
 
57
- ## 전체 리뷰 요약 형식
38
+ ## 전체 요약 형식 (PR 전체 코멘트)
58
39
 
59
40
  ```
60
- ## 코드 리뷰 요약
61
-
62
- | 심각도 | 건수 |
63
- |--------|------|
64
- | CRITICAL | N |
65
- | HIGH | N |
66
- | MEDIUM | N |
67
- | LOW | N |
41
+ ## 리뷰 요약
68
42
 
69
43
  ### 주요 발견사항
70
44
  - ...
71
45
 
72
46
  ### 종합 의견
73
47
  ...
74
-
75
- ---
76
- {footer}
77
48
  ```
78
49
 
79
50
  ---
80
51
 
81
52
  ## 푸터
82
53
 
83
- 기본값: `Reviewed by byLane · model: {model}`
54
+ 기본값: `{model} · {date}`
84
55
 
85
- `{model}` — 실제 사용된 모델명으로 자동 치환됩니다.
86
- 커스터마이즈 예시: `AI 리뷰 by byLane (claude-sonnet-4-6) · {date}`
56
+ `{model}`, `{date}` — 실제 모델명과 날짜로 자동 치환됩니다.