aiwf
Version:
AI Workflow Framework for Claude Code with multi-language support (Korean/English)
393 lines (314 loc) • 12 kB
JavaScript
/**
* 독립 스프린트 생성 명령어
* 마일스톤 없이 독립적으로 실행 가능한 스프린트를 생성
* YOLO 모드의 핵심 기능
*/
import fs from 'fs/promises';
import path from 'path';
import chalk from 'chalk';
import ora from 'ora';
import prompts from 'prompts';
import { execSync } from 'child_process';
// 프로젝트 루트에서 .aiwf 디렉토리 찾기
async function findProjectRoot(startDir = process.cwd()) {
let currentDir = startDir;
while (currentDir !== path.parse(currentDir).root) {
try {
const aiwfPath = path.join(currentDir, '.aiwf');
await fs.access(aiwfPath);
return currentDir;
} catch {
currentDir = path.dirname(currentDir);
}
}
throw new Error('.aiwf 디렉토리를 찾을 수 없습니다. AIWF 프로젝트 내에서 실행해주세요.');
}
// 다음 스프린트 ID 생성
async function getNextSprintId(projectRoot) {
const sprintsDir = path.join(projectRoot, '.aiwf', '03_SPRINTS');
try {
await fs.access(sprintsDir);
} catch {
await fs.mkdir(sprintsDir, { recursive: true });
return 'S01';
}
const entries = await fs.readdir(sprintsDir);
const sprintDirs = entries.filter(entry => entry.match(/^S\d+_/));
if (sprintDirs.length === 0) {
return 'S01';
}
const numbers = sprintDirs.map(dir => {
const match = dir.match(/^S(\d+)_/);
return match ? parseInt(match[1]) : 0;
});
const maxNumber = Math.max(...numbers);
return `S${String(maxNumber + 1).padStart(2, '0')}`;
}
// README에서 TODO 추출
async function extractTodosFromReadme(projectRoot) {
const readmePaths = [
path.join(projectRoot, 'README.md'),
path.join(projectRoot, 'readme.md'),
path.join(projectRoot, 'README.ko.md')
];
for (const readmePath of readmePaths) {
try {
const content = await fs.readFile(readmePath, 'utf-8');
// TODO, Features, 기능, 할 일 등의 섹션 찾기
const todoPatterns = [
/## (?:TODO|To Do|할 일|Tasks?)[\s\S]*?(?=##|$)/gi,
/## (?:Features?|기능)[\s\S]*?(?=##|$)/gi,
/- \[ \] .+/g
];
const todos = [];
for (const pattern of todoPatterns) {
const matches = content.match(pattern);
if (matches) {
matches.forEach(match => {
const todoItems = match.match(/- \[ \] (.+)/g);
if (todoItems) {
todoItems.forEach(item => {
const task = item.replace(/- \[ \] /, '').trim();
todos.push(task);
});
}
});
}
}
if (todos.length > 0) {
return todos;
}
} catch {
// 파일이 없으면 다음 경로 시도
continue;
}
}
return [];
}
// GitHub 이슈에서 정보 추출
async function extractFromGitHubIssue(issueNumber) {
try {
// gh CLI 사용
const issueData = execSync(`gh issue view ${issueNumber} --json title,body`, { encoding: 'utf8' });
const issue = JSON.parse(issueData);
const tasks = [];
const title = issue.title;
// 이슈 본문에서 체크리스트 추출
if (issue.body) {
const checklistItems = issue.body.match(/- \[ \] (.+)/g);
if (checklistItems) {
checklistItems.forEach(item => {
const task = item.replace(/- \[ \] /, '').trim();
tasks.push(task);
});
}
}
return { title, tasks };
} catch (error) {
throw new Error(`GitHub 이슈 #${issueNumber}를 가져올 수 없습니다: ${error.message}`);
}
}
// 스프린트 디렉토리 생성
async function createSprintDirectory(projectRoot, sprintId, sprintName) {
const dirName = `${sprintId}_${sprintName.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_가-힣]/g, '')}`;
const sprintPath = path.join(projectRoot, '.aiwf', '03_SPRINTS', dirName);
await fs.mkdir(sprintPath, { recursive: true });
return { sprintPath, dirName };
}
// 스프린트 메타 파일 생성
async function createSprintMeta(sprintPath, sprintId, sprintName, options) {
const timestamp = new Date().toISOString().slice(0, 10);
const content = `# ${sprintId} - ${sprintName}
**타입**: 독립 스프린트
**생성일**: ${timestamp}
**상태**: active
**마일스톤**: ${options.milestone || 'N/A (독립 스프린트)'}
## 개요
${options.description || '이 스프린트는 마일스톤과 독립적으로 실행되는 작업 단위입니다.'}
## 목표
${options.goals ? options.goals.map(g => `- ${g}`).join('\n') : '- 요구사항 충족\n- 최소 기능 구현\n- 빠른 완성'}
## 범위
${options.scope || '이 스프린트의 범위는 명시된 태스크로 제한됩니다. 오버엔지니어링을 피하고 핵심 기능에 집중합니다.'}
## YOLO 설정
\`\`\`yaml
engineering_level: ${options.engineeringLevel || 'minimal'}
focus_rules:
- requirement_first
- simple_solution
- no_gold_plating
- stay_on_track
\`\`\`
## 태스크 목록
${options.tasks ? options.tasks.map((t, i) => `- [ ] T${String(i + 1).padStart(2, '0')}: ${t} - 상태: pending`).join('\n') : '(태스크가 자동으로 추가됩니다)'}
## 진행 상황
- **계획됨**: ${options.tasks ? options.tasks.length : 0}
- **진행 중**: 0
- **완료됨**: 0
- **전체 진행률**: 0%
## 노트
- 이 스프린트는 YOLO 모드로 실행하기에 최적화되어 있습니다.
- \`/project:aiwf:yolo ${sprintId}\`로 전체 스프린트를 자동 실행할 수 있습니다.
`;
const metaPath = path.join(sprintPath, `${sprintId}_sprint_meta.md`);
await fs.writeFile(metaPath, content);
return metaPath;
}
// 태스크 파일들 생성
async function createTaskFiles(sprintPath, sprintId, tasks) {
const createdTasks = [];
for (let i = 0; i < tasks.length; i++) {
const taskNumber = `T${String(i + 1).padStart(2, '0')}`;
const taskTitle = tasks[i];
const fileName = `${taskNumber}_${sprintId}_${taskTitle.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_가-힣]/g, '')}.md`;
const filePath = path.join(sprintPath, fileName);
const content = `# ${taskNumber}: ${taskTitle}
**스프린트**: ${sprintId}
**생성일**: ${new Date().toISOString().slice(0, 16).replace('T', ' ')}
**상태**: pending
**우선순위**: high
**예상 시간**: TBD
## 개요
${taskTitle}를 구현합니다.
## 요구사항
- [ ] 핵심 기능 구현
- [ ] 기본적인 테스트
- [ ] 필수 문서화
## 승인 기준
- [ ] 요구사항 충족
- [ ] 정상 작동 확인
- [ ] 코드 품질 기준 충족
## 구현 노트
- 가장 간단한 해결책 우선
- 기존 코드 재사용
- 오버엔지니어링 방지
`;
await fs.writeFile(filePath, content);
createdTasks.push({ taskNumber, fileName, taskTitle });
}
return createdTasks;
}
// 메인 함수: 독립 스프린트 생성
export async function createIndependentSprint(options = {}) {
const spinner = ora('독립 스프린트 생성 중...').start();
try {
const projectRoot = await findProjectRoot();
const sprintId = await getNextSprintId(projectRoot);
let sprintName = options.name;
let tasks = options.tasks || [];
let description = options.description;
let engineeringLevel = options.minimal ? 'minimal' : options.engineeringLevel || 'minimal';
// README에서 TODO 추출
if (options.fromReadme) {
spinner.text = 'README에서 TODO 추출 중...';
const todos = await extractTodosFromReadme(projectRoot);
if (todos.length > 0) {
tasks = todos;
sprintName = sprintName || 'README TODO 구현';
description = 'README에서 추출한 TODO 항목들을 구현합니다.';
} else {
spinner.warn('README에서 TODO를 찾을 수 없습니다.');
}
}
// GitHub 이슈에서 추출
if (options.fromIssue) {
spinner.text = `GitHub 이슈 #${options.fromIssue} 로딩 중...`;
const issueData = await extractFromGitHubIssue(options.fromIssue);
sprintName = sprintName || issueData.title;
tasks = issueData.tasks.length > 0 ? issueData.tasks : [`이슈 #${options.fromIssue} 구현`];
description = `GitHub 이슈 #${options.fromIssue}: ${issueData.title}`;
}
// 대화형 입력
if (!sprintName || tasks.length === 0) {
spinner.stop();
const response = await prompts([
{
type: !sprintName ? 'text' : null,
name: 'name',
message: '스프린트 이름:',
initial: '빠른 프로토타입'
},
{
type: tasks.length === 0 ? 'text' : null,
name: 'goal',
message: '주요 목표 (한 줄로):',
initial: '핵심 기능 구현'
}
]);
sprintName = sprintName || response.name;
if (response.goal && tasks.length === 0) {
tasks = [response.goal];
}
spinner.start();
}
// 스프린트 디렉토리 생성
spinner.text = '스프린트 디렉토리 생성 중...';
const { sprintPath, dirName } = await createSprintDirectory(projectRoot, sprintId, sprintName);
// 스프린트 메타 파일 생성
spinner.text = '스프린트 메타 파일 생성 중...';
await createSprintMeta(sprintPath, sprintId, sprintName, {
description,
goals: options.goals || [`${sprintName} 완성`],
scope: options.scope,
engineeringLevel,
tasks,
milestone: null
});
// 태스크 파일 생성
if (tasks.length > 0) {
spinner.text = '태스크 파일 생성 중...';
await createTaskFiles(sprintPath, sprintId, tasks);
}
spinner.succeed('독립 스프린트 생성 완료!');
// 성공 메시지
console.log('\n' + chalk.green('✅ 독립 스프린트 생성 완료!'));
console.log('\n📁 스프린트: ' + chalk.cyan(dirName));
console.log('🆔 ID: ' + chalk.yellow(sprintId));
console.log('📝 이름: ' + chalk.yellow(sprintName));
console.log('🎯 태스크: ' + chalk.blue(`${tasks.length}개`));
console.log('🔧 엔지니어링 레벨: ' + chalk.magenta(engineeringLevel));
console.log('\n' + chalk.bold('🚀 다음 단계:'));
console.log(`1. ${chalk.cyan(`/project:aiwf:yolo ${sprintId}`)}로 전체 스프린트 실행`);
console.log(`2. ${chalk.cyan(`/project:aiwf:yolo ${sprintId} --minimal`)}로 최소 구현 모드 실행`);
console.log(`3. ${chalk.cyan(`aiwf sprint-task ${sprintId} "새 태스크"`)}로 태스크 추가`);
console.log('\n💡 팁: 이 스프린트는 마일스톤과 독립적으로 실행됩니다!');
return { success: true, sprintId, sprintPath, tasks: tasks.length };
} catch (error) {
spinner.fail(`독립 스프린트 생성 실패: ${error.message}`);
throw error;
}
}
// CLI 실행
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const options = {
name: null,
fromReadme: false,
fromIssue: null,
minimal: false,
engineeringLevel: 'minimal'
};
// 인자 파싱
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--from-readme') {
options.fromReadme = true;
} else if (arg === '--from-issue' && args[i + 1]) {
options.fromIssue = args[++i];
} else if (arg === '--minimal') {
options.minimal = true;
options.engineeringLevel = 'minimal';
} else if (arg === '--balanced') {
options.engineeringLevel = 'balanced';
} else if (arg === '--complete') {
options.engineeringLevel = 'complete';
} else if (!options.name) {
options.name = arg;
}
}
createIndependentSprint(options).catch(error => {
console.error(chalk.red(`오류: ${error.message}`));
process.exit(1);
});
}
export default createIndependentSprint;