@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,101 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import {
|
|
6
|
+
cleanupZombieSessions,
|
|
7
|
+
readStatus,
|
|
8
|
+
writeStatus,
|
|
9
|
+
getStatusFilePath
|
|
10
|
+
} from './sessionState.js';
|
|
11
|
+
import { getSessionsDir } from './files.js';
|
|
12
|
+
|
|
13
|
+
describe('좀비 세션 정리 - 프로세스 확인', () => {
|
|
14
|
+
let originalCwd;
|
|
15
|
+
let testDir;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
originalCwd = process.cwd();
|
|
19
|
+
testDir = path.join(process.cwd(), '.test-workspace');
|
|
20
|
+
fs.ensureDirSync(testDir);
|
|
21
|
+
process.chdir(testDir);
|
|
22
|
+
|
|
23
|
+
fs.ensureDirSync(path.join(testDir, 'ai-dev-team'));
|
|
24
|
+
|
|
25
|
+
const initialStatus = {
|
|
26
|
+
version: '1.0',
|
|
27
|
+
updatedAt: new Date().toISOString(),
|
|
28
|
+
currentPhase: 'planning',
|
|
29
|
+
activeSessions: [],
|
|
30
|
+
pendingQuestions: [],
|
|
31
|
+
taskProgress: {},
|
|
32
|
+
notifications: [],
|
|
33
|
+
locks: {}
|
|
34
|
+
};
|
|
35
|
+
const statusFilePath = getStatusFilePath();
|
|
36
|
+
fs.ensureDirSync(path.dirname(statusFilePath));
|
|
37
|
+
fs.writeFileSync(statusFilePath, JSON.stringify(initialStatus, null, 2));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
process.chdir(originalCwd);
|
|
42
|
+
fs.removeSync(testDir);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('실행 중 pid는 오래돼도 유지', () => {
|
|
46
|
+
const status = readStatus();
|
|
47
|
+
status.activeSessions = [
|
|
48
|
+
{
|
|
49
|
+
sessionId: 'sess-live',
|
|
50
|
+
role: 'developer',
|
|
51
|
+
tool: 'codex',
|
|
52
|
+
startedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
|
53
|
+
status: 'active',
|
|
54
|
+
pid: process.pid
|
|
55
|
+
}
|
|
56
|
+
];
|
|
57
|
+
writeStatus(status);
|
|
58
|
+
|
|
59
|
+
const removed = cleanupZombieSessions(60);
|
|
60
|
+
assert.strictEqual(removed, 0);
|
|
61
|
+
|
|
62
|
+
const updated = readStatus();
|
|
63
|
+
assert.strictEqual(updated.activeSessions.length, 1);
|
|
64
|
+
assert.strictEqual(updated.activeSessions[0].sessionId, 'sess-live');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('종료된 pid는 즉시 정리되고 세션 상태가 error로 기록됨', () => {
|
|
68
|
+
const pid = 9999999;
|
|
69
|
+
const sessionId = 'sess-dead';
|
|
70
|
+
const status = readStatus();
|
|
71
|
+
status.activeSessions = [
|
|
72
|
+
{
|
|
73
|
+
sessionId,
|
|
74
|
+
role: 'developer',
|
|
75
|
+
tool: 'codex',
|
|
76
|
+
startedAt: new Date().toISOString(),
|
|
77
|
+
status: 'active',
|
|
78
|
+
pid
|
|
79
|
+
}
|
|
80
|
+
];
|
|
81
|
+
writeStatus(status);
|
|
82
|
+
|
|
83
|
+
const sessionDir = path.join(getSessionsDir(), sessionId);
|
|
84
|
+
fs.ensureDirSync(sessionDir);
|
|
85
|
+
fs.writeFileSync(
|
|
86
|
+
path.join(sessionDir, 'session.json'),
|
|
87
|
+
JSON.stringify({ session_id: sessionId, status: 'active' }, null, 2)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const removed = cleanupZombieSessions(60);
|
|
91
|
+
assert.strictEqual(removed, 1);
|
|
92
|
+
|
|
93
|
+
const updated = readStatus();
|
|
94
|
+
assert.strictEqual(updated.activeSessions.length, 0);
|
|
95
|
+
|
|
96
|
+
const updatedSession = JSON.parse(fs.readFileSync(path.join(sessionDir, 'session.json'), 'utf-8'));
|
|
97
|
+
assert.strictEqual(updatedSession.status, 'error');
|
|
98
|
+
assert.ok(updatedSession.ended_at);
|
|
99
|
+
assert.ok(updatedSession.error);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import {
|
|
6
|
+
addQuestion,
|
|
7
|
+
answerQuestion,
|
|
8
|
+
addNotification,
|
|
9
|
+
markNotificationAsRead,
|
|
10
|
+
markNotificationsAsRead,
|
|
11
|
+
markNotificationsByFilter,
|
|
12
|
+
readStatus,
|
|
13
|
+
writeStatus,
|
|
14
|
+
getStatusFilePath
|
|
15
|
+
} from './sessionState.js';
|
|
16
|
+
|
|
17
|
+
describe('알림 읽음 처리 기능', () => {
|
|
18
|
+
let originalCwd;
|
|
19
|
+
let testDir;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
// 테스트용 임시 디렉토리 생성
|
|
23
|
+
originalCwd = process.cwd();
|
|
24
|
+
testDir = path.join(process.cwd(), '.test-workspace');
|
|
25
|
+
fs.ensureDirSync(testDir);
|
|
26
|
+
process.chdir(testDir);
|
|
27
|
+
|
|
28
|
+
// ai-dev-team 디렉토리 생성
|
|
29
|
+
fs.ensureDirSync(path.join(testDir, 'ai-dev-team'));
|
|
30
|
+
|
|
31
|
+
// 초기 상태 파일 생성
|
|
32
|
+
const initialStatus = {
|
|
33
|
+
version: '1.0',
|
|
34
|
+
updatedAt: new Date().toISOString(),
|
|
35
|
+
currentPhase: 'planning',
|
|
36
|
+
activeSessions: [],
|
|
37
|
+
pendingQuestions: [],
|
|
38
|
+
taskProgress: {},
|
|
39
|
+
notifications: [],
|
|
40
|
+
locks: {}
|
|
41
|
+
};
|
|
42
|
+
const statusFilePath = getStatusFilePath();
|
|
43
|
+
fs.ensureDirSync(path.dirname(statusFilePath));
|
|
44
|
+
fs.writeFileSync(statusFilePath, JSON.stringify(initialStatus, null, 2));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
// 테스트 디렉토리 정리
|
|
49
|
+
process.chdir(originalCwd);
|
|
50
|
+
fs.removeSync(testDir);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('알림 추가 시 to 필드가 포함됨', () => {
|
|
54
|
+
addNotification('info', 'planner', '테스트 알림', 'developer');
|
|
55
|
+
|
|
56
|
+
const status = readStatus();
|
|
57
|
+
assert.strictEqual(status.notifications.length, 1);
|
|
58
|
+
assert.strictEqual(status.notifications[0].to, 'developer');
|
|
59
|
+
assert.strictEqual(status.notifications[0].read, false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('markNotificationAsRead로 알림 읽음 처리', () => {
|
|
63
|
+
addNotification('info', 'planner', '테스트 알림', 'developer');
|
|
64
|
+
const status = readStatus();
|
|
65
|
+
const notifId = status.notifications[0].id;
|
|
66
|
+
|
|
67
|
+
const result = markNotificationAsRead(notifId);
|
|
68
|
+
|
|
69
|
+
assert.strictEqual(result, true);
|
|
70
|
+
|
|
71
|
+
const updatedStatus = readStatus();
|
|
72
|
+
assert.strictEqual(updatedStatus.notifications[0].read, true);
|
|
73
|
+
assert.ok(updatedStatus.notifications[0].readAt);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('이미 읽은 알림은 다시 처리하지 않음', () => {
|
|
77
|
+
addNotification('info', 'planner', '테스트 알림', 'developer');
|
|
78
|
+
const status = readStatus();
|
|
79
|
+
const notifId = status.notifications[0].id;
|
|
80
|
+
|
|
81
|
+
markNotificationAsRead(notifId);
|
|
82
|
+
const firstReadAt = readStatus().notifications[0].readAt;
|
|
83
|
+
|
|
84
|
+
// 약간의 지연
|
|
85
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
86
|
+
return delay(10).then(() => {
|
|
87
|
+
const result = markNotificationAsRead(notifId);
|
|
88
|
+
assert.strictEqual(result, false);
|
|
89
|
+
|
|
90
|
+
const secondReadAt = readStatus().notifications[0].readAt;
|
|
91
|
+
assert.strictEqual(firstReadAt, secondReadAt);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('markNotificationsAsRead로 여러 알림 읽음 처리', () => {
|
|
96
|
+
addNotification('info', 'planner', '알림 1', 'developer');
|
|
97
|
+
addNotification('info', 'planner', '알림 2', 'developer');
|
|
98
|
+
addNotification('info', 'planner', '알림 3', 'developer');
|
|
99
|
+
|
|
100
|
+
const status = readStatus();
|
|
101
|
+
const ids = status.notifications.map(n => n.id);
|
|
102
|
+
|
|
103
|
+
const markedCount = markNotificationsAsRead(ids);
|
|
104
|
+
|
|
105
|
+
assert.strictEqual(markedCount, 3);
|
|
106
|
+
|
|
107
|
+
const updatedStatus = readStatus();
|
|
108
|
+
updatedStatus.notifications.forEach(n => {
|
|
109
|
+
assert.strictEqual(n.read, true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('markNotificationsByFilter로 조건부 읽음 처리', () => {
|
|
114
|
+
addNotification('info', 'planner', '알림 1', 'developer');
|
|
115
|
+
addNotification('warning', 'reviewer', '알림 2', 'developer');
|
|
116
|
+
addNotification('info', 'planner', '알림 3', 'manager');
|
|
117
|
+
|
|
118
|
+
// developer에게 온 알림만 읽음 처리
|
|
119
|
+
const markedCount = markNotificationsByFilter(n => n.to === 'developer');
|
|
120
|
+
|
|
121
|
+
assert.strictEqual(markedCount, 2);
|
|
122
|
+
|
|
123
|
+
const status = readStatus();
|
|
124
|
+
assert.strictEqual(status.notifications[0].read, true); // developer
|
|
125
|
+
assert.strictEqual(status.notifications[1].read, true); // developer
|
|
126
|
+
assert.strictEqual(status.notifications[2].read, false); // manager
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('질문 추가 시 알림의 to가 질문 대상으로 설정됨', () => {
|
|
130
|
+
addQuestion('planner', 'developer', 'API 엔드포인트는?', ['POST /users', 'GET /users']);
|
|
131
|
+
|
|
132
|
+
const status = readStatus();
|
|
133
|
+
assert.strictEqual(status.notifications.length, 1);
|
|
134
|
+
assert.strictEqual(status.notifications[0].to, 'developer');
|
|
135
|
+
assert.ok(status.notifications[0].message.includes('QP001'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('질문 응답 시 관련 알림 자동 읽음 처리', () => {
|
|
139
|
+
const questionId = addQuestion('planner', 'developer', 'API 엔드포인트는?');
|
|
140
|
+
|
|
141
|
+
// 질문 전 상태 확인
|
|
142
|
+
let status = readStatus();
|
|
143
|
+
assert.strictEqual(status.notifications[0].read, false);
|
|
144
|
+
|
|
145
|
+
// 질문 응답
|
|
146
|
+
answerQuestion(questionId, 'POST /users');
|
|
147
|
+
|
|
148
|
+
// 질문 관련 알림이 읽음 처리되었는지 확인
|
|
149
|
+
status = readStatus();
|
|
150
|
+
const questionNotif = status.notifications.find(n => n.message.includes(questionId));
|
|
151
|
+
assert.strictEqual(questionNotif.read, true);
|
|
152
|
+
assert.ok(questionNotif.readAt);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('질문 응답 시 응답 알림이 질문자에게 전달됨', () => {
|
|
156
|
+
const questionId = addQuestion('planner', 'developer', 'API 엔드포인트는?');
|
|
157
|
+
|
|
158
|
+
answerQuestion(questionId, 'POST /users');
|
|
159
|
+
|
|
160
|
+
const status = readStatus();
|
|
161
|
+
const answerNotif = status.notifications.find(n => n.message.includes('응답됨'));
|
|
162
|
+
assert.ok(answerNotif);
|
|
163
|
+
assert.strictEqual(answerNotif.to, 'planner'); // 질문자에게 전달
|
|
164
|
+
assert.strictEqual(answerNotif.from, 'manager');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('통계: 읽지 않은 알림 개수 정확함', () => {
|
|
168
|
+
addNotification('info', 'planner', '알림 1', 'developer');
|
|
169
|
+
addNotification('info', 'planner', '알림 2', 'developer');
|
|
170
|
+
addNotification('info', 'planner', '알림 3', 'developer');
|
|
171
|
+
|
|
172
|
+
let status = readStatus();
|
|
173
|
+
const unreadCount1 = status.notifications.filter(n => !n.read).length;
|
|
174
|
+
assert.strictEqual(unreadCount1, 3);
|
|
175
|
+
|
|
176
|
+
// 하나 읽음 처리
|
|
177
|
+
markNotificationAsRead(status.notifications[0].id);
|
|
178
|
+
|
|
179
|
+
status = readStatus();
|
|
180
|
+
const unreadCount2 = status.notifications.filter(n => !n.read).length;
|
|
181
|
+
assert.strictEqual(unreadCount2, 2);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { parseTaskMetadata } from './taskParser.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 현재 활성 스프린트 찾기
|
|
8
|
+
* @param {string} sprintsDir
|
|
9
|
+
*/
|
|
10
|
+
export function findActiveSprint(sprintsDir) {
|
|
11
|
+
try {
|
|
12
|
+
if (!fs.existsSync(sprintsDir)) return null;
|
|
13
|
+
|
|
14
|
+
const sprints = fs.readdirSync(sprintsDir).filter(d => {
|
|
15
|
+
try {
|
|
16
|
+
return fs.statSync(path.join(sprintsDir, d)).isDirectory() && !d.startsWith('_');
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
for (const sprint of sprints) {
|
|
23
|
+
const metaPath = path.join(sprintsDir, sprint, 'meta.md');
|
|
24
|
+
if (fs.existsSync(metaPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const content = fs.readFileSync(metaPath, 'utf-8');
|
|
27
|
+
if (content.includes('상태 | active')) {
|
|
28
|
+
return sprint;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// 파일 읽기 실패 시 다음 스프린트 확인
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
} catch {
|
|
39
|
+
// 디렉토리 접근 실패 시 null 반환
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 스프린트 동기화 (Task 파일 상태 → meta.md)
|
|
46
|
+
* @param {string} sprintsDir
|
|
47
|
+
* @param {boolean} silent
|
|
48
|
+
*/
|
|
49
|
+
export async function syncSprint(sprintsDir, silent = false) {
|
|
50
|
+
try {
|
|
51
|
+
const activeSprint = findActiveSprint(sprintsDir);
|
|
52
|
+
if (!activeSprint) {
|
|
53
|
+
if (!silent) console.log(chalk.red('❌ 활성 스프린트가 없습니다.'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sprintPath = path.join(sprintsDir, activeSprint);
|
|
58
|
+
const tasksDir = path.join(sprintPath, 'tasks');
|
|
59
|
+
const tasks = [];
|
|
60
|
+
|
|
61
|
+
if (fs.existsSync(tasksDir)) {
|
|
62
|
+
const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.md'));
|
|
63
|
+
|
|
64
|
+
for (const file of taskFiles) {
|
|
65
|
+
try {
|
|
66
|
+
const content = fs.readFileSync(path.join(tasksDir, file), 'utf-8');
|
|
67
|
+
const taskInfo = parseTaskMetadata(content, file);
|
|
68
|
+
tasks.push(taskInfo);
|
|
69
|
+
} catch {
|
|
70
|
+
// 개별 Task 파일 읽기 실패 시 건너뛰기
|
|
71
|
+
if (!silent) console.log(chalk.yellow(` ⚠️ Task 파일 읽기 실패: ${file}`));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// meta.md 업데이트
|
|
77
|
+
updateSprintMeta(sprintPath, tasks);
|
|
78
|
+
|
|
79
|
+
if (!silent) {
|
|
80
|
+
console.log(chalk.green(`✅ ${activeSprint} 메타데이터 동기화 완료`));
|
|
81
|
+
console.log(chalk.gray(` 총 ${tasks.length}개 Task 상태 반영됨`));
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// 동기화 실패해도 호출자의 루프는 계속 진행되도록 함
|
|
85
|
+
if (!silent) {
|
|
86
|
+
console.log(chalk.yellow(` ⚠️ 스프린트 상태 동기화 실패: ${err.message}`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* sprint meta.md 업데이트
|
|
93
|
+
* @param {string} sprintPath
|
|
94
|
+
* @param {Array} tasks
|
|
95
|
+
*/
|
|
96
|
+
export function updateSprintMeta(sprintPath, tasks) {
|
|
97
|
+
const metaPath = path.join(sprintPath, 'meta.md');
|
|
98
|
+
if (!fs.existsSync(metaPath)) return;
|
|
99
|
+
|
|
100
|
+
let metaContent = fs.readFileSync(metaPath, 'utf-8');
|
|
101
|
+
|
|
102
|
+
// Task 목록/요약 섹션 찾기
|
|
103
|
+
// 기존 정규식이 좀 약할 수 있으므로 보완
|
|
104
|
+
const taskSectionRegex = /## Task (?:목록|요약)\s*\n[\s\S]*?\n\n(?=##|$)|## Task (?:목록|요약)\s*\n[\s\S]*?$/;
|
|
105
|
+
const taskSectionTitle = metaContent.includes('## Task 요약') ? '## Task 요약' : '## Task 목록';
|
|
106
|
+
|
|
107
|
+
// 새로운 Task 목록 생성
|
|
108
|
+
let taskListContent = `${taskSectionTitle}\n\n`;
|
|
109
|
+
taskListContent += '| Task | 제목 | 상태 | 우선순위 | 크기 |\n';
|
|
110
|
+
taskListContent += '|------|------|:----:|:--------:|:----:|\n';
|
|
111
|
+
|
|
112
|
+
for (const task of tasks) {
|
|
113
|
+
taskListContent += `| ${task.id} | ${task.title} | ${task.status} | ${task.priority} | ${task.size} |\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
taskListContent += '\n';
|
|
117
|
+
|
|
118
|
+
// 기존 Task 목록 섹션 교체
|
|
119
|
+
if (metaContent.match(/## Task 목록/)) {
|
|
120
|
+
// 섹션이 존재하면 교체
|
|
121
|
+
// 단순히 replace를 쓰면 첫번째 매칭만 되는데, 정규식을 잘 썼으므로 괜찮음.
|
|
122
|
+
// 다만 섹션 뒤에 아무것도 없는 경우를 대비해 regex를 위에서 수정함.
|
|
123
|
+
metaContent = metaContent.replace(taskSectionRegex, taskListContent);
|
|
124
|
+
} else {
|
|
125
|
+
// Task 목록 섹션이 없으면 '## 참고' 섹션 앞이나 파일 끝에 추가
|
|
126
|
+
if (metaContent.includes('## 참고')) {
|
|
127
|
+
metaContent = metaContent.replace(/## 참고/, taskListContent + '## 참고');
|
|
128
|
+
} else {
|
|
129
|
+
metaContent += '\n' + taskListContent;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fs.writeFileSync(metaPath, metaContent);
|
|
134
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Task 파일(.md) 내용을 파싱하여 메타데이터 객체를 반환합니다.
|
|
5
|
+
* @param {string} content - 파일 내용
|
|
6
|
+
* @param {string} filename - 파일 이름 (예: task-001.md)
|
|
7
|
+
* @returns {object} 파싱된 Task 정보
|
|
8
|
+
*/
|
|
9
|
+
export function parseTaskMetadata(content, filename) {
|
|
10
|
+
const lines = content.split('\n');
|
|
11
|
+
|
|
12
|
+
// 1. ID 및 제목 파싱 (첫 줄: # TASK-NNN: [Task 이름])
|
|
13
|
+
// 파일명에서 ID 추출을 우선 시도 (파일명이 더 신뢰도 높음)
|
|
14
|
+
let id = filename ? path.basename(filename, '.md') : '';
|
|
15
|
+
|
|
16
|
+
// 제목 줄 파싱
|
|
17
|
+
const titleLine = lines.find(line => line.startsWith('# '));
|
|
18
|
+
let title = '제목 없음';
|
|
19
|
+
|
|
20
|
+
if (titleLine) {
|
|
21
|
+
// "# TASK-001: 제목" 형태인 경우
|
|
22
|
+
const match = titleLine.match(/^#\s*(TASK-\d+)?[:\s]*\s*(.+)$/i);
|
|
23
|
+
if (match) {
|
|
24
|
+
if (!id && match[1]) id = match[1]; // 파일명이 없으면 제목에서 ID 추출
|
|
25
|
+
title = match[2].trim();
|
|
26
|
+
} else {
|
|
27
|
+
title = titleLine.replace(/^#\s*/, '').trim();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!id) id = 'unknown';
|
|
32
|
+
|
|
33
|
+
// 2. 메타 테이블 파싱 (| 항목 | 값 |)
|
|
34
|
+
const status = parseTableValue(content, ['상태', 'Status'], null) ??
|
|
35
|
+
parseInlineValue(content, ['상태', 'Status']) ?? 'BACKLOG';
|
|
36
|
+
const priority = parseTableValue(content, ['우선순위', 'Priority'], null) ??
|
|
37
|
+
parseInlineValue(content, ['우선순위', 'Priority']) ?? 'P2';
|
|
38
|
+
const size = parseTableValue(content, ['크기', 'Size'], null) ??
|
|
39
|
+
parseInlineValue(content, ['크기', 'Size']) ?? 'M';
|
|
40
|
+
const assignee = parseTableValue(content, ['담당', 'Assignee'], null) ??
|
|
41
|
+
parseInlineValue(content, ['담당', 'Assignee']) ?? '-';
|
|
42
|
+
|
|
43
|
+
// 3. Status Normalize
|
|
44
|
+
const normalizedStatus = normalizeTaskStatus(status);
|
|
45
|
+
|
|
46
|
+
// 4. 추가 정보 (리뷰 리포트 존재 여부 추론 등)
|
|
47
|
+
const hasReviewReport = content.includes('## Review') ||
|
|
48
|
+
content.includes('리뷰 결과');
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
title,
|
|
53
|
+
status: normalizedStatus,
|
|
54
|
+
priority,
|
|
55
|
+
size,
|
|
56
|
+
assignee,
|
|
57
|
+
hasReviewReport
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Task 상태값 정규화
|
|
63
|
+
* @param {string} status
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeTaskStatus(status) {
|
|
66
|
+
if (!status) return 'UNKNOWN';
|
|
67
|
+
const upper = status.toString().toUpperCase().trim().replace(/[\s-]+/g, '_');
|
|
68
|
+
|
|
69
|
+
// 별칭 처리
|
|
70
|
+
if (['IN_PROGRESS', 'PROCESSING', 'DOING', 'DEV', 'ACTIVE'].includes(upper)) return 'IN_DEV';
|
|
71
|
+
if (['IN_REVIEW', 'REVIEW', 'CODE_REVIEW', 'REVIEWING'].includes(upper)) return 'IN_REVIEW';
|
|
72
|
+
if (['IN_QA', 'QA', 'TEST', 'TESTING'].includes(upper)) return 'IN_REVIEW';
|
|
73
|
+
if (['BLOCKED', 'BLOCK', 'ON_HOLD', 'HOLD'].includes(upper)) return 'BLOCKED';
|
|
74
|
+
if (['COMPLETED', 'FINISH', 'FINISHED', 'COMPLETE'].includes(upper)) return 'DONE';
|
|
75
|
+
if (['REJECTED', 'DENIED'].includes(upper)) return 'REJECT';
|
|
76
|
+
|
|
77
|
+
return upper;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 마크다운 테이블에서 특정 키의 값을 추출
|
|
82
|
+
* @param {string} content
|
|
83
|
+
* @param {string} key
|
|
84
|
+
* @param {string} defaultValue
|
|
85
|
+
*/
|
|
86
|
+
function parseTableValue(content, key, defaultValue) {
|
|
87
|
+
// 예: | 상태 | BACKLOG / ... |
|
|
88
|
+
// 공백 유연하게 대응
|
|
89
|
+
const keys = Array.isArray(key) ? key : [key];
|
|
90
|
+
for (const keyItem of keys) {
|
|
91
|
+
const escapedKey = escapeRegExp(keyItem);
|
|
92
|
+
const regex = new RegExp(`\\|\\s*${escapedKey}\\s*\\|\\s*([^\\|]+)\\s*\\|`, 'i');
|
|
93
|
+
const match = content.match(regex);
|
|
94
|
+
|
|
95
|
+
if (match) {
|
|
96
|
+
// "BACKLOG / IN_DEV" 처럼 슬래시로 옵션이 나열된 경우 첫 번째 값 사용
|
|
97
|
+
// 또는 단순히 값이 하나만 있는 경우
|
|
98
|
+
// 템플릿 값("BACKLOG / ...")이 그대로 남아있으면 첫번째 값(BACKLOG) 선택
|
|
99
|
+
let val = match[1].trim();
|
|
100
|
+
if (val.includes('/')) {
|
|
101
|
+
// "BACKLOG / IN_DEV" 형태라면 현재 선택된 값이 무엇인지 모호할 수 있음.
|
|
102
|
+
// 하지만 보통 사람이 편집할 때 나머지를 지우거나,
|
|
103
|
+
// 템플릿 상태라면 첫번째가 기본값이라고 가정.
|
|
104
|
+
// 만약 사용자가 "DONE"만 남겼다면 "/"가 없음.
|
|
105
|
+
val = val.split('/')[0].trim();
|
|
106
|
+
}
|
|
107
|
+
return val;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return defaultValue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 인라인 형식 (예: Status: DONE) 값 추출
|
|
116
|
+
* @param {string} content
|
|
117
|
+
* @param {string|string[]} key
|
|
118
|
+
*/
|
|
119
|
+
function parseInlineValue(content, key) {
|
|
120
|
+
const keys = Array.isArray(key) ? key : [key];
|
|
121
|
+
for (const keyItem of keys) {
|
|
122
|
+
const escapedKey = escapeRegExp(keyItem);
|
|
123
|
+
const regex = new RegExp(`^\\s*${escapedKey}\\s*:\\s*(.+)$`, 'mi');
|
|
124
|
+
const match = content.match(regex);
|
|
125
|
+
if (match) {
|
|
126
|
+
return match[1].trim();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function escapeRegExp(value) {
|
|
133
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
134
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { parseTaskMetadata } from './taskParser.js';
|
|
4
|
+
|
|
5
|
+
test('parseTaskMetadata - 표준 포맷 파싱', (t) => {
|
|
6
|
+
const content = `
|
|
7
|
+
# TASK-001: 로그인 기능
|
|
8
|
+
|
|
9
|
+
| 항목 | 값 |
|
|
10
|
+
|------|-----|
|
|
11
|
+
| 상태 | IN_DEV |
|
|
12
|
+
| 우선순위 | P1 |
|
|
13
|
+
| 크기 | M |
|
|
14
|
+
| 담당 | developer |
|
|
15
|
+
|
|
16
|
+
## 내용
|
|
17
|
+
...
|
|
18
|
+
`;
|
|
19
|
+
const result = parseTaskMetadata(content, 'task-001.md');
|
|
20
|
+
|
|
21
|
+
assert.strictEqual(result.id, 'task-001');
|
|
22
|
+
assert.strictEqual(result.title, '로그인 기능');
|
|
23
|
+
assert.strictEqual(result.status, 'IN_DEV');
|
|
24
|
+
assert.strictEqual(result.priority, 'P1');
|
|
25
|
+
assert.strictEqual(result.size, 'M');
|
|
26
|
+
assert.strictEqual(result.assignee, 'developer');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('parseTaskMetadata - 템플릿 값 잔존 (선택지 포함)', (t) => {
|
|
30
|
+
const content = `
|
|
31
|
+
| 상태 | DONE / REJECTED |
|
|
32
|
+
`;
|
|
33
|
+
// "DONE / REJECTED" 처럼 되어 있으면 첫 번째 값인 DONE을 가져와야 함 (일반적인 AI 수정 패턴)
|
|
34
|
+
// 하지만 유틸리티 구현상 split('/')[0]을 하므로 DONE이 됨.
|
|
35
|
+
const result = parseTaskMetadata(content, 'task-002.md');
|
|
36
|
+
assert.strictEqual(result.status, 'DONE');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('parseTaskMetadata - 제목 파싱 변형', (t) => {
|
|
40
|
+
// # TASK-NNN 없이 그냥 # 제목 인 경우
|
|
41
|
+
const content = `# 회원가입 API 구현`;
|
|
42
|
+
const result = parseTaskMetadata(content, 'task-003.md');
|
|
43
|
+
assert.strictEqual(result.title, '회원가입 API 구현');
|
|
44
|
+
assert.strictEqual(result.id, 'task-003'); // 파일명에서 추출
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('parseTaskMetadata - 공백 불규칙 허용', (t) => {
|
|
48
|
+
const content = `
|
|
49
|
+
|상태| DONE |
|
|
50
|
+
| 우선순위|P0|
|
|
51
|
+
`;
|
|
52
|
+
const result = parseTaskMetadata(content, 'task-004.md');
|
|
53
|
+
assert.strictEqual(result.status, 'DONE');
|
|
54
|
+
assert.strictEqual(result.priority, 'P0');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('parseTaskMetadata - 영문 키 지원 여부 확인', (t) => {
|
|
58
|
+
// AI가 실수로 영문 템플릿을 사용하거나 영문으로 바꾼 경우
|
|
59
|
+
const content = `
|
|
60
|
+
| Status | IN_DEV |
|
|
61
|
+
| Priority | P2 |
|
|
62
|
+
`;
|
|
63
|
+
const result = parseTaskMetadata(content, 'task-005.md');
|
|
64
|
+
|
|
65
|
+
assert.strictEqual(result.status, 'IN_DEV');
|
|
66
|
+
assert.strictEqual(result.priority, 'P2');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('parseTaskMetadata - 리뷰 리포트 존재 감지', (t) => {
|
|
70
|
+
const content = `
|
|
71
|
+
## Review
|
|
72
|
+
- PASS
|
|
73
|
+
`;
|
|
74
|
+
const result = parseTaskMetadata(content, 'task-006.md');
|
|
75
|
+
assert.strictEqual(result.hasReviewReport, true);
|
|
76
|
+
});
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# 예제: Todo App 프로젝트
|
|
2
|
-
|
|
3
|
-
> Artifact-Driven AI Agent Framework 예제
|
|
4
|
-
|
|
5
|
-
## 프로젝트 개요
|
|
6
|
-
|
|
7
|
-
- **규모**: S (Small)
|
|
8
|
-
- **기간**: 1주일
|
|
9
|
-
- **상태**: ✅ 완료
|
|
10
|
-
|
|
11
|
-
## 워크플로우
|
|
12
|
-
|
|
13
|
-
1. Planner: plan.md 작성 → Confirmed
|
|
14
|
-
2. Architect: project.md 작성 → Frozen
|
|
15
|
-
3. Manager: 스프린트 시작
|
|
16
|
-
4. Developer/Reviewer/QA: Task 완료
|
|
17
|
-
5. Manager: 스프린트 종료
|
|
18
|
-
|
|
19
|
-
## 산출물
|
|
20
|
-
|
|
21
|
-
- `artifacts/plan.md` - 기획서
|
|
22
|
-
- `artifacts/project.md` - 기술 기준
|
|
23
|
-
- `artifacts/backlog.md` - Task 목록
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# Backlog
|
|
2
|
-
|
|
3
|
-
> DONE: 5개
|
|
4
|
-
|
|
5
|
-
## ~~TASK-001: 데이터 모델~~ ✅
|
|
6
|
-
- 상태: DONE
|
|
7
|
-
- 크기: S
|
|
8
|
-
|
|
9
|
-
## ~~TASK-002: 추가 기능~~ ✅
|
|
10
|
-
- 상태: DONE
|
|
11
|
-
- 크기: M
|
|
12
|
-
|
|
13
|
-
## ~~TASK-003: 삭제 기능~~ ✅
|
|
14
|
-
- 상태: DONE
|
|
15
|
-
- 크기: S
|
|
16
|
-
|
|
17
|
-
## ~~TASK-004: 완료 토글~~ ✅
|
|
18
|
-
- 상태: DONE
|
|
19
|
-
- 크기: S
|
|
20
|
-
|
|
21
|
-
## ~~TASK-005: 필터~~ ✅
|
|
22
|
-
- 상태: DONE
|
|
23
|
-
- 크기: M
|