@elyun/bylane 1.22.0 → 1.24.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/CLAUDE.md +37 -5
- package/README.md +161 -36
- package/commands/bylane-analyze-agent.md +1 -1
- package/commands/bylane-cleanup.md +1 -1
- package/commands/bylane-code-agent.md +79 -15
- package/commands/bylane-commit-agent.md +1 -1
- package/commands/bylane-issue-agent.md +195 -49
- package/commands/bylane-monitor.md +1 -1
- package/commands/bylane-notify-agent.md +1 -1
- package/commands/bylane-orchestrator.md +65 -29
- package/commands/bylane-pr-agent.md +1 -1
- package/commands/bylane-respond-agent.md +1 -1
- package/commands/bylane-respond-loop.md +2 -2
- package/commands/bylane-review-agent.md +1 -1
- package/commands/bylane-review-loop.md +2 -2
- package/commands/bylane-setup.md +36 -5
- package/commands/bylane-test-agent.md +1 -1
- package/commands/bylane.md +92 -51
- package/package.json +1 -1
- package/src/cli.js +102 -1
- package/src/config.js +9 -0
- package/src/loop-utils.js +102 -0
- package/src/memory.js +93 -0
- package/src/preflight.js +14 -2
- package/src/respond-loop.js +10 -12
- package/src/review-loop.js +10 -12
package/commands/bylane.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bylane
|
|
3
|
-
description: byLane 메인
|
|
3
|
+
description: byLane 메인 진입점. 자연어 명령을 키워드 감지하여 적절한 에이전트로 자동 라우팅한다. 복합 의도는 오케스트레이터로 전달.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# /bylane
|
|
@@ -8,33 +8,41 @@ description: byLane 메인 커맨드. 자연어로 전체 개발 워크플로우
|
|
|
8
8
|
## 사용법
|
|
9
9
|
|
|
10
10
|
```
|
|
11
|
-
/bylane [자연어 명령]
|
|
12
|
-
/bylane setup — 셋업 위자드 (재실행 가능)
|
|
13
|
-
/bylane analyze — 프로젝트 분석 후 .claude/instructions/ 에 instruction 파일 생성
|
|
14
|
-
/bylane monitor — 실시간 TUI 대시보드
|
|
15
|
-
/bylane issue [#번호 | 텍스트]
|
|
16
|
-
/bylane code [#번호]
|
|
17
|
-
/bylane test
|
|
18
|
-
/bylane commit
|
|
19
|
-
/bylane pr
|
|
20
|
-
/bylane review [PR번호]
|
|
21
|
-
/bylane review-loop — 5분 주기 자동 리뷰 루프 시작
|
|
22
|
-
/bylane respond [PR번호]
|
|
23
|
-
/bylane respond-loop — 5분 주기 자동 대응 루프 시작
|
|
24
|
-
/bylane notify
|
|
25
|
-
/bylane status — 현재 상태 한 줄 요약
|
|
11
|
+
/bylane [자연어 명령]
|
|
26
12
|
```
|
|
27
13
|
|
|
28
|
-
|
|
14
|
+
자연어로 개발 의도를 전달하면 키워드를 감지해 적절한 에이전트를 자동 실행한다.
|
|
15
|
+
서브커맨드 없이 **자연어만** 받는다. 개별 에이전트는 `/bylane-*`로 직접 실행한다.
|
|
29
16
|
|
|
30
|
-
|
|
17
|
+
### 개별 에이전트 직접 실행
|
|
18
|
+
|
|
19
|
+
| 커맨드 | 설명 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `/bylane-setup` | GitHub 접근, 이슈 트래커, 알림, 팀 모드, 루프, 브랜치, 모델을 단계별로 설정하여 `.bylane/bylane.json` 생성 |
|
|
22
|
+
| `/bylane-monitor` | blessed 기반 TUI 대시보드 실행 안내. 에이전트 상태/큐/로그를 1초 주기로 표시 |
|
|
23
|
+
| `/bylane-cleanup` | 파일 권한 수정, 죽은 PID 정리, 30분 초과 작업 실패 전환, 큐 pending 복구를 일괄 실행 |
|
|
24
|
+
| `/bylane-analyze-agent` | 코드 스타일/디자인 토큰/아키텍처를 분석하여 `.claude/instructions/`에 저장, CLAUDE.md에 import 추가 |
|
|
25
|
+
| `/bylane-issue-agent` | 코드베이스 병렬 분석 + 사용자 문답으로 전략 스펙 포함 GitHub 이슈 작성. code-agent 입력이 됨 |
|
|
26
|
+
| `/bylane-code-agent` | 이슈 전략 스펙 기반으로 브랜치 생성 + 코드 구현. issueMemory 기록, 실패 시 피드백 재시도 |
|
|
27
|
+
| `/bylane-test-agent` | 테스트 실행 후 PASS/FAIL 반환. FAIL 시 failureDetails를 state에 기록하여 재시도 피드백 제공 |
|
|
28
|
+
| `/bylane-commit-agent` | 변경 파일 분석 → conventional commit(feat/fix/refactor) 메시지 자동 생성. 시크릿 자동 제외 |
|
|
29
|
+
| `/bylane-pr-agent` | 전체 커밋 히스토리 분석 → PR 제목/요약/테스트 계획 자동 작성. 팀 모드 시 리뷰어 할당 |
|
|
30
|
+
| `/bylane-review-agent` | PR diff를 파일별 분석 → 라인별 인라인 코멘트. grammar/domain/code/security 검사, 심각도 분류 |
|
|
31
|
+
| `/bylane-respond-agent` | 리뷰 코멘트 분석 → accept(코드 수정+재커밋) 또는 rebut(근거 반박). CHANGES_REQUESTED 포함 처리 |
|
|
32
|
+
| `/bylane-review-loop` | 설정 주기로 review 요청 PR 감지 → review-queue 기록 → review-agent 자동 실행. 재요청 포함 |
|
|
33
|
+
| `/bylane-respond-loop` | 설정 주기로 내 PR 리뷰/코멘트 감지 → respond-queue 기록 → respond-agent 자동 실행. 재감지 포함 |
|
|
34
|
+
| `/bylane-notify-agent` | 워크플로우 완료/테스트 실패/리뷰 대기/개입 필요 시 Slack/Telegram 알림 발송 |
|
|
35
|
+
|
|
36
|
+
## 사전 점검 (자동 실행)
|
|
37
|
+
|
|
38
|
+
키워드 라우팅 전 아래 점검을 실행한다:
|
|
31
39
|
|
|
32
40
|
```bash
|
|
33
41
|
npx @elyun/bylane preflight
|
|
34
42
|
```
|
|
35
43
|
|
|
36
44
|
점검 항목:
|
|
37
|
-
- `.bylane/bylane.json` 존재 여부 → 없으면
|
|
45
|
+
- `.bylane/bylane.json` 존재 여부 → 없으면 `bylane-setup` 스킬 실행 후 중단
|
|
38
46
|
- GitHub 접근 방법 (`github.method` 기준):
|
|
39
47
|
- `cli`: `gh auth status` → 로그인 안 됐으면 `gh auth login` 안내
|
|
40
48
|
- `api`: `GITHUB_TOKEN` 환경변수 → 없으면 설정 방법 안내
|
|
@@ -42,44 +50,77 @@ npx @elyun/bylane preflight
|
|
|
42
50
|
- 알림 채널 (활성화된 경우만): Slack 채널 설정 여부, Telegram 토큰 여부
|
|
43
51
|
|
|
44
52
|
문제가 있으면 각 항목마다 수정 방법을 출력하고 중단한다.
|
|
45
|
-
|
|
53
|
+
**셋업/상태 관련 키워드는 점검 없이 바로 실행한다.**
|
|
46
54
|
|
|
47
|
-
##
|
|
55
|
+
## 키워드 감지 및 라우팅
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
입력된 자연어에서 키워드를 감지하여 적절한 스킬로 라우팅한다.
|
|
58
|
+
반드시 아래 표의 **"실행 스킬" 전체 이름**을 Skill 도구에 전달한다.
|
|
59
|
+
**매칭되는 키워드가 없거나 복합 의도이면 `bylane-orchestrator`로 전달한다.**
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|---|---|
|
|
54
|
-
| (없음 or 자연어) | `bylane-orchestrator` |
|
|
55
|
-
| `setup` | `bylane-setup` |
|
|
56
|
-
| `monitor` | 아래 참조 |
|
|
57
|
-
| `issue` | `bylane-issue-agent` |
|
|
58
|
-
| `code` | `bylane-code-agent` |
|
|
59
|
-
| `test` | `bylane-test-agent` |
|
|
60
|
-
| `commit` | `bylane-commit-agent` |
|
|
61
|
-
| `pr` | `bylane-pr-agent` |
|
|
62
|
-
| `review` | `bylane-review-agent` |
|
|
63
|
-
| `analyze` | `bylane-analyze-agent` |
|
|
64
|
-
| `review-loop` | `bylane-review-loop` |
|
|
65
|
-
| `respond` | `bylane-respond-agent` |
|
|
66
|
-
| `respond-loop` | `bylane-respond-loop` |
|
|
67
|
-
| `notify` | `bylane-notify-agent` |
|
|
68
|
-
| `status` | `.bylane/state/` 파일 읽어 한 줄 요약 출력 |
|
|
69
|
-
| `preflight` | 연동 상태 점검 및 문제 안내 |
|
|
70
|
-
|
|
71
|
-
## monitor 서브커맨드
|
|
61
|
+
### 유틸리티 (점검 생략)
|
|
72
62
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
63
|
+
| 키워드 | 실행 스킬 | 예시 입력 |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `setup`, `셋업`, `설정 위자드`, `초기 설정`, `설치` | `bylane-setup` | "셋업 다시 해줘", "초기 설정" |
|
|
66
|
+
| `monitor`, `모니터`, `대시보드`, `dashboard` | `bylane-monitor` | "모니터 켜줘", "대시보드 보고 싶어" |
|
|
67
|
+
| `cleanup`, `정리`, `상태 초기화`, `리셋`, `reset` | `bylane-cleanup` | "상태 정리해줘", "리셋" |
|
|
68
|
+
| `status`, `상태`, `현황`, `지금 뭐 하고 있어` | 상태 요약 출력 | "상태 보여줘", "현황 알려줘" |
|
|
69
|
+
| `preflight`, `점검`, `연동 확인`, `헬스체크` | preflight 실행 | "점검해줘", "연동 확인" |
|
|
76
70
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
71
|
+
### 분석
|
|
72
|
+
|
|
73
|
+
| 키워드 | 실행 스킬 | 예시 입력 |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `analyze`, `분석`, `프로젝트 분석`, `코드 분석`, `구조 분석` | `bylane-analyze-agent` | "프로젝트 분석해줘", "코드 구조 파악해줘" |
|
|
76
|
+
|
|
77
|
+
### 이슈 & 구현
|
|
78
|
+
|
|
79
|
+
| 키워드 | 실행 스킬 | 예시 입력 |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `이슈 만들어`, `이슈 작성`, `이슈 생성`, `issue 생성` | `bylane-issue-agent` | "이슈 만들어줘", "버그 이슈 작성" |
|
|
82
|
+
| `#N 구현`, `이슈 #N`, `코드 작성`, `코딩`, `구현` + 이슈번호 | `bylane-code-agent` | "#32 구현해줘", "이슈 #15 코딩" |
|
|
83
|
+
|
|
84
|
+
### 테스트 & 커밋 & PR
|
|
85
|
+
|
|
86
|
+
| 키워드 | 실행 스킬 | 예시 입력 |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `test`, `테스트`, `검증`, `테스트 돌려`, `시험` | `bylane-test-agent` | "테스트 돌려줘", "검증해줘" |
|
|
89
|
+
| `commit`, `커밋`, `커밋해`, `변경사항 저장` | `bylane-commit-agent` | "커밋해줘", "변경사항 커밋" |
|
|
90
|
+
| `pr`, `PR`, `풀리퀘`, `풀 리퀘스트`, `PR 생성`, `PR 만들어` | `bylane-pr-agent` | "PR 만들어줘", "풀리퀘 올려줘" |
|
|
91
|
+
|
|
92
|
+
### 리뷰 & 대응
|
|
93
|
+
|
|
94
|
+
| 키워드 | 실행 스킬 | 예시 입력 |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `리뷰해`, `코드 리뷰`, `review` + PR번호 | `bylane-review-agent` | "PR #45 리뷰해줘", "코드 리뷰 해줘" |
|
|
97
|
+
| `반영`, `반박`, `대응`, `respond`, `수정 반영`, `리뷰 수락` | `bylane-respond-agent` | "리뷰 반영해줘", "#45 반박해" |
|
|
98
|
+
| `리뷰 루프`, `자동 리뷰`, `리뷰 자동화`, `review loop` | `bylane-review-loop` | "자동 리뷰 시작", "리뷰 루프 켜줘" |
|
|
99
|
+
| `대응 루프`, `자동 대응`, `대응 자동화`, `respond loop` | `bylane-respond-loop` | "자동 대응 시작", "대응 루프 시작" |
|
|
100
|
+
| `루프 시작`, `loop start`, `루프 켜`, `자동화 시작` | `bylane-review-loop` + `bylane-respond-loop` 순차 실행 | "루프 시작해줘", "자동화 켜" |
|
|
101
|
+
| `루프 종료`, `loop stop`, `루프 꺼`, `자동화 중단` | 루프 종료 안내 | "루프 꺼줘", "자동화 중단" |
|
|
102
|
+
|
|
103
|
+
### 알림
|
|
104
|
+
|
|
105
|
+
| 키워드 | 실행 스킬 | 예시 입력 |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `notify`, `알림`, `알려줘`, `슬랙`, `텔레그램`, `통보` | `bylane-notify-agent` | "슬랙에 알림 보내줘" |
|
|
108
|
+
|
|
109
|
+
### 복합 의도 → 오케스트레이터
|
|
110
|
+
|
|
111
|
+
아래 경우는 `bylane-orchestrator`로 전달한다:
|
|
112
|
+
- 키워드가 2개 이상 카테고리에 걸칠 때 ("이슈 만들고 구현까지 해줘")
|
|
113
|
+
- 기능 요청 자연어 ("다크모드 토글 추가해줘", "로그인 페이지 만들어줘")
|
|
114
|
+
- 매칭되는 키워드가 없을 때
|
|
115
|
+
|
|
116
|
+
## 라우팅 우선순위
|
|
117
|
+
|
|
118
|
+
1. **유틸리티 키워드** — setup, monitor, cleanup, status, preflight
|
|
119
|
+
2. **루프 키워드** — 리뷰 루프, 대응 루프, 루프 시작/종료
|
|
120
|
+
3. **단일 에이전트 키워드** — review, commit, test, pr 등
|
|
121
|
+
4. **복합 의도 / 자연어** → `bylane-orchestrator`
|
|
81
122
|
|
|
82
|
-
## status
|
|
123
|
+
## status 동작
|
|
83
124
|
|
|
84
125
|
`.bylane/state/*.json` 파일을 읽어 각 에이전트의 현재 상태를 한 줄로 출력:
|
|
85
126
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -185,6 +185,107 @@ if (command === 'install') {
|
|
|
185
185
|
const result = runCleanup()
|
|
186
186
|
console.log(formatCleanupResult(result))
|
|
187
187
|
console.log('\n 완료.\n')
|
|
188
|
+
} else if (command === 'memory') {
|
|
189
|
+
// memory read ISSUE_NUMBER
|
|
190
|
+
// memory append ISSUE_NUMBER AGENT_NAME "content"
|
|
191
|
+
// memory list
|
|
192
|
+
const subCmd = args[1]
|
|
193
|
+
const { loadConfig } = await import('./config.js')
|
|
194
|
+
const { readIssueMemory, appendIssueMemory, listIssueMemories } = await import('./memory.js')
|
|
195
|
+
const config = loadConfig()
|
|
196
|
+
|
|
197
|
+
if (subCmd === 'read') {
|
|
198
|
+
const issueNumber = args[2]
|
|
199
|
+
if (!issueNumber) { console.error('사용법: bylane memory read <issueNumber>'); process.exit(1) }
|
|
200
|
+
const content = readIssueMemory(issueNumber, config)
|
|
201
|
+
console.log(content ?? '(메모리 없음)')
|
|
202
|
+
} else if (subCmd === 'append') {
|
|
203
|
+
const issueNumber = args[2]
|
|
204
|
+
const agentName = args[3]
|
|
205
|
+
const content = args[4]
|
|
206
|
+
if (!issueNumber || !agentName || !content) {
|
|
207
|
+
console.error('사용법: bylane memory append <issueNumber> <agentName> "content"')
|
|
208
|
+
process.exit(1)
|
|
209
|
+
}
|
|
210
|
+
appendIssueMemory(issueNumber, agentName, content, config)
|
|
211
|
+
console.log(` + Issue #${issueNumber} 메모리 기록 완료`)
|
|
212
|
+
} else if (subCmd === 'list') {
|
|
213
|
+
const issues = listIssueMemories(config)
|
|
214
|
+
if (issues.length === 0) {
|
|
215
|
+
console.log(' (기록된 이슈 메모리 없음)')
|
|
216
|
+
} else {
|
|
217
|
+
console.log(' 기록된 이슈 메모리:')
|
|
218
|
+
issues.forEach(n => console.log(` - Issue #${n}`))
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
console.error('사용법: bylane memory <read|append|list> [issueNumber] [agentName] [content]')
|
|
222
|
+
process.exit(1)
|
|
223
|
+
}
|
|
224
|
+
} else if (command === 'loop') {
|
|
225
|
+
const subCmd = args[1] || 'start'
|
|
226
|
+
const { resolveLoopMode, startTmuxLoops, stopTmuxLoops, isTmuxSessionAlive } = await import('./loop-utils.js')
|
|
227
|
+
const { loadConfig } = await import('./config.js')
|
|
228
|
+
const config = loadConfig()
|
|
229
|
+
const sessionName = config.loop?.sessionName ?? 'bylane-loops'
|
|
230
|
+
|
|
231
|
+
if (subCmd === 'start') {
|
|
232
|
+
const mode = resolveLoopMode()
|
|
233
|
+
|
|
234
|
+
if (mode === 'tmux') {
|
|
235
|
+
startTmuxLoops(sessionName)
|
|
236
|
+
console.log(`\n tmux 세션에 접속하려면: tmux attach -t ${sessionName}\n`)
|
|
237
|
+
} else {
|
|
238
|
+
// process 모드: 현재 프로세스에서 직접 실행
|
|
239
|
+
console.log('\n process 모드: review-loop + respond-loop 실행\n')
|
|
240
|
+
const { spawn } = await import('child_process')
|
|
241
|
+
const review = spawn(process.execPath, ['src/review-loop.js'], { stdio: 'inherit', detached: false })
|
|
242
|
+
const respond = spawn(process.execPath, ['src/respond-loop.js'], { stdio: 'inherit', detached: false })
|
|
243
|
+
|
|
244
|
+
function shutdownAll() {
|
|
245
|
+
review.kill('SIGTERM')
|
|
246
|
+
respond.kill('SIGTERM')
|
|
247
|
+
process.exit(0)
|
|
248
|
+
}
|
|
249
|
+
process.on('SIGINT', shutdownAll)
|
|
250
|
+
process.on('SIGTERM', shutdownAll)
|
|
251
|
+
|
|
252
|
+
// 자식 프로세스가 모두 종료되면 부모도 종료
|
|
253
|
+
let exited = 0
|
|
254
|
+
const onExit = () => { if (++exited >= 2) process.exit(0) }
|
|
255
|
+
review.on('exit', onExit)
|
|
256
|
+
respond.on('exit', onExit)
|
|
257
|
+
}
|
|
258
|
+
} else if (subCmd === 'stop') {
|
|
259
|
+
const mode = resolveLoopMode()
|
|
260
|
+
if (mode === 'tmux') {
|
|
261
|
+
stopTmuxLoops(sessionName)
|
|
262
|
+
} else {
|
|
263
|
+
// process 모드: state에서 PID를 읽어 종료
|
|
264
|
+
const { readState } = await import('./state.js')
|
|
265
|
+
for (const loopName of ['review-loop', 'respond-loop']) {
|
|
266
|
+
const state = readState(loopName)
|
|
267
|
+
if (state?.pid) {
|
|
268
|
+
try {
|
|
269
|
+
process.kill(state.pid, 'SIGTERM')
|
|
270
|
+
console.log(` ${loopName} (PID: ${state.pid}) 종료`)
|
|
271
|
+
} catch {
|
|
272
|
+
console.log(` ${loopName} (PID: ${state.pid}) 이미 종료됨`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} else if (subCmd === 'status') {
|
|
278
|
+
const alive = isTmuxSessionAlive(sessionName)
|
|
279
|
+
const { readState } = await import('./state.js')
|
|
280
|
+
const reviewState = readState('review-loop')
|
|
281
|
+
const respondState = readState('respond-loop')
|
|
282
|
+
console.log(`\n tmux 세션 (${sessionName}): ${alive ? '실행 중' : '없음'}`)
|
|
283
|
+
console.log(` review-loop: ${reviewState?.status ?? 'unknown'}`)
|
|
284
|
+
console.log(` respond-loop: ${respondState?.status ?? 'unknown'}\n`)
|
|
285
|
+
} else {
|
|
286
|
+
console.error('사용법: bylane loop <start|stop|status>')
|
|
287
|
+
process.exit(1)
|
|
288
|
+
}
|
|
188
289
|
} else if (command === 'monitor') {
|
|
189
290
|
// 항상 현재 패키지의 모니터 실행 (버전 일치 보장)
|
|
190
291
|
const monitorPath = join(__dirname, 'monitor', 'index.js')
|
|
@@ -193,6 +294,6 @@ if (command === 'install') {
|
|
|
193
294
|
child.on('exit', code => process.exit(code ?? 0))
|
|
194
295
|
} else {
|
|
195
296
|
console.error(`알 수 없는 명령: ${command}`)
|
|
196
|
-
console.error('사용법: npx @elyun/bylane [install|monitor] [--symlink]')
|
|
297
|
+
console.error('사용법: npx @elyun/bylane [install|loop|monitor|preflight|state|memory|cleanup] [--symlink]')
|
|
197
298
|
process.exit(1)
|
|
198
299
|
}
|
package/src/config.js
CHANGED
|
@@ -40,6 +40,10 @@ export const DEFAULT_CONFIG = {
|
|
|
40
40
|
owner: '',
|
|
41
41
|
repo: ''
|
|
42
42
|
},
|
|
43
|
+
memory: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
dir: '.bylane/memory'
|
|
46
|
+
},
|
|
43
47
|
models: {
|
|
44
48
|
default: 'claude-sonnet-4-6',
|
|
45
49
|
orchestrator: 'claude-opus-4-6',
|
|
@@ -53,6 +57,11 @@ export const DEFAULT_CONFIG = {
|
|
|
53
57
|
'notify-agent': 'claude-haiku-4-5-20251001',
|
|
54
58
|
'analyze-agent': 'claude-opus-4-6'
|
|
55
59
|
},
|
|
60
|
+
loop: {
|
|
61
|
+
mode: 'tmux',
|
|
62
|
+
intervalMs: 300000,
|
|
63
|
+
sessionName: 'bylane-loops'
|
|
64
|
+
},
|
|
56
65
|
review: {
|
|
57
66
|
model: 'claude-sonnet-4-6',
|
|
58
67
|
language: 'ko',
|
package/src/loop-utils.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { execSync } from 'child_process'
|
|
6
6
|
import { readState, writeState } from './state.js'
|
|
7
|
+
import { loadConfig } from './config.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* 동일 루프가 이미 실행 중이면 기존 프로세스를 종료하고 기다린다.
|
|
@@ -45,3 +46,104 @@ export function killExistingLoop(loopName, stateDir = '.bylane/state') {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* tmux 세션이 살아있는지 확인
|
|
52
|
+
* @param {string} sessionName
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function isTmuxSessionAlive(sessionName) {
|
|
56
|
+
try {
|
|
57
|
+
execSync(`tmux has-session -t ${sessionName}`, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
58
|
+
return true
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* tmux 세션에서 review-loop + respond-loop을 실행
|
|
66
|
+
* @param {string} sessionName
|
|
67
|
+
*/
|
|
68
|
+
export function startTmuxLoops(sessionName = 'bylane-loops') {
|
|
69
|
+
if (isTmuxSessionAlive(sessionName)) {
|
|
70
|
+
console.log(`[tmux] 세션 '${sessionName}'이 이미 실행 중입니다.`)
|
|
71
|
+
return { started: false, reason: 'already_running' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 첫 번째 윈도우: review-loop
|
|
75
|
+
execSync(
|
|
76
|
+
`tmux new-session -d -s ${sessionName} -n review 'node src/review-loop.js'`,
|
|
77
|
+
{ stdio: 'inherit' }
|
|
78
|
+
)
|
|
79
|
+
// 두 번째 윈도우: respond-loop
|
|
80
|
+
execSync(
|
|
81
|
+
`tmux new-window -t ${sessionName} -n respond 'node src/respond-loop.js'`,
|
|
82
|
+
{ stdio: 'inherit' }
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
console.log(`[tmux] 세션 '${sessionName}' 시작 완료 (review-loop + respond-loop)`)
|
|
86
|
+
return { started: true, sessionName }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* tmux 세션 종료
|
|
91
|
+
* @param {string} sessionName
|
|
92
|
+
*/
|
|
93
|
+
export function stopTmuxLoops(sessionName = 'bylane-loops') {
|
|
94
|
+
if (!isTmuxSessionAlive(sessionName)) {
|
|
95
|
+
console.log(`[tmux] 세션 '${sessionName}'이 존재하지 않습니다.`)
|
|
96
|
+
return { stopped: false, reason: 'not_running' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'inherit' })
|
|
100
|
+
console.log(`[tmux] 세션 '${sessionName}' 종료 완료`)
|
|
101
|
+
return { stopped: true, sessionName }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 절대 시간 기반 폴링 타이머.
|
|
106
|
+
* setInterval과 달리 잠자기 모드 후 깨어났을 때 즉시 보정한다.
|
|
107
|
+
* @param {() => void | Promise<void>} fn 폴링 콜백
|
|
108
|
+
* @param {number} intervalMs 폴링 간격 (ms)
|
|
109
|
+
* @param {number} [checkMs=10000] 체크 주기 (ms)
|
|
110
|
+
* @returns {{ timer: NodeJS.Timeout, stop: () => void }}
|
|
111
|
+
*/
|
|
112
|
+
export function createAbsoluteTimer(fn, intervalMs, checkMs = 10000) {
|
|
113
|
+
let lastRun = Date.now()
|
|
114
|
+
|
|
115
|
+
const timer = setInterval(async () => {
|
|
116
|
+
const now = Date.now()
|
|
117
|
+
if (now - lastRun >= intervalMs) {
|
|
118
|
+
lastRun = now
|
|
119
|
+
await fn()
|
|
120
|
+
}
|
|
121
|
+
}, checkMs)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
timer,
|
|
125
|
+
stop() { clearInterval(timer) }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 현재 설정에서 loop 모드를 결정한다.
|
|
131
|
+
* tmux 모드인데 tmux가 없으면 process로 fallback.
|
|
132
|
+
* @returns {'tmux' | 'process'}
|
|
133
|
+
*/
|
|
134
|
+
export function resolveLoopMode() {
|
|
135
|
+
const config = loadConfig()
|
|
136
|
+
const mode = config.loop?.mode ?? 'tmux'
|
|
137
|
+
|
|
138
|
+
if (mode === 'tmux') {
|
|
139
|
+
try {
|
|
140
|
+
execSync('which tmux', { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
141
|
+
return 'tmux'
|
|
142
|
+
} catch {
|
|
143
|
+
console.log('[loop] tmux 미설치 — process 모드로 fallback합니다.')
|
|
144
|
+
return 'process'
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return 'process'
|
|
149
|
+
}
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { execSync } from 'child_process'
|
|
4
|
+
|
|
5
|
+
// 현재 실행 중인 루프가 있는지 확인 (review-loop, respond-loop)
|
|
6
|
+
function isAnyLoopActive(stateDir = '.bylane/state') {
|
|
7
|
+
if (!existsSync(stateDir)) return false
|
|
8
|
+
try {
|
|
9
|
+
return readdirSync(stateDir)
|
|
10
|
+
.filter(f => f.endsWith('-loop.json'))
|
|
11
|
+
.some(f => {
|
|
12
|
+
try {
|
|
13
|
+
const s = JSON.parse(readFileSync(join(stateDir, f), 'utf8'))
|
|
14
|
+
return s.status === 'running'
|
|
15
|
+
} catch { return false }
|
|
16
|
+
})
|
|
17
|
+
} catch { return false }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getIssueMemoryPath(issueNumber, config) {
|
|
21
|
+
const dir = config?.memory?.dir ?? '.bylane/memory'
|
|
22
|
+
return join(dir, 'issues', `${issueNumber}.md`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readIssueMemory(issueNumber, config = {}) {
|
|
26
|
+
const path = getIssueMemoryPath(issueNumber, config)
|
|
27
|
+
if (!existsSync(path)) return null
|
|
28
|
+
try { return readFileSync(path, 'utf8') } catch { return null }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function appendIssueMemory(issueNumber, agentName, content, config = {}) {
|
|
32
|
+
if (config?.memory?.enabled === false) return
|
|
33
|
+
|
|
34
|
+
const timestamp = new Date().toISOString()
|
|
35
|
+
const entry = `\n## [${agentName}] ${timestamp}\n\n${content}\n`
|
|
36
|
+
|
|
37
|
+
if (isAnyLoopActive()) {
|
|
38
|
+
// 루프 실행 중 → GitHub 이슈 코멘트에 기록
|
|
39
|
+
postGitHubComment(issueNumber, entry, config)
|
|
40
|
+
} else {
|
|
41
|
+
// 루프 없음 → 로컬 파일에 기록
|
|
42
|
+
const path = getIssueMemoryPath(issueNumber, config)
|
|
43
|
+
const memDir = join(config?.memory?.dir ?? '.bylane/memory', 'issues')
|
|
44
|
+
mkdirSync(memDir, { recursive: true })
|
|
45
|
+
|
|
46
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : `# Issue #${issueNumber} Memory\n`
|
|
47
|
+
writeFileSync(path, existing + entry)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function postGitHubComment(issueNumber, content, config) {
|
|
52
|
+
try {
|
|
53
|
+
const method = config?.github?.method ?? 'auto'
|
|
54
|
+
const owner = config?.github?.owner ?? ''
|
|
55
|
+
const repo = config?.github?.repo ?? ''
|
|
56
|
+
|
|
57
|
+
if ((method === 'auto' || method === 'cli') && isCommandAvailable('gh')) {
|
|
58
|
+
const repoFlag = owner && repo ? `--repo ${owner}/${repo}` : ''
|
|
59
|
+
execSync(`gh issue comment ${issueNumber} ${repoFlag} --body ${JSON.stringify(content)}`, { stdio: 'ignore' })
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if ((method === 'auto' || method === 'api') && process.env.GITHUB_TOKEN) {
|
|
64
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`
|
|
65
|
+
execSync(
|
|
66
|
+
`curl -s -X POST -H "Authorization: Bearer ${process.env.GITHUB_TOKEN}" -H "Content-Type: application/json" ${url} -d ${JSON.stringify(JSON.stringify({ body: content }))}`,
|
|
67
|
+
{ stdio: 'ignore' }
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// GitHub 코멘트 실패 시 로컬 파일에 fallback
|
|
72
|
+
const path = getIssueMemoryPath(issueNumber, config)
|
|
73
|
+
const memDir = join(config?.memory?.dir ?? '.bylane/memory', 'issues')
|
|
74
|
+
mkdirSync(memDir, { recursive: true })
|
|
75
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : `# Issue #${issueNumber} Memory\n`
|
|
76
|
+
writeFileSync(path, existing + content)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isCommandAvailable(cmd) {
|
|
81
|
+
try { execSync(`which ${cmd}`, { stdio: 'ignore' }); return true } catch { return false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function listIssueMemories(config = {}) {
|
|
85
|
+
const dir = join(config?.memory?.dir ?? '.bylane/memory', 'issues')
|
|
86
|
+
if (!existsSync(dir)) return []
|
|
87
|
+
try {
|
|
88
|
+
return readdirSync(dir)
|
|
89
|
+
.filter(f => f.endsWith('.md'))
|
|
90
|
+
.map(f => f.replace('.md', ''))
|
|
91
|
+
.sort((a, b) => Number(a) - Number(b))
|
|
92
|
+
} catch { return [] }
|
|
93
|
+
}
|
package/src/preflight.js
CHANGED
|
@@ -17,6 +17,12 @@ function run(cmd) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function checkTmux() {
|
|
21
|
+
const which = run('which tmux')
|
|
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 }
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
function checkGhCli() {
|
|
21
27
|
const which = run('which gh')
|
|
22
28
|
if (!which.ok) return { ok: false, reason: 'gh CLI 미설치', fix: 'brew install gh # 또는 https://cli.github.com' }
|
|
@@ -105,14 +111,20 @@ export function runPreflight() {
|
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
// 3.
|
|
114
|
+
// 3. tmux (loop.mode가 tmux인 경우)
|
|
115
|
+
if (config.loop?.mode === 'tmux') {
|
|
116
|
+
const tmux = checkTmux()
|
|
117
|
+
results.push({ name: 'tmux', ...tmux, warn: !tmux.ok })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 4. 알림 채널 (설정된 경우만)
|
|
109
121
|
const slack = checkSlack(config)
|
|
110
122
|
if (slack) results.push({ name: 'Slack 알림', ...slack })
|
|
111
123
|
|
|
112
124
|
const telegram = checkTelegram(config)
|
|
113
125
|
if (telegram) results.push({ name: 'Telegram 알림', ...telegram })
|
|
114
126
|
|
|
115
|
-
const passed = results.every(r => r.ok)
|
|
127
|
+
const passed = results.filter(r => !r.warn).every(r => r.ok)
|
|
116
128
|
return { passed, results }
|
|
117
129
|
}
|
|
118
130
|
|
package/src/respond-loop.js
CHANGED
|
@@ -8,9 +8,10 @@ import { execSync } from 'child_process'
|
|
|
8
8
|
import { mkdirSync } from 'fs'
|
|
9
9
|
import { writeState, readState, appendLog } from './state.js'
|
|
10
10
|
import { loadConfig } from './config.js'
|
|
11
|
-
import { killExistingLoop } from './loop-utils.js'
|
|
11
|
+
import { killExistingLoop, createAbsoluteTimer } from './loop-utils.js'
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const config = loadConfig()
|
|
14
|
+
const INTERVAL_MS = config.loop?.intervalMs ?? 300000
|
|
14
15
|
const STATE_DIR = '.bylane/state'
|
|
15
16
|
|
|
16
17
|
mkdirSync(STATE_DIR, { recursive: true })
|
|
@@ -173,20 +174,17 @@ async function poll() {
|
|
|
173
174
|
// 초기 실행
|
|
174
175
|
poll()
|
|
175
176
|
|
|
176
|
-
//
|
|
177
|
-
const
|
|
177
|
+
// 절대 시간 기반 폴링 (잠자기 모드 후 즉시 보정)
|
|
178
|
+
const { stop } = createAbsoluteTimer(poll, INTERVAL_MS)
|
|
178
179
|
|
|
179
180
|
// 종료 처리
|
|
180
|
-
|
|
181
|
-
|
|
181
|
+
function shutdown() {
|
|
182
|
+
stop()
|
|
182
183
|
writeState('respond-loop', { status: 'stopped' }, STATE_DIR)
|
|
183
184
|
process.exit(0)
|
|
184
|
-
}
|
|
185
|
-
process.on('
|
|
186
|
-
|
|
187
|
-
writeState('respond-loop', { status: 'stopped' }, STATE_DIR)
|
|
188
|
-
process.exit(0)
|
|
189
|
-
})
|
|
185
|
+
}
|
|
186
|
+
process.on('SIGINT', shutdown)
|
|
187
|
+
process.on('SIGTERM', shutdown)
|
|
190
188
|
|
|
191
189
|
writeState('respond-loop', { status: 'running', startedAt: new Date().toISOString(), pid: process.pid }, STATE_DIR)
|
|
192
190
|
console.log('respond-loop 시작. Ctrl+C로 종료.')
|
package/src/review-loop.js
CHANGED
|
@@ -7,9 +7,10 @@ import { execSync } from 'child_process'
|
|
|
7
7
|
import { mkdirSync } from 'fs'
|
|
8
8
|
import { writeState, readState, appendLog } from './state.js'
|
|
9
9
|
import { loadConfig } from './config.js'
|
|
10
|
-
import { killExistingLoop } from './loop-utils.js'
|
|
10
|
+
import { killExistingLoop, createAbsoluteTimer } from './loop-utils.js'
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const config = loadConfig()
|
|
13
|
+
const INTERVAL_MS = config.loop?.intervalMs ?? 300000
|
|
13
14
|
const STATE_DIR = '.bylane/state'
|
|
14
15
|
|
|
15
16
|
mkdirSync(STATE_DIR, { recursive: true })
|
|
@@ -120,20 +121,17 @@ async function poll() {
|
|
|
120
121
|
// 초기 실행
|
|
121
122
|
poll()
|
|
122
123
|
|
|
123
|
-
//
|
|
124
|
-
const
|
|
124
|
+
// 절대 시간 기반 폴링 (잠자기 모드 후 즉시 보정)
|
|
125
|
+
const { stop } = createAbsoluteTimer(poll, INTERVAL_MS)
|
|
125
126
|
|
|
126
127
|
// 종료 처리
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
function shutdown() {
|
|
129
|
+
stop()
|
|
129
130
|
writeState('review-loop', { status: 'stopped' }, STATE_DIR)
|
|
130
131
|
process.exit(0)
|
|
131
|
-
}
|
|
132
|
-
process.on('
|
|
133
|
-
|
|
134
|
-
writeState('review-loop', { status: 'stopped' }, STATE_DIR)
|
|
135
|
-
process.exit(0)
|
|
136
|
-
})
|
|
132
|
+
}
|
|
133
|
+
process.on('SIGINT', shutdown)
|
|
134
|
+
process.on('SIGTERM', shutdown)
|
|
137
135
|
|
|
138
136
|
writeState('review-loop', { status: 'running', startedAt: new Date().toISOString(), pid: process.pid }, STATE_DIR)
|
|
139
137
|
console.log('review-loop 시작. Ctrl+C로 종료.')
|