import { createHash } from 'node:crypto'; import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { access, cp, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; const execFileAsync = promisify(execFile); const EXEC_MAX_BUFFER = 20 * 1024 * 1024; const repoPath = process.env.PLAN_REPO_PATH ?? process.cwd(); const apiBaseUrl = process.env.PLAN_API_BASE_URL ?? 'http://127.0.0.1:3100/api'; const accessToken = process.env.PLAN_ACCESS_TOKEN?.trim() ?? ''; const planItemId = process.env.PLAN_ITEM_ID ? Number(process.env.PLAN_ITEM_ID) : null; const codexBin = process.env.PLAN_CODEX_BIN ?? 'codex'; const localMainMode = process.env.PLAN_LOCAL_MAIN_MODE === 'true'; const skipWorkComplete = process.env.PLAN_SKIP_WORK_COMPLETE === 'true'; const gitUserName = process.env.PLAN_GIT_USER_NAME ?? 'how2ice'; const gitUserEmail = process.env.PLAN_GIT_USER_EMAIL ?? 'how2ice@naver.com'; const previewBaseUrl = process.env.PLAN_PREVIEW_BASE_URL?.trim() || ''; const previewUrlTemplate = process.env.PLAN_PREVIEW_URL_TEMPLATE?.trim() || ''; const STREAM_CAPTURE_LIMIT = 256 * 1024; const PROMPT_REQUEST_SUMMARY_LIMIT = 72; const SOURCE_SNAPSHOT_MAX_BYTES = 200 * 1024; const ERROR_SUMMARY_MAX_LENGTH = 500; const SOURCE_SNAPSHOT_TEXT_PATTERN = /\.(txt|log|csv|md|mdx|json|jsonl|ya?ml|xml|diff|patch|sh|bash|zsh|ini|cfg|conf|sql|js|jsx|ts|tsx|css|scss|less|html?|java|kt|py|rb|go|rs|svg)$/i; const ERROR_SUMMARY_LINE_PATTERN = /(ERROR:|failed|failure|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i; const ERROR_SUMMARY_NOISE_PATTERN = /^(exec|codex|tokens used|succeeded in \d+ms:?|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i; const CODEX_EXEC_MAX_ATTEMPTS = 2; const CODEX_EXEC_RETRY_DELAY_MS = 3000; const MAX_ERROR_LOG_PLAN_REGISTRATIONS = 6; const CODEX_EXEC_TRANSIENT_FAILURE_PATTERN = /failed to record rollout items|failed to queue rollout items|channel closed/i; const CODEX_HOME_PERMISSION_PATTERN = /failed to record rollout items|failed to queue rollout items|channel closed|read-only file system|eacces|permission/i; const CODEX_HOME_RUNTIME_PATHS = [ 'auth.json', 'config.toml', 'rules', 'skills', 'vendor_imports', 'models_cache.json', 'version.json', ]; function parseCodexTokenUsage(output) { const text = stripAnsi(String(output ?? '')); if (!text) { return null; } const lines = text.split('\n').map((line) => line.trim()); const lastMatchedIndex = lines.reduce((foundIndex, line, index) => { return /tokens?\s+used/i.test(line) ? index : foundIndex; }, -1); if (lastMatchedIndex < 0) { return null; } const matchedLine = lines[lastMatchedIndex] ?? ''; const usageLines = [matchedLine]; if (!/\d/.test(matchedLine)) { for (let index = lastMatchedIndex + 1; index < lines.length && usageLines.length < 5; index += 1) { const nextLine = lines[index]?.trim() ?? ''; if (!nextLine) { if (usageLines.length > 1) { break; } continue; } if (/^(?:\[plan-progress\]|done:|noop:|board_post:|recovered_commit:|git\s|diff --git|commit\s[0-9a-f]{7,})/i.test(nextLine)) { break; } if (!/\d/.test(nextLine) && !/(input|output|total|cached|reasoning)/i.test(nextLine)) { if (usageLines.length > 1) { break; } continue; } usageLines.push(nextLine); } } return usageLines .join(' ') .replace(/\s+/g, ' ') .trim(); } function reportProgress(message) { const text = String(message ?? '').replace(/\s+/g, ' ').trim(); if (!text) { return; } process.stderr.write(`[plan-progress] ${text}\n`); } function requiresSourceChange(note) { const text = String(note ?? ''); return /(?:\/|\\).+\.[a-z0-9]+/i.test(text) || /(생성|만들어|추가|수정|변경|삭제|파일)/.test(text); } function extractRequestedPaths(note) { const text = String(note ?? ''); const matches = text.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+/g) ?? []; return [...new Set(matches.map((item) => item.replace(/\\/g, '/')))]; } function isAutomationEnvironmentFailure(text) { const message = String(text ?? ''); return /bwrap|namespace 오류|샌드박스 제약|파일 쓰기 실패|sandbox/i.test(message); } function isBoardDraftOnlyRequest(note) { const text = String(note ?? '').replace(/\s+/g, ' ').trim(); if (!text) { return false; } const mentionsBoard = /(?:plan|플랜)?\s*게시판|게시글|board/i.test(text); const asksToWrite = /작성만|등록만|글\s*작성|게시글\s*작성|작성해|작성해줘|올려줘|남겨줘/.test(text); const keepsUnreceived = /미접수|접수\s*하지|접수하지|자동화\s*접수\s*(?:하지|금지|안)/.test(text); return mentionsBoard && asksToWrite && keepsUnreceived; } function normalizeAutomationType(value) { return value === 'plan' || value === 'command_execution' || value === 'non_source_work' || value === 'auto_worker' ? value : value === 'plan_registration' ? 'plan' : value === 'general_development' ? 'auto_worker' : 'none'; } function isErrorLogReviewRequest(note) { const text = String(note ?? '').replace(/\s+/g, ' ').trim(); if (!text) { return false; } return /에러\s*로그|error\s*log/i.test(text) && /plan\s*게시판|plan게시판|플랜\s*게시판/i.test(text) && /등록|확인/.test(text); } function shouldProcessAsPlanDocument(item) { return normalizeAutomationType(item?.automationType) === 'plan' && isErrorLogReviewRequest(item?.note); } function shouldForceNoSourceChange(item) { const automationType = normalizeAutomationType(item?.automationType); return automationType === 'command_execution' || automationType === 'non_source_work' || isErrorLogReviewRequest(item?.note); } function shouldSkipSourceChangeRequirement(item) { const automationType = normalizeAutomationType(item?.automationType); return automationType === 'command_execution' || automationType === 'non_source_work' || isErrorLogReviewRequest(item?.note); } function normalizeDateBoundary(value) { if (!value) { return null; } const date = value instanceof Date ? value : new Date(String(value)); if (Number.isNaN(date.getTime())) { return null; } return date; } function formatIsoTimestamp(value) { const date = normalizeDateBoundary(value); return date ? date.toISOString() : String(value ?? ''); } function normalizeRequestPathGroup(requestPath) { const normalized = String(requestPath ?? '') .trim() .toLowerCase() .replace(/\?.*$/u, '') .replace(/\/\d+(?=\/|$)/gu, '/:id') .replace(/[0-9a-f]{8,}(?=\/|$)/giu, ':id'); if (!normalized) { return 'unknown'; } const segments = normalized .split('/') .map((segment) => segment.trim()) .filter(Boolean) .slice(0, 3); return segments.length > 0 ? `/${segments.join('/')}` : normalized; } function buildErrorLogPlanFingerprint(log) { return createHash('sha1') .update( [ String(log.source ?? '').trim().toLowerCase(), String(log.errorType ?? '').trim().toLowerCase(), log.statusCode == null ? '' : String(log.statusCode), ].join('||'), ) .digest('hex') .slice(0, 12); } function buildErrorLogPlanWorkId(log) { return `error-fix-${buildErrorLogPlanFingerprint(log)}`; } function buildErrorLogPlanCandidates(logs) { const grouped = new Map(); for (const log of logs) { const fingerprint = createHash('sha1') .update( [ String(log.source ?? '').trim().toLowerCase(), String(log.errorType ?? '').trim().toLowerCase(), String(log.errorName ?? '').trim().toLowerCase(), log.statusCode == null ? '' : String(log.statusCode), normalizeRequestPathGroup(log.requestPath), ].join('||'), ) .digest('hex') .slice(0, 12); const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime() ?? 0; const existing = grouped.get(fingerprint); const requestPathGroup = normalizeRequestPathGroup(log.requestPath); const errorMessage = String(log.errorMessage ?? '').trim(); if (!existing) { grouped.set(fingerprint, { sample: log, count: 1, firstCreatedAt: log.createdAt, firstTimeMs: createdAtMs, lastCreatedAt: log.createdAt, lastTimeMs: createdAtMs, requestPathGroup, requestPaths: log.requestPath ? new Set([String(log.requestPath).trim()]) : new Set(), errorMessages: errorMessage ? new Set([errorMessage]) : new Set(), errorNames: log.errorName ? new Set([String(log.errorName).trim()]) : new Set(), sampleLogIds: log.id != null ? [log.id] : [], }); continue; } existing.count += 1; if (log.requestPath) { existing.requestPaths.add(String(log.requestPath).trim()); } if (errorMessage) { existing.errorMessages.add(errorMessage); } if (log.errorName) { existing.errorNames.add(String(log.errorName).trim()); } if (log.id != null && existing.sampleLogIds.length < 5) { existing.sampleLogIds.push(log.id); } if (createdAtMs <= existing.firstTimeMs) { existing.firstCreatedAt = log.createdAt; existing.firstTimeMs = createdAtMs; } if (createdAtMs >= existing.lastTimeMs) { existing.sample = log; existing.lastCreatedAt = log.createdAt; existing.lastTimeMs = createdAtMs; } } return [...grouped.entries()] .map(([fingerprint, entry]) => ({ fingerprint, workId: buildErrorLogPlanWorkId(entry.sample), source: entry.sample.source, sourceLabel: entry.sample.sourceLabel, errorType: entry.sample.errorType, errorName: entry.sample.errorName, errorMessage: entry.sample.errorMessage, requestPath: entry.sample.requestPath, requestPathGroup: entry.requestPathGroup, requestPaths: [...entry.requestPaths].filter(Boolean).slice(0, 5), statusCode: entry.sample.statusCode, count: entry.count, firstCreatedAt: entry.firstCreatedAt, lastCreatedAt: entry.lastCreatedAt, sampleLogId: entry.sample.id, sampleLogIds: entry.sampleLogIds, errorNames: [...entry.errorNames].filter(Boolean).slice(0, 5), representativeMessages: [...entry.errorMessages].filter(Boolean).slice(0, 5), })) .sort((left, right) => { if (right.count !== left.count) { return right.count - left.count; } const leftLastTime = normalizeDateBoundary(left.lastCreatedAt)?.getTime() ?? 0; const rightLastTime = normalizeDateBoundary(right.lastCreatedAt)?.getTime() ?? 0; if (rightLastTime !== leftLastTime) { return rightLastTime - leftLastTime; } return Number(right.sampleLogId ?? 0) - Number(left.sampleLogId ?? 0); }); } function mergeErrorLogPlanCandidateBucket(bucket, bucketIndex) { const sortedBucket = [...bucket].sort((left, right) => { if (right.count !== left.count) { return right.count - left.count; } return String(left.workId ?? '').localeCompare(String(right.workId ?? '')); }); const representative = sortedBucket[0]; const uniqueFingerprints = [...new Set(sortedBucket.map((candidate) => candidate.fingerprint).filter(Boolean))].sort(); const mergedFingerprint = createHash('sha1') .update(uniqueFingerprints.join('||')) .digest('hex') .slice(0, 12); const firstCreatedAt = sortedBucket .map((candidate) => normalizeDateBoundary(candidate.firstCreatedAt)?.getTime() ?? Number.POSITIVE_INFINITY) .reduce((min, value) => Math.min(min, value), Number.POSITIVE_INFINITY); const lastCreatedAt = sortedBucket .map((candidate) => normalizeDateBoundary(candidate.lastCreatedAt)?.getTime() ?? 0) .reduce((max, value) => Math.max(max, value), 0); const requestPaths = [...new Set(sortedBucket.flatMap((candidate) => candidate.requestPaths ?? []).filter(Boolean))].slice(0, 8); const representativeMessages = [...new Set(sortedBucket.flatMap((candidate) => candidate.representativeMessages ?? []).filter(Boolean))].slice(0, 8); const errorNames = [...new Set(sortedBucket.flatMap((candidate) => candidate.errorNames ?? []).filter(Boolean))].slice(0, 8); const groupedScopes = sortedBucket .slice(0, 8) .map((candidate) => { const parts = [candidate.sourceLabel || candidate.source, candidate.errorType]; if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') { parts.push(candidate.requestPathGroup); } return parts.filter(Boolean).join(' / '); }) .filter(Boolean); return { fingerprint: mergedFingerprint, workId: `error-fix-bundle-${mergedFingerprint}`, source: representative.source, sourceLabel: representative.sourceLabel, errorType: sortedBucket.length > 1 ? '다중 에러 묶음' : representative.errorType, errorName: representative.errorName, errorMessage: representative.errorMessage, requestPath: representative.requestPath, requestPathGroup: representative.requestPathGroup, requestPaths, statusCode: representative.statusCode, count: sortedBucket.reduce((sum, candidate) => sum + Number(candidate.count ?? 0), 0), firstCreatedAt: Number.isFinite(firstCreatedAt) ? new Date(firstCreatedAt).toISOString() : representative.firstCreatedAt, lastCreatedAt: lastCreatedAt > 0 ? new Date(lastCreatedAt).toISOString() : representative.lastCreatedAt, sampleLogId: representative.sampleLogId, sampleLogIds: [...new Set(sortedBucket.flatMap((candidate) => candidate.sampleLogIds ?? []).filter((value) => value != null))].slice(0, 8), errorNames, representativeMessages, groupedScopes, groupedCandidateCount: sortedBucket.length, bucketIndex, }; } function coalesceErrorLogPlanCandidates(candidates, maxGroups = MAX_ERROR_LOG_PLAN_REGISTRATIONS) { const sortedCandidates = [...candidates].sort((left, right) => { if (right.count !== left.count) { return right.count - left.count; } return String(left.workId ?? '').localeCompare(String(right.workId ?? '')); }); if (sortedCandidates.length <= maxGroups) { return sortedCandidates; } const bucketSize = Math.ceil(sortedCandidates.length / maxGroups); const merged = []; for (let index = 0; index < sortedCandidates.length; index += bucketSize) { const bucket = sortedCandidates.slice(index, index + bucketSize); if (bucket.length === 0) { continue; } merged.push(mergeErrorLogPlanCandidateBucket(bucket, merged.length + 1)); } return merged.slice(0, maxGroups); } function filterLogsWithinRange(logs, rangeStart, rangeEnd) { const startTime = rangeStart.getTime(); const endTime = rangeEnd.getTime(); return logs.filter((log) => { const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime(); return createdAtMs != null && createdAtMs >= startTime && createdAtMs <= endTime; }); } function formatErrorLogPlanNote(candidate, rangeStart, rangeEnd) { const lines = [ `조회 구간: ${formatIsoTimestamp(rangeStart)} ~ ${formatIsoTimestamp(rangeEnd)}`, `발생 건수: ${candidate.count}건`, `최근 발생: ${formatIsoTimestamp(candidate.lastCreatedAt)}`, `최초 발생: ${formatIsoTimestamp(candidate.firstCreatedAt)}`, `에러 유형: ${candidate.errorType}`, ]; if (candidate.errorName) { lines.push(`에러 이름: ${candidate.errorName}`); } if (candidate.sourceLabel || candidate.source) { lines.push(`발생 위치: ${candidate.sourceLabel || candidate.source}`); } if (candidate.groupedCandidateCount && candidate.groupedCandidateCount > 1) { lines.push(`묶인 에러 그룹: ${candidate.groupedCandidateCount}개`); } if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') { lines.push(`주요 경로 그룹: ${candidate.requestPathGroup}`); } if (Array.isArray(candidate.requestPaths) && candidate.requestPaths.length > 0) { lines.push(`대표 경로: ${candidate.requestPaths.join(', ')}`); } if (candidate.statusCode != null) { lines.push(`상태 코드: ${candidate.statusCode}`); } if (Array.isArray(candidate.representativeMessages) && candidate.representativeMessages.length > 0) { lines.push('대표 메시지:'); lines.push(...candidate.representativeMessages.map((message, index) => `${index + 1}. ${message}`)); } else { lines.push(`대표 메시지: ${String(candidate.errorMessage ?? '').trim()}`); } if (Array.isArray(candidate.sampleLogIds) && candidate.sampleLogIds.length > 0) { lines.push(`대표 로그 ID: ${candidate.sampleLogIds.join(', ')}`); } else { lines.push(`대표 로그 ID: ${candidate.sampleLogId}`); } if (Array.isArray(candidate.groupedScopes) && candidate.groupedScopes.length > 0) { lines.push('묶인 에러 범위:'); lines.push(...candidate.groupedScopes.map((scope, index) => `${index + 1}. ${scope}`)); } lines.push(''); lines.push('처리 요청:'); lines.push('1. 재현 경로와 영향 범위를 확인합니다.'); lines.push('2. 수정이 필요한 경우 별도 Plan으로 소스 작업을 진행합니다.'); lines.push('3. 테스트와 재발 방지 필요 여부를 검토합니다.'); return lines.join('\n'); } function stripAnsi(text) { return String(text ?? '').replace(/\u001B\[[0-9;]*m/g, ''); } function normalizeErrorSummaryLine(line) { return stripAnsi(line) .replace(/^\[plan-progress\]\s*/u, '') .replace(/^\d{4}-\d{2}-\d{2}T\S+\s+ERROR\s+[^:]+:\s*/u, '') .replace(/\s+/g, ' ') .trim(); } function summarizeFailureOutput(output, fallback) { const normalizedLines = String(output ?? '') .split('\n') .map((line) => normalizeErrorSummaryLine(line)) .filter(Boolean) .filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line)); const bestLine = normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ?? normalizedLines.at(-1) ?? String(fallback ?? '').trim(); const summary = bestLine.replace(/\s+/g, ' ').trim(); const normalizedSummary = summary.slice(0, ERROR_SUMMARY_MAX_LENGTH) || '자동 작업 실행에 실패했습니다.'; if (!CODEX_HOME_PERMISSION_PATTERN.test(normalizedSummary)) { return normalizedSummary; } const codexHome = process.env.CODEX_HOME?.trim() || '(unset)'; const templateHome = process.env.PLAN_CODEX_TEMPLATE_HOME?.trim() || process.env.CODEX_HOME_TEMPLATE?.trim() || '(unset)'; return `${normalizedSummary} [hint: CODEX_HOME=${codexHome}, template=${templateHome}]`; } function isTransientCodexExecFailure(error) { const message = error instanceof Error ? error.message : String(error ?? ''); return CODEX_EXEC_TRANSIENT_FAILURE_PATTERN.test(message); } async function wait(ms) { await new Promise((resolve) => { setTimeout(resolve, ms); }); } async function prepareWritableCodexHome(tempDir) { const writableCodexHome = process.env.CODEX_HOME?.trim() || path.join(tempDir, '.codex'); const sourceCodexHome = process.env.PLAN_CODEX_TEMPLATE_HOME?.trim() || process.env.CODEX_HOME_TEMPLATE?.trim() || path.join(process.env.HOME ?? '/root', '.codex'); await mkdir(writableCodexHome, { recursive: true }); for (const relativePath of CODEX_HOME_RUNTIME_PATHS) { const sourcePath = path.join(sourceCodexHome, relativePath); const targetPath = path.join(writableCodexHome, relativePath); try { await access(sourcePath); } catch { continue; } await mkdir(path.dirname(targetPath), { recursive: true }); await cp(sourcePath, targetPath, { recursive: true, force: true }); } return writableCodexHome; } function summarizeRequest(note, limit = PROMPT_REQUEST_SUMMARY_LIMIT) { const text = String(note ?? '') .replace(/\s+/g, ' ') .trim(); if (!text) { return '요청 내용 없음'; } if (text.length <= limit) { return text; } return `${text.slice(0, Math.max(0, limit - 1)).trim()}…`; } function escapeTemplateValue(value) { return encodeURIComponent(String(value ?? '').trim()); } function buildPreviewUrl(item, commitHash = '') { if (previewUrlTemplate) { return previewUrlTemplate .replaceAll('{branch}', escapeTemplateValue(item.assignedBranch ?? '')) .replaceAll('{commit}', escapeTemplateValue(commitHash)) .replaceAll('{workId}', escapeTemplateValue(item.workId ?? '')) .replaceAll('{planId}', escapeTemplateValue(item.id ?? '')); } if (previewBaseUrl) { return previewBaseUrl; } return null; } async function request(pathname, init) { const headers = { 'Content-Type': 'application/json', ...(accessToken ? { 'X-Access-Token': accessToken } : {}), ...(init?.headers ?? {}), }; const response = await fetch(`${apiBaseUrl}${pathname}`, { headers, ...init, }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data?.message ?? `Plan API 요청에 실패했습니다: ${pathname}`); } return data; } async function fetchPlanActionHistories(planItemId) { const data = await request(`/plan/items/${planItemId}/actions`); return data.items ?? []; } async function fetchPlanIssueHistories(planItemId) { const data = await request(`/plan/items/${planItemId}/issues`); return data.items ?? []; } function formatRecentActionHistories(histories) { if (!histories.length) { return '없음'; } return histories .slice(0, 5) .map( (history, index) => `${index + 1}. [${history.actionType}] ${history.createdAt}\n${String(history.note ?? '').trim()}`, ) .join('\n\n'); } function formatRecentIssueHistories(histories) { if (!histories.length) { return '없음'; } return histories .slice(0, 5) .map((history, index) => { const actionNote = history.actionNote ? `\n조치이력:\n${String(history.actionNote).trim()}` : ''; return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${String(history.message ?? '').trim()}${actionNote}`; }) .join('\n\n'); } async function runCommand(command, args) { if (command === 'git') { await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', gitUserName], { encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER, }); await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.email', gitUserEmail], { encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER, }); return execFileAsync('git', ['-c', `safe.directory=${repoPath}`, ...args], { cwd: repoPath, encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER, }); } return execFileAsync(command, args, { cwd: repoPath, encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER, }); } async function hasGitChanges() { const { stdout } = await runCommand('git', ['status', '--porcelain']); return Boolean(stdout.trim()); } async function getHeadCommit() { const { stdout } = await runCommand('git', ['rev-parse', 'HEAD']); return stdout.trim(); } async function getHeadCommitSubject() { const { stdout } = await runCommand('git', ['show', '--format=%s', '--no-patch', 'HEAD']); return stdout.trim(); } async function listHeadChangedFiles() { const { stdout } = await runCommand('git', ['show', '--format=', '--name-only', '--find-renames', 'HEAD']); return stdout .split('\n') .map((line) => line.trim()) .filter(Boolean); } async function listChangedFiles() { const { stdout } = await runCommand('git', ['status', '--short']); return stdout .split('\n') .map((line) => line.trim()) .filter(Boolean); } function normalizeChangedPaths(changedFiles) { return changedFiles .map((line) => line.replace(/^[A-Z?]{1,2}\s+/, '').trim()) .map((line) => line.split(' -> ').at(-1) ?? line) .map((line) => line.replace(/\\/g, '/')); } function inferSourceLanguage(filePath) { const normalizedPath = String(filePath ?? '').toLowerCase(); if (normalizedPath.endsWith('.tsx')) { return 'tsx'; } if (normalizedPath.endsWith('.ts')) { return 'typescript'; } if (normalizedPath.endsWith('.jsx')) { return 'jsx'; } if (normalizedPath.endsWith('.js')) { return 'javascript'; } if (normalizedPath.endsWith('.json') || normalizedPath.endsWith('.jsonl')) { return 'json'; } if (normalizedPath.endsWith('.yml') || normalizedPath.endsWith('.yaml')) { return 'yaml'; } if (normalizedPath.endsWith('.css') || normalizedPath.endsWith('.scss') || normalizedPath.endsWith('.less')) { return 'css'; } if (normalizedPath.endsWith('.html') || normalizedPath.endsWith('.htm')) { return 'html'; } if (normalizedPath.endsWith('.diff') || normalizedPath.endsWith('.patch')) { return 'diff'; } if (normalizedPath.endsWith('.sh') || normalizedPath.endsWith('.bash') || normalizedPath.endsWith('.zsh')) { return 'bash'; } if (normalizedPath.endsWith('.py')) { return 'python'; } if (normalizedPath.endsWith('.md') || normalizedPath.endsWith('.mdx')) { return 'markdown'; } return 'text'; } async function collectSourceFileSnapshots() { const { stdout } = await runCommand('git', ['show', '--format=', '--name-status', '--find-renames', 'HEAD']); const lines = stdout .split('\n') .map((line) => line.trimEnd()) .filter(Boolean); const snapshots = await Promise.all( lines.map(async (line) => { const columns = line.split('\t').filter(Boolean); if (columns.length < 2) { return null; } const rawStatus = columns[0] ?? ''; const statusCode = rawStatus.charAt(0).toUpperCase(); const isRename = statusCode === 'R'; const previousPath = isRename ? (columns[1] ?? null) : null; const pathValue = isRename ? columns[2] : columns[1]; const normalizedPath = String(pathValue ?? '').replace(/\\/g, '/'); if (!normalizedPath) { return null; } const normalizedStatus = statusCode === 'A' ? 'added' : statusCode === 'M' ? 'modified' : statusCode === 'D' ? 'deleted' : statusCode === 'R' ? 'renamed' : 'unknown'; if (normalizedStatus === 'deleted') { return { path: normalizedPath, previousPath, status: normalizedStatus, language: 'text', content: '삭제된 파일이라 본문 스냅샷이 없습니다.', }; } if (!SOURCE_SNAPSHOT_TEXT_PATTERN.test(normalizedPath)) { return { path: normalizedPath, previousPath, status: 'binary', language: 'text', content: '텍스트 미리보기를 지원하지 않는 파일입니다.', }; } const absolutePath = path.resolve(repoPath, normalizedPath); try { const content = await readFile(absolutePath, 'utf8'); if (Buffer.byteLength(content, 'utf8') > SOURCE_SNAPSHOT_MAX_BYTES) { return { path: normalizedPath, previousPath, status: normalizedStatus, language: inferSourceLanguage(normalizedPath), content: `파일 크기가 커서 전체 스냅샷을 저장하지 않았습니다. (${Math.round( Buffer.byteLength(content, 'utf8') / 1024, )}KB)`, }; } return { path: normalizedPath, previousPath, status: normalizedStatus, language: inferSourceLanguage(normalizedPath), content, }; } catch { return { path: normalizedPath, previousPath, status: normalizedStatus, language: inferSourceLanguage(normalizedPath), content: '파일 스냅샷을 읽지 못했습니다.', }; } }), ); return snapshots.filter(Boolean); } async function collectWorkingTreeSourceSnapshots(changedPaths) { const uniquePaths = [...new Set((changedPaths ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))]; const snapshots = await Promise.all( uniquePaths.map(async (normalizedPath) => { const absolutePath = path.resolve(repoPath, normalizedPath); if (!SOURCE_SNAPSHOT_TEXT_PATTERN.test(normalizedPath)) { return { path: normalizedPath, previousPath: null, status: 'binary', language: 'text', content: '텍스트 미리보기를 지원하지 않는 파일입니다.', }; } try { const content = await readFile(absolutePath, 'utf8'); if (Buffer.byteLength(content, 'utf8') > SOURCE_SNAPSHOT_MAX_BYTES) { return { path: normalizedPath, previousPath: null, status: 'modified', language: inferSourceLanguage(normalizedPath), content: `파일 크기가 커서 앞부분만 보관했습니다.\n\n${content.slice(0, SOURCE_SNAPSHOT_MAX_BYTES)}`, }; } return { path: normalizedPath, previousPath: null, status: 'modified', language: inferSourceLanguage(normalizedPath), content, }; } catch { return { path: normalizedPath, previousPath: null, status: 'deleted', language: 'text', content: '삭제된 파일이라 본문 스냅샷이 없습니다.', }; } }), ); return snapshots.filter(Boolean); } async function hasLocalBranch(branchName) { try { await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]); return true; } catch { return false; } } async function getGitDirPath() { const { stdout } = await runCommand('git', ['rev-parse', '--git-dir']); const gitDir = stdout.trim() || '.git'; return path.resolve(repoPath, gitDir); } async function exists(targetPath) { try { await access(targetPath); return true; } catch { return false; } } async function tryGitCleanup(args) { try { await runCommand('git', args); return true; } catch { return false; } } async function clearInterruptedGitOperations() { const gitDirPath = await getGitDirPath(); const checks = await Promise.all([ exists(path.join(gitDirPath, 'MERGE_HEAD')), exists(path.join(gitDirPath, 'rebase-merge')), exists(path.join(gitDirPath, 'rebase-apply')), exists(path.join(gitDirPath, 'CHERRY_PICK_HEAD')), exists(path.join(gitDirPath, 'REVERT_HEAD')), exists(path.join(gitDirPath, 'BISECT_LOG')), ]); const [hasMergeHead, hasRebaseMerge, hasRebaseApply, hasCherryPickHead, hasRevertHead, hasBisectLog] = checks; if (hasMergeHead) { await tryGitCleanup(['merge', '--abort']); } if (hasRebaseMerge || hasRebaseApply) { await tryGitCleanup(['rebase', '--abort']); } if (hasCherryPickHead) { await tryGitCleanup(['cherry-pick', '--abort']); } if (hasRevertHead) { await tryGitCleanup(['revert', '--abort']); } if (hasBisectLog) { await tryGitCleanup(['bisect', 'reset']); } } async function ensureCleanWorkingTree() { if (localMainMode) { reportProgress('로컬 main 직접 작업 모드라 git reset/clean은 건너뜁니다.'); return; } reportProgress('이전 git 중간 상태와 작업 트리를 정리하는 중입니다.'); await clearInterruptedGitOperations(); await runCommand('git', ['reset', '--hard']); await runCommand('git', ['clean', '-fd']); } async function ensureWorkingBranch(item) { if (localMainMode) { reportProgress(`로컬 main 직접 작업 모드로 ${item.assignedBranch || 'main'} 작업본을 그대로 사용합니다.`); return; } const branchName = item.assignedBranch; const baseBranch = 'main'; if (await hasLocalBranch(branchName)) { await runCommand('git', ['switch', branchName]); return; } await runCommand('git', ['switch', baseBranch]); await runCommand('git', ['switch', '-C', branchName, baseBranch]); } async function commitAndPushBranch(item, summary) { if (localMainMode) { throw new Error('로컬 main 직접 작업 모드에서는 commit/push를 수행하지 않습니다.'); } const commitMessage = `plan(${item.workId}): ${summary}`.slice(0, 120); await runCommand('git', ['add', '-A']); await runCommand('git', ['commit', '-m', commitMessage]); await runCommand('git', ['push', '-u', 'origin', item.assignedBranch]); const { stdout } = await runCommand('git', ['rev-parse', '--short', 'HEAD']); return { commitHash: stdout.trim(), commitMessage, }; } function parseBoardPostResult(result) { const text = String(result ?? '').trim(); if (!text.startsWith('BOARD_POST:')) { return null; } const rawPayload = text .replace(/^BOARD_POST:\s*/u, '') .replace(/^```(?:json)?\s*/u, '') .replace(/\s*```$/u, '') .trim(); try { const parsed = JSON.parse(rawPayload); const title = String(parsed?.title ?? '').trim(); const content = String(parsed?.content ?? '').trim(); if (!title || !content) { throw new Error('게시글 제목 또는 본문이 비어 있습니다.'); } return { title, content }; } catch (error) { const message = error instanceof Error ? error.message : 'JSON 파싱 실패'; throw new Error(`게시판 작성 결과를 해석하지 못했습니다. ${message}`); } } function parseRecoveredCommitResult(result) { const text = String(result ?? '').trim(); if (!text.startsWith('RECOVERED_COMMIT:')) { return null; } const lines = text.split('\n'); const commitHash = lines[0]?.replace(/^RECOVERED_COMMIT:\s*/u, '').trim() ?? ''; const summary = lines[1]?.trim() || 'Codex가 생성한 로컬 커밋을 복구했습니다.'; const originalError = lines.slice(2).join('\n').replace(/^Original error:\s*/u, '').trim(); return { commitHash, summary, originalError, }; } async function runCodexForPlan(item) { reportProgress('최근 조치 이력과 이슈 이력을 조회하는 중입니다.'); const tempDir = await mkdtemp(path.join(tmpdir(), 'plan-codex-')); const outputFile = path.join(tempDir, `plan-${item.id}.txt`); const writableCodexHome = await prepareWritableCodexHome(tempDir); const [recentActions, recentIssues] = await Promise.all([ fetchPlanActionHistories(item.id), fetchPlanIssueHistories(item.id), ]); const requestSummary = summarizeRequest(item.note); const prompt = [ '당신은 현재 저장소에서 Plan 항목을 처리하는 Codex 실행기입니다.', `작업 ID: ${item.workId}`, `요청 요약: ${requestSummary}`, `상세 요청:\n${item.note || '(비어 있음)'}`, `최근 조치 이력:\n${formatRecentActionHistories(recentActions)}`, `최근 이슈 이력:\n${formatRecentIssueHistories(recentIssues)}`, '규칙:', '0. git commit, git push, release/main merge는 직접 실행하지 마세요. 파일 변경만 남기면 이 실행기가 커밋/푸시/반영을 처리합니다.', '1. 요청이 구체적이고 실제 코드 작업이 필요하면 저장소를 수정하고 최종 답변을 반드시 "DONE: "으로 시작하세요.', '2. 요청이 테스트 수준이거나 별도 작업이 불필요하면 코드를 수정하지 말고 최종 답변을 반드시 "NOOP: "으로 시작하세요.', '3. 최종 답변은 한국어 한두 문장으로 간단히 작성하세요.', '4. 최근 조치 이력에 재처리나 보완 지시가 있으면 그 내용을 우선 반영해 다시 작업하세요.', '5. 응답 톤은 CLI처럼 짧고 직접적으로 작성하세요. 두루뭉술한 설명이나 동문서답을 피하세요.', '6. 미리보기 확인이 필요한 작업이면 preview 링크가 필요한지 판단하고, 이미 정해진 preview 주소 규칙이 있으면 그 링크를 최종 답변에도 함께 적으세요.', shouldForceNoSourceChange(item) ? '7. 이번 요청은 선택된 자동화 처리 유형상 저장소 파일을 수정하면 안 됩니다. 필요한 조회/명령/정리만 수행하고 최종 답변은 반드시 "NOOP: "으로 시작하세요.' : '', isBoardDraftOnlyRequest(item.note) ? '8. 이번 요청은 게시판 작성 전용입니다. 저장소 파일을 수정하지 말고, 최종 답변을 반드시 `BOARD_POST:` 다음 줄에 `{"title":"...","content":"..."}` JSON 하나만 붙여서 작성하세요. 게시글 본문에는 실제 접수 후 작업자가 바로 실행할 내용만 작성하고, 검토용/미접수/자동화 접수 대기 같은 운영 상태 문구는 넣지 마세요.' : '', ].join('\n'); const runCodexExecOnce = async (attempt) => { if (attempt > 1) { reportProgress(`Codex 내부 채널 오류로 자동 작업을 재시도합니다. (${attempt}/${CODEX_EXEC_MAX_ATTEMPTS})`); } return await new Promise((resolve, reject) => { let settled = false; const child = spawn( codexBin, [ 'exec', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-o', outputFile, '-', ], { cwd: repoPath, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, CODEX_HOME: writableCodexHome, }, }, ); let stdout = ''; let stderr = ''; child.stdout?.on('data', (chunk) => { const text = String(chunk); stdout = (stdout + text).slice(-STREAM_CAPTURE_LIMIT); process.stderr.write(text); }); child.stderr?.on('data', (chunk) => { const text = String(chunk); stderr = (stderr + text).slice(-STREAM_CAPTURE_LIMIT); process.stderr.write(text); }); child.stdin?.end(prompt); child.on('error', (error) => { if (settled) { return; } settled = true; reject(error); }); child.on('close', (code, signal) => { if (settled) { return; } settled = true; if (code === 0) { resolve({ stdout, stderr, tokenUsage: parseCodexTokenUsage(`${stderr}\n${stdout}`), }); return; } const details = summarizeFailureOutput( `${stderr}\n${stdout}`, `Codex exited with code ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`, ); reject(new Error(details)); }); }); }; const runCodexExecWithRetry = async () => { let lastError = null; for (let attempt = 1; attempt <= CODEX_EXEC_MAX_ATTEMPTS; attempt += 1) { try { return await runCodexExecOnce(attempt); } catch (error) { lastError = error; if (attempt >= CODEX_EXEC_MAX_ATTEMPTS || !isTransientCodexExecFailure(error)) { throw error; } const outputText = await readFile(outputFile, 'utf8').catch(() => ''); if (outputText.trim()) { reportProgress('Codex 결과 파일이 남아 있어 내부 채널 오류를 성공으로 처리합니다.'); return; } if (await hasGitChanges()) { throw error; } await wait(CODEX_EXEC_RETRY_DELAY_MS); } } throw lastError ?? new Error('Codex 자동 작업 실행에 실패했습니다.'); }; try { reportProgress(`작업 브랜치 ${item.assignedBranch ?? item.releaseTarget ?? 'release'} 상태를 준비하는 중입니다.`); await ensureWorkingBranch(item); const headBeforeCodex = await getHeadCommit(); reportProgress('Codex 자동 작업을 실행하는 중입니다.'); let codexRunMetadata = { tokenUsage: null }; try { codexRunMetadata = (await runCodexExecWithRetry()) ?? codexRunMetadata; } catch (error) { const headAfterCodex = await getHeadCommit(); if (headAfterCodex && headAfterCodex !== headBeforeCodex) { const summary = await getHeadCommitSubject(); const message = error instanceof Error ? error.message : String(error); return [`RECOVERED_COMMIT: ${headAfterCodex}`, summary, `Original error: ${message}`].join('\n'); } throw error; } reportProgress('Codex 응답을 정리하는 중입니다.'); const message = (await readFile(outputFile, 'utf8')).trim(); return { message: message || 'NOOP: 별도 작업 결과를 남기지 않았습니다.', tokenUsage: codexRunMetadata.tokenUsage, }; } finally { await rm(tempDir, { recursive: true, force: true }); } } async function processErrorLogReviewPlan(item) { const rangeEnd = new Date(); const rangeStart = new Date(rangeEnd.getTime() - 24 * 60 * 60 * 1000); reportProgress('에러 로그 기반 Plan 게시판 등록 요청을 감지해 최근 24시간 로그를 집계하는 중입니다.'); const result = await request('/plan/registrations/error-logs', { method: 'POST', body: JSON.stringify({ rangeStart: rangeStart.toISOString(), rangeEnd: rangeEnd.toISOString(), }), }); const createdBoardPosts = result?.createdBoardPosts ?? []; const skippedBoardPosts = result?.skippedBoardPosts ?? []; const recentLogCount = Number(result?.recentLogCount ?? 0); const rawCandidateCount = Number(result?.rawCandidateCount ?? 0); const candidateCount = Number(result?.candidateCount ?? 0); const summaryLines = [ `조회 구간: ${rangeStart.toISOString()} ~ ${rangeEnd.toISOString()}`, `조회 로그: ${recentLogCount}건`, `에러 후보 원본: ${rawCandidateCount}건`, `에러 후보 등록용 묶음: ${candidateCount}건`, `신규 게시글 등록: ${createdBoardPosts.length}건`, `중복 제외: ${skippedBoardPosts.length}건`, ]; if (createdBoardPosts.length > 0) { summaryLines.push(''); summaryLines.push('등록된 게시글:'); summaryLines.push(...createdBoardPosts.map((post) => `- 게시글 #${post.postId} ${post.workId} (${post.count}건)`)); } if (skippedBoardPosts.length > 0) { summaryLines.push(''); summaryLines.push('제외된 항목:'); summaryLines.push(...skippedBoardPosts.map((post) => `- ${post.workId}: ${post.reason}`)); } const summary = summaryLines.join('\n'); await request(`/plan/items/${item.id}/actions/note`, { method: 'POST', body: JSON.stringify({ actionType: '에러로그점검', actionNote: [ summary, '', 'Plan 게시판 등록 전용 요청으로 처리했고, 저장소 소스 수정 이력은 남기지 않았습니다.', ].join('\n'), }), }); await request(`/plan/items/${item.id}/actions/complete`, { method: 'POST', body: JSON.stringify({ note: createdBoardPosts.length > 0 ? `에러 로그 기반 Plan 게시글 ${createdBoardPosts.length}건을 등록했습니다.` : '등록할 신규 에러 Plan 게시글이 없었습니다.', }), }); return { id: item.id, outcome: 'error-log-review-complete', createdBoardPosts, skippedBoardPosts, }; } async function processPlan(item) { reportProgress(`Plan ${item.workId} 요청 처리를 시작합니다.`); if (shouldProcessAsPlanDocument(item)) { return processErrorLogReviewPlan(item); } const baselineChangedFiles = localMainMode ? await listChangedFiles() : []; const baselineChangedPathSet = new Set(normalizeChangedPaths(baselineChangedFiles)); const codexResult = await runCodexForPlan(item); const result = codexResult.message; const tokenUsage = codexResult.tokenUsage; const tokenUsageLine = tokenUsage ? `토큰 사용량: ${tokenUsage}` : null; const summary = result.replace(/^DONE:\s*/, '').replace(/^NOOP:\s*/, '').trim() || '요청 검토를 완료했습니다.'; const boardPost = parseBoardPostResult(result); const recoveredCommit = parseRecoveredCommitResult(result); if (isAutomationEnvironmentFailure(summary) || isAutomationEnvironmentFailure(result)) { throw new Error( '자동화 실행 환경 문제로 실제 작업을 수행하지 못했습니다. 상세: ' + summary, ); } if (boardPost) { if (!isBoardDraftOnlyRequest(item.note)) { throw new Error('게시판 작성 전용 요청이 아닌데 BOARD_POST 결과가 반환되었습니다.'); } reportProgress('게시판 작성 전용 요청을 Plan 게시판에 미접수 글로 등록하는 중입니다.'); const created = await request('/board/posts', { method: 'POST', body: JSON.stringify(boardPost), }); const boardPostId = created?.item?.id ?? null; const boardSummary = `Plan 게시판 미접수 글을 작성했습니다.${boardPostId ? ` 게시글 #${boardPostId}` : ''}`; await request(`/plan/items/${item.id}/source-works`, { method: 'POST', body: JSON.stringify({ summary: boardSummary, branchName: item.assignedBranch || item.releaseTarget || 'release', commitHash: null, changedFiles: [], commandLog: [ `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, tokenUsageLine, `POST /api/board/posts${boardPostId ? ` -> #${boardPostId}` : ''}`, 'BOARD_POST: 저장소 파일 변경 없음', ] .filter(Boolean) .join('\n'), diffText: null, }), }); await request(`/plan/items/${item.id}/actions/note`, { method: 'POST', body: JSON.stringify({ actionType: '게시판작성', actionNote: [ boardSummary, tokenUsageLine, `제목: ${boardPost.title}`, '자동화 접수는 하지 않고 미접수 상태로 남겼습니다.', ] .filter(Boolean) .join('\n'), }), }); await request(`/plan/items/${item.id}/actions/complete`, { method: 'POST', body: JSON.stringify({ note: boardSummary, }), }); return { id: item.id, outcome: 'board-post-complete', boardPostId, result, }; } if (recoveredCommit) { reportProgress('Codex 비정상 종료 후 남은 로컬 커밋을 복구해 원격 브랜치와 Plan 이력에 기록하는 중입니다.'); await runCommand('git', ['push', '-u', 'origin', item.assignedBranch]); const previewUrl = buildPreviewUrl(item, recoveredCommit.commitHash); const changedPaths = await listHeadChangedFiles(); const [{ stdout: diffText }, sourceFiles] = await Promise.all([ runCommand('git', ['show', '--stat', '--patch', '--format=medium', 'HEAD']), collectSourceFileSnapshots(), ]); const recoveredSummary = [ recoveredCommit.summary, recoveredCommit.originalError ? `Codex 종료 오류 복구: ${recoveredCommit.originalError}` : null, ] .filter(Boolean) .join('\n'); await request(`/plan/items/${item.id}/source-works`, { method: 'POST', body: JSON.stringify({ summary: previewUrl ? `${recoveredSummary}\nPreview: ${previewUrl}` : recoveredSummary, branchName: item.assignedBranch, commitHash: recoveredCommit.commitHash.slice(0, 12), previewUrl, changedFiles: changedPaths, commandLog: [ `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, tokenUsageLine, `RECOVERED_COMMIT: ${recoveredCommit.commitHash}`, `git push -u origin ${item.assignedBranch}`, 'git show --stat --patch --format=medium HEAD', ] .filter(Boolean) .join('\n'), diffText: diffText.trim(), sourceFiles, }), }); await request(`/plan/items/${item.id}/actions/note`, { method: 'POST', body: JSON.stringify({ actionType: '소스작업복구', actionNote: [ recoveredSummary, tokenUsageLine, previewUrl ? `Preview: ${previewUrl}` : null, changedPaths.length > 0 ? `변경 파일:\n${changedPaths.join('\n')}` : null, `커밋: ${recoveredCommit.commitHash.slice(0, 12)}`, `브랜치: ${item.assignedBranch}`, ] .filter(Boolean) .join('\n\n'), }), }); return { id: item.id, outcome: 'development-complete', result, }; } if (isBoardDraftOnlyRequest(item.note)) { throw new Error('게시판 작성 전용 요청인데 자동화가 BOARD_POST 결과를 반환하지 않았습니다. 저장소 파일 변경으로 처리하지 않습니다.'); } const changed = await hasGitChanges(); if (result.startsWith('NOOP:')) { reportProgress('코드 변경 없이 처리 가능한 요청인지 검증하는 중입니다.'); if (!shouldSkipSourceChangeRequirement(item) && requiresSourceChange(item.note)) { throw new Error( `소스 변경이 필요한 요청인데 자동화가 작업을 수행하지 못했습니다. 응답: ${summary || 'NOOP'}` ); } reportProgress('NOOP 결과를 Plan 이력에 기록하는 중입니다.'); await request(`/plan/items/${item.id}/source-works`, { method: 'POST', body: JSON.stringify({ summary: summary || '요청을 검토하고 별도 코드 변경 없이 처리했습니다.', branchName: item.assignedBranch || item.releaseTarget || 'release', commitHash: null, changedFiles: [], commandLog: [ `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, tokenUsageLine, 'NOOP: 별도 코드 변경 없음', ] .filter(Boolean) .join('\n'), diffText: null, }), }); await request(`/plan/items/${item.id}/actions/note`, { method: 'POST', body: JSON.stringify({ actionType: '자동완료메모', actionNote: [summary || '별도 코드 변경 없이 요청을 처리했습니다.', tokenUsageLine].filter(Boolean).join('\n'), }), }); await request(`/plan/items/${item.id}/actions/complete`, { method: 'POST', body: JSON.stringify({ note: summary || '별도 작업이 없어 완료 처리했습니다.', }), }); return { id: item.id, outcome: 'noop-complete', result, }; } reportProgress('변경 파일과 요청 경로 일치 여부를 검증하는 중입니다.'); if (!changed) { throw new Error( `자동 작업 결과에 실제 변경 파일이 없습니다. 응답: ${summary || '작업 결과 요약이 비어 있습니다.'}`, ); } const changedFiles = await listChangedFiles(); const changedPaths = normalizeChangedPaths(changedFiles); const effectiveChangedPaths = localMainMode ? changedPaths.filter((changedPath) => !baselineChangedPathSet.has(changedPath)) : changedPaths; const requestedPaths = extractRequestedPaths(item.note); if (requestedPaths.length > 0) { const matched = requestedPaths.some((requestedPath) => effectiveChangedPaths.some((changedPath) => changedPath === requestedPath || changedPath.endsWith('/' + requestedPath)), ); if (!matched) { throw new Error( '요청한 파일 경로가 실제 변경 목록에 없습니다. 요청 경로: ' + requestedPaths.join(', ') + ' / 실제 변경: ' + (effectiveChangedPaths.join(', ') || '없음'), ); } } if (localMainMode) { reportProgress('로컬 main 직접 작업 결과를 이력과 조치 메모로 저장하는 중입니다.'); const sourceFiles = await collectWorkingTreeSourceSnapshots(effectiveChangedPaths); const diffText = effectiveChangedPaths.length > 0 ? (await runCommand('git', ['diff', '--', ...effectiveChangedPaths]).then(({ stdout }) => stdout).catch(() => '')) : ''; const commandLog = [ `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, tokenUsageLine, 'local main mode: commit/push skipped', effectiveChangedPaths.length > 0 ? `git diff -- ${effectiveChangedPaths.join(' ')}` : 'git diff -- 변경 파일 없음', ] .filter(Boolean) .join('\n'); await request(`/plan/items/${item.id}/source-works`, { method: 'POST', body: JSON.stringify({ summary: summary || 'Codex 로컬 main 소스 작업 결과', branchName: item.assignedBranch || 'main', commitHash: null, previewUrl: null, changedFiles: effectiveChangedPaths, commandLog, diffText: diffText.trim() || null, sourceFiles, }), }); await request(`/plan/items/${item.id}/actions/note`, { method: 'POST', body: JSON.stringify({ actionType: '소스작업', actionNote: [ summary || 'Codex 로컬 main 작업 결과를 기록했습니다.', tokenUsageLine, effectiveChangedPaths.length > 0 ? `변경 파일:\n${effectiveChangedPaths.join('\n')}` : null, `브랜치: ${item.assignedBranch || 'main'} (로컬 직접 작업)`, '커밋과 원격 푸시는 수행하지 않았습니다.', ] .filter(Boolean) .join('\n\n'), }), }); if (!skipWorkComplete) { await request(`/plan/items/${item.id}/actions/complete-development`, { method: 'POST', body: JSON.stringify({}), }); } reportProgress('Plan 상태를 로컬 main 작업 완료로 반영했습니다.'); return { id: item.id, outcome: 'development-complete', result, }; } reportProgress('변경 내용을 커밋하고 원격 브랜치로 푸시하는 중입니다.'); const { commitHash, commitMessage } = await commitAndPushBranch(item, summary); const previewUrl = buildPreviewUrl(item, commitHash); const [{ stdout: diffText }, sourceFiles] = await Promise.all([ runCommand('git', ['show', '--stat', '--patch', '--format=medium', 'HEAD']), collectSourceFileSnapshots(), ]); const commandLog = [ `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, tokenUsageLine, 'git add -A', `git commit -m "${commitMessage}"`, `git push -u origin ${item.assignedBranch}`, 'git show --stat --patch --format=medium HEAD', ] .filter(Boolean) .join('\n'); reportProgress('소스 작업 이력과 조치 메모를 저장하는 중입니다.'); await request(`/plan/items/${item.id}/source-works`, { method: 'POST', body: JSON.stringify({ summary: previewUrl ? `${summary || 'Codex 소스 작업 결과'}\nPreview: ${previewUrl}` : summary || 'Codex 소스 작업 결과', branchName: item.assignedBranch, commitHash, previewUrl, changedFiles: changedPaths, commandLog, diffText: diffText.trim(), sourceFiles, }), }); await request(`/plan/items/${item.id}/actions/note`, { method: 'POST', body: JSON.stringify({ actionType: '소스작업', actionNote: [ summary || 'Codex 작업 결과를 기록했습니다.', tokenUsageLine, previewUrl ? `Preview: ${previewUrl}` : null, changedFiles.length > 0 ? `변경 파일:\n${changedFiles.join('\n')}` : null, commitHash ? `커밋: ${commitHash}` : null, `브랜치: ${item.assignedBranch}`, ] .filter(Boolean) .join('\n\n'), }), }); if (!skipWorkComplete) { await request(`/plan/items/${item.id}/actions/complete-development`, { method: 'POST', body: JSON.stringify({}), }); } reportProgress('Plan 상태를 개발 완료로 전환하는 중입니다.'); return { id: item.id, outcome: 'development-complete', result, }; } async function main() { const isExecutableTarget = (item) => item && item.status === '작업중' && Boolean(item.assignedBranch) && ['브랜치준비', '자동작업중'].includes(item.workerStatus); let targets = []; if (planItemId) { const data = await request(`/plan/items/${planItemId}`); const item = data.item; targets = isExecutableTarget(item) ? [item] : []; } else { const data = await request('/plan/items'); targets = (data.items ?? []).filter((item) => isExecutableTarget(item)); } if (targets.length === 0) { console.log('처리할 Plan 항목이 없습니다.'); return; } for (const item of targets) { await ensureCleanWorkingTree(); const processed = await processPlan(item); console.log(JSON.stringify(processed, null, 2)); } } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });