@silbaram/artifact-driven-agent 0.1.6 → 0.1.9
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/README.md +709 -516
- package/ai-dev-team/.ada-status.json +10 -0
- package/ai-dev-team/.ada-version +6 -0
- package/ai-dev-team/.current-template +1 -0
- package/ai-dev-team/.sessions/logs/20260124-014551-00f04724.log +5 -0
- package/ai-dev-team/.sessions/logs/20260124-014623-cb2b1d44.log +5 -0
- package/ai-dev-team/ada.config.json +15 -0
- package/ai-dev-team/artifacts/api.md +212 -0
- package/ai-dev-team/artifacts/decision.md +72 -0
- package/ai-dev-team/artifacts/improvement-reports/IMP-0000-template.md +57 -0
- package/ai-dev-team/artifacts/plan.md +187 -0
- package/ai-dev-team/artifacts/project.md +193 -0
- package/ai-dev-team/artifacts/sprints/_template/docs/release-notes.md +37 -0
- package/ai-dev-team/artifacts/sprints/_template/meta.md +54 -0
- package/ai-dev-team/artifacts/sprints/_template/retrospective.md +50 -0
- package/ai-dev-team/artifacts/sprints/_template/review-reports/review-template.md +49 -0
- package/ai-dev-team/artifacts/sprints/_template/tasks/task-template.md +43 -0
- package/ai-dev-team/artifacts/ui.md +104 -0
- package/ai-dev-team/roles/analyzer.md +265 -0
- package/ai-dev-team/roles/developer.md +222 -0
- package/ai-dev-team/roles/documenter.md +715 -0
- package/ai-dev-team/roles/improver.md +461 -0
- package/ai-dev-team/roles/manager.md +544 -0
- package/ai-dev-team/roles/planner.md +398 -0
- package/ai-dev-team/roles/reviewer.md +294 -0
- package/ai-dev-team/rules/api-change.md +198 -0
- package/ai-dev-team/rules/document-priority.md +199 -0
- package/ai-dev-team/rules/escalation.md +172 -0
- package/ai-dev-team/rules/iteration.md +236 -0
- package/ai-dev-team/rules/rfc.md +31 -0
- package/ai-dev-team/rules/rollback.md +218 -0
- package/bin/cli.js +49 -5
- package/core/artifacts/sprints/_template/meta.md +4 -4
- package/core/docs-templates/mkdocs/docs/architecture/overview.md +29 -0
- package/core/docs-templates/mkdocs/docs/changelog.md +36 -0
- package/core/docs-templates/mkdocs/docs/contributing/contributing.md +60 -0
- package/core/docs-templates/mkdocs/docs/getting-started/configuration.md +51 -0
- package/core/docs-templates/mkdocs/docs/getting-started/installation.md +41 -0
- package/core/docs-templates/mkdocs/docs/getting-started/quick-start.md +56 -0
- package/core/docs-templates/mkdocs/docs/guides/api-reference.md +83 -0
- package/core/docs-templates/mkdocs/docs/index.md +32 -0
- package/core/docs-templates/mkdocs/mkdocs.yml +86 -0
- package/core/roles/analyzer.md +32 -10
- package/core/roles/developer.md +222 -223
- package/core/roles/documenter.md +592 -170
- package/core/roles/improver.md +461 -0
- package/core/roles/manager.md +4 -1
- package/core/roles/planner.md +160 -10
- package/core/roles/reviewer.md +31 -3
- package/core/rules/document-priority.md +2 -1
- package/core/rules/rollback.md +3 -3
- package/package.json +1 -1
- package/src/commands/config.js +371 -0
- package/src/commands/docs.js +502 -0
- package/src/commands/interactive.js +324 -33
- package/src/commands/monitor.js +236 -0
- package/src/commands/run.js +360 -122
- package/src/commands/sessions.js +270 -70
- package/src/commands/setup.js +22 -1
- package/src/commands/sprint.js +295 -54
- package/src/commands/status.js +34 -1
- package/src/commands/upgrade.js +416 -0
- package/src/commands/validate.js +4 -3
- package/src/index.js +1 -0
- package/src/ui/dashboard.js +518 -0
- package/src/ui/keyHandler.js +147 -0
- package/src/ui/quickActions.js +111 -0
- package/src/utils/config.js +74 -0
- package/src/utils/files.js +70 -3
- package/src/utils/sessionState.js +472 -328
- package/src/utils/sessionState.process.test.js +101 -0
- package/src/utils/sessionState.test.js +183 -0
- package/src/utils/sprintUtils.js +134 -0
- package/src/utils/taskParser.js +134 -0
- package/src/utils/taskParser.test.js +76 -0
- package/ai-dev-team/artifacts/features/_template/qa.md +0 -16
- package/examples/todo-app/README.md +0 -23
- package/examples/todo-app/artifacts/backlog.md +0 -23
- package/examples/todo-app/artifacts/plan.md +0 -23
- package/examples/todo-app/artifacts/project.md +0 -23
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { getLogsDir, getWorkspaceDir, isWorkspaceSetup } from '../utils/files.js';
|
|
5
|
+
import { readStatus, getActiveSessions, getPendingQuestions } from '../utils/sessionState.js';
|
|
6
|
+
import { parseTaskMetadata } from '../utils/taskParser.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 대시보드 UI 구성 상수
|
|
10
|
+
*/
|
|
11
|
+
const DASHBOARD_WIDTH = 80;
|
|
12
|
+
const HALF_WIDTH = Math.floor(DASHBOARD_WIDTH / 2);
|
|
13
|
+
const MAX_LOG_SESSIONS = 2;
|
|
14
|
+
const MAX_LOG_LINES = 6;
|
|
15
|
+
const MAX_LOG_BYTES = 8192;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 프로젝트 상태 정보 수집
|
|
19
|
+
*/
|
|
20
|
+
export function gatherProjectState() {
|
|
21
|
+
const result = {
|
|
22
|
+
isSetup: false,
|
|
23
|
+
template: null,
|
|
24
|
+
currentSprint: null,
|
|
25
|
+
tasks: {
|
|
26
|
+
backlog: [],
|
|
27
|
+
inDev: [],
|
|
28
|
+
inReview: [],
|
|
29
|
+
done: [],
|
|
30
|
+
reject: [],
|
|
31
|
+
blocked: []
|
|
32
|
+
},
|
|
33
|
+
sessions: [],
|
|
34
|
+
sessionLogs: [],
|
|
35
|
+
pendingQuestions: [],
|
|
36
|
+
nextRecommendation: null
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (!isWorkspaceSetup()) {
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
result.isSetup = true;
|
|
44
|
+
const workspace = getWorkspaceDir();
|
|
45
|
+
|
|
46
|
+
// 템플릿 확인
|
|
47
|
+
const templateFile = path.join(workspace, '.current-template');
|
|
48
|
+
if (fs.existsSync(templateFile)) {
|
|
49
|
+
result.template = fs.readFileSync(templateFile, 'utf-8').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 활성 세션
|
|
53
|
+
result.sessions = getActiveSessions();
|
|
54
|
+
|
|
55
|
+
// 세션 로그 (실시간 요약)
|
|
56
|
+
result.sessionLogs = readSessionLogTails(result.sessions);
|
|
57
|
+
|
|
58
|
+
// 대기 질문
|
|
59
|
+
result.pendingQuestions = getPendingQuestions();
|
|
60
|
+
|
|
61
|
+
// 스프린트 정보
|
|
62
|
+
const artifactsDir = path.join(workspace, 'artifacts');
|
|
63
|
+
const sprintsDir = path.join(artifactsDir, 'sprints');
|
|
64
|
+
|
|
65
|
+
if (fs.existsSync(sprintsDir)) {
|
|
66
|
+
const sprints = fs.readdirSync(sprintsDir, { withFileTypes: true })
|
|
67
|
+
.filter(d => d.isDirectory() && /^sprint-\d+$/.test(d.name))
|
|
68
|
+
.map(d => d.name)
|
|
69
|
+
.sort((a, b) => {
|
|
70
|
+
const numA = parseInt(a.split('-')[1]);
|
|
71
|
+
const numB = parseInt(b.split('-')[1]);
|
|
72
|
+
return numB - numA;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (sprints.length > 0) {
|
|
76
|
+
const currentSprintName = sprints[0];
|
|
77
|
+
const sprintDir = path.join(sprintsDir, currentSprintName);
|
|
78
|
+
result.currentSprint = currentSprintName;
|
|
79
|
+
|
|
80
|
+
// Task 파싱
|
|
81
|
+
const tasksDir = path.join(sprintDir, 'tasks');
|
|
82
|
+
const reviewReportsDir = path.join(sprintDir, 'review-reports');
|
|
83
|
+
|
|
84
|
+
if (fs.existsSync(tasksDir)) {
|
|
85
|
+
const taskFiles = fs.readdirSync(tasksDir)
|
|
86
|
+
.filter(f => f.endsWith('.md') && f.startsWith('task-'));
|
|
87
|
+
|
|
88
|
+
taskFiles.forEach(taskFile => {
|
|
89
|
+
const taskPath = path.join(tasksDir, taskFile);
|
|
90
|
+
const content = fs.readFileSync(taskPath, 'utf-8');
|
|
91
|
+
const taskInfo = parseTaskMetadata(content, taskFile);
|
|
92
|
+
|
|
93
|
+
// 실제 review-reports 디렉토리에서 리뷰 리포트 파일 존재 여부 확인
|
|
94
|
+
const reviewReportPath = path.join(reviewReportsDir, taskFile);
|
|
95
|
+
if (fs.existsSync(reviewReportPath)) {
|
|
96
|
+
taskInfo.hasReviewReport = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const status = taskInfo.status.toUpperCase();
|
|
100
|
+
if (status === 'BACKLOG') result.tasks.backlog.push(taskInfo);
|
|
101
|
+
else if (status === 'IN_DEV') result.tasks.inDev.push(taskInfo);
|
|
102
|
+
else if (status === 'IN_REVIEW') result.tasks.inReview.push(taskInfo);
|
|
103
|
+
else if (status === 'DONE') result.tasks.done.push(taskInfo);
|
|
104
|
+
else if (status === 'REJECTED' || status === 'REJECT') result.tasks.reject.push(taskInfo);
|
|
105
|
+
else if (status === 'BLOCKED') result.tasks.blocked.push(taskInfo);
|
|
106
|
+
else result.tasks.backlog.push(taskInfo);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 다음 추천 액션 결정
|
|
113
|
+
result.nextRecommendation = determineNextAction(result);
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 다음 추천 액션 결정
|
|
120
|
+
*/
|
|
121
|
+
function determineNextAction(state) {
|
|
122
|
+
if (!state.isSetup) {
|
|
123
|
+
return { action: 'setup', reason: 'Setup 필요' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (state.tasks.reject.length > 0) {
|
|
127
|
+
return { role: 'developer', reason: `REJECT ${state.tasks.reject.length}개 수정` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (state.tasks.inReview.length > 0) {
|
|
131
|
+
return { role: 'reviewer', reason: `IN_REVIEW ${state.tasks.inReview.length}개 리뷰` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (state.tasks.backlog.length > 0 && state.tasks.inDev.length === 0) {
|
|
135
|
+
return { role: 'developer', reason: `BACKLOG ${state.tasks.backlog.length}개 개발 시작` };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (state.tasks.inDev.length > 0) {
|
|
139
|
+
return { role: 'developer', reason: `IN_DEV ${state.tasks.inDev.length}개 개발 계속` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (state.tasks.done.length > 0) {
|
|
143
|
+
const needsReview = state.tasks.done.filter(t => !t.hasReviewReport);
|
|
144
|
+
if (needsReview.length > 0) {
|
|
145
|
+
return { role: 'reviewer', reason: `완료 ${needsReview.length}개 리뷰 필요` };
|
|
146
|
+
}
|
|
147
|
+
return { role: 'documenter', reason: '모든 Task 완료, 문서화' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!state.currentSprint) {
|
|
151
|
+
return { action: 'sprint', reason: '스프린트 생성 필요' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { role: 'planner', reason: 'Task 추가 또는 기획' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 박스 문자 (유니코드)
|
|
159
|
+
*/
|
|
160
|
+
const BOX = {
|
|
161
|
+
topLeft: '\u250C',
|
|
162
|
+
topRight: '\u2510',
|
|
163
|
+
bottomLeft: '\u2514',
|
|
164
|
+
bottomRight: '\u2518',
|
|
165
|
+
horizontal: '\u2500',
|
|
166
|
+
vertical: '\u2502',
|
|
167
|
+
teeRight: '\u251C',
|
|
168
|
+
teeLeft: '\u2524',
|
|
169
|
+
teeDown: '\u252C',
|
|
170
|
+
teeUp: '\u2534',
|
|
171
|
+
cross: '\u253C'
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 수평선 생성
|
|
176
|
+
*/
|
|
177
|
+
function horizontalLine(width, left = BOX.teeRight, right = BOX.teeLeft) {
|
|
178
|
+
return left + BOX.horizontal.repeat(width - 2) + right;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 텍스트 패딩 (왼쪽 정렬)
|
|
183
|
+
*/
|
|
184
|
+
function padText(text, width) {
|
|
185
|
+
const visibleLength = stripAnsi(text).length;
|
|
186
|
+
const padding = width - visibleLength;
|
|
187
|
+
if (padding > 0) {
|
|
188
|
+
return text + ' '.repeat(padding);
|
|
189
|
+
}
|
|
190
|
+
return text.substring(0, width);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* ANSI 코드 제거
|
|
195
|
+
*/
|
|
196
|
+
function stripAnsi(str) {
|
|
197
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 문자열 자르기 (말줄임)
|
|
202
|
+
*/
|
|
203
|
+
function truncate(str, maxLength) {
|
|
204
|
+
if (!str) return '';
|
|
205
|
+
if (stripAnsi(str).length <= maxLength) return str;
|
|
206
|
+
return str.substring(0, maxLength - 1) + '\u2026';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 로그 라인 색상 처리
|
|
211
|
+
*/
|
|
212
|
+
function colorizeLogLine(line) {
|
|
213
|
+
if (line.includes('[ERROR]')) return chalk.red(line);
|
|
214
|
+
if (line.includes('[WARN]')) return chalk.yellow(line);
|
|
215
|
+
if (line.includes('[INFO]')) return chalk.gray(line);
|
|
216
|
+
return line;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 세션 로그 테일 읽기 (최근 라인만)
|
|
221
|
+
*/
|
|
222
|
+
function readSessionLogTails(sessions) {
|
|
223
|
+
if (!sessions || sessions.length === 0) {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const logsDir = getLogsDir();
|
|
228
|
+
if (!fs.existsSync(logsDir)) {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sortedSessions = sessions.slice().sort((a, b) => {
|
|
233
|
+
const aTime = new Date(a.startedAt || 0).getTime();
|
|
234
|
+
const bTime = new Date(b.startedAt || 0).getTime();
|
|
235
|
+
return bTime - aTime;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const sessionsToShow = sortedSessions.slice(0, MAX_LOG_SESSIONS);
|
|
239
|
+
const linesPerSession = Math.max(1, Math.floor(MAX_LOG_LINES / sessionsToShow.length));
|
|
240
|
+
|
|
241
|
+
return sessionsToShow.map(session => {
|
|
242
|
+
const logFile = path.join(logsDir, `${session.sessionId}.log`);
|
|
243
|
+
return {
|
|
244
|
+
sessionId: session.sessionId,
|
|
245
|
+
lines: readLogTail(logFile, linesPerSession)
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 로그 파일의 마지막 부분 읽기
|
|
252
|
+
*/
|
|
253
|
+
function readLogTail(filePath, maxLines) {
|
|
254
|
+
if (!fs.existsSync(filePath)) {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const stats = fs.statSync(filePath);
|
|
260
|
+
if (!stats.isFile() || stats.size === 0) {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const readSize = Math.min(stats.size, MAX_LOG_BYTES);
|
|
265
|
+
const buffer = Buffer.alloc(readSize);
|
|
266
|
+
const fd = fs.openSync(filePath, 'r');
|
|
267
|
+
fs.readSync(fd, buffer, 0, readSize, stats.size - readSize);
|
|
268
|
+
fs.closeSync(fd);
|
|
269
|
+
|
|
270
|
+
const content = buffer.toString('utf-8');
|
|
271
|
+
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
272
|
+
return lines.slice(-maxLines);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 경과 시간 계산
|
|
280
|
+
*/
|
|
281
|
+
function getElapsedTime(startedAt) {
|
|
282
|
+
if (!startedAt) return '?';
|
|
283
|
+
const elapsed = Date.now() - new Date(startedAt).getTime();
|
|
284
|
+
const minutes = Math.floor(elapsed / 60000);
|
|
285
|
+
if (minutes < 60) return `${minutes}분`;
|
|
286
|
+
const hours = Math.floor(minutes / 60);
|
|
287
|
+
return `${hours}시간 ${minutes % 60}분`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 현재 시간 문자열
|
|
292
|
+
*/
|
|
293
|
+
function getCurrentTime() {
|
|
294
|
+
const now = new Date();
|
|
295
|
+
return now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 대시보드 렌더링
|
|
300
|
+
*/
|
|
301
|
+
export function renderDashboard(state, statusMessage = '준비됨') {
|
|
302
|
+
const lines = [];
|
|
303
|
+
const innerWidth = DASHBOARD_WIDTH - 2;
|
|
304
|
+
const leftPanelWidth = HALF_WIDTH - 1;
|
|
305
|
+
const rightPanelWidth = DASHBOARD_WIDTH - leftPanelWidth - 3;
|
|
306
|
+
|
|
307
|
+
// 상단 헤더
|
|
308
|
+
lines.push(
|
|
309
|
+
chalk.cyan(BOX.topLeft + BOX.horizontal.repeat(innerWidth) + BOX.topRight)
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const titleText = 'ADA UI Mode';
|
|
313
|
+
const timeText = `[${getCurrentTime()}]`;
|
|
314
|
+
const titleLine = ` ${titleText}${' '.repeat(innerWidth - titleText.length - timeText.length - 1)}${timeText}`;
|
|
315
|
+
lines.push(chalk.cyan(BOX.vertical) + chalk.cyan.bold(titleLine) + chalk.cyan(BOX.vertical));
|
|
316
|
+
|
|
317
|
+
// 구분선
|
|
318
|
+
lines.push(chalk.cyan(horizontalLine(DASHBOARD_WIDTH, BOX.teeRight, BOX.teeLeft)));
|
|
319
|
+
|
|
320
|
+
// 중간 패널 (스프린트 | 세션)
|
|
321
|
+
const sprintLines = renderSprintPanel(state, leftPanelWidth);
|
|
322
|
+
const sessionLines = renderSessionPanel(state, rightPanelWidth);
|
|
323
|
+
|
|
324
|
+
const maxPanelLines = Math.max(sprintLines.length, sessionLines.length);
|
|
325
|
+
for (let i = 0; i < maxPanelLines; i++) {
|
|
326
|
+
const leftLine = sprintLines[i] || ' '.repeat(leftPanelWidth);
|
|
327
|
+
const rightLine = sessionLines[i] || ' '.repeat(rightPanelWidth);
|
|
328
|
+
lines.push(
|
|
329
|
+
chalk.cyan(BOX.vertical) +
|
|
330
|
+
padText(leftLine, leftPanelWidth) +
|
|
331
|
+
chalk.cyan(BOX.vertical) +
|
|
332
|
+
padText(rightLine, rightPanelWidth) +
|
|
333
|
+
chalk.cyan(BOX.vertical)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Quick Actions 섹션
|
|
338
|
+
lines.push(chalk.cyan(horizontalLine(DASHBOARD_WIDTH, BOX.teeRight, BOX.teeLeft)));
|
|
339
|
+
const quickActionsLines = renderQuickActions(innerWidth);
|
|
340
|
+
quickActionsLines.forEach(line => {
|
|
341
|
+
lines.push(chalk.cyan(BOX.vertical) + padText(line, innerWidth) + chalk.cyan(BOX.vertical));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// 상태 바
|
|
345
|
+
lines.push(chalk.cyan(horizontalLine(DASHBOARD_WIDTH, BOX.teeRight, BOX.teeLeft)));
|
|
346
|
+
|
|
347
|
+
const nextRec = state.nextRecommendation;
|
|
348
|
+
let nextText = '';
|
|
349
|
+
if (nextRec) {
|
|
350
|
+
if (nextRec.action) {
|
|
351
|
+
nextText = `${nextRec.action} (${nextRec.reason})`;
|
|
352
|
+
} else if (nextRec.role) {
|
|
353
|
+
nextText = `${nextRec.role} (${nextRec.reason})`;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const statusLine = ` STATUS: ${statusMessage} | ${chalk.green('다음 추천:')} ${nextText}`;
|
|
358
|
+
lines.push(chalk.cyan(BOX.vertical) + padText(statusLine, innerWidth) + chalk.cyan(BOX.vertical));
|
|
359
|
+
|
|
360
|
+
// 하단
|
|
361
|
+
lines.push(
|
|
362
|
+
chalk.cyan(BOX.bottomLeft + BOX.horizontal.repeat(innerWidth) + BOX.bottomRight)
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
// 출력
|
|
366
|
+
const output = lines.join('\n');
|
|
367
|
+
if (process.stdout.isTTY) {
|
|
368
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
369
|
+
process.stdout.write(output);
|
|
370
|
+
} else {
|
|
371
|
+
console.log(output);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 스프린트 패널 렌더링
|
|
377
|
+
*/
|
|
378
|
+
function renderSprintPanel(state, width) {
|
|
379
|
+
const lines = [];
|
|
380
|
+
const contentWidth = width - 2;
|
|
381
|
+
|
|
382
|
+
if (!state.isSetup) {
|
|
383
|
+
lines.push(chalk.yellow(' SPRINT: Setup 필요'));
|
|
384
|
+
lines.push(chalk.gray(' ada setup <template>'));
|
|
385
|
+
return lines;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!state.currentSprint) {
|
|
389
|
+
lines.push(chalk.yellow(' SPRINT: 없음'));
|
|
390
|
+
lines.push(chalk.gray(' ada sprint create'));
|
|
391
|
+
return lines;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
lines.push(chalk.white.bold(` SPRINT: ${chalk.cyan(state.currentSprint)}`));
|
|
395
|
+
|
|
396
|
+
const taskStats = [
|
|
397
|
+
{ label: 'BACKLOG', count: state.tasks.backlog.length, color: chalk.gray },
|
|
398
|
+
{ label: 'IN_DEV', count: state.tasks.inDev.length, color: chalk.yellow },
|
|
399
|
+
{ label: 'IN_REVIEW', count: state.tasks.inReview.length, color: chalk.blue },
|
|
400
|
+
{ label: 'DONE', count: state.tasks.done.length, color: chalk.green },
|
|
401
|
+
{ label: 'REJECT', count: state.tasks.reject.length, color: chalk.red }
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
taskStats.forEach(stat => {
|
|
405
|
+
if (stat.count > 0) {
|
|
406
|
+
let taskList = [];
|
|
407
|
+
if (stat.label === 'BACKLOG') taskList = state.tasks.backlog;
|
|
408
|
+
else if (stat.label === 'IN_DEV') taskList = state.tasks.inDev;
|
|
409
|
+
else if (stat.label === 'REJECT') taskList = state.tasks.reject;
|
|
410
|
+
|
|
411
|
+
const taskIds = taskList.slice(0, 3).map(t => t.id).join(', ');
|
|
412
|
+
const extra = taskList.length > 3 ? `...` : '';
|
|
413
|
+
lines.push(stat.color(` \u251C\u2500 ${stat.label}: ${stat.count}개`));
|
|
414
|
+
if (taskIds) {
|
|
415
|
+
lines.push(stat.color(` \u2502 (${truncate(taskIds + extra, contentWidth - 6)})`));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
if (state.tasks.blocked.length > 0) {
|
|
421
|
+
lines.push(chalk.red(` \u2514\u2500 BLOCKED: ${state.tasks.blocked.length}개`));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return lines;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 세션 패널 렌더링
|
|
429
|
+
*/
|
|
430
|
+
function renderSessionPanel(state, width) {
|
|
431
|
+
const lines = [];
|
|
432
|
+
|
|
433
|
+
lines.push(chalk.white.bold(' SESSIONS'));
|
|
434
|
+
|
|
435
|
+
if (state.sessions.length === 0) {
|
|
436
|
+
lines.push(chalk.gray(' (활성 세션 없음)'));
|
|
437
|
+
} else {
|
|
438
|
+
state.sessions.forEach(session => {
|
|
439
|
+
const elapsed = getElapsedTime(session.startedAt);
|
|
440
|
+
const icon = session.status === 'active' ? '+' : '-';
|
|
441
|
+
const roleText = `${session.role} (${session.tool})`;
|
|
442
|
+
lines.push(chalk.green(` ${icon} ${truncate(roleText, width - 10)} - ${elapsed}`));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
lines.push('');
|
|
447
|
+
|
|
448
|
+
const qCount = state.pendingQuestions.length;
|
|
449
|
+
if (qCount > 0) {
|
|
450
|
+
lines.push(chalk.yellow(` QUESTIONS: ${qCount}개 대기`));
|
|
451
|
+
} else {
|
|
452
|
+
lines.push(chalk.gray(' QUESTIONS: 없음'));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
lines.push('');
|
|
456
|
+
lines.push(chalk.white.bold(' LOGS'));
|
|
457
|
+
|
|
458
|
+
if (!state.sessionLogs || state.sessionLogs.length === 0) {
|
|
459
|
+
lines.push(chalk.gray(' (로그 없음)'));
|
|
460
|
+
return lines;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
state.sessionLogs.forEach(sessionLog => {
|
|
464
|
+
lines.push(chalk.cyan(` ${truncate(sessionLog.sessionId, width - 2)}`));
|
|
465
|
+
|
|
466
|
+
if (!sessionLog.lines || sessionLog.lines.length === 0) {
|
|
467
|
+
lines.push(chalk.gray(' (로그 없음)'));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
sessionLog.lines.forEach(line => {
|
|
472
|
+
const trimmed = truncate(line, width - 2);
|
|
473
|
+
lines.push(colorizeLogLine(` ${trimmed}`));
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
return lines;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Quick Actions 렌더링
|
|
482
|
+
*/
|
|
483
|
+
function renderQuickActions(width) {
|
|
484
|
+
const lines = [];
|
|
485
|
+
|
|
486
|
+
lines.push(chalk.white.bold(' QUICK ACTIONS'));
|
|
487
|
+
lines.push('');
|
|
488
|
+
|
|
489
|
+
// 알파벳 키
|
|
490
|
+
const alphaCol1 = [
|
|
491
|
+
chalk.yellow('[s]') + ' sessions'
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
const alphaCol2 = [
|
|
495
|
+
chalk.yellow('[l]') + ' logs'
|
|
496
|
+
];
|
|
497
|
+
|
|
498
|
+
const alphaCol3 = [
|
|
499
|
+
chalk.yellow('[t]') + ' status'
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
const colWidth = Math.floor((width - 4) / 3);
|
|
503
|
+
const rowCount = Math.max(alphaCol1.length, alphaCol2.length, alphaCol3.length);
|
|
504
|
+
for (let i = 0; i < rowCount; i++) {
|
|
505
|
+
const c1 = padText(' ' + (alphaCol1[i] || ''), colWidth);
|
|
506
|
+
const c2 = padText(alphaCol2[i] || '', colWidth);
|
|
507
|
+
const c3 = padText(alphaCol3[i] || '', colWidth);
|
|
508
|
+
lines.push(c1 + c2 + c3);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
lines.push('');
|
|
512
|
+
lines.push(
|
|
513
|
+
' ' + chalk.cyan('[q]') + ' 종료 ' +
|
|
514
|
+
chalk.cyan('[h]') + ' 도움말'
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
return lines;
|
|
518
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
import { QUICK_ACTIONS, executeQuickAction } from './quickActions.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 키 입력 핸들러 클래스
|
|
6
|
+
* 터미널에서 단일 키 입력을 감지하여 콜백 실행
|
|
7
|
+
*/
|
|
8
|
+
export class KeyHandler {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.onKey = options.onKey || (() => {});
|
|
11
|
+
this.onRefresh = options.onRefresh || (() => {});
|
|
12
|
+
this.onQuit = options.onQuit || (() => {});
|
|
13
|
+
this.paused = false;
|
|
14
|
+
this.rl = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 키 입력 감지 시작
|
|
19
|
+
*/
|
|
20
|
+
start() {
|
|
21
|
+
// Raw mode 설정 (한 글자씩 즉시 입력)
|
|
22
|
+
if (process.stdin.isTTY) {
|
|
23
|
+
readline.emitKeypressEvents(process.stdin);
|
|
24
|
+
process.stdin.setRawMode(true);
|
|
25
|
+
process.stdin.resume();
|
|
26
|
+
|
|
27
|
+
process.stdin.on('keypress', (str, key) => {
|
|
28
|
+
this.handleKeypress(str, key);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 키 입력 감지 중지
|
|
35
|
+
*/
|
|
36
|
+
stop() {
|
|
37
|
+
if (process.stdin.isTTY) {
|
|
38
|
+
process.stdin.setRawMode(false);
|
|
39
|
+
process.stdin.pause();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 일시 정지 (에이전트 실행 중)
|
|
45
|
+
*/
|
|
46
|
+
pause() {
|
|
47
|
+
this.paused = true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 재개
|
|
52
|
+
*/
|
|
53
|
+
resume() {
|
|
54
|
+
this.paused = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 키 입력 처리
|
|
59
|
+
*/
|
|
60
|
+
handleKeypress(str, key) {
|
|
61
|
+
// Ctrl+C 처리
|
|
62
|
+
if (key && key.ctrl && key.name === 'c') {
|
|
63
|
+
this.onQuit();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 일시 정지 상태면 무시 (일부 키만 허용)
|
|
68
|
+
if (this.paused) {
|
|
69
|
+
// 일시 정지 중에도 q는 허용
|
|
70
|
+
if (str === 'q' || str === 'Q') {
|
|
71
|
+
this.onQuit();
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 키 처리
|
|
77
|
+
const lowerKey = str ? str.toLowerCase() : '';
|
|
78
|
+
|
|
79
|
+
// 특수 키 처리
|
|
80
|
+
if (lowerKey === 'q') {
|
|
81
|
+
this.onQuit();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Quick Action 키 확인 (숫자, 알파벳 모두)
|
|
86
|
+
if (QUICK_ACTIONS[lowerKey]) {
|
|
87
|
+
this.onKey(lowerKey);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 알파벳 키 처리 (s, l, t)
|
|
92
|
+
if (str && /^[slt]$/i.test(str)) {
|
|
93
|
+
this.onKey(lowerKey);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 단일 키 입력 대기 (Promise 기반)
|
|
101
|
+
* @returns {Promise<string>} 입력된 키
|
|
102
|
+
*/
|
|
103
|
+
export function waitForKey() {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
if (!process.stdin.isTTY) {
|
|
106
|
+
// TTY가 아닌 경우 즉시 반환
|
|
107
|
+
resolve('');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const wasRaw = process.stdin.isRaw;
|
|
112
|
+
const wasPaused = typeof process.stdin.isPaused === 'function'
|
|
113
|
+
? process.stdin.isPaused()
|
|
114
|
+
: false;
|
|
115
|
+
readline.emitKeypressEvents(process.stdin);
|
|
116
|
+
process.stdin.setRawMode(true);
|
|
117
|
+
process.stdin.resume();
|
|
118
|
+
|
|
119
|
+
const handler = (str, key) => {
|
|
120
|
+
process.stdin.removeListener('keypress', handler);
|
|
121
|
+
if (!wasRaw) {
|
|
122
|
+
process.stdin.setRawMode(false);
|
|
123
|
+
}
|
|
124
|
+
if (wasPaused) {
|
|
125
|
+
process.stdin.pause();
|
|
126
|
+
} else {
|
|
127
|
+
process.stdin.resume();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Ctrl+C
|
|
131
|
+
if (key && key.ctrl && key.name === 'c') {
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
resolve(str || '');
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
process.stdin.on('keypress', handler);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* stdin이 TTY인지 확인
|
|
144
|
+
*/
|
|
145
|
+
export function isTTY() {
|
|
146
|
+
return process.stdin.isTTY;
|
|
147
|
+
}
|