@elyun/bylane 1.11.0 → 1.14.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-commit-agent.md +15 -0
- package/commands/bylane-pr-agent.md +19 -0
- package/commands/bylane-respond-agent.md +46 -1
- package/commands/bylane-review-agent.md +55 -0
- package/commands/bylane-review-loop.md +18 -0
- package/hooks/bylane-agent-tracker.js +84 -0
- package/package.json +1 -1
- package/src/cli.js +49 -3
- package/src/monitor/index.js +41 -8
- package/src/monitor/layout.js +1 -1
- package/src/monitor/panels/log.js +16 -10
- package/src/monitor/panels/pipeline.js +21 -1
- package/src/monitor/panels/queue.js +23 -13
|
@@ -17,6 +17,21 @@ description: 변경된 파일들을 conventional commit 형식으로 커밋한
|
|
|
17
17
|
node -e "import('./src/state.js').then(({writeState})=>writeState('commit-agent',{status:'in_progress',startedAt:new Date().toISOString(),progress:0,retries:0,log:[]}))"
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
## GitHub 커밋 템플릿 탐지
|
|
21
|
+
|
|
22
|
+
커밋 메시지 작성 전 아래 순서로 템플릿을 탐색한다:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# 1. git commit.template 설정 확인
|
|
26
|
+
git config commit.template 2>/dev/null
|
|
27
|
+
|
|
28
|
+
# 2. 일반적인 위치
|
|
29
|
+
ls .gitmessage .github/commit-template.txt .github/COMMIT_TEMPLATE.md 2>/dev/null | head -1
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
템플릿이 있으면 해당 형식을 **반드시** 따른다.
|
|
33
|
+
없으면 conventional commit 형식(`feat:`, `fix:` 등)을 사용한다.
|
|
34
|
+
|
|
20
35
|
## 실행 흐름
|
|
21
36
|
|
|
22
37
|
1. 브랜치명 생성:
|
|
@@ -21,6 +21,25 @@ description: 현재 브랜치의 커밋들로 GitHub Pull Request를 생성한
|
|
|
21
21
|
- `.bylane/state/commit-agent.json`의 `branchName`, `commitSha`
|
|
22
22
|
- `.bylane/state/issue-agent.json`의 `spec.title`, `issueNumber`
|
|
23
23
|
|
|
24
|
+
## GitHub PR 템플릿 탐지
|
|
25
|
+
|
|
26
|
+
PR 본문 작성 전 아래 순서로 템플릿을 탐색한다:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 단일 파일
|
|
30
|
+
ls .github/PULL_REQUEST_TEMPLATE.md \
|
|
31
|
+
.github/pull_request_template.md \
|
|
32
|
+
docs/PULL_REQUEST_TEMPLATE.md \
|
|
33
|
+
PULL_REQUEST_TEMPLATE.md 2>/dev/null | head -1
|
|
34
|
+
|
|
35
|
+
# 다중 템플릿 디렉토리
|
|
36
|
+
ls .github/PULL_REQUEST_TEMPLATE/*.md 2>/dev/null | head -5
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
템플릿이 있으면 해당 구조를 **반드시** 따른다.
|
|
40
|
+
다중 템플릿이 있으면 이슈 유형에 맞는 템플릿을 선택한다.
|
|
41
|
+
템플릿이 없으면 기본 Summary/Test Plan 형식을 사용한다.
|
|
42
|
+
|
|
24
43
|
## 실행 전 상태 기록
|
|
25
44
|
|
|
26
45
|
```bash
|
|
@@ -16,10 +16,55 @@ description: PR 리뷰 코멘트에 반박하거나 코드를 수정하여 반
|
|
|
16
16
|
| `"api"` | REST API + `$GITHUB_TOKEN` |
|
|
17
17
|
| `"auto"` (기본) | MCP → CLI → API 순서로 시도 |
|
|
18
18
|
|
|
19
|
+
## GitHub 리뷰 대응 템플릿 탐지
|
|
20
|
+
|
|
21
|
+
답글 작성 전 프로젝트 내 대응 템플릿을 탐색한다:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
ls .github/REVIEW_RESPONSE_TEMPLATE.md \
|
|
25
|
+
.github/review_response_template.md \
|
|
26
|
+
.github/CONTRIBUTING.md 2>/dev/null | head -1
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `REVIEW_RESPONSE_TEMPLATE.md`가 있으면 답글 형식을 **반드시** 따른다.
|
|
30
|
+
- `CONTRIBUTING.md`가 있으면 코드 기여 가이드라인을 참고하여 대응 톤/형식을 맞춘다.
|
|
31
|
+
- 없으면 기본 형식으로 작성한다.
|
|
32
|
+
|
|
19
33
|
## 입력
|
|
20
34
|
|
|
21
35
|
- PR 번호
|
|
22
|
-
-
|
|
36
|
+
- 모드 (선택): `auto` (기본) | `accept` | `rebut` | `manual`
|
|
37
|
+
|
|
38
|
+
## 모드 동작
|
|
39
|
+
|
|
40
|
+
| 모드 | 동작 |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `auto` (기본값, 미지정 시) | 각 코멘트를 분석하여 수정 반영 또는 반박을 **자동 판단** 후 진행 |
|
|
43
|
+
| `accept` | 모든 코멘트를 수정 반영 |
|
|
44
|
+
| `rebut` | 모든 코멘트에 반박 |
|
|
45
|
+
| `manual` | 코멘트별로 사용자에게 "수정 반영할까요, 반박할까요?" 질문 후 진행 |
|
|
46
|
+
|
|
47
|
+
### auto 모드 판단 기준
|
|
48
|
+
|
|
49
|
+
각 코멘트를 분석하여:
|
|
50
|
+
- **반영**: 버그 지적, 명확한 컨벤션 위반, 테스트 누락, 성능 문제
|
|
51
|
+
- **반박**: 의견 차이, 이미 의도된 설계, 스펙 요구사항과 일치, 트레이드오프 판단이 필요한 경우
|
|
52
|
+
|
|
53
|
+
자동 판단 결과를 사용자에게 먼저 요약으로 보여준 뒤 실행한다:
|
|
54
|
+
```
|
|
55
|
+
코멘트 #1: [반영] null 체크 누락 → 코드 수정
|
|
56
|
+
코멘트 #2: [반박] 의도된 설계 (이슈 #12 참조)
|
|
57
|
+
코멘트 #3: [반영] 타입 오류
|
|
58
|
+
진행할까요? (y/n)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### manual 모드
|
|
62
|
+
|
|
63
|
+
각 코멘트를 보여주고 사용자에게 선택을 요청:
|
|
64
|
+
```
|
|
65
|
+
[코멘트 #1] null 체크가 없습니다.
|
|
66
|
+
→ (1) 수정 반영 (2) 반박 (3) 건너뜀
|
|
67
|
+
```
|
|
23
68
|
|
|
24
69
|
## 실행 전 상태 기록
|
|
25
70
|
|
|
@@ -16,6 +16,20 @@ description: PR의 diff를 분석하여 코드 리뷰 코멘트를 작성한다.
|
|
|
16
16
|
| `"api"` | REST API + `$GITHUB_TOKEN` |
|
|
17
17
|
| `"auto"` (기본) | MCP → CLI → API 순서로 시도 |
|
|
18
18
|
|
|
19
|
+
## GitHub 리뷰 템플릿 탐지
|
|
20
|
+
|
|
21
|
+
bylane 설정보다 먼저 프로젝트 내 GitHub 리뷰 템플릿을 탐색한다:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
ls .github/REVIEW_TEMPLATE.md \
|
|
25
|
+
.github/review_template.md \
|
|
26
|
+
.github/CODE_REVIEW_TEMPLATE.md \
|
|
27
|
+
docs/REVIEW_TEMPLATE.md 2>/dev/null | head -1
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
GitHub 템플릿이 있으면 해당 형식을 **최우선**으로 따른다.
|
|
31
|
+
없으면 아래 bylane 설정의 템플릿을 사용한다.
|
|
32
|
+
|
|
19
33
|
## 리뷰 템플릿 로드
|
|
20
34
|
|
|
21
35
|
실행 전 `.bylane/bylane.json`의 `review` 설정 읽기:
|
|
@@ -44,6 +58,47 @@ import('./src/config.js').then(({loadConfig}) => {
|
|
|
44
58
|
cat TEMPLATE_FILE_PATH
|
|
45
59
|
```
|
|
46
60
|
|
|
61
|
+
## 검사 항목 선택
|
|
62
|
+
|
|
63
|
+
PR 번호와 함께 검사 범위를 지정할 수 있다. 인자가 없으면 사용자에게 묻는다.
|
|
64
|
+
|
|
65
|
+
### 인자로 지정
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
/bylane review #45 code,security
|
|
69
|
+
/bylane review #45 grammar,domain,code
|
|
70
|
+
/bylane review #45 ← 선택 없으면 아래 질문
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 대화형 선택 (인자 미지정 시)
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
검사 항목을 선택하세요 (쉼표 구분, Enter=전체):
|
|
77
|
+
1. grammar — 문법, 오탈자, 주석/변수명 언어 일관성
|
|
78
|
+
2. domain — 비즈니스 로직, 도메인 규칙 준수 여부
|
|
79
|
+
3. code — 코드 스타일, 컨벤션, 중복, 복잡도
|
|
80
|
+
4. security — 보안 취약점, 시크릿 노출, 인증/인가 이슈
|
|
81
|
+
|
|
82
|
+
선택 (예: 1,3 또는 Enter):
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Enter 또는 아무것도 선택하지 않으면 → `all` (전체 검사)
|
|
86
|
+
|
|
87
|
+
### 검사 항목별 포커스
|
|
88
|
+
|
|
89
|
+
| 항목 | 중점 확인 사항 |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `grammar` | 오탈자, 주석 언어 일관성, 변수/함수명 문법, 문서 일관성 |
|
|
92
|
+
| `domain` | 비즈니스 규칙 위반, 도메인 용어 오용, 로직 정합성 |
|
|
93
|
+
| `code` | 컨벤션 위반, 중복 코드, 복잡도, 불변성, 테스트 커버리지 |
|
|
94
|
+
| `security` | 시크릿 노출, SQL/XSS 인젝션, 인증·인가 누락, 민감 데이터 처리 |
|
|
95
|
+
| `all` | 위 4가지 전체 |
|
|
96
|
+
|
|
97
|
+
선택된 항목만 집중 검사하고, 리뷰 코멘트 상단에 검사 범위를 명시한다:
|
|
98
|
+
```
|
|
99
|
+
> 검사 범위: code, security
|
|
100
|
+
```
|
|
101
|
+
|
|
47
102
|
## 입력
|
|
48
103
|
|
|
49
104
|
PR 번호 (`.bylane/state/pr-agent.json`에서 자동 로드, 또는 수동 전달)
|
|
@@ -11,6 +11,23 @@ description: 5분 주기로 GitHub review 요청된 PR을 감지하여 자동으
|
|
|
11
11
|
`.bylane/state/review-queue.json`에 기록한다. 이 skill은 해당 큐를 감시하다가
|
|
12
12
|
`status: "pending"` 항목이 생기면 `bylane-review-agent`를 실행한다.
|
|
13
13
|
|
|
14
|
+
## 검사 범위 설정
|
|
15
|
+
|
|
16
|
+
루프 시작 전 사용자에게 검사 범위를 묻는다:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
자동 리뷰 검사 항목을 선택하세요 (쉼표 구분, Enter=전체):
|
|
20
|
+
1. grammar — 문법, 오탈자, 주석/변수명 언어 일관성
|
|
21
|
+
2. domain — 비즈니스 로직, 도메인 규칙 준수 여부
|
|
22
|
+
3. code — 코드 스타일, 컨벤션, 중복, 복잡도
|
|
23
|
+
4. security — 보안 취약점, 시크릿 노출, 인증/인가 이슈
|
|
24
|
+
|
|
25
|
+
선택 (예: 1,3 또는 Enter=전체):
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
선택된 범위를 `.bylane/state/review-queue.json`의 `scope` 필드에 저장하고,
|
|
29
|
+
이후 각 `bylane-review-agent` 호출 시 해당 scope를 전달한다.
|
|
30
|
+
|
|
14
31
|
## 시작
|
|
15
32
|
|
|
16
33
|
### 1. 폴러 시작 (백그라운드)
|
|
@@ -67,6 +84,7 @@ import('./src/state.js').then(({readState, writeState}) => {
|
|
|
67
84
|
{
|
|
68
85
|
"agent": "review-queue",
|
|
69
86
|
"status": "running",
|
|
87
|
+
"scope": ["code", "security"],
|
|
70
88
|
"queue": [
|
|
71
89
|
{
|
|
72
90
|
"number": 45,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bylane-agent-tracker.js
|
|
4
|
+
* PreToolUse / PostToolUse 훅 — Agent 도구 호출을 추적하고 취소 플래그를 검사한다.
|
|
5
|
+
*
|
|
6
|
+
* 등록 방법 (npx @elyun/bylane 이 자동 등록):
|
|
7
|
+
* settings.json > hooks > PreToolUse : node .../bylane-agent-tracker.js pre
|
|
8
|
+
* settings.json > hooks > PostToolUse : node .../bylane-agent-tracker.js post
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
|
|
14
|
+
const hookType = process.argv[2] ?? 'pre' // 'pre' | 'post'
|
|
15
|
+
const STATE_DIR = '.bylane/state'
|
|
16
|
+
const SUBAGENTS_FILE = join(STATE_DIR, 'subagents.json')
|
|
17
|
+
const CANCEL_FILE = join(STATE_DIR, 'cancel.json')
|
|
18
|
+
|
|
19
|
+
let input
|
|
20
|
+
try {
|
|
21
|
+
input = JSON.parse(readFileSync('/dev/stdin', 'utf8'))
|
|
22
|
+
} catch {
|
|
23
|
+
process.exit(0)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { session_id, tool_name, tool_input, tool_result } = input
|
|
27
|
+
|
|
28
|
+
// Agent 도구 호출만 처리
|
|
29
|
+
if (tool_name !== 'Agent') process.exit(0)
|
|
30
|
+
|
|
31
|
+
function readSubagents() {
|
|
32
|
+
if (!existsSync(SUBAGENTS_FILE)) return { active: [], recent: [] }
|
|
33
|
+
try { return JSON.parse(readFileSync(SUBAGENTS_FILE, 'utf8')) } catch { return { active: [], recent: [] } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeSubagents(data) {
|
|
37
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
38
|
+
writeFileSync(SUBAGENTS_FILE, JSON.stringify(data, null, 2))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (hookType === 'pre') {
|
|
42
|
+
// 취소 플래그 확인 → 있으면 에이전트 실행 차단
|
|
43
|
+
if (existsSync(CANCEL_FILE)) {
|
|
44
|
+
const out = JSON.stringify({
|
|
45
|
+
decision: 'block',
|
|
46
|
+
reason: '사용자가 에이전트 실행을 취소했습니다. (byLane monitor에서 [c] 키로 설정됨)'
|
|
47
|
+
})
|
|
48
|
+
process.stdout.write(out)
|
|
49
|
+
process.exit(0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 신규 하위 에이전트 기록
|
|
53
|
+
const data = readSubagents()
|
|
54
|
+
const entry = {
|
|
55
|
+
id: `${session_id}-${Date.now()}`,
|
|
56
|
+
sessionId: session_id,
|
|
57
|
+
subagentType: tool_input?.subagent_type ?? 'general-purpose',
|
|
58
|
+
prompt: (tool_input?.prompt ?? '').slice(0, 120),
|
|
59
|
+
status: 'running',
|
|
60
|
+
startedAt: new Date().toISOString()
|
|
61
|
+
}
|
|
62
|
+
data.active.push(entry)
|
|
63
|
+
// active는 최대 20개 유지
|
|
64
|
+
if (data.active.length > 20) data.active.shift()
|
|
65
|
+
writeSubagents(data)
|
|
66
|
+
|
|
67
|
+
} else if (hookType === 'post') {
|
|
68
|
+
// 완료 처리 — active → recent 이동
|
|
69
|
+
const data = readSubagents()
|
|
70
|
+
const idx = [...data.active].reverse()
|
|
71
|
+
.findIndex(a => a.sessionId === session_id && a.status === 'running')
|
|
72
|
+
|
|
73
|
+
if (idx !== -1) {
|
|
74
|
+
const realIdx = data.active.length - 1 - idx
|
|
75
|
+
const agent = { ...data.active[realIdx], status: 'completed', completedAt: new Date().toISOString() }
|
|
76
|
+
data.active.splice(realIdx, 1)
|
|
77
|
+
data.recent.unshift(agent)
|
|
78
|
+
// recent는 최대 10개 유지
|
|
79
|
+
if (data.recent.length > 10) data.recent.pop()
|
|
80
|
+
writeSubagents(data)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
process.exit(0)
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, symlinkSync, existsSync, readdirSync, copyFileSync, renameSync, readFileSync, writeFileSync } from 'fs'
|
|
3
3
|
import { join, dirname } from 'path'
|
|
4
4
|
import { fileURLToPath } from 'url'
|
|
5
5
|
import { homedir } from 'os'
|
|
@@ -13,8 +13,8 @@ const command = args[0] || 'install'
|
|
|
13
13
|
const useSymlink = args.includes('--symlink')
|
|
14
14
|
|
|
15
15
|
const TARGETS = [
|
|
16
|
-
{ src: join(ROOT, 'commands'), dest: join(CLAUDE_DIR, 'commands'),
|
|
17
|
-
{ src: join(ROOT, 'hooks'), dest: join(CLAUDE_DIR, 'hooks'),
|
|
16
|
+
{ src: join(ROOT, 'commands'), dest: join(CLAUDE_DIR, 'commands'), label: 'Commands' },
|
|
17
|
+
{ src: join(ROOT, 'hooks'), dest: join(CLAUDE_DIR, 'hooks'), label: 'Hooks' },
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
function backupAndCopy(src, dest, file, label) {
|
|
@@ -38,6 +38,49 @@ function backupAndCopy(src, dest, file, label) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function registerHooks() {
|
|
42
|
+
const settingsPath = join(CLAUDE_DIR, 'settings.json')
|
|
43
|
+
let settings = {}
|
|
44
|
+
if (existsSync(settingsPath)) {
|
|
45
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')) } catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hookScript = join(CLAUDE_DIR, 'hooks', 'bylane-agent-tracker.js')
|
|
49
|
+
settings.hooks = settings.hooks ?? {}
|
|
50
|
+
|
|
51
|
+
// PreToolUse
|
|
52
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse ?? []
|
|
53
|
+
const preExists = settings.hooks.PreToolUse.some(h =>
|
|
54
|
+
h.hooks?.some(hh => hh.command?.includes('bylane-agent-tracker'))
|
|
55
|
+
)
|
|
56
|
+
if (!preExists) {
|
|
57
|
+
settings.hooks.PreToolUse.push({
|
|
58
|
+
matcher: 'Agent',
|
|
59
|
+
hooks: [{ type: 'command', command: `node ${hookScript} pre` }]
|
|
60
|
+
})
|
|
61
|
+
console.log(' + Hook: PreToolUse/Agent → bylane-agent-tracker')
|
|
62
|
+
} else {
|
|
63
|
+
console.log(' = Hook: PreToolUse/Agent (이미 등록됨)')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// PostToolUse
|
|
67
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse ?? []
|
|
68
|
+
const postExists = settings.hooks.PostToolUse.some(h =>
|
|
69
|
+
h.hooks?.some(hh => hh.command?.includes('bylane-agent-tracker'))
|
|
70
|
+
)
|
|
71
|
+
if (!postExists) {
|
|
72
|
+
settings.hooks.PostToolUse.push({
|
|
73
|
+
matcher: 'Agent',
|
|
74
|
+
hooks: [{ type: 'command', command: `node ${hookScript} post` }]
|
|
75
|
+
})
|
|
76
|
+
console.log(' + Hook: PostToolUse/Agent → bylane-agent-tracker')
|
|
77
|
+
} else {
|
|
78
|
+
console.log(' = Hook: PostToolUse/Agent (이미 등록됨)')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2))
|
|
82
|
+
}
|
|
83
|
+
|
|
41
84
|
function install() {
|
|
42
85
|
console.log('\n byLane 설치 중...\n')
|
|
43
86
|
|
|
@@ -63,6 +106,9 @@ function install() {
|
|
|
63
106
|
}
|
|
64
107
|
}
|
|
65
108
|
|
|
109
|
+
console.log('')
|
|
110
|
+
registerHooks()
|
|
111
|
+
|
|
66
112
|
console.log(`
|
|
67
113
|
byLane 설치 완료!
|
|
68
114
|
|
package/src/monitor/index.js
CHANGED
|
@@ -1,26 +1,59 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createLayout } from './layout.js'
|
|
3
3
|
import { createPoller } from './poller.js'
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs'
|
|
5
|
+
import { join } from 'path'
|
|
4
6
|
|
|
5
7
|
const { screen, header, pipeline, log, queue, status, onCleanup } = createLayout()
|
|
6
8
|
const poller = createPoller()
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
const startTime = Date.now()
|
|
11
|
+
const STATE_DIR = '.bylane/state'
|
|
12
|
+
const SUBAGENTS_FILE = join(STATE_DIR, 'subagents.json')
|
|
13
|
+
const CANCEL_FILE = join(STATE_DIR, 'cancel.json')
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
})
|
|
15
|
-
}, 1000)
|
|
15
|
+
function readSubagents() {
|
|
16
|
+
if (!existsSync(SUBAGENTS_FILE)) return { active: [], recent: [] }
|
|
17
|
+
try { return JSON.parse(readFileSync(SUBAGENTS_FILE, 'utf8')) } catch { return { active: [], recent: [] } }
|
|
18
|
+
}
|
|
16
19
|
|
|
17
20
|
onCleanup(() => poller.stop())
|
|
18
21
|
|
|
19
22
|
poller.onChange((states) => {
|
|
20
|
-
|
|
23
|
+
const active = Object.values(states).find(s => s.status === 'in_progress')
|
|
24
|
+
const workflowTitle = active ? (active.currentTask ?? active.agent) : 'Idle'
|
|
25
|
+
|
|
26
|
+
header.update({
|
|
27
|
+
workflowTitle,
|
|
28
|
+
time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
|
|
29
|
+
elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
|
|
30
|
+
})
|
|
31
|
+
pipeline.update(states, readSubagents())
|
|
21
32
|
log.update(states)
|
|
22
33
|
queue.update()
|
|
23
34
|
status.update()
|
|
24
35
|
})
|
|
25
36
|
|
|
37
|
+
// 'c' — 하위 에이전트 취소 플래그 토글
|
|
38
|
+
const cancelLabel = () => existsSync(CANCEL_FILE) ? '[취소 활성]' : ''
|
|
39
|
+
screen.key('c', () => {
|
|
40
|
+
if (existsSync(CANCEL_FILE)) {
|
|
41
|
+
unlinkSync(CANCEL_FILE)
|
|
42
|
+
header.update({
|
|
43
|
+
workflowTitle: '에이전트 취소 해제',
|
|
44
|
+
time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
|
|
45
|
+
elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
|
|
46
|
+
})
|
|
47
|
+
} else {
|
|
48
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
49
|
+
writeFileSync(CANCEL_FILE, JSON.stringify({ cancelledAt: new Date().toISOString() }))
|
|
50
|
+
header.update({
|
|
51
|
+
workflowTitle: '!! 에이전트 취소 요청됨 !!',
|
|
52
|
+
time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
|
|
53
|
+
elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
screen.render()
|
|
57
|
+
})
|
|
58
|
+
|
|
26
59
|
screen.render()
|
package/src/monitor/layout.js
CHANGED
|
@@ -23,7 +23,7 @@ export function createLayout() {
|
|
|
23
23
|
left: 0,
|
|
24
24
|
width: '100%',
|
|
25
25
|
height: 1,
|
|
26
|
-
content: ' [q]종료 [
|
|
26
|
+
content: ' [q]종료 [c]에이전트취소토글 [Tab]포커스 [j/k]로그스크롤',
|
|
27
27
|
style: { fg: 'black', bg: 'cyan' }
|
|
28
28
|
})
|
|
29
29
|
screen.append(footer)
|
|
@@ -16,24 +16,30 @@ export function createLogPanel(screen) {
|
|
|
16
16
|
vi: true
|
|
17
17
|
})
|
|
18
18
|
screen.append(box)
|
|
19
|
+
|
|
19
20
|
const lines = []
|
|
21
|
+
const seen = new Set()
|
|
20
22
|
|
|
21
23
|
return {
|
|
22
24
|
update(states) {
|
|
23
|
-
|
|
25
|
+
let changed = false
|
|
24
26
|
for (const state of Object.values(states)) {
|
|
25
|
-
for (const entry of (state.log ?? [])
|
|
27
|
+
for (const entry of (state.log ?? [])) {
|
|
28
|
+
const key = `${state.agent}:${entry.ts}`
|
|
29
|
+
if (seen.has(key)) continue
|
|
30
|
+
seen.add(key)
|
|
26
31
|
const ts = new Date(entry.ts).toLocaleTimeString('ko-KR', { hour12: false })
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
lines.push(` ${ts} {cyan-fg}${state.agent}{/cyan-fg}`)
|
|
33
|
+
lines.push(` > ${entry.msg}`)
|
|
34
|
+
changed = true
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
if (changed) {
|
|
38
|
+
if (lines.length > 200) lines.splice(0, lines.length - 200)
|
|
39
|
+
box.setContent(lines.join('\n'))
|
|
40
|
+
box.scrollTo(lines.length)
|
|
41
|
+
screen.render()
|
|
42
|
+
}
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
}
|
|
@@ -27,7 +27,7 @@ export function createPipelinePanel(screen) {
|
|
|
27
27
|
screen.append(box)
|
|
28
28
|
|
|
29
29
|
return {
|
|
30
|
-
update(states) {
|
|
30
|
+
update(states, subagents = { active: [], recent: [] }) {
|
|
31
31
|
const lines = AGENTS.map(name => {
|
|
32
32
|
const s = states[name]
|
|
33
33
|
if (!s) return ` ${STATUS_ICON.idle} ${name.padEnd(16)} 대기`
|
|
@@ -40,9 +40,29 @@ export function createPipelinePanel(screen) {
|
|
|
40
40
|
: ''
|
|
41
41
|
return ` ${icon} ${name.padEnd(16)} ${elapsed.padEnd(6)} ${bar}`
|
|
42
42
|
})
|
|
43
|
+
|
|
43
44
|
const retries = states['orchestrator']?.retries ?? 0
|
|
44
45
|
const maxRetries = states['orchestrator']?.maxRetries ?? 3
|
|
45
46
|
lines.push('', ` Retries: ${retries}/${maxRetries}`)
|
|
47
|
+
|
|
48
|
+
// 하위 에이전트 섹션
|
|
49
|
+
lines.push('', ' SUBAGENTS')
|
|
50
|
+
if (subagents.active.length === 0) {
|
|
51
|
+
lines.push(' 실행 중 없음')
|
|
52
|
+
} else {
|
|
53
|
+
for (const a of subagents.active) {
|
|
54
|
+
const elapsed = `${Math.floor((Date.now() - new Date(a.startedAt)) / 1000)}s`
|
|
55
|
+
const type = (a.subagentType ?? 'general').padEnd(14)
|
|
56
|
+
const prompt = a.prompt.length > 28
|
|
57
|
+
? a.prompt.slice(0, 28) + '...'
|
|
58
|
+
: a.prompt.padEnd(31)
|
|
59
|
+
lines.push(` [>] ${type} ${elapsed.padEnd(6)} ${prompt}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (subagents.recent.length > 0) {
|
|
63
|
+
lines.push(` 최근 완료: ${subagents.recent.length}건`)
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
box.setContent(lines.join('\n'))
|
|
47
67
|
screen.render()
|
|
48
68
|
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import blessed from 'blessed'
|
|
2
2
|
import { existsSync, readFileSync } from 'fs'
|
|
3
3
|
|
|
4
|
+
function readQueue(path) {
|
|
5
|
+
if (!existsSync(path)) return []
|
|
6
|
+
try {
|
|
7
|
+
const data = JSON.parse(readFileSync(path, 'utf8'))
|
|
8
|
+
return data.queue ?? []
|
|
9
|
+
} catch {
|
|
10
|
+
return []
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
export function createQueuePanel(screen) {
|
|
5
15
|
const box = blessed.box({
|
|
6
16
|
top: '60%',
|
|
@@ -16,20 +26,20 @@ export function createQueuePanel(screen) {
|
|
|
16
26
|
|
|
17
27
|
return {
|
|
18
28
|
update() {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const header = ` ${'#'.padEnd(3)} ${'TYPE'.padEnd(12)} ${'TARGET'.padEnd(10)} STATUS`
|
|
29
|
-
const rows = queue.slice(0, 8).map((item, i) =>
|
|
30
|
-
` ${String(i + 1).padEnd(3)} ${(item.type ?? '').padEnd(12)} ${(item.target ?? '').padEnd(10)} ${item.status ?? ''}`
|
|
29
|
+
const reviewItems = readQueue('.bylane/state/review-queue.json')
|
|
30
|
+
.map(p => ({ type: 'review', target: `PR #${p.number}`, status: p.status ?? '' }))
|
|
31
|
+
const respondItems = readQueue('.bylane/state/respond-queue.json')
|
|
32
|
+
.map(p => ({ type: 'respond', target: `PR #${p.number}`, status: p.status ?? '' }))
|
|
33
|
+
|
|
34
|
+
const all = [...reviewItems, ...respondItems]
|
|
35
|
+
const header = ` ${'#'.padEnd(3)} ${'TYPE'.padEnd(10)} ${'TARGET'.padEnd(10)} STATUS`
|
|
36
|
+
const rows = all.slice(0, 8).map((item, i) =>
|
|
37
|
+
` ${String(i + 1).padEnd(3)} ${item.type.padEnd(10)} ${item.target.padEnd(10)} ${item.status}`
|
|
31
38
|
)
|
|
32
|
-
|
|
39
|
+
const content = all.length === 0
|
|
40
|
+
? [header, ' 대기 중인 항목 없음']
|
|
41
|
+
: [header, ...rows]
|
|
42
|
+
box.setContent(content.join('\n'))
|
|
33
43
|
screen.render()
|
|
34
44
|
}
|
|
35
45
|
}
|